pi-famulus 0.1.2 → 0.1.4
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/README.md +3 -1
- package/package.json +31 -12
- package/src/agents/builtins.ts +5 -22
- package/src/agents/definition.ts +16 -12
- package/src/bash-override.ts +8 -21
- package/src/behavior-guidelines.ts +40 -15
- package/src/clock.ts +5 -0
- package/src/comms/comms.ts +7 -3
- package/src/comms/tools.ts +11 -36
- package/src/config.ts +25 -1
- package/src/format.ts +83 -5
- package/src/index.ts +155 -30
- package/src/manager-client.ts +265 -114
- package/src/monitor.ts +14 -33
- package/src/notify.ts +7 -7
- package/src/prompts.generated.ts +183 -0
- package/src/shell-exec.ts +6 -2
- package/src/subagent/admission.ts +224 -0
- package/src/subagent/agent-records.ts +66 -3
- package/src/subagent/child-bash.ts +65 -56
- package/src/subagent/model-spec.ts +134 -16
- package/src/subagent/overrun.ts +191 -0
- package/src/subagent/pi-runtime.ts +118 -33
- package/src/subagent/registry.ts +235 -34
- package/src/subagent/runner.ts +565 -99
- package/src/subagent/tool.ts +111 -61
- package/src/subagent/types.ts +71 -7
- package/src/subagent/usage.ts +48 -0
- package/src/task-tools.ts +15 -25
- package/src/thinking-levels.ts +26 -0
- package/src/tui/message-renderers.ts +52 -2
- package/src/wake-delivery.ts +137 -0
- package/src/wake.ts +270 -7
- package/src/work-index.ts +4 -0
|
@@ -25,6 +25,8 @@ import type {
|
|
|
25
25
|
import { taskOutputPath } from "../config";
|
|
26
26
|
import { realClock, type Clock } from "../clock";
|
|
27
27
|
import type { ManagerClient } from "../manager-client";
|
|
28
|
+
import type { ChildShellTracker } from "./overrun";
|
|
29
|
+
import { fill, PROMPTS } from "../prompts.generated";
|
|
28
30
|
import {
|
|
29
31
|
appendStatus,
|
|
30
32
|
bareSleepError,
|
|
@@ -43,10 +45,8 @@ const MAX_BYTES = SHELL_MAX_BYTES;
|
|
|
43
45
|
const WAIT_SLICE_MS = 60_000;
|
|
44
46
|
|
|
45
47
|
const childBashParameters = Type.Object({
|
|
46
|
-
command: Type.String({ description: "
|
|
47
|
-
timeout: Type.Optional(
|
|
48
|
-
Type.Number({ description: "Hard kill timeout in seconds (optional, no default timeout)" }),
|
|
49
|
-
),
|
|
48
|
+
command: Type.String({ description: PROMPTS["tools.child.bash.param.command"] }),
|
|
49
|
+
timeout: Type.Optional(Type.Number({ description: PROMPTS["tools.child.bash.param.timeout"] })),
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
type ChildBashParams = { command: string; timeout?: number };
|
|
@@ -66,13 +66,12 @@ export interface ChildBashDeps {
|
|
|
66
66
|
childId?: string;
|
|
67
67
|
runId?: string;
|
|
68
68
|
clock?: Clock;
|
|
69
|
+
/** Records the shell this child is blocked on, for its overrun wake. */
|
|
70
|
+
shells?: Pick<ChildShellTracker, "start" | "end">;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
const CHILD_SLEEP_GUIDANCE =
|
|
72
|
-
"Sleeping to wait for work is never useful: run the actual command directly, or report back to the supervisor if you are blocked.";
|
|
73
|
-
|
|
74
73
|
function rejectBareSleep(command: string): string | null {
|
|
75
|
-
return bareSleepError(command,
|
|
74
|
+
return bareSleepError(command, "tools.child.bash.error.bare-sleep");
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
function resolveTimeoutMs(timeoutSeconds: number | undefined): number | null {
|
|
@@ -98,12 +97,8 @@ export function createChildBashTool(
|
|
|
98
97
|
return {
|
|
99
98
|
name: "bash",
|
|
100
99
|
label: "Bash",
|
|
101
|
-
description:
|
|
102
|
-
|
|
103
|
-
`Output is truncated to last ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB (whichever is hit first). ` +
|
|
104
|
-
"The command runs to completion (or the optional timeout in seconds, after which it is killed). " +
|
|
105
|
-
"There is no background execution inside subagents.",
|
|
106
|
-
promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
|
|
100
|
+
description: fill("tools.child.bash.description", { maxLines: MAX_LINES, maxKb: MAX_BYTES / 1024 }),
|
|
101
|
+
promptSnippet: PROMPTS["tools.child.bash.snippet"],
|
|
107
102
|
parameters: childBashParameters,
|
|
108
103
|
async execute(_toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) {
|
|
109
104
|
const input = params as ChildBashParams;
|
|
@@ -132,52 +127,66 @@ export function createChildBashTool(
|
|
|
132
127
|
const outputPath = taskOutputPath(deps.home, deps.sessionId(), start.task_id);
|
|
133
128
|
|
|
134
129
|
const clock = deps.clock ?? realClock;
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
130
|
+
const childId = deps.childId;
|
|
131
|
+
if (childId) {
|
|
132
|
+
deps.shells?.start(childId, {
|
|
133
|
+
taskId: start.task_id,
|
|
134
|
+
command: input.command,
|
|
135
|
+
startedAt: clock.now(),
|
|
136
|
+
outputPath,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const deadline = timeoutMs !== null ? clock.now() + timeoutMs : null;
|
|
141
|
+
let waitResult: { done: boolean; exit_code?: number | null } | null = null;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const budget =
|
|
144
|
+
deadline === null
|
|
145
|
+
? WAIT_SLICE_MS
|
|
146
|
+
: Math.min(WAIT_SLICE_MS, Math.max(1, deadline - clock.now()));
|
|
147
|
+
try {
|
|
148
|
+
waitResult = await withAbort(client.wait(start.task_id, budget), signal, () => {
|
|
149
|
+
client.stop(start.task_id, "tool").catch(() => {});
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as Error).message === "aborted") {
|
|
153
|
+
throw new Error("Command aborted (task stopped)");
|
|
154
|
+
}
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
|
|
157
|
+
`${(err as Error).message}. Output so far: ${outputPath}.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (waitResult.done) break;
|
|
161
|
+
if (deadline !== null && clock.now() >= deadline) {
|
|
162
|
+
await client.stop(start.task_id, "timeout").catch(() => {});
|
|
163
|
+
const collected = await collectOutput(client, start.task_id).catch(() => null);
|
|
164
|
+
const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
|
|
165
|
+
throw new Error(
|
|
166
|
+
appendStatus(text, timedOutStatus(input.timeout!)),
|
|
167
|
+
);
|
|
149
168
|
}
|
|
150
|
-
throw new Error(
|
|
151
|
-
`Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
|
|
152
|
-
`${(err as Error).message}. Output so far: ${outputPath}.`,
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
if (waitResult.done) break;
|
|
156
|
-
if (deadline !== null && clock.now() >= deadline) {
|
|
157
|
-
await client.stop(start.task_id, "timeout").catch(() => {});
|
|
158
|
-
const collected = await collectOutput(client, start.task_id).catch(() => null);
|
|
159
|
-
const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
|
|
160
|
-
throw new Error(
|
|
161
|
-
appendStatus(text, timedOutStatus(input.timeout!)),
|
|
162
|
-
);
|
|
163
169
|
}
|
|
164
|
-
}
|
|
165
170
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
171
|
+
const collected = await collectOutput(client, start.task_id);
|
|
172
|
+
const { text, details } = formatFinishedOutput(collected, outputPath);
|
|
173
|
+
const exitCode = waitResult.exit_code ?? null;
|
|
174
|
+
if (exitCode !== 0 && exitCode !== null) {
|
|
175
|
+
throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
|
|
176
|
+
}
|
|
177
|
+
// No exit code: killed (timeout, stop, crash), unless the manager
|
|
178
|
+
// finished it as completed with its runner status unobservable.
|
|
179
|
+
if (exitCode === null && collected.status !== "completed") {
|
|
180
|
+
throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
|
|
181
|
+
}
|
|
182
|
+
const finalDetails = details
|
|
183
|
+
? { ...details, task_id: start.task_id }
|
|
184
|
+
: ({ task_id: start.task_id } as FamulusChildBashDetails);
|
|
185
|
+
return { content: [{ type: "text", text }], details: finalDetails };
|
|
186
|
+
} finally {
|
|
187
|
+
// The overrun wake must not name a shell that already returned.
|
|
188
|
+
if (childId) deps.shells?.end(childId, start.task_id);
|
|
176
189
|
}
|
|
177
|
-
const finalDetails = details
|
|
178
|
-
? { ...details, task_id: start.task_id }
|
|
179
|
-
: ({ task_id: start.task_id } as FamulusChildBashDetails);
|
|
180
|
-
return { content: [{ type: "text", text }], details: finalDetails };
|
|
181
190
|
},
|
|
182
191
|
};
|
|
183
192
|
}
|
|
@@ -6,8 +6,23 @@
|
|
|
6
6
|
* candidate set (scopedModels whitelist when non-empty, else all available).
|
|
7
7
|
*
|
|
8
8
|
* Spec grammar: `[provider/|provider:]id[:thinking]`
|
|
9
|
-
* - thinking suffix:
|
|
10
|
-
*
|
|
9
|
+
* - thinking suffix: one of VALID_THINKING_LEVELS (pi core parity;
|
|
10
|
+
* src/thinking-levels.ts is the single source of truth). Unrecognized
|
|
11
|
+
* suffixes are NOT silently kept in the id: when the full spec fails to
|
|
12
|
+
* match, the resolver retries without the trailing ":suffix" and adapts
|
|
13
|
+
* with a warning — the same semantics as pi's
|
|
14
|
+
* parseModelPattern(allowInvalidThinkingLevelFallback). The retry only
|
|
15
|
+
* fires on a full-spec no-match, so literal ids whose trailing ':segment'
|
|
16
|
+
* is not a valid level (OpenRouter ":exacto") still win at exact match.
|
|
17
|
+
* Note the converse: an id literally ending in a valid level (":high")
|
|
18
|
+
* is read as a thinking override, never as a literal id.
|
|
19
|
+
* A "provider:id" separator is never a thinking suffix: when the
|
|
20
|
+
* trailing ':' is the spec's first separator and its prefix is a known
|
|
21
|
+
* provider (case-insensitive; candidate.provider or the first '/' segment
|
|
22
|
+
* of candidate.id), the colon is left in the base ("openai:off" /
|
|
23
|
+
* "openai:max" / "openai:nonexistent" stay hard no-matches). Detection
|
|
24
|
+
* runs before valid-level splitting so a recognized level cannot bypass
|
|
25
|
+
* the guard. Bare "gpt-5.2:off" and "openai:gpt-5.2:off" still split.
|
|
11
26
|
* - provider prefix: "provider/id" or "provider:id" both accepted
|
|
12
27
|
* - bare id: exact unique match, else case-insensitive substring on id/name
|
|
13
28
|
*/
|
|
@@ -19,10 +34,24 @@ export interface ModelCandidate {
|
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
export type ModelResolution =
|
|
22
|
-
| { ok: true; provider: string; id: string; thinking?: string }
|
|
23
|
-
| {
|
|
37
|
+
| { ok: true; provider: string; id: string; thinking?: string; warning?: string }
|
|
38
|
+
| {
|
|
39
|
+
ok: false;
|
|
40
|
+
error: "no-match" | "ambiguous";
|
|
41
|
+
candidates: string[];
|
|
42
|
+
thinking?: string;
|
|
43
|
+
/** Set when a trailing ":suffix" was stripped and retried unsuccessfully. */
|
|
44
|
+
suffixHint?: string;
|
|
45
|
+
};
|
|
24
46
|
|
|
25
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Thinking levels pi core accepts — re-exported from the single source of
|
|
49
|
+
* truth (src/thinking-levels.ts) for convenience of resolver callers.
|
|
50
|
+
*/
|
|
51
|
+
export { VALID_THINKING_LEVELS } from "../thinking-levels";
|
|
52
|
+
import { VALID_THINKING_LEVELS } from "../thinking-levels";
|
|
53
|
+
|
|
54
|
+
const THINKING_LEVELS = new Set<string>(VALID_THINKING_LEVELS);
|
|
26
55
|
|
|
27
56
|
function label(c: ModelCandidate): string {
|
|
28
57
|
return `${c.provider}/${c.id}`;
|
|
@@ -49,10 +78,42 @@ function splitProviderPrefix(spec: string): { provider: string; id: string } | n
|
|
|
49
78
|
return null;
|
|
50
79
|
}
|
|
51
80
|
|
|
52
|
-
|
|
53
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Provider names the separator guard recognizes: each candidate's `provider`
|
|
83
|
+
* field, plus the first `/` segment of its id. OpenRouter ids look like
|
|
84
|
+
* `openai/gpt-5.2:exacto`; if the scoped whitelist omits the openai provider
|
|
85
|
+
* itself, that nested prefix still has to count, or `openai:nonexistent`
|
|
86
|
+
* retries as `openai` and unique-fuzzy-matches the OpenRouter id.
|
|
87
|
+
*/
|
|
88
|
+
function knownProviders(candidates: ModelCandidate[]): string[] {
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
for (const c of candidates) {
|
|
91
|
+
out.push(c.provider);
|
|
92
|
+
const slash = c.id.indexOf("/");
|
|
93
|
+
if (slash > 0) out.push(c.id.slice(0, slash));
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True when the ':' at `idx` is the "provider:id" separator rather than a
|
|
100
|
+
* thinking suffix: it is the spec's first separator and the text before it
|
|
101
|
+
* names a known provider (case-insensitive). Stripping it would retry the
|
|
102
|
+
* bare provider name, which fuzzy-matches unrelated ids (e.g.
|
|
103
|
+
* "openai:nonexistent" → the OpenRouter id "openai/gpt-5.2:exacto") and
|
|
104
|
+
* silently drops the requested id.
|
|
105
|
+
*/
|
|
106
|
+
function isProviderSeparator(spec: string, idx: number, providers: readonly string[]): boolean {
|
|
107
|
+
const provider = spec.slice(0, idx);
|
|
108
|
+
if (/[/:]/.test(provider)) return false;
|
|
109
|
+
const needle = provider.toLowerCase();
|
|
110
|
+
return providers.some((p) => p.toLowerCase() === needle);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The three matching rules, shared by both resolution passes. */
|
|
114
|
+
function matchBase(base: string, candidates: ModelCandidate[]): ModelResolution {
|
|
54
115
|
if (base.length === 0) {
|
|
55
|
-
return { ok: false, error: "no-match", candidates: candidates.map(label)
|
|
116
|
+
return { ok: false, error: "no-match", candidates: candidates.map(label) };
|
|
56
117
|
}
|
|
57
118
|
|
|
58
119
|
// 1) provider-qualified exact match
|
|
@@ -61,17 +122,17 @@ export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): Mo
|
|
|
61
122
|
const hit = candidates.find(
|
|
62
123
|
(c) => c.provider === qualified.provider && c.id === qualified.id,
|
|
63
124
|
);
|
|
64
|
-
if (hit) return { ok: true, provider: hit.provider, id: hit.id
|
|
125
|
+
if (hit) return { ok: true, provider: hit.provider, id: hit.id };
|
|
65
126
|
// Fall through: maybe the whole thing is a bare id containing a separator.
|
|
66
127
|
}
|
|
67
128
|
|
|
68
129
|
// 2) bare-id exact match (must be unique across providers)
|
|
69
130
|
const exact = candidates.filter((c) => c.id === base);
|
|
70
131
|
if (exact.length === 1) {
|
|
71
|
-
return { ok: true, provider: exact[0].provider, id: exact[0].id
|
|
132
|
+
return { ok: true, provider: exact[0].provider, id: exact[0].id };
|
|
72
133
|
}
|
|
73
134
|
if (exact.length > 1) {
|
|
74
|
-
return { ok: false, error: "ambiguous", candidates: exact.map(label)
|
|
135
|
+
return { ok: false, error: "ambiguous", candidates: exact.map(label) };
|
|
75
136
|
}
|
|
76
137
|
|
|
77
138
|
// 3) case-insensitive substring on id or display name
|
|
@@ -82,26 +143,83 @@ export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): Mo
|
|
|
82
143
|
(c.name !== undefined && c.name.toLowerCase().includes(needle)),
|
|
83
144
|
);
|
|
84
145
|
if (fuzzy.length === 1) {
|
|
85
|
-
return { ok: true, provider: fuzzy[0].provider, id: fuzzy[0].id
|
|
146
|
+
return { ok: true, provider: fuzzy[0].provider, id: fuzzy[0].id };
|
|
86
147
|
}
|
|
87
148
|
if (fuzzy.length > 1) {
|
|
88
|
-
return { ok: false, error: "ambiguous", candidates: fuzzy.map(label)
|
|
149
|
+
return { ok: false, error: "ambiguous", candidates: fuzzy.map(label) };
|
|
150
|
+
}
|
|
151
|
+
return { ok: false, error: "no-match", candidates: candidates.map(label) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): ModelResolution {
|
|
155
|
+
const trimmed = spec.trim();
|
|
156
|
+
const providers = knownProviders(candidates);
|
|
157
|
+
const lastColon = trimmed.lastIndexOf(":");
|
|
158
|
+
// Known-provider / first-colon detection runs BEFORE valid thinking-suffix
|
|
159
|
+
// splitting. Otherwise a recognized level (off/max, newly in this PR's
|
|
160
|
+
// parity list) strips the separator, matchBase("openai") fuzzy-matches an
|
|
161
|
+
// unrelated id, and the retry guard at the bottom never sees the spec.
|
|
162
|
+
const skipThinkingSplit =
|
|
163
|
+
lastColon > 0 &&
|
|
164
|
+
lastColon < trimmed.length - 1 &&
|
|
165
|
+
isProviderSeparator(trimmed, lastColon, providers);
|
|
166
|
+
const { base, thinking } = skipThinkingSplit
|
|
167
|
+
? { base: trimmed, thinking: undefined }
|
|
168
|
+
: splitThinkingSuffix(trimmed);
|
|
169
|
+
const first = matchBase(base, candidates);
|
|
170
|
+
if (first.ok || thinking !== undefined || first.error === "ambiguous") {
|
|
171
|
+
// Success, a valid thinking suffix, or an ambiguity: all final. Ambiguity
|
|
172
|
+
// is semantic, not syntax — report it instead of adapting past it.
|
|
173
|
+
return { ...first, thinking };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Unknown trailing ":suffix" on a no-match: mirror pi core's
|
|
177
|
+
// parseModelPattern(allowInvalidThinkingLevelFallback) — strip the suffix,
|
|
178
|
+
// retry, and adapt with a warning instead of failing the whole call. The
|
|
179
|
+
// retry only fires when the full spec matched nothing, so literal ids
|
|
180
|
+
// containing a colon (OpenRouter ":exacto") are unaffected.
|
|
181
|
+
const idx = trimmed.lastIndexOf(":");
|
|
182
|
+
if (idx <= 0 || idx === trimmed.length - 1) return first;
|
|
183
|
+
if (isProviderSeparator(trimmed, idx, providers)) return first;
|
|
184
|
+
const suffix = trimmed.slice(idx + 1);
|
|
185
|
+
const retriedBase = trimmed.slice(0, idx);
|
|
186
|
+
const retried = matchBase(retriedBase, candidates);
|
|
187
|
+
if (retried.ok) {
|
|
188
|
+
return {
|
|
189
|
+
...retried,
|
|
190
|
+
warning:
|
|
191
|
+
`unknown thinking level "${suffix}" in model spec "${trimmed}"; ` +
|
|
192
|
+
`using the model's default thinking ` +
|
|
193
|
+
`(valid levels: ${VALID_THINKING_LEVELS.join(", ")})`,
|
|
194
|
+
};
|
|
89
195
|
}
|
|
90
|
-
return {
|
|
196
|
+
return {
|
|
197
|
+
...retried,
|
|
198
|
+
suffixHint:
|
|
199
|
+
`":${suffix}" is not a valid thinking level ` +
|
|
200
|
+
`(valid: ${VALID_THINKING_LEVELS.join(", ")}); ` +
|
|
201
|
+
`retried without it as "${retriedBase}"`,
|
|
202
|
+
};
|
|
91
203
|
}
|
|
92
204
|
|
|
93
205
|
/** Render a resolution failure as an actionable error message. */
|
|
94
206
|
export function modelResolutionError(spec: string, res: ModelResolution & { ok: false }): string {
|
|
95
207
|
const list = res.candidates.slice(0, 20).join(", ");
|
|
96
208
|
const more = res.candidates.length > 20 ? `, … +${res.candidates.length - 20} more` : "";
|
|
209
|
+
const hint = res.suffixHint ? ` (${res.suffixHint})` : "";
|
|
97
210
|
if (res.error === "ambiguous") {
|
|
211
|
+
// With a suffixHint the ambiguity belongs to the retried base, not the
|
|
212
|
+
// full spec — say so, and name the dropped suffix either way.
|
|
98
213
|
return (
|
|
99
|
-
|
|
214
|
+
(res.suffixHint
|
|
215
|
+
? `model spec "${spec}" is ambiguous on the retried base`
|
|
216
|
+
: `model spec "${spec}" is ambiguous`) +
|
|
217
|
+
` (matches: ${list}${more}).${hint} ` +
|
|
100
218
|
`Qualify with "provider/<id>".`
|
|
101
219
|
);
|
|
102
220
|
}
|
|
103
221
|
return (
|
|
104
|
-
`model spec "${spec}" matched nothing
|
|
222
|
+
`model spec "${spec}" matched nothing.${hint} ` +
|
|
105
223
|
`Available: ${list || "none"}${more}. ` +
|
|
106
224
|
`Use subagent({action:"models"}) to list selectable models.`
|
|
107
225
|
);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overrun wake inputs (design doc §4.6 child lifecycle): which foreground
|
|
3
|
+
* shell each child is blocked on, and the builder that turns a runner
|
|
4
|
+
* overrun tick into the wake payload.
|
|
5
|
+
*
|
|
6
|
+
* Zero pi dependency; the output-file stat is injected so tests can drive it.
|
|
7
|
+
*/
|
|
8
|
+
import { formatSubagentOverrunBatch, type SubagentOverrunInfo } from "../format";
|
|
9
|
+
import type { FormattedWake, OverrunShell } from "../wake";
|
|
10
|
+
import type { ConversationTurn } from "./types";
|
|
11
|
+
|
|
12
|
+
export interface ChildShell {
|
|
13
|
+
taskId: string;
|
|
14
|
+
command: string;
|
|
15
|
+
startedAt: number;
|
|
16
|
+
outputPath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Output that changed within this window counts as growing on a first reading. */
|
|
20
|
+
export const OUTPUT_GROWING_WINDOW_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Foreground shells per child. Child bash records a shell when the manager
|
|
24
|
+
* starts it and clears it when the call returns or throws; a child runs at
|
|
25
|
+
* most one foreground shell at a time (no background bash in children).
|
|
26
|
+
*/
|
|
27
|
+
export class ChildShellTracker {
|
|
28
|
+
private readonly shells = new Map<string, ChildShell>();
|
|
29
|
+
/** Output size at the previous overrun reading, per task. */
|
|
30
|
+
private readonly sizes = new Map<string, number>();
|
|
31
|
+
|
|
32
|
+
start(childId: string, shell: ChildShell): void {
|
|
33
|
+
this.shells.set(childId, shell);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
end(childId: string, taskId: string): void {
|
|
37
|
+
if (this.shells.get(childId)?.taskId === taskId) this.shells.delete(childId);
|
|
38
|
+
this.sizes.delete(taskId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
current(childId: string): ChildShell | undefined {
|
|
42
|
+
return this.shells.get(childId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Record a size reading and return the previous one for the same task. */
|
|
46
|
+
swapSize(taskId: string, size: number): number | undefined {
|
|
47
|
+
const previous = this.sizes.get(taskId);
|
|
48
|
+
this.sizes.set(taskId, size);
|
|
49
|
+
return previous;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the runner knows at an overrun tick. */
|
|
54
|
+
export interface OverrunTick {
|
|
55
|
+
childId: string;
|
|
56
|
+
elapsedMs: number;
|
|
57
|
+
budgetMs: number;
|
|
58
|
+
reminder: number;
|
|
59
|
+
nextReminderMs: number;
|
|
60
|
+
lastEventAt: number;
|
|
61
|
+
/** Time left until the opt-in hard ceiling aborts the child; null when none is set. */
|
|
62
|
+
hardRemainingMs: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface OverrunInfoDeps {
|
|
66
|
+
now: () => number;
|
|
67
|
+
/** Run id and display name; undefined once the child's run is gone. */
|
|
68
|
+
lookupChild: (childId: string) => { runId: string; name: string } | undefined;
|
|
69
|
+
conversation: (childId: string) => ConversationTurn[];
|
|
70
|
+
shells: ChildShellTracker;
|
|
71
|
+
/** Output file size and mtime; null when it cannot be read. */
|
|
72
|
+
stat: (path: string) => { size: number; mtimeMs: number } | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lastActivityText(turns: ConversationTurn[]): string {
|
|
76
|
+
const last = turns[turns.length - 1];
|
|
77
|
+
return last ? `${last.role}: ${last.text}` : "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function describeShell(deps: OverrunInfoDeps, shell: ChildShell): OverrunShell {
|
|
81
|
+
const now = deps.now();
|
|
82
|
+
const st = deps.stat(shell.outputPath);
|
|
83
|
+
let outputBytes: number | null = null;
|
|
84
|
+
let outputIdleMs: number | null = null;
|
|
85
|
+
let growing: boolean | null = null;
|
|
86
|
+
if (st) {
|
|
87
|
+
outputBytes = st.size;
|
|
88
|
+
outputIdleMs = Math.max(0, now - st.mtimeMs);
|
|
89
|
+
const previous = deps.shells.swapSize(shell.taskId, st.size);
|
|
90
|
+
growing = (previous !== undefined && st.size > previous) || outputIdleMs < OUTPUT_GROWING_WINDOW_MS;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
taskId: shell.taskId,
|
|
94
|
+
command: shell.command,
|
|
95
|
+
elapsedMs: Math.max(0, now - shell.startedAt),
|
|
96
|
+
outputPath: shell.outputPath,
|
|
97
|
+
outputBytes,
|
|
98
|
+
outputIdleMs,
|
|
99
|
+
growing,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildOverrunInfo(deps: OverrunInfoDeps, tick: OverrunTick): SubagentOverrunInfo | undefined {
|
|
104
|
+
const child = deps.lookupChild(tick.childId);
|
|
105
|
+
if (!child) return undefined;
|
|
106
|
+
const shell = deps.shells.current(tick.childId);
|
|
107
|
+
return {
|
|
108
|
+
runId: child.runId,
|
|
109
|
+
childId: tick.childId,
|
|
110
|
+
name: child.name,
|
|
111
|
+
elapsedMs: tick.elapsedMs,
|
|
112
|
+
budgetMs: tick.budgetMs,
|
|
113
|
+
reminder: tick.reminder,
|
|
114
|
+
nextReminderMs: tick.nextReminderMs,
|
|
115
|
+
...(tick.hardRemainingMs !== null ? { hardCeilingMs: tick.hardRemainingMs } : {}),
|
|
116
|
+
lastActivity: {
|
|
117
|
+
agoMs: Math.max(0, deps.now() - tick.lastEventAt),
|
|
118
|
+
text: lastActivityText(deps.conversation(tick.childId)),
|
|
119
|
+
},
|
|
120
|
+
...(shell ? { shell: describeShell(deps, shell) } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface OverrunNotifierDeps {
|
|
125
|
+
now: () => number;
|
|
126
|
+
registry: {
|
|
127
|
+
list(): { runId: string; children: { childId: string; name: string }[] }[];
|
|
128
|
+
handle(childId: string): { conversation(): ConversationTurn[]; collectDueOverrun?(now: number): OverrunTick | undefined } | undefined;
|
|
129
|
+
};
|
|
130
|
+
shells: ChildShellTracker;
|
|
131
|
+
stat: OverrunInfoDeps["stat"];
|
|
132
|
+
/** Parent wake (NotifyCenter.notify). */
|
|
133
|
+
notify: (wake: FormattedWake) => void;
|
|
134
|
+
logEvent: (type: string, fields: Record<string, unknown>) => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The runner's onOverrun: build the payload, log `agent.overrun`, wake the
|
|
139
|
+
* parent. A child whose run is already gone produces neither.
|
|
140
|
+
*/
|
|
141
|
+
export function createOverrunNotifier(deps: OverrunNotifierDeps): (tick: OverrunTick) => void {
|
|
142
|
+
const infoDeps: OverrunInfoDeps = {
|
|
143
|
+
now: deps.now,
|
|
144
|
+
lookupChild: (childId) => {
|
|
145
|
+
for (const run of deps.registry.list()) {
|
|
146
|
+
const child = run.children.find((c) => c.childId === childId);
|
|
147
|
+
if (child) return { runId: run.runId, name: child.name };
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
},
|
|
151
|
+
conversation: (childId) => deps.registry.handle(childId)?.conversation() ?? [],
|
|
152
|
+
shells: deps.shells,
|
|
153
|
+
stat: deps.stat,
|
|
154
|
+
};
|
|
155
|
+
return (tick) => {
|
|
156
|
+
const infos: SubagentOverrunInfo[] = [];
|
|
157
|
+
const primary = buildOverrunInfo(infoDeps, tick);
|
|
158
|
+
if (!primary) return;
|
|
159
|
+
infos.push(primary);
|
|
160
|
+
const now = deps.now();
|
|
161
|
+
for (const run of deps.registry.list()) {
|
|
162
|
+
for (const child of run.children) {
|
|
163
|
+
if (child.childId === tick.childId) continue;
|
|
164
|
+
const due = deps.registry.handle(child.childId)?.collectDueOverrun?.(now);
|
|
165
|
+
if (due) {
|
|
166
|
+
const info = buildOverrunInfo(infoDeps, due);
|
|
167
|
+
if (info) infos.push(info);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const info of infos) {
|
|
172
|
+
const shell = info.shell;
|
|
173
|
+
deps.logEvent("agent.overrun", {
|
|
174
|
+
child_id: info.childId,
|
|
175
|
+
run_id: info.runId,
|
|
176
|
+
reminder: info.reminder,
|
|
177
|
+
elapsed_ms: info.elapsedMs,
|
|
178
|
+
budget_ms: info.budgetMs,
|
|
179
|
+
...(shell
|
|
180
|
+
? {
|
|
181
|
+
shell_task_id: shell.taskId,
|
|
182
|
+
shell_elapsed_ms: shell.elapsedMs,
|
|
183
|
+
...(shell.outputBytes !== null ? { output_bytes: shell.outputBytes } : {}),
|
|
184
|
+
...(shell.growing !== null ? { growing: shell.growing } : {}),
|
|
185
|
+
}
|
|
186
|
+
: {}),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
deps.notify(formatSubagentOverrunBatch(infos));
|
|
190
|
+
};
|
|
191
|
+
}
|