@yagni-app/code-staging 1.1.3-staging.1376.1 → 1.1.3-staging.1377.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.
@@ -285,15 +285,23 @@ export async function registerYagni(pi, deps = {}) {
285
285
  }
286
286
  // Lock the interactive session to the `advanced` tier only. The backend
287
287
  // catalog returns all tiers, but only `advanced` is registered with the
288
- // `yagni` provider, so /model and Ctrl+P show a single entry. Child
289
- // processes (/go, subagents, advisor) fetch their own catalog and register
290
- // their own provider, so they are unaffected by this filter.
288
+ // `yagni` provider, so /model and Ctrl+P show a single entry.
291
289
  //
292
- // Eval mode is exempt: a headless harness (scoping sessions, code evals)
293
- // names its tier explicitly the backend's scoping sessions run `--model
294
- // standard` — and there is no /model picker to keep tidy. Filtering there
295
- // turns a valid tier request into "no models match".
296
- const catalog = evalMode ? fullCatalog : fullCatalog.filter((m) => m.id === "advanced");
290
+ // Two exemptions:
291
+ // - CHILD processes: the runner stamps YAGNI_CALLER on every /go, subagent,
292
+ // and advisor spawn (`isDriverCaller` false). A child names its tier on
293
+ // the command line (`--model standard` for a general subagent, `--model
294
+ // efficient` for a searcher) and must resolve it EXACTLY against its own
295
+ // catalog — the advanced-only filter used to leave those tier names
296
+ // unresolved, so pi fell back to its custom-model-id path (a stderr
297
+ // warning on every non-advanced child and default-shaped capability
298
+ // metadata instead of the real context-window/token caps).
299
+ // - Eval mode: a headless harness (scoping sessions, code evals) names its
300
+ // tier explicitly — the backend's scoping sessions run `--model standard`
301
+ // — and there is no /model picker to keep tidy. Filtering there turns a
302
+ // valid tier request into "no models match".
303
+ const driver = isDriverCaller(env);
304
+ const catalog = evalMode || !driver ? fullCatalog : fullCatalog.filter((m) => m.id === "advanced");
297
305
  // YAG-471: the driver's own completions carry attribution headers read from
298
306
  // this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
299
307
  // defaults to "driver" when unset, i.e. every session that is not a /go
@@ -41,9 +41,19 @@ export type RunStageFn = (stage: PipelineStage, ctx: {
41
41
  export interface ResilienceAttemptRecord {
42
42
  stageId: string;
43
43
  lens?: ReviewLens;
44
+ /** The stage's agent name (a subagent child's agent, or the /go stage id). */
45
+ agent?: string;
46
+ /**
47
+ * Which task of a multi-task subagent call this attempt belongs to
48
+ * (0-based). Content-free — an index, never task text — so parallel
49
+ * same-agent failures are attributable without leaking content.
50
+ */
51
+ taskIndex?: number;
44
52
  /** 1-based attempt number. */
45
53
  attempt: number;
46
54
  outcome: "ok" | "transient" | "fatal" | "timeout" | "aborted";
55
+ /** Which timer fired, when the attempt timed out. */
56
+ timeoutKind?: "idle" | "wall";
47
57
  exitCode: number;
48
58
  stopReason?: string;
49
59
  elapsedMs: number;
@@ -31,6 +31,8 @@
31
31
  const WRITE_STAGE_IDS = ["implement", "fix"];
32
32
  /** Honest message stamped on a stage we aborted for exceeding its time budget. */
33
33
  const TIMEOUT_MESSAGE = "stage exceeded its idle or wall-clock timeout";
34
+ const WALL_TIMEOUT_MESSAGE = "stage exceeded its wall-clock timeout";
35
+ const IDLE_TIMEOUT_MESSAGE = "stage exceeded its idle timeout";
34
36
  /** stderr fingerprints of a transient transport/provider blip worth retrying. */
35
37
  const TRANSIENT_STDERR = /\b429\b|\b5\d\d\b|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up/i;
36
38
  /**
@@ -86,7 +88,12 @@ export function withResilience(base, policy, opts = {}) {
86
88
  const telemetry = opts.telemetry;
87
89
  return async (stage, ctx, deps) => {
88
90
  const callerSignal = deps.signal;
89
- const isWriteStage = WRITE_STAGE_IDS.includes(stage.id);
91
+ // The write-gate keys on what the child can DO, not on the stage id: a
92
+ // subagent stage carries the synthetic id "implement" for /go-compat, so
93
+ // the subagent tool passes an explicit per-agent flag (a read-only
94
+ // verifier is retried freely; an edit-capable child keeps the no-re-run
95
+ // discipline). Absent, the /go stage-id vocabulary decides as before.
96
+ const isWriteStage = deps.stageWriteCapable ?? WRITE_STAGE_IDS.includes(stage.id);
90
97
  let last;
91
98
  for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
92
99
  // A caller abort during a backoff window: stop before spending another attempt.
@@ -95,9 +102,11 @@ export function withResilience(base, policy, opts = {}) {
95
102
  const timeoutController = new AbortController();
96
103
  const composed = composeAbortSignal(callerSignal, timeoutController.signal);
97
104
  let timedOut = false;
105
+ let timeoutKind;
98
106
  let sawAnyEvent = false;
99
- const fireTimeout = () => {
107
+ const fireTimeout = (kind) => {
100
108
  timedOut = true;
109
+ timeoutKind = kind;
101
110
  if (!timeoutController.signal.aborted)
102
111
  timeoutController.abort();
103
112
  };
@@ -120,9 +129,9 @@ export function withResilience(base, policy, opts = {}) {
120
129
  armIdle();
121
130
  return;
122
131
  }
123
- fireTimeout();
132
+ fireTimeout("idle");
124
133
  };
125
- const wallTimer = setTimeout(fireTimeout, policy.wallTimeoutMs);
134
+ const wallTimer = setTimeout(() => fireTimeout("wall"), policy.wallTimeoutMs);
126
135
  wallTimer.unref?.();
127
136
  armIdle();
128
137
  const originalOnEvent = deps.onEvent;
@@ -150,8 +159,11 @@ export function withResilience(base, policy, opts = {}) {
150
159
  telemetry?.({
151
160
  stageId: stage.id,
152
161
  ...(ctx.lens ? { lens: ctx.lens } : {}),
162
+ ...(stage.agent ? { agent: stage.agent } : {}),
163
+ ...(deps.taskIndex !== undefined ? { taskIndex: deps.taskIndex } : {}),
153
164
  attempt,
154
165
  outcome,
166
+ ...(timedOut && timeoutKind ? { timeoutKind } : {}),
155
167
  exitCode: last.exitCode,
156
168
  ...(last.stopReason ? { stopReason: last.stopReason } : {}),
157
169
  elapsedMs,
@@ -167,12 +179,17 @@ export function withResilience(base, policy, opts = {}) {
167
179
  record("ok", false);
168
180
  return last;
169
181
  }
170
- if (timedOut)
171
- last.errorMessage = TIMEOUT_MESSAGE;
182
+ if (timedOut) {
183
+ last.errorMessage = timeoutKind === "wall" ? WALL_TIMEOUT_MESSAGE : timeoutKind === "idle" ? IDLE_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE;
184
+ }
172
185
  const transient = classifyTransient(last, timedOut);
186
+ // A wall-clock exhaustion is a "task too big" signal, not a stall: a
187
+ // lane that opts out (subagents) fails honestly instead of re-burning
188
+ // the same 20 minutes per attempt. Idle stalls stay retryable.
189
+ const wallNotRetryable = timedOut && timeoutKind === "wall" && policy.retryWallTimeout === false;
173
190
  // No double-apply: a write stage that already started working is never re-run.
174
191
  const blockedByWriteGate = isWriteStage && sawAnyEvent;
175
- const willRetry = transient && !blockedByWriteGate && attempt < policy.maxAttempts;
192
+ const willRetry = transient && !wallNotRetryable && !blockedByWriteGate && attempt < policy.maxAttempts;
176
193
  record(timedOut ? "timeout" : transient ? "transient" : "fatal", willRetry);
177
194
  if (!willRetry)
178
195
  return last;
@@ -54,6 +54,20 @@ export interface RunStageDeps {
54
54
  * subagent tool and the advisor pass their own label through this seam.
55
55
  */
56
56
  callerLabel?: string;
57
+ /**
58
+ * Write-gate override for the resilience wrapper: whether THIS child's tool
59
+ * list can mutate the workspace (edit/write). The subagent tool sets it per
60
+ * agent (read-only verifiers retry; edit-capable children do not); the /go
61
+ * pipeline leaves it absent so the stage-id vocabulary decides as before.
62
+ */
63
+ stageWriteCapable?: boolean;
64
+ /**
65
+ * Which task of a multi-task subagent call this child runs (0-based). Rides
66
+ * the resilience attempt record only — content-free attribution so parallel
67
+ * same-agent failures stay distinguishable in the telemetry. Absent for
68
+ * single-task /go stages.
69
+ */
70
+ taskIndex?: number;
57
71
  /**
58
72
  * Additive live tap: invoked once per parsed NDJSON event, in stream order,
59
73
  * right after it is buffered for the reducers. PURE side-channel for the
@@ -524,6 +524,14 @@ export interface ResiliencePolicy {
524
524
  backoffMaxMs: number;
525
525
  /** Jitter as a fraction of the computed delay, applied as +/- (0 = none). */
526
526
  jitterRatio: number;
527
+ /**
528
+ * Whether a WALL-clock exhaustion is retryable. Default true (the /go
529
+ * doctrine: a wedged stage is worth another attempt). A lane whose children
530
+ * run read-only, fast work (subagents) sets false: a 20-minute burn means
531
+ * the task is too big, and re-running it just multiplies the spend before
532
+ * the same honest fail. Idle-stall timeouts stay retryable either way.
533
+ */
534
+ retryWallTimeout?: boolean;
527
535
  }
528
536
  /**
529
537
  * Default resilience policy. Generous timeouts so a legitimately long but live
@@ -29,6 +29,7 @@ export const DEFAULT_RESILIENCE_POLICY = {
29
29
  backoffBaseMs: 1_000,
30
30
  backoffMaxMs: 30_000,
31
31
  jitterRatio: 0.25,
32
+ retryWallTimeout: true,
32
33
  };
33
34
  /**
34
35
  * R3-a tool-failure health gate. A WRITE stage (implement / fix) that exits 0 is
@@ -53,6 +53,8 @@ export interface SubagentTaskProgress {
53
53
  }
54
54
  export interface SubagentDetails {
55
55
  tasks: SubagentTaskProgress[];
56
+ /** True when every task in the call failed — keys the tool_result handler's isError override. */
57
+ allFailed?: boolean;
56
58
  }
57
59
  /** Bound on the retained action log so a chatty child cannot grow details unbounded. */
58
60
  export declare const ACTION_LOG_MAX = 120;
@@ -21,9 +21,22 @@ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-cod
21
21
  import { Type } from "typebox";
22
22
  import type { ChildUsageHandle } from "./childUsage.js";
23
23
  import type { WorkingLineHandle } from "./workingLine.js";
24
+ import { type ResilienceAttemptRecord } from "./pipeline/resilience.js";
24
25
  import { runStage } from "./pipeline/runner.js";
25
- import { type ModelTier, type PipelineStage } from "./pipeline/types.js";
26
+ import { type ModelTier, type PipelineStage, type ResiliencePolicy } from "./pipeline/types.js";
26
27
  import { renderSubagentCall, renderSubagentResult } from "./subagentRender.js";
28
+ /**
29
+ * pi's tool contract signals an error by THROWING, and a normally-returned
30
+ * result is always isError=false — the `isError` field on an AgentToolResult
31
+ * is never read by the agent loop. But the `tool_result` EXTENSION event CAN
32
+ * override the flag (`{ isError: true }` from a handler is merged by
33
+ * agent-session's afterToolCall). A completed subagent call that failed ALL
34
+ * its tasks must keep the partial child output in the content (so throwing
35
+ * is wrong there), and this handler is the one seam that marks it as an
36
+ * error in session history. Registered by registerSubagents; fail-soft —
37
+ * a throw inside is swallowed by the runner anyway, but never rely on it.
38
+ */
39
+ export declare function registerSubagentFailureFlag(pi: ExtensionAPI): void;
27
40
  export declare const SUBAGENT_TOOL_NAME = "subagent";
28
41
  export declare const GENERAL_AGENT_NAME = "general";
29
42
  export declare const MAX_PARALLEL_SUBAGENTS = 4;
@@ -93,6 +106,8 @@ export interface MakeSubagentToolDeps {
93
106
  runStageImpl?: typeof runStage;
94
107
  discover?: (deps: DiscoverDeps) => SubagentDef[];
95
108
  homeDir?: string;
109
+ /** Env seam for the telemetry sessionId read (defaults to process.env). */
110
+ env?: NodeJS.ProcessEnv;
96
111
  /** Live ultra-mode probe (/ultra): widens the per-call fan-out ceiling. */
97
112
  isUltra?: () => boolean;
98
113
  /**
@@ -116,6 +131,22 @@ export interface MakeSubagentToolDeps {
116
131
  */
117
132
  childUsage?: ChildUsageHandle;
118
133
  }
134
+ /**
135
+ * The subagent lane's resilience policy: the /go defaults, except a wall-
136
+ * clock exhaustion is NOT retried (a 20-minute burn means the task is too
137
+ * big — fail honestly instead of re-burning it per attempt; idle stalls stay
138
+ * retryable). Exported so a test can pin the composed policy the production
139
+ * path actually runs (every unit test injects runStageImpl instead).
140
+ */
141
+ export declare function subagentRunPolicy(): ResiliencePolicy;
142
+ /**
143
+ * The per-attempt telemetry adapter: one content-free error-sink row per
144
+ * resilience attempt (agent, taskIndex, outcome, timeoutKind, exit code,
145
+ * elapsed, willRetry — never task text or stderr). Exported for the same
146
+ * reason as subagentRunPolicy: the default `run` composes it, and a test
147
+ * drives the exact production adapter rather than a parallel re-statement.
148
+ */
149
+ export declare function subagentTelemetry(env: NodeJS.ProcessEnv): (rec: ResilienceAttemptRecord) => void;
119
150
  export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
120
151
  name: string;
121
152
  label: string;
@@ -139,20 +170,13 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
139
170
  }>;
140
171
  details: unknown;
141
172
  }) => void, ctx?: ExtensionContext): Promise<{
142
- content: {
143
- type: "text";
144
- text: string;
145
- }[];
146
- details: {};
147
- isError: boolean;
148
- } | {
149
- isError?: boolean | undefined;
150
173
  content: {
151
174
  type: "text";
152
175
  text: string;
153
176
  }[];
154
177
  details: {
155
178
  tasks: import("./subagentRender.js").SubagentTaskProgress[];
179
+ allFailed: boolean;
156
180
  };
157
181
  }>;
158
182
  };
@@ -168,7 +192,7 @@ export interface RegisterSubagentsDeps {
168
192
  /** Session child-usage accumulator; see MakeSubagentToolDeps.childUsage. */
169
193
  childUsage?: ChildUsageHandle;
170
194
  }
171
- /** Wire the subagent tool and the /agents listing command. */
195
+ /** Wire the subagent tool, the failure flag handler, and the /agents listing command. */
172
196
  export declare function registerSubagents(pi: ExtensionAPI, deps?: RegisterSubagentsDeps): void;
173
197
  export {};
174
198
  //# sourceMappingURL=subagents.d.ts.map
@@ -23,10 +23,39 @@ import { delimiter, join } from "node:path";
23
23
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
  import { sanitizeCallerSegment } from "./config.js";
26
+ import { logEvent } from "./errorSink.js";
26
27
  import { withResilience } from "./pipeline/resilience.js";
27
28
  import { runStage } from "./pipeline/runner.js";
28
29
  import { DEFAULT_RESILIENCE_POLICY } from "./pipeline/types.js";
29
30
  import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, renderSubagentCall, renderSubagentResult, } from "./subagentRender.js";
31
+ /**
32
+ * pi's tool contract signals an error by THROWING, and a normally-returned
33
+ * result is always isError=false — the `isError` field on an AgentToolResult
34
+ * is never read by the agent loop. But the `tool_result` EXTENSION event CAN
35
+ * override the flag (`{ isError: true }` from a handler is merged by
36
+ * agent-session's afterToolCall). A completed subagent call that failed ALL
37
+ * its tasks must keep the partial child output in the content (so throwing
38
+ * is wrong there), and this handler is the one seam that marks it as an
39
+ * error in session history. Registered by registerSubagents; fail-soft —
40
+ * a throw inside is swallowed by the runner anyway, but never rely on it.
41
+ */
42
+ export function registerSubagentFailureFlag(pi) {
43
+ pi.on("tool_result", async (event) => {
44
+ try {
45
+ if (event.toolName !== SUBAGENT_TOOL_NAME || event.isError)
46
+ return undefined;
47
+ const details = event.details;
48
+ if (!details || typeof details !== "object" || !Array.isArray(details.tasks))
49
+ return undefined;
50
+ if (!details.allFailed)
51
+ return undefined;
52
+ return { isError: true };
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ });
58
+ }
30
59
  /**
31
60
  * YAG-471 attribution: the `x-yagni-caller` prefix for a subagent invocation.
32
61
  * The sanitized agent name is capped so the WHOLE label (prefix + name) stays
@@ -274,6 +303,15 @@ function loadAgentsFromDir(dir, source) {
274
303
  agents.push(def);
275
304
  }
276
305
  catch {
306
+ // A malformed file used to vanish silently — now it leaves a warn row in
307
+ // the sink so a broken definition is diagnosable. Content-free: dir +
308
+ // file name only, never the parse error text (unknown shape).
309
+ logEvent({
310
+ source: "subagent",
311
+ level: "warn",
312
+ event: "agent_file_unparseable",
313
+ fields: { dir, file: entry.name },
314
+ });
277
315
  continue;
278
316
  }
279
317
  }
@@ -419,19 +457,61 @@ const parameters = Type.Object({
419
457
  agent: Type.Optional(Type.String({ description: "Agent name (see /agents). Defaults to general." })),
420
458
  tasks: Type.Optional(Type.Array(Type.Object({
421
459
  task: Type.String(),
422
- agent: Type.Optional(Type.String()),
460
+ agent: Type.Optional(Type.String({ description: "Agent name; defaults to the top-level `agent`, then general." })),
423
461
  }), {
424
- description: `Run several independent tasks in parallel (max ${MAX_PARALLEL_SUBAGENTS}; ${MAX_PARALLEL_SUBAGENTS_ULTRA} in ultra mode). Use INSTEAD of task.`,
462
+ description: `Run several independent tasks in parallel (max ${MAX_PARALLEL_SUBAGENTS}; ${MAX_PARALLEL_SUBAGENTS_ULTRA} in ultra mode). Use INSTEAD of task. Entries without their own agent run as the top-level agent.`,
425
463
  })),
426
464
  });
465
+ /**
466
+ * The subagent lane's resilience policy: the /go defaults, except a wall-
467
+ * clock exhaustion is NOT retried (a 20-minute burn means the task is too
468
+ * big — fail honestly instead of re-burning it per attempt; idle stalls stay
469
+ * retryable). Exported so a test can pin the composed policy the production
470
+ * path actually runs (every unit test injects runStageImpl instead).
471
+ */
472
+ export function subagentRunPolicy() {
473
+ return { ...DEFAULT_RESILIENCE_POLICY, retryWallTimeout: false };
474
+ }
475
+ /**
476
+ * The per-attempt telemetry adapter: one content-free error-sink row per
477
+ * resilience attempt (agent, taskIndex, outcome, timeoutKind, exit code,
478
+ * elapsed, willRetry — never task text or stderr). Exported for the same
479
+ * reason as subagentRunPolicy: the default `run` composes it, and a test
480
+ * drives the exact production adapter rather than a parallel re-statement.
481
+ */
482
+ export function subagentTelemetry(env) {
483
+ return (rec) => logEvent({
484
+ source: "subagent",
485
+ level: rec.outcome === "ok" ? "info" : rec.outcome === "timeout" || rec.outcome === "fatal" ? "error" : "warn",
486
+ event: `attempt_${rec.outcome}`,
487
+ sessionId: env.YAGNI_SESSION_ID,
488
+ fields: {
489
+ ...(rec.agent ? { agent: rec.agent } : {}),
490
+ ...(rec.taskIndex !== undefined ? { taskIndex: rec.taskIndex } : {}),
491
+ attempt: rec.attempt,
492
+ ...(rec.timeoutKind ? { timeoutKind: rec.timeoutKind } : {}),
493
+ exitCode: rec.exitCode,
494
+ ...(rec.stopReason ? { stopReason: rec.stopReason } : {}),
495
+ elapsedMs: rec.elapsedMs,
496
+ willRetry: rec.willRetry,
497
+ },
498
+ });
499
+ }
427
500
  export function makeSubagentTool(deps = {}) {
428
501
  // The default runner rides the /go pipeline's resilience wrapper, so a chat
429
502
  // subagent gets the same idle + wall-clock ceilings and transient-only retry
430
503
  // as a /go stage child (previously a hung subagent hung the tool call until
431
- // the user pressed Esc). The synthetic stage id is "implement", so the
432
- // wrapper's write-gate already refuses to re-run a child that may have
433
- // landed a partial edit.
434
- const run = deps.runStageImpl ?? withResilience(runStage, DEFAULT_RESILIENCE_POLICY);
504
+ // the user pressed Esc). Two subagent-specific knobs ride the wrapper:
505
+ // retryWallTimeout=false (a 20-minute burn means the task is too big fail
506
+ // honestly instead of re-burning it per attempt) and per-attempt telemetry
507
+ // into the error sink (a subagent failure previously left NO trace outside
508
+ // the tool result text). The write-gate keys on the resolved agent's tools,
509
+ // passed per task below — the synthetic stage id is "implement" and would
510
+ // otherwise block retry of a read-only verifier forever.
511
+ const run = deps.runStageImpl ??
512
+ withResilience(runStage, subagentRunPolicy(), {
513
+ telemetry: subagentTelemetry(deps.env ?? process.env),
514
+ });
435
515
  const discover = deps.discover ?? discoverSubagents;
436
516
  const grounded = deps.grounded !== false;
437
517
  // Resolve the effective body for a spawned subagent: the grounding-free
@@ -442,6 +522,29 @@ export function makeSubagentTool(deps = {}) {
442
522
  return def.body;
443
523
  return BUILTIN_BLIND_BODIES[def.name] ?? def.body;
444
524
  };
525
+ // One-line-per-agent listing so the model never guesses a name (a
526
+ // hallucinated agent name is a hard error). Built at registration time —
527
+ // the list is current as of session launch; /agents shows the live set.
528
+ // Fails soft: discovery problems never block the tool.
529
+ let agentListBlock = "";
530
+ try {
531
+ const discovered = discover({ cwd: process.cwd(), homeDir: deps.homeDir });
532
+ if (discovered.length > 0) {
533
+ agentListBlock =
534
+ " Available agents (first sentence only; /agents lists them in full):\n" +
535
+ discovered
536
+ .map((a) => `- ${a.name}: ${a.description.split(/[.!?]/)[0]?.trim() || a.description}`)
537
+ .join("\n");
538
+ }
539
+ }
540
+ catch {
541
+ // A discovery failure here silently ships the tool without the name
542
+ // enumeration — the model is back to guessing names (the failure class
543
+ // this listing exists to prevent) — so it must leave a trace. Content-
544
+ // free: no dir list, no error text (unknown shape).
545
+ logEvent({ source: "subagent", level: "warn", event: "agent_list_discovery_failed" });
546
+ agentListBlock = "";
547
+ }
445
548
  return {
446
549
  name: SUBAGENT_TOOL_NAME,
447
550
  label: "Subagent",
@@ -449,7 +552,9 @@ export function makeSubagentTool(deps = {}) {
449
552
  "a compressed report. Use it for context-heavy exploration you don't need blow-by-blow, or to " +
450
553
  "run independent tasks in parallel via `tasks`. The subagent shares NONE of your conversation: " +
451
554
  "spell out the task completely. Agents come from this repo's .claude/agents and .pi/agents " +
452
- "(list them with /agents); omit `agent` for the general-purpose one.",
555
+ "(list them with /agents); omit `agent` for the general-purpose one. The list below was " +
556
+ "captured at session launch — if the session's cwd differs from the launch cwd it may be " +
557
+ "stale; an unknown name is a hard error, so trust /agents over this list when they differ." + agentListBlock,
453
558
  promptSnippet: "subagent: delegate a self-contained task (or parallel tasks) to a fresh-context agent; returns its report.",
454
559
  parameters,
455
560
  // Self-framed: the condensed transcript look has no tinted tool boxes.
@@ -457,11 +562,15 @@ export function makeSubagentTool(deps = {}) {
457
562
  renderCall: renderSubagentCall,
458
563
  renderResult: renderSubagentResult,
459
564
  async execute(toolCallId, params, signal, onUpdate, ctx) {
460
- const fail = (text) => ({
461
- content: [{ type: "text", text }],
462
- details: {},
463
- isError: true,
464
- });
565
+ // pi's tool contract signals an error by THROWING: a normally-returned
566
+ // result is always isError=false (agent-loop.js stamps it), and the
567
+ // `isError` field on a returned AgentToolResult was never read — the
568
+ // old `fail()` helper's flag was inert, so the driver model saw these
569
+ // as normal text. Thrown Error messages become the tool result content
570
+ // verbatim with isError=true, which is exactly the old intent.
571
+ const fail = (text) => {
572
+ throw new Error(text);
573
+ };
465
574
  const requested = params.tasks && params.tasks.length > 0
466
575
  ? params.tasks
467
576
  : params.task
@@ -478,7 +587,10 @@ export function makeSubagentTool(deps = {}) {
478
587
  const agents = discover({ cwd, homeDir: deps.homeDir });
479
588
  const resolved = [];
480
589
  for (const req of requested) {
481
- const name = (req.agent ?? GENERAL_AGENT_NAME).toLowerCase();
590
+ // A tasks[] entry names its own agent first; absent that, it inherits
591
+ // the call's top-level `agent` (previously silently dropped here, so
592
+ // `{agent: "verification", tasks: [{task}]}` ran as general).
593
+ const name = (req.agent ?? params.agent ?? GENERAL_AGENT_NAME).toLowerCase();
482
594
  const def = agents.find((a) => a.name === name);
483
595
  if (!def) {
484
596
  return fail(`Error: unknown agent "${name}". Available agents:\n${formatAgentList(agents)}`);
@@ -518,6 +630,21 @@ export function makeSubagentTool(deps = {}) {
518
630
  const result = await run(stage, stageCtx, {
519
631
  cwd,
520
632
  signal,
633
+ // Write-gate input: whether the agent's DECLARED tool list has
634
+ // edit/write. This is a retry-discipline heuristic, not a
635
+ // capability claim — bash can still mutate, but it runs behind
636
+ // the same sandbox + permission stack as the driver's, and the
637
+ // gate's purpose is only to refuse re-running a child that may
638
+ // have landed a partial edit. Read-only-by-declaration verifiers
639
+ // retry freely; edit-capable children keep the discipline.
640
+ // Computed from the tools the runner will actually pass the
641
+ // child (`--tools`), so a custom definition cannot opt out by
642
+ // name.
643
+ stageWriteCapable: (stage.tools).some((t) => t === "edit" || t === "write"),
644
+ // Content-free task discriminator: which entry of THIS call's
645
+ // task list the attempt belongs to (parallel same-agent
646
+ // failures stay attributable without leaking task text).
647
+ taskIndex: index,
521
648
  personaBody: () => bodyFor(def),
522
649
  // YAG-471: attribute this child's completions to the specific
523
650
  // subagent, not the generic /go stage label the runner would
@@ -535,7 +662,7 @@ export function makeSubagentTool(deps = {}) {
535
662
  // identity — per invocation, so parallel tasks never collide).
536
663
  deps.childUsage?.record("subagent", progressKey(toolCallId, progress, index), result.usage);
537
664
  emit();
538
- return { agent: def.name, task, result };
665
+ return { agent: def.name, task, result, def };
539
666
  }));
540
667
  }
541
668
  finally {
@@ -548,29 +675,46 @@ export function makeSubagentTool(deps = {}) {
548
675
  const allFailed = outcomes.every((o) => o.result.exitCode !== 0);
549
676
  const sections = outcomes.map((o) => {
550
677
  const output = o.result.finalOutput.trim();
678
+ // Honest failure framing: the child's partial text must never read as
679
+ // a completed report. The prefix says what happened; the failed note
680
+ // keeps the exit code + stderr tail for diagnosis. Plain text only
681
+ // (no emojis — this reaches the driver model's context).
682
+ const failureBanner = o.result.exitCode !== 0
683
+ ? `SUBAGENT FAILED (exit ${o.result.exitCode}${o.result.errorMessage ? `, ${o.result.errorMessage}` : ""}) — the text below is PARTIAL output, not a completed report.`
684
+ : "";
551
685
  const failedNote = o.result.exitCode !== 0
552
686
  ? `\n\n(subagent failed, exit ${o.result.exitCode}${o.result.stderr.trim() ? `: ${o.result.stderr.trim().slice(-500)}` : ""})`
553
687
  : "";
554
688
  const bodyText = output || (o.result.exitCode === 0 ? "(no output)" : "");
689
+ // The verification agent's contract is a `## Verdict` header; an
690
+ // output without it is not a verdict, and the driver must not treat
691
+ // a truncated probe log as "HOLDS".
692
+ const missingVerdict = o.def.source === "builtin" && o.agent === "verification" && o.result.exitCode === 0 && !output.includes("## Verdict")
693
+ ? "\n\n(no verdict — the verification run did not produce its verdict section)"
694
+ : "";
555
695
  return outcomes.length === 1
556
- ? `${bodyText}${failedNote}`
557
- : `## ${o.agent}: ${o.task}\n\n${bodyText}${failedNote}`;
696
+ ? `${failureBanner ? failureBanner + "\n\n" : ""}${bodyText}${missingVerdict}${failedNote}`
697
+ : `## ${o.agent}: ${o.task}\n\n${failureBanner ? failureBanner + "\n\n" : ""}${bodyText}${missingVerdict}${failedNote}`;
558
698
  });
559
699
  return {
560
700
  content: [{ type: "text", text: sections.join("\n\n") }],
561
701
  // The folded progress records ARE the final details: a superset of the
562
702
  // old {agent, task, exitCode, usage, toolCalls} shape, plus the action
563
- // log and report that renderSubagentResult paints.
564
- details: { tasks: progresses },
565
- ...(allFailed ? { isError: true } : {}),
703
+ // log and report that renderSubagentResult paints. `allFailed` keys
704
+ // the tool_result handler's isError override (see
705
+ // registerSubagentFailureFlag) the returned field itself is inert
706
+ // in pi's contract (errors are signaled by throwing, and here we keep
707
+ // the partial child output in the result instead).
708
+ details: { tasks: progresses, allFailed },
566
709
  };
567
710
  },
568
711
  };
569
712
  }
570
- /** Wire the subagent tool and the /agents listing command. */
713
+ /** Wire the subagent tool, the failure flag handler, and the /agents listing command. */
571
714
  export function registerSubagents(pi, deps = {}) {
572
715
  const discover = deps.discover ?? discoverSubagents;
573
716
  pi.registerTool(makeSubagentTool(deps));
717
+ registerSubagentFailureFlag(pi);
574
718
  pi.registerCommand("agents", {
575
719
  description: "List the subagents available in this repo (.claude/agents, .pi/agents).",
576
720
  handler: async (_args, ctx) => {
@@ -194,16 +194,6 @@ export declare function makeTodoTool(get: () => TodoItem[], set: (todos: TodoIte
194
194
  details: {
195
195
  todos: TodoItem[];
196
196
  };
197
- isError: boolean;
198
- } | {
199
- content: {
200
- type: "text";
201
- text: string;
202
- }[];
203
- details: {
204
- todos: TodoItem[];
205
- };
206
- isError?: undefined;
207
197
  }>;
208
198
  };
209
199
  /**
@@ -367,11 +367,11 @@ export function makeTodoTool(get, set, completedAt) {
367
367
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
368
368
  const normalized = normalizeTodos(params.todos);
369
369
  if (!normalized.ok) {
370
- return {
371
- content: [{ type: "text", text: `Error: ${normalized.error}` }],
372
- details: { todos: get() },
373
- isError: true,
374
- };
370
+ // pi signals tool errors by THROWING (a returned result is always
371
+ // isError=false; the returned `isError` field was never read), so the
372
+ // error text must ride the thrown message to reach the model as an
373
+ // error.
374
+ throw new Error(`Error: ${normalized.error}`);
375
375
  }
376
376
  set(normalized.todos);
377
377
  paintWidget(ctx, normalized.todos, completedAt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.3-staging.1376.1",
3
+ "version": "1.1.3-staging.1377.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "ad5363e506fdbfaa52dc79d778d3488661659426"
61
+ "yagniSourceSha": "2d36b38964c6959ed433421c6eb2502d5d351423"
62
62
  }