fix(runtime): autonomous beforeToolCall + project agent-event to Console log
Two coupled user-visible defects were fixed.
1. Every tool call (shell / edit / write / read / ...) triggered a
synchronous human-approval prompt under BOTH execution modes. The
worker blocked inside pi's beforeToolCall waiting on a DB approval
row, so runs never progressed — "等待日志输出" forever.
The execution-mode contract is now enforced:
- AI CEO → fully autonomous, no framework gate.
- AI Co-Founder → still fully autonomous at the pi layer. Human
review is a model-driven decision (LLM calls an explicit
`request_approval` tool when it judges review necessary), not
a framework-level interrupt.
HumanApprovalHook collapses to a no-op `before` / `after` /
`cancelAll`. All machinery that queued `dangerous_command`
approvals from the worker-side hook is removed. No more starving
the agent on `node -e` self-call checks.
2. The Console panel only subscribed to `run-log` stdout/stderr
chunks, so pi's agent-event stream (assistant_message_delta,
tool_call_start/end, errors, turn_end) produced zero visible
output. With bug (1) fixed, runs now execute — but the user
would still see a blank Console.
runtime-bridge.handleAgentEvent now projects each meaningful
agent-event into a run-log chunk (assistant_message_delta → raw
delta, tool_call_start → `[tool:bash] {args}`, tool_call_end →
`[tool:done|error:name] {result}`, error → stderr). ConsoleView
picks these up via the existing run-log DesktopEvent pipe with
no renderer change.
tests/unit/approval-hook.test.ts rewritten to assert the new
autonomous contract (before always returns {}, no approval rows
written, cancelAll is a no-op). Tests 508/508 pass.
typecheck 0 / lint 0-0 / build:verify 0-0.
This commit is contained in:
1 parent
9b45b12508
commit
683dded501
4 files changed
+131
-577
No files matched your search
@@ -26,109 +26,40 @@ export interface HumanApprovalHookDeps {
|
||||
};
|
||||
}
|
||||
|
||||
interface PendingApproval {
|
||||
approvalId: string;
|
||||
resolve: (verdict: { block?: boolean; reason?: string }) => void;
|
||||
}
|
||||
|
||||
const TOOL_LABEL: Record<string, string> = {
|
||||
bash: "shell",
|
||||
edit: "edit",
|
||||
write: "write",
|
||||
read: "read",
|
||||
ls: "ls",
|
||||
grep: "grep",
|
||||
find: "find",
|
||||
};
|
||||
|
||||
function summariseArgs(toolName: string, args: unknown): string {
|
||||
if (args === null || typeof args !== "object") return String(args);
|
||||
const argsObj = args as Record<string, unknown>;
|
||||
switch (toolName) {
|
||||
case "bash": {
|
||||
const command = typeof argsObj.command === "string" ? argsObj.command : "";
|
||||
return command.length > 200 ? `${command.slice(0, 197)}...` : command;
|
||||
}
|
||||
case "write":
|
||||
case "edit":
|
||||
case "read": {
|
||||
const path = typeof argsObj.path === "string" ? argsObj.path : (typeof argsObj.file_path === "string" ? argsObj.file_path : "");
|
||||
return path;
|
||||
}
|
||||
default:
|
||||
try {
|
||||
const json = JSON.stringify(args);
|
||||
return json.length > 200 ? `${json.slice(0, 197)}...` : json;
|
||||
} catch {
|
||||
return "<unserialisable args>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-in-the-loop hook bridging pi `beforeToolCall` to
|
||||
* AgentCompany's `ApprovalService` via the existing approvals table.
|
||||
* pi `beforeToolCall` / `afterToolCall` bridge.
|
||||
*
|
||||
* **Execution-mode contract.** Both AI CEO and AI Co-Founder modes run the
|
||||
* pi Agent fully autonomously — the framework layer never gates a tool
|
||||
* call with a synchronous human-approval promise. Under AI Co-Founder, the
|
||||
* model itself is instructed (via the system prompt + a dedicated
|
||||
* `request_approval` tool) to pause when it judges human review necessary.
|
||||
* Blocking every `bash` / `edit` / `write` at the Electron layer would
|
||||
* defeat the pi-mono runtime and starve the agent of progress.
|
||||
*
|
||||
* `before` therefore always approves. `after` is a no-op audit point; the
|
||||
* agent-event stream persists the real tool record via SqliteSessionStore.
|
||||
* `cancelAll` remains for shutdown symmetry but has no queued callers.
|
||||
*/
|
||||
export class HumanApprovalHook {
|
||||
private readonly pending = new Map<string, PendingApproval>();
|
||||
constructor(private readonly _deps: HumanApprovalHookDeps) {
|
||||
void this._deps;
|
||||
}
|
||||
|
||||
constructor(private readonly deps: HumanApprovalHookDeps) {}
|
||||
async before(_runId: string, _payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async before(runId: string, payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> {
|
||||
const { db, emitEvent } = this.deps;
|
||||
const summary = summariseArgs(payload.toolName, payload.args);
|
||||
const label = TOOL_LABEL[payload.toolName] ?? payload.toolName;
|
||||
const approvalId = db.requestApproval({
|
||||
companyId: payload.companyId,
|
||||
relatedTaskId: payload.taskId,
|
||||
requestedByAgentId: payload.agentId,
|
||||
type: "dangerous_command",
|
||||
payloadSummary: `${label}: ${summary}`.slice(0, 280),
|
||||
impactSummary: `Agent ${payload.agentId} requested ${label} during run ${runId}.`,
|
||||
payloadJson: JSON.stringify({
|
||||
runId,
|
||||
toolCallId: payload.toolCallId,
|
||||
toolName: payload.toolName,
|
||||
args: payload.args,
|
||||
}),
|
||||
});
|
||||
|
||||
emitEvent({ type: "approval-updated", approvalId, action: "created" });
|
||||
emitEvent({ type: "domain-changed" });
|
||||
|
||||
return new Promise<{ block?: boolean; reason?: string }>((resolve) => {
|
||||
this.pending.set(approvalId, { approvalId, resolve });
|
||||
});
|
||||
async after(_runId: string, _payload: AfterRequestPayload): Promise<void> {
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the IPC approval-decided pipeline when a user resolves a
|
||||
* pending approval. If we have a pending pi `beforeToolCall` waiting on
|
||||
* this approval, resolve it.
|
||||
* Retained so `RuntimeBridge.release()` can call it during app shutdown
|
||||
* without knowing whether any approvals were actually queued. The
|
||||
* autonomous hook never queues anything, so this is always a no-op.
|
||||
*/
|
||||
resolveApproval(approvalId: string, decision: "approve" | "reject" | "revision_requested", note: string): boolean {
|
||||
const pending = this.pending.get(approvalId);
|
||||
if (!pending) return false;
|
||||
this.pending.delete(approvalId);
|
||||
if (decision === "approve") {
|
||||
pending.resolve({});
|
||||
} else {
|
||||
pending.resolve({ block: true, reason: note || `Tool execution ${decision}.` });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Called when a run is cancelled — resolves any pending approvals as blocked. */
|
||||
cancelAll(reason: string): void {
|
||||
for (const [id, pending] of this.pending) {
|
||||
pending.resolve({ block: true, reason });
|
||||
this.pending.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async after(_runId: string, _payload: AfterRequestPayload): Promise<void> {
|
||||
// Audit hook intentionally minimal — actual tool result is captured by
|
||||
// the agent-event stream and persisted via SqliteSessionStore.
|
||||
cancelAll(_reason: string): void {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,55 @@ import { CostWriter } from "./cost-writer";
|
||||
import type { CredentialResolver } from "@main/credentials";
|
||||
import { getConnectorDefinition } from "@main/connectors";
|
||||
|
||||
/**
|
||||
* Projects a pi AgentEvent to a human-readable log line for the Console.
|
||||
* Returns null when the event should not surface as log output (state-only
|
||||
* events such as `turn_start` are already implied by run-status).
|
||||
*/
|
||||
function projectAgentEventToLogChunk(
|
||||
event: { type: string } & Record<string, unknown>,
|
||||
): { stream: "stdout" | "stderr"; chunk: string } | null {
|
||||
switch (event.type) {
|
||||
case "assistant_message_delta": {
|
||||
const delta = typeof event.delta === "string" ? event.delta : "";
|
||||
if (!delta) return null;
|
||||
return { stream: "stdout", chunk: delta };
|
||||
}
|
||||
case "tool_call_start": {
|
||||
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
||||
const argsPreview = summariseJson(event.args, 200);
|
||||
return { stream: "stdout", chunk: `\n[tool:${toolName}] ${argsPreview}\n` };
|
||||
}
|
||||
case "tool_call_end": {
|
||||
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
||||
const isError = event.isError === true;
|
||||
const resultPreview = summariseJson(event.result, 300);
|
||||
const tag = isError ? "tool:error" : "tool:done";
|
||||
return { stream: isError ? "stderr" : "stdout", chunk: `[${tag}:${toolName}] ${resultPreview}\n` };
|
||||
}
|
||||
case "error": {
|
||||
const msg = typeof event.message === "string" ? event.message : "(unknown error)";
|
||||
return { stream: "stderr", chunk: `[error] ${msg}\n` };
|
||||
}
|
||||
case "turn_end": {
|
||||
return { stream: "stdout", chunk: "\n" };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function summariseJson(value: unknown, maxLength: number): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
try {
|
||||
const json = typeof value === "string" ? value : JSON.stringify(value);
|
||||
if (json.length <= maxLength) return json;
|
||||
return `${json.slice(0, maxLength - 3)}...`;
|
||||
} catch {
|
||||
return "<unserialisable>";
|
||||
}
|
||||
}
|
||||
|
||||
export interface RuntimeBridgeDeps {
|
||||
db: AppDatabase;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
@@ -93,6 +142,16 @@ export class RuntimeBridge {
|
||||
event: envelope.event,
|
||||
});
|
||||
|
||||
const logChunk = projectAgentEventToLogChunk(envelope.event);
|
||||
if (logChunk) {
|
||||
this.deps.emitEvent({
|
||||
type: "run-log",
|
||||
runId: envelope.runId,
|
||||
stream: logChunk.stream,
|
||||
chunk: logChunk.chunk,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.event.type === "message_end") {
|
||||
const ctx = this.activeRuns.get(envelope.runId);
|
||||
const messageRaw = envelope.event.message as
|
||||
|
||||
Reference in new issue
Block a user