@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.
- package/dist/extension/index.js +16 -8
- package/dist/extension/pipeline/resilience.d.ts +10 -0
- package/dist/extension/pipeline/resilience.js +24 -7
- package/dist/extension/pipeline/runner.d.ts +14 -0
- package/dist/extension/pipeline/types.d.ts +8 -0
- package/dist/extension/pipeline/types.js +1 -0
- package/dist/extension/subagentRender.d.ts +2 -0
- package/dist/extension/subagents.d.ts +34 -10
- package/dist/extension/subagents.js +164 -20
- package/dist/extension/todos.d.ts +0 -10
- package/dist/extension/todos.js +5 -5
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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).
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
/**
|
package/dist/extension/todos.js
CHANGED
|
@@ -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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "2d36b38964c6959ed433421c6eb2502d5d351423"
|
|
62
62
|
}
|