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:
ZacharyZhang-NY committed 2026-04-17 10:23:50 -04:00
1 parent 9b45b12508
commit 683dded501
4 files changed
+131 -577

No files matched your search

+26 -95
View File
@@ -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 */
}
}
+59
View File
@@ -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