pi-subagents 0.44.0 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +10 -0
- package/src/extension/tool-description.ts +2 -2
- package/src/inspectors/herdr/actions.ts +1 -1
- package/src/inspectors/herdr/project-panes.ts +1 -1
- package/src/runs/background/async-execution.ts +10 -2
- package/src/runs/background/async-status.ts +4 -1
- package/src/runs/background/control-channel.ts +4 -37
- package/src/runs/background/result-watcher.ts +5 -0
- package/src/runs/background/subagent-runner.ts +34 -35
- package/src/runs/background/subagent-wait.ts +12 -2
- package/src/runs/background/wait-completions.ts +112 -0
- package/src/runs/foreground/async-steering-action.ts +1 -1
- package/src/runs/foreground/subagent-executor.ts +79 -6
- package/src/shared/settings.ts +16 -3
- package/src/shared/types.ts +34 -0
- package/src/workflows/scripted-workflow.ts +25 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.45.1] - 2026-08-09
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Simplified async workflow activity projection and its regression test to reuse canonical status types.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Add actionable guidance when Markdown fence backticks make a `workflowScript` invalid JavaScript.
|
|
12
|
+
- Prevent async interrupt requests from signaling unverified runner PIDs, including the shared host PID stored by workflows. Thanks to @kdasme for #925.
|
|
13
|
+
|
|
14
|
+
## [0.45.0] - 2026-08-09
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Surface terminal completion payloads in `subagent_wait` tool-result details (`details.completions`): run identity, per-child agent/`runId`/success, and artifact paths. Async completions previously reached the parent only as text — the result file is consumed and deleted after delivery — so extensions and automation had no structured way to learn which runs finished or where their artifacts live. Workflow result files now also record each child's `runId`, which was previously dropped even though the workflow engine knows it; a workflow child's `artifactPaths` entry points at its saved output (`outputs/<runId>/…`), so without the explicit field the child's identity was not recoverable from the payload. Thanks to @lucasgrecco for #915.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Clarified mission-use policy in the packaged `pi-subagents` skill.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Prefix quoted Herdr pane commands with PowerShell's call operator on Windows. Thanks to @qsgy-edge for #921.
|
|
24
|
+
- Report live child activity for async workflow runs instead of deriving a false activity age from the workflow launch time. Thanks to @alexei-led (Alexei Ledenev) for #920.
|
|
25
|
+
- Expand `reads` home paths and apply configured reads to single-run launches. Thanks to @Adjuvant (Thomas Deacon) for #916.
|
|
26
|
+
- Drop late workflow child responses after worker settlement. Thanks to @xz-dev (Xiangzhe) for #922.
|
|
27
|
+
- Stabilize steering recovery tests by invalidating cached status metadata after fast test rewrites.
|
|
28
|
+
|
|
5
29
|
## [0.44.0] - 2026-08-08
|
|
6
30
|
|
|
7
31
|
### Added
|
package/package.json
CHANGED
|
@@ -280,6 +280,16 @@ Ordinary launches with a task create a mission by default, so substantial delega
|
|
|
280
280
|
|
|
281
281
|
Use `mission.update` while work runs to record decisions, artifacts, labels, summaries, or delivery receipts. A receipt records a pull request, CI, deployment, or release link with a concise status; it does not authorize or automate merge, CI polling, or deployment. Record open product, architecture, or safety decisions there and escalate them upward; do not let a child decide silently. Use `mission.attach-run` only for runs launched outside the normal mission-backed path, and use `mission.close` with a terminal status and concise summary when the mission is done.
|
|
282
282
|
|
|
283
|
+
### Mission use policy
|
|
284
|
+
|
|
285
|
+
- **Keep the default.** Every ordinary `workflowScript` launch with a task creates one enclosing mission automatically. All workflow children share it and never get their own. Do not add `mission: {...}` boilerplate. Pass it only to set the title, objective, labels, or to enable `goal` with `budget`.
|
|
286
|
+
- **Use `mission: false` for noise.** Use it for trivial one-shot lookups, scouts, disposable probes, and quick validation where a recovery record is noise. It removes the mission and the `state` global for the whole workflow, so do not use it for monitors or multi-workflow loops that coordinate through `state`. Scheduled runs already launch without automatic missions.
|
|
287
|
+
- **Use `missionId` for follow-up work.** Attach later work to an existing objective with `missionId`; attachment re-marks the mission active. `missionId` and `mission` are mutually exclusive. Explicit attachment fails before launch if the mission is missing, while automatic missions degrade to `details.missionWarning` without blocking the run.
|
|
288
|
+
- **Keep `state` small.** Mission `state` is JSON coordination across workflows on the same mission. Keys use the same format as run keys, values must be JSON, and the whole state file is capped at 256 KiB. Each `set` merges one key under a file lock. Put large content in artifact files and store paths in state. In goal missions, write `state.set("nextReadyAction", "...")` so the next idle-turn notice names the exact ready step.
|
|
289
|
+
- **Use artifacts and receipts as evidence.** Mission-backed launches already record run artifacts such as async `status.json`, `events.jsonl`, child output paths, and handoff manifests. Add `mission.update` artifacts only for extra durable outputs such as `patch`, `review`, or `note` files. Add receipts for external outcomes: `pull_request`, `ci`, `deployment`, or `release`; each receipt needs an absolute URL. Receipts are evidence, not authority to merge, deploy, or release.
|
|
290
|
+
- **Treat decisions as append-only.** `mission.update` `decisions` can only add open decisions. No tool action resolves one. In a goal mission, an unresolved decision becomes the fallback next ready action in each notice. Use decisions sparingly there; record them for escalation and audit, steer goal continuation through `state.nextReadyAction`, and close the mission when the question is settled.
|
|
291
|
+
- **Close missions when done.** `mission.close` takes `missionStatus` `completed`, `failed`, or `cancelled` plus a concise `summary`, and ends any goal loop. Goal notices go only to the owning session and stop silently at `budget-exhausted` without closing or claiming success, so close explicitly. Terminal missions are pruned beyond configured retention, so store durable outputs as artifacts, receipts, and summary before closing.
|
|
292
|
+
|
|
283
293
|
After compaction, restart, or confusing history, recover from durable state first: `mission.list` in the project, `mission.list` with `missionScope: "global"` for the user-local cross-project pointer index, then `mission.show` for the relevant mission. `mission.show` refreshes linked async status when available and returns warnings instead of hiding the mission if a linked status file is temporarily unreadable. Use the linked run ids with normal `status`, `steer`, `resume`, or `stop` actions. Project mission JSON remains authoritative over chat history.
|
|
284
294
|
|
|
285
295
|
Routing rule:
|
|
@@ -18,7 +18,7 @@ export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { work
|
|
|
18
18
|
|
|
19
19
|
EXECUTION:
|
|
20
20
|
• Before executing, use { action: "list" } and run only executable/non-disabled configured agents.
|
|
21
|
-
• WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, and resume keeps the stored agent/model/tool contract. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
|
|
21
|
+
• WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, and resume keeps the stored agent/model/tool contract. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
|
|
22
22
|
• Sequential example: { workflowScript: "const a = await runs.run('analyze', {agent:'agent-a', task:'Analyze the request'}); return (await runs.run('plan', {agent:'agent-b', task:'Plan from: '+a.output})).output" }
|
|
23
23
|
• Parallel example: { workflowScript: "const [a,b] = await runs.all([{key:'correctness',agent:'agent-a',task:'Review correctness'},{key:'tests',agent:'agent-b',task:'Review tests'}]); return {correctness:a.output,tests:b.output}" }
|
|
24
24
|
• Optional context is "fresh" or "fork". timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls; evidence levels end at verified, and acceptance.review.required requests independent writer review.
|
|
@@ -37,7 +37,7 @@ export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { w
|
|
|
37
37
|
|
|
38
38
|
EXECUTE:
|
|
39
39
|
• Call { action:"list" } first and use only executable/non-disabled agents.
|
|
40
|
-
• SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off.
|
|
40
|
+
• SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off.
|
|
41
41
|
• Example: {workflowScript:"const [a,b]=await runs.all([{key:'a',agent:'agent-a',task:'Implement A',worktree:true},{key:'b',agent:'agent-b',task:'Implement B',worktree:true}]); return [a.output,b.output]"}
|
|
42
42
|
• context can be fresh or fork. timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls.
|
|
43
43
|
|
|
@@ -93,7 +93,7 @@ function inspectorCommand(input: { runnerPath: string; asyncDir: string; runId:
|
|
|
93
93
|
const args = [process.execPath, input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop)];
|
|
94
94
|
if (input.index !== undefined) args.push("--index", String(input.index));
|
|
95
95
|
if (input.missionPath) args.push("--mission-path", input.missionPath);
|
|
96
|
-
return args.map(shellQuote).join(" ")
|
|
96
|
+
return `${process.platform === "win32" ? "& " : ""}${args.map(shellQuote).join(" ")}`;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
function missionForRun(asyncDir: string, cwd: string, config: MissionStoreConfig | undefined, runId: string): { id: string; path: string } | undefined {
|
|
@@ -94,7 +94,7 @@ async function paneExists(client: HerdrClient, paneId: string, signal?: AbortSig
|
|
|
94
94
|
function projectPaneCommand(message: string | undefined): string {
|
|
95
95
|
const args = message?.trim() ? [message.trim()] : [];
|
|
96
96
|
const command = getPiSpawnCommand(args);
|
|
97
|
-
return [command.command, ...command.args].map(shellQuote).join(" ")
|
|
97
|
+
return `${process.platform === "win32" ? "& " : ""}${[command.command, ...command.args].map(shellQuote).join(" ")}`;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export async function handleHerdrProjectPaneAction(action: HerdrProjectPaneAction, params: ProjectPaneParams, deps: ProjectPaneDeps): Promise<AgentToolResult<Details>> {
|
|
@@ -15,7 +15,7 @@ import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts"
|
|
|
15
15
|
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
16
16
|
import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
|
|
17
17
|
import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
18
|
-
import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
|
|
18
|
+
import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
|
|
19
19
|
import type { RunnerStep } from "../shared/parallel-utils.ts";
|
|
20
20
|
import type { ContextMode } from "../shared/context-mode.ts";
|
|
21
21
|
import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
|
|
@@ -192,6 +192,7 @@ interface AsyncSingleParams {
|
|
|
192
192
|
context?: ContextMode;
|
|
193
193
|
skills?: string[];
|
|
194
194
|
output?: string | boolean;
|
|
195
|
+
reads?: string[] | false;
|
|
195
196
|
outputMode?: "inline" | "file-only";
|
|
196
197
|
outputBaseDir?: string;
|
|
197
198
|
agentContract?: AgentContract;
|
|
@@ -1288,6 +1289,13 @@ export function executeAsyncSingle(
|
|
|
1288
1289
|
const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
|
|
1289
1290
|
if (validationError) return formatAsyncStartError("single", validationError);
|
|
1290
1291
|
const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath, agentConfig);
|
|
1292
|
+
// Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
|
|
1293
|
+
// absolute paths pass through; relative paths resolve against the child cwd.
|
|
1294
|
+
const reads = params.reads !== undefined ? params.reads : agentConfig.defaultReads ?? false;
|
|
1295
|
+
const readsInstruction = Array.isArray(reads) && reads.length > 0
|
|
1296
|
+
? `[Read from: ${reads.map((f) => resolveChainPath(f, runnerCwd)).join(", ")}]\n\n`
|
|
1297
|
+
: "";
|
|
1298
|
+
const taskText = readsInstruction + taskWithOutputInstruction;
|
|
1291
1299
|
const primaryModel = externalRunner ? undefined : resolveSubagentModelOverride(
|
|
1292
1300
|
params.modelOverride ?? agentConfig.model,
|
|
1293
1301
|
ctx.currentModel,
|
|
@@ -1415,7 +1423,7 @@ export function executeAsyncSingle(
|
|
|
1415
1423
|
permissionRules,
|
|
1416
1424
|
...(capabilityCeiling ? { capabilityCeiling } : {}),
|
|
1417
1425
|
agent,
|
|
1418
|
-
task:
|
|
1426
|
+
task: taskText,
|
|
1419
1427
|
...(agentConfig.runner ? { runner: agentConfig.runner } : {}),
|
|
1420
1428
|
...(params.context ? { context: params.context } : {}),
|
|
1421
1429
|
cwd: runnerCwd,
|
|
@@ -207,7 +207,10 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
|
|
|
207
207
|
const currentStep = typeof status.currentStep === "number" ? status.steps?.[status.currentStep] : undefined;
|
|
208
208
|
return {
|
|
209
209
|
activityState: status.activityState,
|
|
210
|
-
lastActivityAt: status.lastActivityAt
|
|
210
|
+
lastActivityAt: status.lastActivityAt
|
|
211
|
+
?? outputFileMtime(outputPath)
|
|
212
|
+
?? currentStep?.lastActivityAt
|
|
213
|
+
?? (status.mode === "workflow" ? undefined : currentStep?.startedAt ?? status.startedAt),
|
|
211
214
|
};
|
|
212
215
|
}
|
|
213
216
|
|
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
* This module adds a portable, file-based control inbox inside the run directory.
|
|
10
10
|
* The parent drops an interrupt request file; the runner watches the inbox and
|
|
11
11
|
* routes the request into its existing graceful `interruptRunner()` (pause +
|
|
12
|
-
* resumable), identically on every platform. The
|
|
13
|
-
*
|
|
14
|
-
* authoritative.
|
|
12
|
+
* resumable), identically on every platform. The file inbox is authoritative and
|
|
13
|
+
* avoids signaling a PID that the extension cannot prove belongs to the runner.
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
import { randomUUID } from "node:crypto";
|
|
@@ -21,13 +20,6 @@ import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
|
21
20
|
import { POLL_INTERVAL_MS } from "../../shared/types.ts";
|
|
22
21
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
22
|
|
|
24
|
-
/**
|
|
25
|
-
* Opportunistic fast-path interrupt signal. On Unix `SIGUSR2` is trapped by the
|
|
26
|
-
* runner; on Windows `process.kill(pid, "SIGBREAK")` is not deliverable
|
|
27
|
-
* cross-process and throws `ENOSYS`, so the file inbox below is the real channel.
|
|
28
|
-
*/
|
|
29
|
-
export const INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
|
|
30
|
-
|
|
31
23
|
export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
|
|
32
24
|
export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
|
|
33
25
|
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
|
|
@@ -545,38 +537,13 @@ export function consumeCheckpointDecisionRequest(
|
|
|
545
537
|
return undefined;
|
|
546
538
|
}
|
|
547
539
|
|
|
548
|
-
/**
|
|
549
|
-
* Parent side: portable interrupt = authoritative file request + best-effort OS
|
|
550
|
-
* signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
|
|
551
|
-
* is swallowed because the file inbox is authoritative there. Other signal
|
|
552
|
-
* failures are surfaced because they usually mean the runner is not alive to
|
|
553
|
-
* consume the request.
|
|
554
|
-
*/
|
|
540
|
+
/** Parent side: write the authoritative portable interrupt request. */
|
|
555
541
|
export function deliverInterruptRequest(input: {
|
|
556
542
|
asyncDir: string;
|
|
557
|
-
pid?: number;
|
|
558
|
-
kill?: KillFn;
|
|
559
|
-
signal?: NodeJS.Signals;
|
|
560
543
|
now?: () => number;
|
|
561
544
|
source?: string;
|
|
562
545
|
}): void {
|
|
563
|
-
|
|
564
|
-
if (typeof input.pid === "number" && input.pid > 0) {
|
|
565
|
-
try {
|
|
566
|
-
(input.kill ?? process.kill)(input.pid, input.signal ?? INTERRUPT_SIGNAL);
|
|
567
|
-
} catch (error) {
|
|
568
|
-
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOSYS") {
|
|
569
|
-
// File inbox is authoritative when custom cross-process signals are unavailable.
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
try {
|
|
573
|
-
fs.rmSync(requestPath, { force: true });
|
|
574
|
-
} catch {
|
|
575
|
-
// Best effort cleanup; the caller still gets the signal failure.
|
|
576
|
-
}
|
|
577
|
-
throw error;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
546
|
+
requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
|
|
580
547
|
}
|
|
581
548
|
|
|
582
549
|
export function deliverTimeoutRequest(input: {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "../../intercom/result-intercom.ts";
|
|
21
21
|
import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
|
|
22
22
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
|
+
import { recordWaitCompletion } from "./wait-completions.ts";
|
|
23
24
|
import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
|
|
24
25
|
|
|
25
26
|
const WATCHER_RESTART_DELAY_MS = 3000;
|
|
@@ -155,6 +156,10 @@ export function createResultWatcher(
|
|
|
155
156
|
}
|
|
156
157
|
const epoch = deliveryEpoch;
|
|
157
158
|
if (!ownsSession(data.sessionId, epoch)) return;
|
|
159
|
+
// Recorded before dedupe and before the unlink below: the result file is
|
|
160
|
+
// the only durable carrier of the per-run payload, and subagent_wait
|
|
161
|
+
// surfaces this record in details once the file is gone.
|
|
162
|
+
recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs);
|
|
158
163
|
const hasExplicitNestedChildren = data.nestedChildren !== undefined;
|
|
159
164
|
let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
|
|
160
165
|
if (!nestedChildren?.length && !hasExplicitNestedChildren) {
|
|
@@ -2287,7 +2287,7 @@ async function runSubagent(
|
|
|
2287
2287
|
const nestedAsyncDir = run.asyncDir ?? resolveNestedAsyncDir(config.nestedRoute.rootRunId, run);
|
|
2288
2288
|
if (!nestedAsyncDir) continue;
|
|
2289
2289
|
try {
|
|
2290
|
-
deliverInterruptRequest(
|
|
2290
|
+
deliverInterruptRequest({ asyncDir: nestedAsyncDir, source: "ancestor-interrupt" });
|
|
2291
2291
|
} catch (error) {
|
|
2292
2292
|
appendJsonl(eventsPath, JSON.stringify({
|
|
2293
2293
|
type: "subagent.nested.interrupt_failed",
|
|
@@ -4439,40 +4439,6 @@ async function runSubagent(
|
|
|
4439
4439
|
statusPayload.error = `Step failed: ${failedStep.agent}`;
|
|
4440
4440
|
}
|
|
4441
4441
|
}
|
|
4442
|
-
writeStatusPayload();
|
|
4443
|
-
appendJsonl(
|
|
4444
|
-
eventsPath,
|
|
4445
|
-
JSON.stringify({
|
|
4446
|
-
type: "subagent.run.completed",
|
|
4447
|
-
lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
|
|
4448
|
-
ts: runEndedAt,
|
|
4449
|
-
runId: id,
|
|
4450
|
-
status: statusPayload.state,
|
|
4451
|
-
durationMs: runEndedAt - overallStartTime,
|
|
4452
|
-
totalTokens: statusPayload.totalTokens,
|
|
4453
|
-
totalCost: finalTotalCost,
|
|
4454
|
-
usageBudget: statusPayload.usageBudget,
|
|
4455
|
-
}),
|
|
4456
|
-
);
|
|
4457
|
-
writeRunLog(logPath, omitUndefinedProperties({
|
|
4458
|
-
id,
|
|
4459
|
-
mode: statusPayload.mode,
|
|
4460
|
-
cwd,
|
|
4461
|
-
startedAt: overallStartTime,
|
|
4462
|
-
endedAt: runEndedAt,
|
|
4463
|
-
steps: statusPayload.steps.map((step) => omitUndefinedProperties({
|
|
4464
|
-
agent: step.agent,
|
|
4465
|
-
status: step.status,
|
|
4466
|
-
durationMs: step.durationMs,
|
|
4467
|
-
})),
|
|
4468
|
-
summary,
|
|
4469
|
-
truncated,
|
|
4470
|
-
artifactsDir,
|
|
4471
|
-
sessionFile: effectiveSessionFile,
|
|
4472
|
-
shareUrl,
|
|
4473
|
-
shareError,
|
|
4474
|
-
}));
|
|
4475
|
-
|
|
4476
4442
|
try {
|
|
4477
4443
|
writeAtomicJson(resultPath, {
|
|
4478
4444
|
lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
|
|
@@ -4572,6 +4538,38 @@ async function runSubagent(
|
|
|
4572
4538
|
} catch (err) {
|
|
4573
4539
|
console.error(`Failed to write result file ${resultPath}:`, err);
|
|
4574
4540
|
}
|
|
4541
|
+
appendJsonl(
|
|
4542
|
+
eventsPath,
|
|
4543
|
+
JSON.stringify({
|
|
4544
|
+
type: "subagent.run.completed",
|
|
4545
|
+
lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
|
|
4546
|
+
ts: runEndedAt,
|
|
4547
|
+
runId: id,
|
|
4548
|
+
status: statusPayload.state,
|
|
4549
|
+
durationMs: runEndedAt - overallStartTime,
|
|
4550
|
+
totalTokens: statusPayload.totalTokens,
|
|
4551
|
+
totalCost: finalTotalCost,
|
|
4552
|
+
usageBudget: statusPayload.usageBudget,
|
|
4553
|
+
}),
|
|
4554
|
+
);
|
|
4555
|
+
writeRunLog(logPath, omitUndefinedProperties({
|
|
4556
|
+
id,
|
|
4557
|
+
mode: statusPayload.mode,
|
|
4558
|
+
cwd,
|
|
4559
|
+
startedAt: overallStartTime,
|
|
4560
|
+
endedAt: runEndedAt,
|
|
4561
|
+
steps: statusPayload.steps.map((step) => omitUndefinedProperties({
|
|
4562
|
+
agent: step.agent,
|
|
4563
|
+
status: step.status,
|
|
4564
|
+
durationMs: step.durationMs,
|
|
4565
|
+
})),
|
|
4566
|
+
summary,
|
|
4567
|
+
truncated,
|
|
4568
|
+
artifactsDir,
|
|
4569
|
+
sessionFile: effectiveSessionFile,
|
|
4570
|
+
shareUrl,
|
|
4571
|
+
shareError,
|
|
4572
|
+
}));
|
|
4575
4573
|
if (config.runnerProcessInstanceId) {
|
|
4576
4574
|
const writers: Record<string, Array<{ processInstanceId: string; kind: "pi-writer"; attempt: number; closeObservedAt: number; exitCode: number | null; signal: string | null }>> = {};
|
|
4577
4575
|
const expectedWriters: Record<string, number> = {};
|
|
@@ -4594,6 +4592,7 @@ async function runSubagent(
|
|
|
4594
4592
|
console.error(`Failed to write process-terminal candidate for '${id}':`, error);
|
|
4595
4593
|
}
|
|
4596
4594
|
}
|
|
4595
|
+
writeStatusPayload();
|
|
4597
4596
|
}
|
|
4598
4597
|
|
|
4599
4598
|
async function waitForStartupControl(
|
|
@@ -58,8 +58,10 @@ import {
|
|
|
58
58
|
type Details,
|
|
59
59
|
type ForegroundResumeRun,
|
|
60
60
|
type SubagentState,
|
|
61
|
+
type WaitCompletion,
|
|
61
62
|
} from "../../shared/types.ts";
|
|
62
63
|
import { formatDuration, shortenPath } from "../../shared/formatters.ts";
|
|
64
|
+
import { collectWaitCompletions } from "./wait-completions.ts";
|
|
63
65
|
export { WAIT_TOOL_ENABLED_ENV, resolveWaitToolConfig, type ResolvedWaitToolConfig } from "./wait-config.ts";
|
|
64
66
|
|
|
65
67
|
/** States that mean a run is still in flight (not yet resolved). */
|
|
@@ -303,11 +305,15 @@ function summarizeTerminalRuns(runs: AsyncRunSummary[], providerFinishedCount =
|
|
|
303
305
|
return parts.join(", ");
|
|
304
306
|
}
|
|
305
307
|
|
|
306
|
-
function result(text: string, isError = false): AgentToolResult<Details> {
|
|
308
|
+
function result(text: string, isError = false, completions?: WaitCompletion[]): AgentToolResult<Details> {
|
|
307
309
|
return {
|
|
308
310
|
content: [{ type: "text", text }],
|
|
309
311
|
...(isError ? { isError: true } : {}),
|
|
310
|
-
details: {
|
|
312
|
+
details: {
|
|
313
|
+
mode: "management",
|
|
314
|
+
results: [],
|
|
315
|
+
...(completions && completions.length > 0 ? { completions } : {}),
|
|
316
|
+
},
|
|
311
317
|
};
|
|
312
318
|
}
|
|
313
319
|
|
|
@@ -597,6 +603,7 @@ export async function waitForSubagents(
|
|
|
597
603
|
let terminalSummary: string;
|
|
598
604
|
let finishedAsyncCount: number;
|
|
599
605
|
let failedAsyncCount: number;
|
|
606
|
+
let completions: WaitCompletion[] | undefined;
|
|
600
607
|
const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
|
|
601
608
|
const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
|
|
602
609
|
try {
|
|
@@ -605,6 +612,7 @@ export async function waitForSubagents(
|
|
|
605
612
|
finishedAsyncCount = terminal.length;
|
|
606
613
|
failedAsyncCount = terminal.filter((run) => run.state === "failed").length;
|
|
607
614
|
terminalSummary = summarizeTerminalRuns(terminal, providerFinishedCount);
|
|
615
|
+
completions = collectWaitCompletions(terminal, deps.state, deps.resultsDir ?? DIRS.results);
|
|
608
616
|
} catch (error) {
|
|
609
617
|
return result(error instanceof Error ? error.message : String(error), true);
|
|
610
618
|
}
|
|
@@ -631,6 +639,7 @@ export async function waitForSubagents(
|
|
|
631
639
|
return result(
|
|
632
640
|
`Waited ${elapsed} for ${scope}; ${status}.${outcome}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
|
|
633
641
|
deps.failOnFailedRuns === true && failedAsyncCount > 0,
|
|
642
|
+
completions,
|
|
634
643
|
);
|
|
635
644
|
}
|
|
636
645
|
|
|
@@ -647,5 +656,6 @@ export async function waitForSubagents(
|
|
|
647
656
|
return result(
|
|
648
657
|
`Waited ${elapsed}; ${progress}.${outcome}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
|
|
649
658
|
deps.failOnFailedRuns === true && failedAsyncCount > 0,
|
|
659
|
+
completions,
|
|
650
660
|
);
|
|
651
661
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ArtifactPaths, SubagentState, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
|
|
4
|
+
import type { AsyncRunSummary } from "./async-status.ts";
|
|
5
|
+
|
|
6
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
7
|
+
return typeof value === "string" && value ? value : undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function errorCode(error: unknown): string | undefined {
|
|
11
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
12
|
+
? (error as NodeJS.ErrnoException).code
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function errorMessage(error: unknown): string {
|
|
17
|
+
return error instanceof Error ? error.message : String(error);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Project a terminal result payload into the slim shape that is safe to surface in
|
|
22
|
+
* tool_result details: run identity, per-child outcome, and the artifact trail.
|
|
23
|
+
* Output text is deliberately excluded — it already travels in the tool result
|
|
24
|
+
* content, and duplicating it in details would double the payload for every wait.
|
|
25
|
+
*/
|
|
26
|
+
export function toWaitCompletion(data: Record<string, unknown>, runId: string): WaitCompletion {
|
|
27
|
+
const results = Array.isArray(data.results)
|
|
28
|
+
? data.results.flatMap((entry): WaitCompletionChild[] => {
|
|
29
|
+
if (entry === null || typeof entry !== "object") return [];
|
|
30
|
+
const child = entry as Record<string, unknown>;
|
|
31
|
+
const outputState = child.outputState === "present" || child.outputState === "absent" || child.outputState === "unknown"
|
|
32
|
+
? child.outputState
|
|
33
|
+
: undefined;
|
|
34
|
+
const artifactPaths = child.artifactPaths !== null && typeof child.artifactPaths === "object"
|
|
35
|
+
? (child.artifactPaths as Partial<ArtifactPaths>)
|
|
36
|
+
: undefined;
|
|
37
|
+
const agent = asNonEmptyString(child.agent);
|
|
38
|
+
const childRunId = asNonEmptyString(child.runId);
|
|
39
|
+
const error = asNonEmptyString(child.error);
|
|
40
|
+
const model = asNonEmptyString(child.model);
|
|
41
|
+
return [{
|
|
42
|
+
...(agent ? { agent } : {}),
|
|
43
|
+
...(childRunId ? { runId: childRunId } : {}),
|
|
44
|
+
...(typeof child.success === "boolean" ? { success: child.success } : {}),
|
|
45
|
+
...(outputState ? { outputState } : {}),
|
|
46
|
+
...(error ? { error } : {}),
|
|
47
|
+
...(model ? { model } : {}),
|
|
48
|
+
...(artifactPaths ? { artifactPaths } : {}),
|
|
49
|
+
}];
|
|
50
|
+
})
|
|
51
|
+
: undefined;
|
|
52
|
+
const agent = asNonEmptyString(data.agent);
|
|
53
|
+
const mode = asNonEmptyString(data.mode);
|
|
54
|
+
const state = asNonEmptyString(data.state);
|
|
55
|
+
return {
|
|
56
|
+
runId,
|
|
57
|
+
...(agent ? { agent } : {}),
|
|
58
|
+
...(mode ? { mode } : {}),
|
|
59
|
+
...(state ? { state } : {}),
|
|
60
|
+
...(typeof data.success === "boolean" ? { success: data.success } : {}),
|
|
61
|
+
...(results && results.length > 0 ? { results } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Record a consumed terminal payload for later surfacing by subagent_wait, pruning
|
|
67
|
+
* stale entries with the same TTL that dedupes completion notifications. The result
|
|
68
|
+
* file is deleted after delivery, so this record is the only in-process source once
|
|
69
|
+
* the watcher has consumed it.
|
|
70
|
+
*/
|
|
71
|
+
export function recordWaitCompletion(state: SubagentState, runId: string, data: Record<string, unknown>, now: number, ttlMs: number): void {
|
|
72
|
+
const store = state.completedResults ??= new Map();
|
|
73
|
+
for (const [key, entry] of store) {
|
|
74
|
+
if (now - entry.seenAt > ttlMs) store.delete(key);
|
|
75
|
+
}
|
|
76
|
+
store.set(runId, { seenAt: now, completion: toWaitCompletion(data, runId) });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Terminal payloads for the runs a wait covered: the watcher's in-memory record
|
|
81
|
+
* first, then the not-yet-consumed result file. Result files are written atomically,
|
|
82
|
+
* so a direct read never observes a torn write; the read is deliberately read-only —
|
|
83
|
+
* the watcher owns notification and cleanup.
|
|
84
|
+
*/
|
|
85
|
+
export function collectWaitCompletions(terminal: AsyncRunSummary[], state: SubagentState, resultsDir: string): WaitCompletion[] | undefined {
|
|
86
|
+
if (terminal.length === 0) return undefined;
|
|
87
|
+
const completions: WaitCompletion[] = [];
|
|
88
|
+
for (const run of terminal) {
|
|
89
|
+
const recorded = state.completedResults?.get(run.id);
|
|
90
|
+
if (recorded) {
|
|
91
|
+
completions.push(recorded.completion);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const resultPath = path.join(resultsDir, `${run.id}.json`);
|
|
95
|
+
try {
|
|
96
|
+
const raw = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as Record<string, unknown>;
|
|
97
|
+
completions.push(toWaitCompletion(raw, run.id));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (errorCode(error) !== "ENOENT") {
|
|
100
|
+
throw new Error(`Failed to read subagent result '${resultPath}': ${errorMessage(error)}`, {
|
|
101
|
+
cause: error instanceof Error ? error : undefined,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// The watcher may have consumed the file between the store check and the
|
|
105
|
+
// read; its record is authoritative when present, otherwise the payload
|
|
106
|
+
// is gone and the text summary remains the only surface for this run.
|
|
107
|
+
const late = state.completedResults?.get(run.id);
|
|
108
|
+
if (late) completions.push(late.completion);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return completions.length > 0 ? completions : undefined;
|
|
112
|
+
}
|
|
@@ -173,7 +173,7 @@ export async function steerAsyncRun(input: {
|
|
|
173
173
|
return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: preCommitResult } };
|
|
174
174
|
}
|
|
175
175
|
try {
|
|
176
|
-
deliverInterruptRequest({ asyncDir,
|
|
176
|
+
deliverInterruptRequest({ asyncDir, source: "steering-recovery" });
|
|
177
177
|
} catch (error) {
|
|
178
178
|
fs.rmSync(markerPath, { force: true });
|
|
179
179
|
fs.rmSync(claimPath, { force: true });
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
getStepAgents,
|
|
37
37
|
isParallelStep,
|
|
38
38
|
isDynamicParallelStep,
|
|
39
|
+
resolveChainPath,
|
|
39
40
|
resolveStepBehavior,
|
|
40
41
|
suppressProgressForReadOnlyTask,
|
|
41
42
|
taskDisallowsFileUpdates,
|
|
@@ -256,6 +257,8 @@ export interface SubagentParamsLike {
|
|
|
256
257
|
focus?: boolean;
|
|
257
258
|
skill?: string | string[] | boolean;
|
|
258
259
|
output?: string | boolean;
|
|
260
|
+
/** Internal-only; not part of the public tool schema. Wired for single-run reads (chain steps use their own field). */
|
|
261
|
+
reads?: string[] | false;
|
|
259
262
|
outputMode?: "inline" | "file-only";
|
|
260
263
|
outputSchema?: JsonSchemaObject;
|
|
261
264
|
agentScope?: unknown;
|
|
@@ -865,8 +868,15 @@ function interruptAsyncRun(
|
|
|
865
868
|
details: { mode: "management", results: [] },
|
|
866
869
|
};
|
|
867
870
|
}
|
|
871
|
+
if (status.mode === "workflow") {
|
|
872
|
+
return {
|
|
873
|
+
content: [{ type: "text", text: `Interrupt is unsupported for async workflow ${target.asyncId}; use stop instead.` }],
|
|
874
|
+
isError: true,
|
|
875
|
+
details: { mode: "management", results: [] },
|
|
876
|
+
};
|
|
877
|
+
}
|
|
868
878
|
try {
|
|
869
|
-
deliverInterruptRequest(
|
|
879
|
+
deliverInterruptRequest({ asyncDir: target.asyncDir, source: "interrupt-action" });
|
|
870
880
|
const tracked = state.asyncJobs.get(target.asyncId);
|
|
871
881
|
if (tracked) {
|
|
872
882
|
delete tracked.activityState;
|
|
@@ -1175,7 +1185,7 @@ function directNestedAsyncInterrupt(target: ResolvedSubagentRunId & { kind: "nes
|
|
|
1175
1185
|
const pid = typeof status?.pid === "number" && status.pid > 0 ? status.pid : run.pid;
|
|
1176
1186
|
if (!status || status.state !== "running" || typeof pid !== "number" || pid <= 0) return undefined;
|
|
1177
1187
|
try {
|
|
1178
|
-
deliverInterruptRequest({ asyncDir,
|
|
1188
|
+
deliverInterruptRequest({ asyncDir, source: "nested-interrupt" });
|
|
1179
1189
|
return { content: [{ type: "text", text: `Interrupt requested for nested async run ${run.id}.` }], details: { mode: "management", results: [] } };
|
|
1180
1190
|
} catch (error) {
|
|
1181
1191
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2562,6 +2572,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
|
|
|
2562
2572
|
skills,
|
|
2563
2573
|
output: effectiveOutput,
|
|
2564
2574
|
outputMode: effectiveOutputMode,
|
|
2575
|
+
...(params.reads !== undefined ? { reads: params.reads } : {}),
|
|
2565
2576
|
outputBaseDir: resolveSingleRunOutputBaseDir(deps, artifactsDir, id),
|
|
2566
2577
|
modelOverride,
|
|
2567
2578
|
thinkingOverride: thinkingOverrideForTask(params.agent!, 0, modelOverride),
|
|
@@ -3590,6 +3601,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
|
|
|
3590
3601
|
data.modelScope === undefined ? {} : { scope: data.modelScope },
|
|
3591
3602
|
);
|
|
3592
3603
|
let skillOverride: string[] | false | undefined = normalizeSkillInput(params.skill);
|
|
3604
|
+
let readsOverride: string[] | false | undefined = params.reads;
|
|
3593
3605
|
const rawOutput = params.output !== undefined ? params.output : agentConfig.output;
|
|
3594
3606
|
let effectiveOutput = normalizeSingleOutputOverride(rawOutput, agentConfig.output);
|
|
3595
3607
|
const effectiveOutputMode = params.outputMode ?? "inline";
|
|
@@ -3627,6 +3639,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
|
|
|
3627
3639
|
if (override?.model !== undefined) modelOverride = resolveEffectiveSubagentModel(override.model, agentConfig.model, parentModel, availableModels, currentProvider, data.modelScope === undefined ? {} : { scope: data.modelScope });
|
|
3628
3640
|
if (override?.output !== undefined) effectiveOutput = normalizeSingleOutputOverride(override.output, agentConfig.output);
|
|
3629
3641
|
if (override?.skills !== undefined) skillOverride = override.skills;
|
|
3642
|
+
if (override?.reads !== undefined) readsOverride = override.reads;
|
|
3630
3643
|
|
|
3631
3644
|
if (result.runInBackground) {
|
|
3632
3645
|
if (!isAsyncAvailable()) {
|
|
@@ -3666,6 +3679,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
|
|
|
3666
3679
|
skills: skillOverride === false ? [] : skillOverride,
|
|
3667
3680
|
output: effectiveOutput,
|
|
3668
3681
|
outputMode: effectiveOutputMode,
|
|
3682
|
+
...(readsOverride !== undefined ? { reads: readsOverride } : {}),
|
|
3669
3683
|
outputBaseDir: resolveSingleRunOutputBaseDir(deps, artifactsDir, id),
|
|
3670
3684
|
modelOverride,
|
|
3671
3685
|
thinkingOverride: thinkingOverrideForTask(params.agent!, 0, modelOverride),
|
|
@@ -3702,6 +3716,13 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
|
|
|
3702
3716
|
const structuredRuntime = params.outputSchema
|
|
3703
3717
|
? createStructuredOutputRuntime(params.outputSchema, artifactConfig.enabled ? path.join(artifactsDir, "structured-output", runId) : undefined)
|
|
3704
3718
|
: undefined;
|
|
3719
|
+
// Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
|
|
3720
|
+
// absolute paths pass through; relative paths resolve against the child cwd.
|
|
3721
|
+
const reads = readsOverride !== undefined ? readsOverride : agentConfig.defaultReads ?? false;
|
|
3722
|
+
const readsInstruction = Array.isArray(reads) && reads.length > 0
|
|
3723
|
+
? `[Read from: ${reads.map((f) => resolveChainPath(f, effectiveCwd)).join(", ")}]\n\n`
|
|
3724
|
+
: "";
|
|
3725
|
+
task = readsInstruction + task;
|
|
3705
3726
|
task = injectSingleOutputInstruction(task, outputPath, agentConfig);
|
|
3706
3727
|
|
|
3707
3728
|
let effectiveSkills: string[] | undefined;
|
|
@@ -4016,6 +4037,7 @@ function prepareWorkflowChildParams(params: SubagentParamsLike): SubagentParamsL
|
|
|
4016
4037
|
acceptance,
|
|
4017
4038
|
agentContract,
|
|
4018
4039
|
toolBudget,
|
|
4040
|
+
reads,
|
|
4019
4041
|
...runParams
|
|
4020
4042
|
} = params;
|
|
4021
4043
|
return {
|
|
@@ -4027,6 +4049,7 @@ function prepareWorkflowChildParams(params: SubagentParamsLike): SubagentParamsL
|
|
|
4027
4049
|
...(model !== undefined ? { model } : {}),
|
|
4028
4050
|
...(skill !== undefined ? { skill } : {}),
|
|
4029
4051
|
...(output !== undefined ? { output } : {}),
|
|
4052
|
+
...(reads !== undefined ? { reads } : {}),
|
|
4030
4053
|
...(outputMode !== undefined ? { outputMode } : {}),
|
|
4031
4054
|
...(outputSchema !== undefined ? { outputSchema } : {}),
|
|
4032
4055
|
...(acceptance !== undefined ? { acceptance } : {}),
|
|
@@ -4238,6 +4261,14 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4238
4261
|
if (job) {
|
|
4239
4262
|
job.status = status.state;
|
|
4240
4263
|
job.updatedAt = status.lastUpdate;
|
|
4264
|
+
job.activityState = status.activityState;
|
|
4265
|
+
job.lastActivityAt = status.lastActivityAt;
|
|
4266
|
+
job.currentTool = status.currentTool;
|
|
4267
|
+
job.currentToolStartedAt = status.currentToolStartedAt;
|
|
4268
|
+
job.currentPath = status.currentPath;
|
|
4269
|
+
job.turnCount = status.turnCount;
|
|
4270
|
+
job.toolCount = status.toolCount;
|
|
4271
|
+
job.currentStep = status.currentStep;
|
|
4241
4272
|
if (status.steps) {
|
|
4242
4273
|
job.steps = status.steps.map((step, index) => ({ ...step, index }));
|
|
4243
4274
|
job.agents = status.steps.map((step) => step.agent);
|
|
@@ -4248,6 +4279,27 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4248
4279
|
job.workflow = status.workflow;
|
|
4249
4280
|
}
|
|
4250
4281
|
};
|
|
4282
|
+
const projectWorkflowActivity = () => {
|
|
4283
|
+
const steps = status.steps ?? [];
|
|
4284
|
+
const runningSteps = steps.filter((step) => step.status === "running");
|
|
4285
|
+
const lastActivityAt = runningSteps.reduce<number | undefined>((latest, step) => step.lastActivityAt === undefined ? latest : Math.max(latest ?? step.lastActivityAt, step.lastActivityAt), undefined);
|
|
4286
|
+
const activeToolStep = runningSteps
|
|
4287
|
+
.filter((step) => step.currentTool)
|
|
4288
|
+
.sort((left, right) => (left.lastActivityAt ?? 0) - (right.lastActivityAt ?? 0))
|
|
4289
|
+
.at(-1);
|
|
4290
|
+
status.activityState = runningSteps.some((step) => step.activityState === "needs_attention")
|
|
4291
|
+
? "needs_attention"
|
|
4292
|
+
: runningSteps.some((step) => step.activityState === "active_long_running") ? "active_long_running" : undefined;
|
|
4293
|
+
status.lastActivityAt = lastActivityAt;
|
|
4294
|
+
status.currentTool = activeToolStep?.currentTool;
|
|
4295
|
+
status.currentToolStartedAt = activeToolStep?.currentToolStartedAt;
|
|
4296
|
+
status.currentPath = activeToolStep?.currentPath;
|
|
4297
|
+
const turnCounts = steps.flatMap((step) => step.turnCount === undefined ? [] : [step.turnCount]);
|
|
4298
|
+
const toolCounts = steps.flatMap((step) => step.toolCount === undefined ? [] : [step.toolCount]);
|
|
4299
|
+
status.turnCount = turnCounts.length > 0 ? turnCounts.reduce((total, count) => total + count, 0) : undefined;
|
|
4300
|
+
status.toolCount = toolCounts.length > 0 ? toolCounts.reduce((total, count) => total + count, 0) : undefined;
|
|
4301
|
+
status.currentStep = runningSteps.length === 1 ? steps.indexOf(runningSteps[0]!) : undefined;
|
|
4302
|
+
};
|
|
4251
4303
|
const workflowJob: AsyncJobState = { asyncId: workflowRunId, asyncDir, cwd: workflowCwd, status: "running", sessionId: currentSessionId ?? undefined, mode: "workflow", agents: [], steps: [], startedAt, updatedAt: startedAt, ...(timeout !== undefined ? { timeoutMs: timeout, deadlineAt: startedAt + timeout } : {}), workflow: status.workflow };
|
|
4252
4304
|
deps.state.asyncJobs.set(workflowRunId, workflowJob);
|
|
4253
4305
|
deps.state.fleetJobs ??= new Map();
|
|
@@ -4271,9 +4323,10 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4271
4323
|
if (entry.durationMs === undefined) delete existing.durationMs;
|
|
4272
4324
|
else existing.durationMs = entry.durationMs;
|
|
4273
4325
|
} else {
|
|
4274
|
-
status.steps?.push({ agent: entry.key, label: entry.key, workflowKey: entry.key, parentWorkflowRunId: workflowRunId, status: mapped });
|
|
4326
|
+
status.steps?.push({ agent: entry.key, label: entry.key, workflowKey: entry.key, parentWorkflowRunId: workflowRunId, status: mapped, startedAt: Date.now() });
|
|
4275
4327
|
}
|
|
4276
4328
|
}
|
|
4329
|
+
projectWorkflowActivity();
|
|
4277
4330
|
persist();
|
|
4278
4331
|
appendWorkflowEvent({ type: "subagent.workflow.trace", trace });
|
|
4279
4332
|
};
|
|
@@ -4296,7 +4349,27 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4296
4349
|
if (budgetState?.exhausted) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, usageBudgetExceededMessage(budgetState)));
|
|
4297
4350
|
patchMissionObjective(childParams.task);
|
|
4298
4351
|
const childRequest = prepareWorkflowLaunchParams(workflowChildDefaults, childParams, workflowRunId, key, { missionDetached: detachWorkflowChildMissions });
|
|
4299
|
-
const result = await execute(randomUUID(), childRequest, workflowSignal,
|
|
4352
|
+
const result = await execute(randomUUID(), childRequest, workflowSignal, (update) => {
|
|
4353
|
+
const progress = update.details.progress?.[0];
|
|
4354
|
+
const step = status.steps?.find((candidate) => candidate.workflowKey === key);
|
|
4355
|
+
if (!progress || !step) return;
|
|
4356
|
+
step.status = progress.status === "completed" ? "completed" : progress.status === "failed" ? "failed" : "running";
|
|
4357
|
+
step.activityState = progress.activityState;
|
|
4358
|
+
step.lastActivityAt = progress.lastActivityAt;
|
|
4359
|
+
step.currentTool = progress.currentTool;
|
|
4360
|
+
step.currentToolArgs = progress.currentToolArgs;
|
|
4361
|
+
step.currentToolStartedAt = progress.currentToolStartedAt;
|
|
4362
|
+
step.currentPath = progress.currentPath;
|
|
4363
|
+
step.recentTools = progress.recentTools.map((tool) => ({ ...tool }));
|
|
4364
|
+
step.recentOutput = [...progress.recentOutput];
|
|
4365
|
+
step.turnCount = progress.turnCount;
|
|
4366
|
+
step.toolCount = progress.toolCount;
|
|
4367
|
+
step.model = progress.model;
|
|
4368
|
+
step.thinking = progress.thinking;
|
|
4369
|
+
step.error = progress.error;
|
|
4370
|
+
projectWorkflowActivity();
|
|
4371
|
+
persist();
|
|
4372
|
+
}, ctx, preserveActiveSession);
|
|
4300
4373
|
workflowResults.push(...result.details.results);
|
|
4301
4374
|
const child = workflowChildResult(key, result);
|
|
4302
4375
|
if (result.details.asyncId) {
|
|
@@ -4312,16 +4385,16 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4312
4385
|
const summary = `Workflow completed with ${workflow.children.length} child run(s). Return: ${returnPreview}${emitPreview} Trace: ${workflow.trace.length} event(s).`;
|
|
4313
4386
|
const workflowUsage = sumResultsUsage(workflowResults);
|
|
4314
4387
|
status = { ...status, state: "complete", endedAt: Date.now(), workflow: { value: workflow.value, trace: workflow.trace, emits: workflow.emits, console: workflow.console }, totalTokens: { input: workflowUsage.input, output: workflowUsage.output, total: workflowUsage.input + workflowUsage.output }, totalCost: sumResultsCost(workflowResults) };
|
|
4388
|
+
writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: true, state: "complete", summary, output: summary, results: workflow.children.map((child) => ({ agent: child.key, ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
|
|
4315
4389
|
persist();
|
|
4316
4390
|
appendWorkflowEvent({ type: "subagent.workflow.completed", state: "complete" });
|
|
4317
|
-
writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: true, state: "complete", summary, output: summary, results: workflow.children.map((child) => ({ agent: child.key, output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
|
|
4318
4391
|
} catch (error) {
|
|
4319
4392
|
const partial = error instanceof WorkflowScriptError ? error.partial : { trace: [], emits: [], console: [], children: [] };
|
|
4320
4393
|
const stopped = controller.signal.aborted;
|
|
4321
4394
|
status = compactOptional<AsyncStatus>({ ...status, state: stopped ? "stopped" : "failed", stopped: stopped || undefined, error: error instanceof Error ? error.message : String(error), endedAt: Date.now(), workflow: { trace: partial.trace, emits: partial.emits, console: partial.console } });
|
|
4395
|
+
writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: false, state: status.state, summary: status.error, error: status.error, stopped: status.stopped, results: partial.children.map((child) => ({ agent: child.key, ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
|
|
4322
4396
|
persist();
|
|
4323
4397
|
appendWorkflowEvent({ type: "subagent.workflow.completed", state: status.state, error: status.error });
|
|
4324
|
-
writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: false, state: status.state, summary: status.error, error: status.error, stopped: status.stopped, results: partial.children.map((child) => ({ agent: child.key, output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
|
|
4325
4398
|
} finally {
|
|
4326
4399
|
deps.state.workflowControllers?.delete(workflowRunId);
|
|
4327
4400
|
}
|
package/src/shared/settings.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import type { AgentConfig } from "../agents/agents.ts";
|
|
8
9
|
import { normalizeSkillInput } from "../agents/skills.ts";
|
|
@@ -334,10 +335,22 @@ export function suppressProgressForReadOnlyTask(behavior: ResolvedStepBehavior,
|
|
|
334
335
|
// =============================================================================
|
|
335
336
|
|
|
336
337
|
/**
|
|
337
|
-
*
|
|
338
|
+
* Expand a leading `~`/`~/` to the user's home directory. Other forms (relative,
|
|
339
|
+
* absolute, `~user/`) pass through unchanged.
|
|
338
340
|
*/
|
|
339
|
-
function
|
|
340
|
-
|
|
341
|
+
export function expandHomePath(filePath: string): string {
|
|
342
|
+
if (filePath === "~") return os.homedir();
|
|
343
|
+
if (filePath.startsWith("~/")) return path.join(os.homedir(), filePath.slice(2));
|
|
344
|
+
return filePath;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Resolve a file path: `~`/`~/` expand to home first, then absolute paths pass
|
|
349
|
+
* through and relative paths get chainDir prepended.
|
|
350
|
+
*/
|
|
351
|
+
export function resolveChainPath(filePath: string, chainDir: string): string {
|
|
352
|
+
const expanded = expandHomePath(filePath);
|
|
353
|
+
return path.isAbsolute(expanded) ? expanded : path.join(chainDir, expanded);
|
|
341
354
|
}
|
|
342
355
|
|
|
343
356
|
/**
|
package/src/shared/types.ts
CHANGED
|
@@ -947,6 +947,31 @@ export interface SpawnBudgetSnapshot {
|
|
|
947
947
|
grantHistory: SpawnBudgetGrant[];
|
|
948
948
|
}
|
|
949
949
|
|
|
950
|
+
/** Slim per-child projection of a terminal result payload, safe to surface in tool_result details. */
|
|
951
|
+
export interface WaitCompletionChild {
|
|
952
|
+
agent?: string;
|
|
953
|
+
/** Child run identity where the producer records one (workflow children); artifact files are keyed by it. */
|
|
954
|
+
runId?: string;
|
|
955
|
+
success?: boolean;
|
|
956
|
+
outputState?: SubagentOutputState;
|
|
957
|
+
error?: string;
|
|
958
|
+
model?: string;
|
|
959
|
+
artifactPaths?: Partial<ArtifactPaths>;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Terminal completion observed for a run a subagent_wait call covered. Carries run
|
|
964
|
+
* identity and the artifact trail; output text stays in the tool result content.
|
|
965
|
+
*/
|
|
966
|
+
export interface WaitCompletion {
|
|
967
|
+
runId: string;
|
|
968
|
+
agent?: string;
|
|
969
|
+
mode?: string;
|
|
970
|
+
state?: string;
|
|
971
|
+
success?: boolean;
|
|
972
|
+
results?: WaitCompletionChild[];
|
|
973
|
+
}
|
|
974
|
+
|
|
950
975
|
export interface Details {
|
|
951
976
|
mode: SubagentResultMode | "management";
|
|
952
977
|
runId?: string;
|
|
@@ -955,6 +980,13 @@ export interface Details {
|
|
|
955
980
|
/** Run-level context summary. "mixed" when children resolved to different modes. */
|
|
956
981
|
context?: "fresh" | "fork" | "mixed";
|
|
957
982
|
results: SingleResult[];
|
|
983
|
+
/**
|
|
984
|
+
* Terminal completion payloads for runs this subagent_wait call observed
|
|
985
|
+
* finishing. Async completions travel as result files that are consumed and
|
|
986
|
+
* deleted after text delivery, so without this field their run and artifact
|
|
987
|
+
* identity never reaches tool_result details.
|
|
988
|
+
*/
|
|
989
|
+
completions?: WaitCompletion[];
|
|
958
990
|
controlEvents?: ControlEvent[];
|
|
959
991
|
steering?: SteerActionResult;
|
|
960
992
|
asyncId?: string;
|
|
@@ -1582,6 +1614,8 @@ export interface SubagentState {
|
|
|
1582
1614
|
lastUiContext: ExtensionContext | null;
|
|
1583
1615
|
poller: NodeJS.Timeout | null;
|
|
1584
1616
|
completionSeen: Map<string, number>;
|
|
1617
|
+
/** Terminal result payloads observed by the result watcher, keyed by run id and pruned by the completion TTL. */
|
|
1618
|
+
completedResults?: Map<string, { seenAt: number; completion: WaitCompletion }>;
|
|
1585
1619
|
watcher: FSWatcher | null;
|
|
1586
1620
|
watcherRestartTimer: ReturnType<typeof setTimeout> | null;
|
|
1587
1621
|
resultFileCoalescer: {
|
|
@@ -105,6 +105,17 @@ const capturedConsole = Object.freeze(Object.fromEntries(
|
|
|
105
105
|
}]),
|
|
106
106
|
));
|
|
107
107
|
|
|
108
|
+
function formatWorkflowScriptSyntaxError(error) {
|
|
109
|
+
const details = error && error.stack ? error.stack : String(error);
|
|
110
|
+
return [
|
|
111
|
+
"workflowScript must be valid JavaScript.",
|
|
112
|
+
"If task text contains Markdown fences or backticks, use an array joined with \"\\n\" or escaped strings instead of a raw backtick template literal.",
|
|
113
|
+
"",
|
|
114
|
+
"Original SyntaxError:",
|
|
115
|
+
details,
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
function assertJsonValue(value, path = "emit", seen = new Set()) {
|
|
109
120
|
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
110
121
|
if (typeof value === "number") {
|
|
@@ -143,7 +154,14 @@ parentPort.on("message", async (message) => {
|
|
|
143
154
|
if (message.stateEnabled) sandbox.state = state;
|
|
144
155
|
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
145
156
|
contextObjectPrototype = vm.runInContext("Object.prototype", context);
|
|
146
|
-
|
|
157
|
+
let compiled;
|
|
158
|
+
try {
|
|
159
|
+
compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "workflow-script.js" });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
162
|
+
parentPort.postMessage({ type: "error", error: formatWorkflowScriptSyntaxError(error) });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
147
165
|
const value = await compiled.runInContext(context);
|
|
148
166
|
const persistedValue = value === undefined ? null : value;
|
|
149
167
|
assertJsonValue(persistedValue, "return");
|
|
@@ -385,8 +403,12 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
385
403
|
|
|
386
404
|
const respond = (promise: Promise<unknown>) => {
|
|
387
405
|
void promise.then(
|
|
388
|
-
(value) =>
|
|
389
|
-
|
|
406
|
+
(value) => {
|
|
407
|
+
if (!settled) worker.postMessage({ type: "response", callId: message.callId, ok: true, value: omitUndefinedWorkflowValues(value) });
|
|
408
|
+
},
|
|
409
|
+
(error: unknown) => {
|
|
410
|
+
if (!settled) worker.postMessage({ type: "response", callId: message.callId, ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
411
|
+
},
|
|
390
412
|
);
|
|
391
413
|
};
|
|
392
414
|
|