pi-famulus 0.1.2 → 0.1.3
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/package.json +28 -11
- package/src/agents/definition.ts +12 -6
- package/src/behavior-guidelines.ts +1 -0
- package/src/clock.ts +5 -0
- package/src/config.ts +25 -1
- package/src/format.ts +62 -1
- package/src/index.ts +36 -1
- package/src/manager-client.ts +1 -1
- package/src/notify.ts +7 -7
- package/src/subagent/child-bash.ts +59 -42
- package/src/subagent/model-spec.ts +134 -16
- package/src/subagent/overrun.ts +176 -0
- package/src/subagent/pi-runtime.ts +2 -2
- package/src/subagent/registry.ts +81 -3
- package/src/subagent/runner.ts +242 -51
- package/src/subagent/tool.ts +83 -10
- package/src/subagent/types.ts +11 -2
- package/src/thinking-levels.ts +26 -0
- package/src/tui/message-renderers.ts +36 -2
- package/src/wake-delivery.ts +137 -0
- package/src/wake.ts +238 -4
|
@@ -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,176 @@
|
|
|
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 { formatSubagentOverrun, 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[] } | 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 info = buildOverrunInfo(infoDeps, tick);
|
|
157
|
+
if (!info) return;
|
|
158
|
+
const shell = info.shell;
|
|
159
|
+
deps.logEvent("agent.overrun", {
|
|
160
|
+
child_id: info.childId,
|
|
161
|
+
run_id: info.runId,
|
|
162
|
+
reminder: info.reminder,
|
|
163
|
+
elapsed_ms: info.elapsedMs,
|
|
164
|
+
budget_ms: info.budgetMs,
|
|
165
|
+
...(shell
|
|
166
|
+
? {
|
|
167
|
+
shell_task_id: shell.taskId,
|
|
168
|
+
shell_elapsed_ms: shell.elapsedMs,
|
|
169
|
+
...(shell.outputBytes !== null ? { output_bytes: shell.outputBytes } : {}),
|
|
170
|
+
...(shell.growing !== null ? { growing: shell.growing } : {}),
|
|
171
|
+
}
|
|
172
|
+
: {}),
|
|
173
|
+
});
|
|
174
|
+
deps.notify(formatSubagentOverrun(info));
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -137,13 +137,13 @@ function resolveModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel
|
|
|
137
137
|
if (!res.ok) throw new Error(modelResolutionError(req.model, res));
|
|
138
138
|
const model = findModel(res.provider, res.id);
|
|
139
139
|
if (!model) throw new Error(`model ${res.provider}/${res.id} is not usable in this session`);
|
|
140
|
-
return { model, thinkingOverride: res.thinking };
|
|
140
|
+
return { model, thinkingOverride: res.thinking, warning: res.warning };
|
|
141
141
|
}
|
|
142
142
|
if (req.agent.model !== undefined) {
|
|
143
143
|
const res = resolveModelSpec(req.agent.model, candidates);
|
|
144
144
|
if (res.ok) {
|
|
145
145
|
const model = findModel(res.provider, res.id);
|
|
146
|
-
if (model) return { model, thinkingOverride: res.thinking };
|
|
146
|
+
if (model) return { model, thinkingOverride: res.thinking, warning: res.warning };
|
|
147
147
|
}
|
|
148
148
|
return {
|
|
149
149
|
model: deps.getParentModel(),
|
package/src/subagent/registry.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* - lineage: v1 = same runId.
|
|
11
11
|
*
|
|
12
12
|
* Wiring contract: the runner must be constructed with
|
|
13
|
-
* `acquire: (req) => registry.admitChild(req.childId)` so every
|
|
14
|
-
* (initial start and resume) passes through admission.
|
|
13
|
+
* `acquire: (req, ticket) => registry.admitChild(req.childId, ticket)` so every
|
|
14
|
+
* generation (initial start and resume) passes through admission. Forward the
|
|
15
|
+
* ticket: without it admitChild falls back to the handle's current status and
|
|
16
|
+
* a stale queued request can admit a newer resume. startChild() wires the
|
|
15
17
|
* first generation's result; admitChild() wires subsequent generations.
|
|
16
18
|
*
|
|
17
19
|
* Zero pi dependency.
|
|
@@ -47,6 +49,8 @@ export interface RunRecord {
|
|
|
47
49
|
result?: ChildResult;
|
|
48
50
|
startedAt: number;
|
|
49
51
|
endedAt?: number;
|
|
52
|
+
/** User turns so far: 1 for the launch, +1 per accepted resume. */
|
|
53
|
+
turn?: number;
|
|
50
54
|
}[];
|
|
51
55
|
status: "running" | "completed" | "partial" | "failed" | "interrupted";
|
|
52
56
|
createdAt: number;
|
|
@@ -109,6 +113,7 @@ interface InternalChild {
|
|
|
109
113
|
result?: ChildResult;
|
|
110
114
|
startedAt: number;
|
|
111
115
|
endedAt?: number;
|
|
116
|
+
turn: number;
|
|
112
117
|
handle?: ChildHandle;
|
|
113
118
|
shouldStart?: () => boolean;
|
|
114
119
|
}
|
|
@@ -157,6 +162,10 @@ class FailedChildHandle implements ChildHandle {
|
|
|
157
162
|
return Promise.reject(this.dead());
|
|
158
163
|
}
|
|
159
164
|
|
|
165
|
+
extend(): never {
|
|
166
|
+
throw this.dead();
|
|
167
|
+
}
|
|
168
|
+
|
|
160
169
|
interrupt(): Promise<void> {
|
|
161
170
|
return Promise.resolve();
|
|
162
171
|
}
|
|
@@ -303,6 +312,7 @@ export class SubagentRegistry implements RunRegistry {
|
|
|
303
312
|
agent: info.agent,
|
|
304
313
|
status: "pending",
|
|
305
314
|
startedAt: this.now(),
|
|
315
|
+
turn: 1,
|
|
306
316
|
};
|
|
307
317
|
run.children.push(child);
|
|
308
318
|
this.children.set(child.childId, child);
|
|
@@ -360,7 +370,7 @@ export class SubagentRegistry implements RunRegistry {
|
|
|
360
370
|
* and wires result settlement for resumed generations. Returns the slot
|
|
361
371
|
* releaser.
|
|
362
372
|
*/
|
|
363
|
-
async admitChild(childId: string): Promise<() => void> {
|
|
373
|
+
async admitChild(childId: string, ticket?: { current: () => boolean }): Promise<() => void> {
|
|
364
374
|
await this.acquireSlot();
|
|
365
375
|
let released = false;
|
|
366
376
|
const release = () => {
|
|
@@ -377,6 +387,15 @@ export class SubagentRegistry implements RunRegistry {
|
|
|
377
387
|
release();
|
|
378
388
|
throw new ChildCancelledError();
|
|
379
389
|
}
|
|
390
|
+
// The generation that queued this request settled while it waited
|
|
391
|
+
// (interrupt/dispose; maybe a newer resume is queued behind other
|
|
392
|
+
// children): hand the slot on, without touching the child's status.
|
|
393
|
+
// Without a ticket, fall back to the handle's status.
|
|
394
|
+
const stale = ticket ? !ticket.current() : (child.handle?.status() ?? "pending") !== "pending";
|
|
395
|
+
if (stale) {
|
|
396
|
+
release();
|
|
397
|
+
throw new ChildCancelledError("settled while queued");
|
|
398
|
+
}
|
|
380
399
|
this.transitionChild(child, "running");
|
|
381
400
|
const handle = child.handle;
|
|
382
401
|
if (handle) {
|
|
@@ -387,6 +406,64 @@ export class SubagentRegistry implements RunRegistry {
|
|
|
387
406
|
return release;
|
|
388
407
|
}
|
|
389
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Resume an ended child: a new user turn on its session. Returns once the
|
|
411
|
+
* request is accepted; admission runs in the background (design.md §4.6
|
|
412
|
+
* "Child lifecycle", resume rows). Until a slot is granted the child is
|
|
413
|
+
* pending. `queuedBehind` is null when a slot was free at request time,
|
|
414
|
+
* otherwise how many children were already waiting for one.
|
|
415
|
+
*/
|
|
416
|
+
async resumeChild(
|
|
417
|
+
childId: string,
|
|
418
|
+
message: string,
|
|
419
|
+
opts: { timeoutMs?: number } = {},
|
|
420
|
+
): Promise<{ queuedBehind: number | null }> {
|
|
421
|
+
const child = this.children.get(childId);
|
|
422
|
+
if (!child) throw new Error(`unknown child ${childId}`);
|
|
423
|
+
const handle = child.handle;
|
|
424
|
+
if (!handle) throw new Error(`subagent ${childId} has no live session to resume`);
|
|
425
|
+
const queuedBehind = this.activeSlots >= this.maxChildren ? this.slotWaiters.length : null;
|
|
426
|
+
const previous = {
|
|
427
|
+
status: child.status,
|
|
428
|
+
result: child.result,
|
|
429
|
+
startedAt: child.startedAt,
|
|
430
|
+
endedAt: child.endedAt,
|
|
431
|
+
};
|
|
432
|
+
const accepted = handle.resume(message, opts);
|
|
433
|
+
// Mark the child queued before awaiting: with a free slot, admitChild's
|
|
434
|
+
// continuation can run first and move it to running, which must win.
|
|
435
|
+
child.status = "pending";
|
|
436
|
+
child.result = undefined;
|
|
437
|
+
child.endedAt = undefined;
|
|
438
|
+
child.startedAt = this.now();
|
|
439
|
+
child.turn += 1;
|
|
440
|
+
try {
|
|
441
|
+
await accepted;
|
|
442
|
+
} catch (err) {
|
|
443
|
+
// Undo this attempt's +1 even if a concurrent accepted resume has
|
|
444
|
+
// already moved the child to running (the pending guard would skip).
|
|
445
|
+
child.turn -= 1;
|
|
446
|
+
if (child.status === "pending") {
|
|
447
|
+
child.status = previous.status;
|
|
448
|
+
child.result = previous.result;
|
|
449
|
+
child.startedAt = previous.startedAt;
|
|
450
|
+
child.endedAt = previous.endedAt;
|
|
451
|
+
}
|
|
452
|
+
throw err;
|
|
453
|
+
}
|
|
454
|
+
// Wired at request time, not admission: a turn that never gets a slot
|
|
455
|
+
// (interrupt/dispose while queued, abort drain bound) still settles here.
|
|
456
|
+
handle.result.then((result) => this.settleChild(childId, result));
|
|
457
|
+
if (child.status === "pending") {
|
|
458
|
+
const run = this.runs.get(child.runId);
|
|
459
|
+
if (run) {
|
|
460
|
+
this.recomputeRunStatus(run);
|
|
461
|
+
this.emit(run);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { queuedBehind };
|
|
465
|
+
}
|
|
466
|
+
|
|
390
467
|
/** Current generation's result promise for a child (post-resume aware). */
|
|
391
468
|
getResult(childId: string): Promise<ChildResult> | undefined {
|
|
392
469
|
return this.children.get(childId)?.handle?.result;
|
|
@@ -570,6 +647,7 @@ function snapshot(run: InternalRun): RunRecord {
|
|
|
570
647
|
result: c.result,
|
|
571
648
|
startedAt: c.startedAt,
|
|
572
649
|
endedAt: c.endedAt,
|
|
650
|
+
turn: c.turn,
|
|
573
651
|
})),
|
|
574
652
|
};
|
|
575
653
|
}
|