/** * Consumption loop that drives a Task to completion (CLI side, shared by run and chat). * * New protocol: `session.run(prompt, { signal, approve })` runs the entire ReAct loop in one * call — within a turn, the engine invokes the `approve` callback for each tool_call, executing * it on allow, with execution possibly overlapping. The CLI only needs to consume the output * stream and supply `approve`. The approval strategy is determined by the permission mode * (allow-all / deny-all / read-only / always-ask per-call approval). * * Goal mode rides the same call (`opts.goal` → `session.run(prompt, { goal })`): core loops * the rounds inside the one run, so this loop only adds the per-round rendering rhythm — * a dim round line at each `[goal]` round boundary, per-round stats via `endTask`, and the * outcome summary read from the stream's terminal `goal_finished` event. */ import { goalFinishedOf, isEventMessage, isGoalRoundInput } from "@prismshadow/penguin-core"; import type { ApproveFn, GoalOutcome, OmniMessage, Session } from "@prismshadow/penguin-core"; import { dim, humanizeTokens } from "./render.js"; import type { StreamRenderer } from "./render.js"; import { makeApprove, promptApproval, type ApprovalMode } from "./approval.js"; import type { Messages } from "./i18n.js"; export interface RunTaskOptions { /** Approval mode (default allow-all). */ mode?: ApprovalMode; /** Interrupt signal (Ctrl-C, etc.). */ signal?: AbortSignal; renderer: StreamRenderer; /** The actual Q&A for interactive approval; defaults to the one-off `promptApproval`. */ interactivePrompt?: ApproveFn; /** Message set. */ t: Messages; /** * Present = goal mode: the prompt's text is the objective and the one `session.run` loops * until the goal reaches a terminal state (a single AbortSignal spans every round). `out` * receives the dim round/summary lines the renderer doesn't own. */ goal?: { budget: number; out: NodeJS.WritableStream }; } /** Result of one Task: `aborted` = the Task ended with an abort event (LLM failure/reconnect exhausted/user interrupt); `goal` = the outcome of a goal-mode run (absent when the stream was cut off before the terminal event). */ export interface RunTaskResult { aborted: boolean; goal?: GoalOutcome; } export async function runTask( session: Session, prompt: OmniMessage[], opts: RunTaskOptions, ): Promise { const basePrompt: ApproveFn = opts.interactivePrompt ?? (() => promptApproval({ t: opts.t })); // Lock the renderer while waiting for the user's approval input: messages from concurrent // tools/subsessions are queued and released together once the Q&A finishes, so the prompt // isn't scrambled by later output. The pending tool_call is passed in so its call line stays // right before the prompt; the approval result is rendered in place **before unlocking** — // "tool call → approval prompt → approval result" stays three consecutive lines, for both // the main Agent and subagents (messages arriving via the async pipeline may lag behind the // approval callback, hence render-in-place plus de-duplication of the copy). // // Serialization: the parent session and a run_subagent child session share this callback and // may request approval concurrently (the parent is waiting on one approval while an // already-approved child session starts its own). Concurrent prompts would clobber the same // Q&A state and fight over the same stdin (one answer resolving two questions, leaving the // other permanently stuck); a promise chain queues them so only one question is asked at a // time. let promptChain: Promise = Promise.resolve(); const interactivePrompt: ApproveFn = (tc) => { const result = promptChain.then(async () => { opts.renderer.beginUserPrompt(tc); try { const decision = await basePrompt(tc); opts.renderer.noteApprovalDecision(tc, decision); return decision; } finally { opts.renderer.endUserPrompt(); } }); promptChain = result.then( () => undefined, () => undefined, ); return result; }; const approveByMode = makeApprove({ mode: opts.mode ?? "allow-all", toolPermission: (name) => session.toolPermission(name), interactivePrompt, }); // The auto-approval path (allow-all / deny-all / read-only approvals) has no prompt: it // likewise renders the "call line → approval result" pair in place; the interactive path's // already-rendered copy is idempotently de-duplicated inside note. const approve: ApproveFn = async (tc) => { const decision = await approveByMode(tc); opts.renderer.noteApprovalDecision(tc, decision); return decision; }; // A single run drives the whole ReAct loop (the engine requests approval per call and runs // tools concurrently within a turn). Once the task ends (including on error), endTask // prints this task's stats (context/Token/elapsed time). The engine collapses failures // (auth errors, reconnect exhausted, etc.) into a main-session abort event rather than // throwing; the result reported here reflects that, for `penguin run` to map to // an exit code. // // In goal mode the round boundaries are the injected `[goal]` user messages core yields // before each round: stats settle per round (the per-Task rhythm of a normal chat), so // `segmentStartedAt` tracks the current round rather than the whole run. const goal = opts.goal; const startedAt = Date.now(); let segmentStartedAt = startedAt; let aborted = false; let round = 0; let outcome: GoalOutcome | undefined; try { for await (const msg of session.run(prompt, { approve, ...(opts.signal ? { signal: opts.signal } : {}), ...(goal ? { goal: { budget: goal.budget } } : {}), })) { if (isEventMessage(msg) && msg.payload.type === "abort" && (msg.origin?.length ?? 0) === 0) { aborted = true; } if (goal) { if (isGoalRoundInput(msg)) { // Settle the previous round's stats before announcing the next (endTask is what // prints the per-task `[stats]` line in a normal chat). if (round > 0) opts.renderer.endTask(Date.now() - segmentStartedAt); round++; segmentStartedAt = Date.now(); goal.out.write(`${dim(opts.t.goalRound(round))}\n`); } outcome = goalFinishedOf(msg) ?? outcome; } opts.renderer.handle(msg); } } finally { opts.renderer.endTask(Date.now() - segmentStartedAt); } if (goal && outcome) { goal.out.write( `${dim(opts.t.goalFinished(outcome.outcome, outcome.rounds, humanizeTokens(outcome.tokensUsed)))}\n`, ); } return { aborted, ...(outcome !== undefined ? { goal: outcome } : {}) }; }