pi-famulus 0.1.9 → 0.2.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/README.md +4 -4
- package/package.json +8 -8
- package/src/bounded-output.ts +17 -1
- package/src/child-mode.ts +214 -0
- package/src/comms/comms.ts +18 -13
- package/src/comms/registry-host.ts +10 -0
- package/src/comms/tools.ts +9 -2
- package/src/comms/types.ts +9 -3
- package/src/config.ts +16 -0
- package/src/format.ts +4 -0
- package/src/index.ts +391 -36
- package/src/manager-client.ts +147 -5
- package/src/notify.ts +1 -0
- package/src/pi-command.ts +123 -0
- package/src/prompts.generated.ts +8 -9
- package/src/subagent/agent-records.ts +51 -2
- package/src/subagent/hosted-session.ts +314 -0
- package/src/subagent/overrun.ts +11 -0
- package/src/subagent/pi-runtime.ts +4 -0
- package/src/subagent/registry.ts +78 -0
- package/src/subagent/runner.ts +94 -17
- package/src/subagent/tool.ts +79 -9
- package/src/subagent/types.ts +10 -1
- package/src/tui/attach-command.ts +164 -0
- package/src/work-index.ts +6 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChildSessionAdapter over a daemon-hosted `pi --mode rpc` process.
|
|
3
|
+
* The runner's timers stay; only the session lives in another process.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { ManagerClient, ManagerEvent } from "../manager-client";
|
|
9
|
+
import { parentResourceArgs, resolvePiArgv } from "../pi-command";
|
|
10
|
+
import { resolveChildModel, type PiRuntimeDeps } from "./pi-runtime";
|
|
11
|
+
import type {
|
|
12
|
+
ChildRunRequest,
|
|
13
|
+
ChildSessionAdapter,
|
|
14
|
+
ChildSessionEvent,
|
|
15
|
+
ChildTokenUsage,
|
|
16
|
+
CreateSessionFn,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
export interface HostedSessionDeps extends PiRuntimeDeps {
|
|
20
|
+
getClient: () => ManagerClient | null;
|
|
21
|
+
getHome: () => string;
|
|
22
|
+
/** This extension's entry file, passed to the child with -e. */
|
|
23
|
+
extensionEntry: string;
|
|
24
|
+
piCommand?: string | null;
|
|
25
|
+
trustProject?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface HostingView {
|
|
29
|
+
residency: string;
|
|
30
|
+
activity: string;
|
|
31
|
+
driver: string;
|
|
32
|
+
cacheExpiresAt?: number;
|
|
33
|
+
sessionFile?: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn {
|
|
38
|
+
return async (req) => {
|
|
39
|
+
const client = deps.getClient();
|
|
40
|
+
if (!client || !client.isAvailable()) {
|
|
41
|
+
throw new Error("pi-famulus daemon is not connected; a subagent needs it to run pi --mode rpc.");
|
|
42
|
+
}
|
|
43
|
+
client.requireHostedProtocol();
|
|
44
|
+
const pi = resolvePiArgv(deps.piCommand);
|
|
45
|
+
if ("error" in pi) throw new Error(pi.error);
|
|
46
|
+
const model = resolveChildModel(deps, req);
|
|
47
|
+
const thinking =
|
|
48
|
+
model.thinkingOverride ?? req.agent.thinking ?? deps.getParentThinkingLevel() ?? "off";
|
|
49
|
+
const cwd = deps.getCwd();
|
|
50
|
+
const promptPath = join(deps.getHome(), "agents", "prompts");
|
|
51
|
+
mkdirSync(promptPath, { recursive: true });
|
|
52
|
+
const promptFile = join(promptPath, `${req.childId}.md`);
|
|
53
|
+
writeFileSync(promptFile, req.agent.systemPrompt);
|
|
54
|
+
const inherited = parentResourceArgs();
|
|
55
|
+
const extensionPaths = inherited.filter((_, i) => inherited[i - 1] === "-e");
|
|
56
|
+
const alreadyLoadsUs = extensionPaths.some((path) => path === deps.extensionEntry);
|
|
57
|
+
const trustInherited = inherited.some((arg) => arg === "-a" || arg === "--approve" || arg === "-na" || arg === "--no-approve");
|
|
58
|
+
const argv = [
|
|
59
|
+
...pi.argv,
|
|
60
|
+
"--mode", "rpc",
|
|
61
|
+
"--session-id", req.childId,
|
|
62
|
+
"--name", `${req.agent.name}: ${req.name}`,
|
|
63
|
+
...inherited,
|
|
64
|
+
...(alreadyLoadsUs ? [] : ["-e", deps.extensionEntry]),
|
|
65
|
+
...(model.model ? ["--model", `${model.model.provider}/${model.model.id}`] : []),
|
|
66
|
+
"--thinking", String(thinking),
|
|
67
|
+
"--append-system-prompt", promptFile,
|
|
68
|
+
...(trustInherited ? [] : [deps.getProjectTrusted?.() ? "-a" : "-na"]),
|
|
69
|
+
...(req.agent.tools ? ["--tools", hostedChildTools(req.agent.tools).join(",")] : []),
|
|
70
|
+
];
|
|
71
|
+
const env = { ...process.env } as Record<string, string>;
|
|
72
|
+
if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = hostedChildTools(req.agent.tools).join(",");
|
|
73
|
+
env.PI_FAMULUS_CHILD_RUN = req.runId;
|
|
74
|
+
env.PI_FAMULUS_CHILD_NAME = req.name;
|
|
75
|
+
const spawned = await client.agentSpawn({
|
|
76
|
+
childId: req.childId,
|
|
77
|
+
runId: req.runId,
|
|
78
|
+
name: req.name,
|
|
79
|
+
argv,
|
|
80
|
+
cwd,
|
|
81
|
+
env,
|
|
82
|
+
});
|
|
83
|
+
const warning = model.warning;
|
|
84
|
+
await client.agentWatch(req.childId);
|
|
85
|
+
const session = new HostedRpcSession(client, req.childId, {
|
|
86
|
+
warning,
|
|
87
|
+
resolvedModel: model.model ? `${model.model.provider}/${model.model.id}` : undefined,
|
|
88
|
+
effectiveThinkingLevel: typeof thinking === "string" ? thinking : undefined,
|
|
89
|
+
sessionFile: typeof spawned.session_file === "string" ? spawned.session_file : undefined,
|
|
90
|
+
sessionId: typeof spawned.session_id === "string" ? spawned.session_id : req.childId,
|
|
91
|
+
});
|
|
92
|
+
return session;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class HostedRpcSession implements ChildSessionAdapter {
|
|
97
|
+
readonly warning?: string;
|
|
98
|
+
readonly resolvedModel?: string;
|
|
99
|
+
readonly effectiveThinkingLevel?: string;
|
|
100
|
+
private readonly listeners = new Set<(event: ChildSessionEvent) => void>();
|
|
101
|
+
private readonly unsubscribe: () => void;
|
|
102
|
+
private streaming = false;
|
|
103
|
+
private lastText = "";
|
|
104
|
+
private lastFailure: { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
|
|
105
|
+
private turns: { role: string; text: string }[] = [];
|
|
106
|
+
private usage: ChildTokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
107
|
+
private settle: (() => void) | null = null;
|
|
108
|
+
private hostingView: HostingView;
|
|
109
|
+
private disposed = false;
|
|
110
|
+
|
|
111
|
+
constructor(
|
|
112
|
+
private readonly client: ManagerClient,
|
|
113
|
+
private readonly childId: string,
|
|
114
|
+
init: { warning?: string; resolvedModel?: string; effectiveThinkingLevel?: string; sessionFile?: string; sessionId?: string },
|
|
115
|
+
) {
|
|
116
|
+
this.warning = init.warning;
|
|
117
|
+
this.resolvedModel = init.resolvedModel;
|
|
118
|
+
this.effectiveThinkingLevel = init.effectiveThinkingLevel;
|
|
119
|
+
this.hostingView = {
|
|
120
|
+
residency: "warm",
|
|
121
|
+
activity: "starting",
|
|
122
|
+
driver: "parent",
|
|
123
|
+
sessionFile: init.sessionFile,
|
|
124
|
+
sessionId: init.sessionId,
|
|
125
|
+
};
|
|
126
|
+
this.unsubscribe = this.client.onEvent((event) => this.onManagerEvent(event as ManagerEvent & Record<string, unknown>));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
hosting(): HostingView {
|
|
130
|
+
return { ...this.hostingView };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async prompt(text: string): Promise<void> {
|
|
134
|
+
const done = this.armSettle();
|
|
135
|
+
const op = this.streaming ? "prompt_steer" : "prompt";
|
|
136
|
+
await this.client.agentInput(this.childId, op, text, "parent");
|
|
137
|
+
if (!this.streaming) await done;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async steer(text: string): Promise<void> {
|
|
141
|
+
await this.client.agentInput(this.childId, "prompt_steer", text, "parent");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async followUp(text: string): Promise<void> {
|
|
145
|
+
await this.client.agentInput(this.childId, "prompt_follow", text, "parent");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async abort(): Promise<void> {
|
|
149
|
+
await this.client.agentInput(this.childId, "abort", "", "parent");
|
|
150
|
+
this.streaming = false;
|
|
151
|
+
this.settle?.();
|
|
152
|
+
this.settle = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async park(): Promise<void> {
|
|
156
|
+
await this.client.agentPark(this.childId);
|
|
157
|
+
this.hostingView.residency = "parked";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async waitForIdle(): Promise<void> {
|
|
161
|
+
if (!this.streaming) return;
|
|
162
|
+
await this.armSettle();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
getLastAssistantText(): string | undefined {
|
|
166
|
+
return this.lastText || undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
getLastAssistantFailure(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined {
|
|
170
|
+
return this.lastFailure;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
getConversation() {
|
|
174
|
+
return this.turns.map((turn) => ({ ...turn }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
tokenUsage(): ChildTokenUsage {
|
|
178
|
+
return { ...this.usage };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
isStreaming(): boolean {
|
|
182
|
+
return this.streaming;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
subscribe(listener: (event: ChildSessionEvent) => void): () => void {
|
|
186
|
+
this.listeners.add(listener);
|
|
187
|
+
return () => this.listeners.delete(listener);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
dispose(): void {
|
|
191
|
+
if (this.disposed) return;
|
|
192
|
+
this.disposed = true;
|
|
193
|
+
this.unsubscribe();
|
|
194
|
+
this.settle?.();
|
|
195
|
+
this.settle = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private armSettle(): Promise<void> {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
this.settle = resolve;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private onManagerEvent(event: ManagerEvent & Record<string, unknown>): void {
|
|
205
|
+
if (event.child_id !== this.childId) return;
|
|
206
|
+
if (event.event === "agent_state") {
|
|
207
|
+
this.hostingView = {
|
|
208
|
+
residency: String(event.residency ?? this.hostingView.residency),
|
|
209
|
+
activity: String(event.activity ?? this.hostingView.activity),
|
|
210
|
+
driver: String(event.driver ?? this.hostingView.driver),
|
|
211
|
+
cacheExpiresAt: typeof event.cache_expires_at === "number" ? event.cache_expires_at : this.hostingView.cacheExpiresAt,
|
|
212
|
+
sessionFile: typeof event.session_file === "string" ? event.session_file : this.hostingView.sessionFile,
|
|
213
|
+
sessionId: typeof event.session_id === "string" ? event.session_id : this.hostingView.sessionId,
|
|
214
|
+
};
|
|
215
|
+
if (event.activity === "running") this.streaming = true;
|
|
216
|
+
if (event.activity === "idle" || event.activity === "failed") {
|
|
217
|
+
if (event.activity === "failed" && !this.lastFailure) {
|
|
218
|
+
this.lastFailure = { stopReason: "error", errorMessage: "hosted child process failed" };
|
|
219
|
+
}
|
|
220
|
+
this.streaming = false;
|
|
221
|
+
this.settle?.();
|
|
222
|
+
this.settle = null;
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (event.event !== "agent_rpc" || !event.record || typeof event.record !== "object") return;
|
|
227
|
+
const record = event.record as {
|
|
228
|
+
type?: string;
|
|
229
|
+
message?: {
|
|
230
|
+
role?: string;
|
|
231
|
+
content?: { type?: string; text?: string }[];
|
|
232
|
+
usage?: Record<string, unknown>;
|
|
233
|
+
stopReason?: string;
|
|
234
|
+
errorMessage?: string;
|
|
235
|
+
};
|
|
236
|
+
toolCallId?: string;
|
|
237
|
+
toolName?: string;
|
|
238
|
+
args?: unknown;
|
|
239
|
+
isError?: boolean;
|
|
240
|
+
};
|
|
241
|
+
const type = record.type ?? "";
|
|
242
|
+
if (type === "agent_start") this.streaming = true;
|
|
243
|
+
if (type === "agent_settled") {
|
|
244
|
+
this.streaming = false;
|
|
245
|
+
this.settle?.();
|
|
246
|
+
this.settle = null;
|
|
247
|
+
}
|
|
248
|
+
if (type === "message_end" && record.message?.role === "assistant") {
|
|
249
|
+
const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
|
|
250
|
+
if (text) {
|
|
251
|
+
this.lastText = text;
|
|
252
|
+
this.turns.push({ role: "assistant", text });
|
|
253
|
+
}
|
|
254
|
+
const stopReason = record.message.stopReason;
|
|
255
|
+
if (stopReason === "error" || stopReason === "aborted") {
|
|
256
|
+
this.lastFailure = {
|
|
257
|
+
stopReason,
|
|
258
|
+
...(record.message.errorMessage ? { errorMessage: record.message.errorMessage } : {}),
|
|
259
|
+
};
|
|
260
|
+
if (record.message.errorMessage) {
|
|
261
|
+
this.turns.push({ role: "assistant error", text: record.message.errorMessage });
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
this.lastFailure = undefined;
|
|
265
|
+
}
|
|
266
|
+
const usage = record.message.usage;
|
|
267
|
+
if (usage) {
|
|
268
|
+
this.usage.input += num(usage.input);
|
|
269
|
+
this.usage.output += num(usage.output);
|
|
270
|
+
this.usage.cacheRead += num(usage.cacheRead);
|
|
271
|
+
this.usage.cacheWrite += num(usage.cacheWrite);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (type === "message_end" && record.message?.role === "user") {
|
|
275
|
+
const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
|
|
276
|
+
if (text) this.turns.push({ role: "user", text });
|
|
277
|
+
}
|
|
278
|
+
if (type === "tool_execution_end") {
|
|
279
|
+
const name = record.toolName ?? "tool";
|
|
280
|
+
const text = record.args === undefined ? name : `${name} ${JSON.stringify(record.args)}`;
|
|
281
|
+
this.turns.push({
|
|
282
|
+
role: record.isError ? `tool ${name} error` : `tool ${name}`,
|
|
283
|
+
text,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const adapted: ChildSessionEvent = {
|
|
287
|
+
type,
|
|
288
|
+
...(record.message?.role ? { role: record.message.role } : {}),
|
|
289
|
+
...(record.toolCallId ? { toolCallId: record.toolCallId } : {}),
|
|
290
|
+
...(record.toolName ? { toolName: record.toolName } : {}),
|
|
291
|
+
...(record.args !== undefined ? { args: record.args } : {}),
|
|
292
|
+
...(record.isError !== undefined ? { isError: record.isError } : {}),
|
|
293
|
+
};
|
|
294
|
+
for (const listener of this.listeners) listener(adapted);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function num(value: unknown): number {
|
|
299
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const SUPERVISOR_TOOLS = ["contact_supervisor", "agent_message"];
|
|
303
|
+
|
|
304
|
+
/** Agent allowlists still keep the two supervisor tools. */
|
|
305
|
+
export function hostedChildTools(tools: string[]): string[] {
|
|
306
|
+
const kept = new Set(tools);
|
|
307
|
+
for (const name of SUPERVISOR_TOOLS) kept.add(name);
|
|
308
|
+
return [...kept];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Request id for a child→parent decision. */
|
|
312
|
+
export function newCommsRequestId(): string {
|
|
313
|
+
return `rq_${randomUUID().slice(0, 8)}`;
|
|
314
|
+
}
|
package/src/subagent/overrun.ts
CHANGED
|
@@ -38,6 +38,17 @@ export class ChildShellTracker {
|
|
|
38
38
|
this.sizes.delete(taskId);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** A hosted child's shell is observed from the parent session's task_exited. */
|
|
42
|
+
endByTask(taskId: string): void {
|
|
43
|
+
for (const [childId, shell] of this.shells) {
|
|
44
|
+
if (shell.taskId === taskId) {
|
|
45
|
+
this.shells.delete(childId);
|
|
46
|
+
this.sizes.delete(taskId);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
current(childId: string): ChildShell | undefined {
|
|
42
53
|
return this.shells.get(childId);
|
|
43
54
|
}
|
|
@@ -157,6 +157,10 @@ interface ResolvedModel {
|
|
|
157
157
|
* falls back to the parent model with a warning (user-authored files must not
|
|
158
158
|
* hard-fail across machines).
|
|
159
159
|
*/
|
|
160
|
+
export function resolveChildModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel {
|
|
161
|
+
return resolveModel(deps, req);
|
|
162
|
+
}
|
|
163
|
+
|
|
160
164
|
function resolveModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel {
|
|
161
165
|
const candidates = modelCandidates(deps);
|
|
162
166
|
const findModel = (provider: string, id: string): Model | undefined =>
|
package/src/subagent/registry.ts
CHANGED
|
@@ -342,6 +342,84 @@ export class SubagentRegistry implements RunRegistry {
|
|
|
342
342
|
// M3 extensions beyond Appendix B
|
|
343
343
|
// -------------------------------------------------------------------------
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Reconstruct a run/child the previous parent process wrote to disk
|
|
347
|
+
* so resume can reattach by child_id.
|
|
348
|
+
*/
|
|
349
|
+
adoptExisting(info: {
|
|
350
|
+
runId: string;
|
|
351
|
+
childId: string;
|
|
352
|
+
name: string;
|
|
353
|
+
agent: string;
|
|
354
|
+
workKind?: string;
|
|
355
|
+
startedAt?: number;
|
|
356
|
+
prompt?: string;
|
|
357
|
+
}): void {
|
|
358
|
+
if (this.children.has(info.childId)) return;
|
|
359
|
+
let run = this.runs.get(info.runId);
|
|
360
|
+
if (!run) {
|
|
361
|
+
run = {
|
|
362
|
+
runId: info.runId,
|
|
363
|
+
kind: "tasks",
|
|
364
|
+
status: "interrupted",
|
|
365
|
+
createdAt: info.startedAt ?? this.now(),
|
|
366
|
+
children: [],
|
|
367
|
+
};
|
|
368
|
+
this.runs.set(info.runId, run);
|
|
369
|
+
}
|
|
370
|
+
const child: InternalChild = {
|
|
371
|
+
childId: info.childId,
|
|
372
|
+
runId: info.runId,
|
|
373
|
+
name: info.name,
|
|
374
|
+
agent: info.agent,
|
|
375
|
+
workKind: info.workKind ?? "other",
|
|
376
|
+
status: "interrupted",
|
|
377
|
+
startedAt: info.startedAt ?? this.now(),
|
|
378
|
+
endedAt: this.now(),
|
|
379
|
+
turn: 1,
|
|
380
|
+
queueMs: 0,
|
|
381
|
+
prompt: info.prompt,
|
|
382
|
+
};
|
|
383
|
+
run.children.push(child);
|
|
384
|
+
this.children.set(child.childId, child);
|
|
385
|
+
this.emit(run);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Bind a live hosted session to an adopted child without sending its first prompt. */
|
|
389
|
+
async attachChild(req: ChildRunRequest): Promise<ChildHandle> {
|
|
390
|
+
const child = this.children.get(req.childId);
|
|
391
|
+
if (!child) throw new Error(`unknown child ${req.childId} (adoptExisting first)`);
|
|
392
|
+
const runner = this.runner;
|
|
393
|
+
if (!runner?.attach) {
|
|
394
|
+
throw new Error(`subagent ${req.childId} has no live session to resume`);
|
|
395
|
+
}
|
|
396
|
+
child.prompt = req.taskPrompt ?? req.prompt;
|
|
397
|
+
child.preamble = req.agent.systemPrompt || undefined;
|
|
398
|
+
child.model = req.model ?? req.agent.model;
|
|
399
|
+
let handle: ChildHandle;
|
|
400
|
+
try {
|
|
401
|
+
handle = await runner.attach(req, (startingHandle) => {
|
|
402
|
+
child.handle = startingHandle;
|
|
403
|
+
});
|
|
404
|
+
} catch (error) {
|
|
405
|
+
const failed = child.handle as DisposableChildHandle | undefined;
|
|
406
|
+
child.handle = undefined;
|
|
407
|
+
await failed?.dispose?.();
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
if (!this.children.has(child.childId)) return handle;
|
|
411
|
+
child.handle = handle;
|
|
412
|
+
handle.result.then((result) => this.settleChild(child.childId, result));
|
|
413
|
+
const resolved = handle.resolvedModel();
|
|
414
|
+
if (resolved) {
|
|
415
|
+
const thinking = handle.effectiveThinkingLevel?.();
|
|
416
|
+
child.model = `${resolved}${thinking ? `:${thinking}` : ""}`;
|
|
417
|
+
}
|
|
418
|
+
const run = this.runs.get(child.runId);
|
|
419
|
+
if (run) this.emit(run);
|
|
420
|
+
return handle;
|
|
421
|
+
}
|
|
422
|
+
|
|
345
423
|
/** Allocate a pending child inside a run. Returns the child id. */
|
|
346
424
|
addChild(runId: string, info: { name: string; agent: string; workKind?: string }): string {
|
|
347
425
|
const run = this.runs.get(runId);
|
package/src/subagent/runner.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface InProcessRunnerOptions {
|
|
|
87
87
|
stallRetryDelayMs?: number;
|
|
88
88
|
/** Called on every stall detection (before any retry), with the 1-based attempt. */
|
|
89
89
|
onStall?: (childId: string, attempt: number) => void;
|
|
90
|
+
/** Same tool name and arguments three times in a row. */
|
|
91
|
+
onToolLoop?: (childId: string, toolName: string, args: unknown) => void;
|
|
90
92
|
/** Overrun reminder interval (ms). Default 10 minutes. */
|
|
91
93
|
overrunRepeatMs?: number;
|
|
92
94
|
/** Abort ceiling per user turn (ms). 0/absent = off: the soft deadline never aborts. */
|
|
@@ -99,10 +101,10 @@ export interface InProcessRunnerOptions {
|
|
|
99
101
|
/** Shared time source and scheduler. */
|
|
100
102
|
clock?: Clock;
|
|
101
103
|
/**
|
|
102
|
-
* Per-generation admission hook. Awaited before each (re)start
|
|
103
|
-
* releaser
|
|
104
|
-
*
|
|
105
|
-
* Rejecting cancels the generation as {status:"interrupted", error}.
|
|
104
|
+
* Per-generation admission hook. Awaited before each (re)start. The
|
|
105
|
+
* releaser is called with terminal=true when the turn settles, including
|
|
106
|
+
* an interrupted turn: the machine permit is released and the next turn
|
|
107
|
+
* acquires again. Rejecting cancels the generation as {status:"interrupted", error}.
|
|
106
108
|
*/
|
|
107
109
|
acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<(terminal?: boolean) => void>;
|
|
108
110
|
/**
|
|
@@ -116,6 +118,20 @@ function errorMessage(err: unknown): string {
|
|
|
116
118
|
return err instanceof Error ? err.message : String(err);
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
/** 429 / rate-limit text is recorded separately from other model errors. */
|
|
122
|
+
export function classifyModelFailure(message: string): {
|
|
123
|
+
endReason: "model-error" | "rate-limit";
|
|
124
|
+
retryAfter?: number;
|
|
125
|
+
} {
|
|
126
|
+
const rateLimited = /\b429\b|rate[\s-]?limit|too many requests/i.test(message);
|
|
127
|
+
if (!rateLimited) return { endReason: "model-error" };
|
|
128
|
+
const retry = /retry[\s-]?after[:\s]+(\d+)/i.exec(message);
|
|
129
|
+
return {
|
|
130
|
+
endReason: "rate-limit",
|
|
131
|
+
...(retry ? { retryAfter: Number(retry[1]) } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
119
135
|
export class InProcessRunner implements ChildRunner {
|
|
120
136
|
private readonly opts: InProcessRunnerOptions;
|
|
121
137
|
|
|
@@ -129,6 +145,13 @@ export class InProcessRunner implements ChildRunner {
|
|
|
129
145
|
await handle.launch();
|
|
130
146
|
return handle;
|
|
131
147
|
}
|
|
148
|
+
|
|
149
|
+
async attach(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<DisposableChildHandle> {
|
|
150
|
+
const handle = new InProcessChildHandle(req, this.opts);
|
|
151
|
+
onHandle?.(handle);
|
|
152
|
+
await handle.attachIdle();
|
|
153
|
+
return handle;
|
|
154
|
+
}
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
class InProcessChildHandle implements DisposableChildHandle {
|
|
@@ -138,6 +161,9 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
138
161
|
private readonly stallRetries: number;
|
|
139
162
|
private readonly stallRetryDelayMs: number;
|
|
140
163
|
private readonly onStall?: (childId: string, attempt: number) => void;
|
|
164
|
+
private readonly onToolLoop?: (childId: string, toolName: string, args: unknown) => void;
|
|
165
|
+
private lastToolKey: string | null = null;
|
|
166
|
+
private lastToolCount = 0;
|
|
141
167
|
private readonly overrunRepeatMs: number;
|
|
142
168
|
private readonly hardTimeoutMs: number;
|
|
143
169
|
private readonly onOverrun?: (tick: OverrunTick) => void;
|
|
@@ -233,6 +259,7 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
233
259
|
? Math.max(0, Math.floor(rawDelay))
|
|
234
260
|
: DEFAULT_STALL_RETRY_DELAY_MS;
|
|
235
261
|
this.onStall = opts.onStall;
|
|
262
|
+
this.onToolLoop = opts.onToolLoop;
|
|
236
263
|
const rawRepeat = opts.overrunRepeatMs ?? DEFAULT_OVERRUN_REPEAT_MS;
|
|
237
264
|
this.overrunRepeatMs =
|
|
238
265
|
Number.isFinite(rawRepeat) && rawRepeat > 0
|
|
@@ -331,6 +358,27 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
331
358
|
await this.beginGeneration(this.req.prompt, true);
|
|
332
359
|
}
|
|
333
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Bind an already-hosted session so resume() can send the next turn.
|
|
363
|
+
* The previous parent died; this generation is interrupted, not prompted.
|
|
364
|
+
*/
|
|
365
|
+
async attachIdle(): Promise<void> {
|
|
366
|
+
this.sessionCreation = this.createSession(this.req);
|
|
367
|
+
const created =
|
|
368
|
+
await this.sessionCreation;
|
|
369
|
+
this.sessionCreation = null;
|
|
370
|
+
if (this.disposed) return;
|
|
371
|
+
this.session = created;
|
|
372
|
+
this.resolvedModel_ = created.resolvedModel;
|
|
373
|
+
this.unsubscribe = this.session.subscribe((event) => this.handleSessionEvent(event));
|
|
374
|
+
this.settle(this.generation, {
|
|
375
|
+
status: "interrupted",
|
|
376
|
+
text: this.session.getLastAssistantText() ?? "",
|
|
377
|
+
error: "parent session ended",
|
|
378
|
+
durationMs: 0,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
334
382
|
async steer(message: string): Promise<void> {
|
|
335
383
|
if (this.status_ !== "running" || !this.session) {
|
|
336
384
|
throw new Error(`subagent ${this.req.childId} is not running (status: ${this.status_})`);
|
|
@@ -462,8 +510,17 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
462
510
|
await this.beginGeneration(message, false, false, gen);
|
|
463
511
|
}
|
|
464
512
|
|
|
513
|
+
hosting(): { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } | undefined {
|
|
514
|
+
const session = this.session as { hosting?: () => { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } } | null;
|
|
515
|
+
return session?.hosting?.();
|
|
516
|
+
}
|
|
517
|
+
|
|
465
518
|
async interrupt(): Promise<void> {
|
|
466
|
-
if (this.status_ !== "running" && this.status_ !== "pending")
|
|
519
|
+
if (this.status_ !== "running" && this.status_ !== "pending") {
|
|
520
|
+
const session = this.session as { park?: () => Promise<void> } | null;
|
|
521
|
+
if (session?.park) await session.park();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
467
524
|
this.settle(this.generation, {
|
|
468
525
|
status: "interrupted",
|
|
469
526
|
// A pending turn has produced nothing yet; the last assistant text
|
|
@@ -471,11 +528,10 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
471
528
|
text: this.status_ === "pending" ? "" : this.partialText(),
|
|
472
529
|
durationMs: this.now() - this.startedAt,
|
|
473
530
|
});
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
531
|
+
// Resume may be accepted as soon as the interrupted result is visible.
|
|
532
|
+
// Drain this abort just like a timeout/stall abort before prompting again.
|
|
533
|
+
this.abortPromise = this.session ? this.session.abort().catch(() => {}) : null;
|
|
534
|
+
await this.abortPromise;
|
|
479
535
|
}
|
|
480
536
|
|
|
481
537
|
dispose(): void | Promise<void> {
|
|
@@ -563,8 +619,8 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
563
619
|
signal: admissionAbort.signal,
|
|
564
620
|
});
|
|
565
621
|
if (this.disposed || this.isSettled(gen)) {
|
|
566
|
-
//
|
|
567
|
-
release(
|
|
622
|
+
// The turn is over (interrupted or disposed). The next turn acquires again.
|
|
623
|
+
release(true);
|
|
568
624
|
return;
|
|
569
625
|
}
|
|
570
626
|
this.releaseSlot = release;
|
|
@@ -681,11 +737,14 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
681
737
|
if (!this.isCurrent(gen)) return;
|
|
682
738
|
const failure = session?.getLastAssistantFailure?.();
|
|
683
739
|
if (failure) {
|
|
740
|
+
const error = failure.errorMessage?.trim() || `Model stopped with ${failure.stopReason}`;
|
|
741
|
+
const classified = classifyModelFailure(error);
|
|
684
742
|
this.settle(gen, {
|
|
685
743
|
status: "failed",
|
|
686
744
|
text: this.partialText(),
|
|
687
|
-
error
|
|
688
|
-
endReason:
|
|
745
|
+
error,
|
|
746
|
+
endReason: classified.endReason,
|
|
747
|
+
...(classified.retryAfter !== undefined ? { retryAfter: classified.retryAfter } : {}),
|
|
689
748
|
durationMs: this.now() - this.startedAt,
|
|
690
749
|
});
|
|
691
750
|
return;
|
|
@@ -756,8 +815,9 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
756
815
|
result.warning = this.session.warning;
|
|
757
816
|
}
|
|
758
817
|
this.status_ = result.status;
|
|
759
|
-
|
|
760
|
-
|
|
818
|
+
// Every settled turn releases its machine permit, including an
|
|
819
|
+
// interrupted one. The next turn (resume, send, user input) acquires again.
|
|
820
|
+
this.release(true);
|
|
761
821
|
this.notifyActivity();
|
|
762
822
|
this.resolveResult(result);
|
|
763
823
|
}
|
|
@@ -861,7 +921,7 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
861
921
|
this.toolStartedAt = null;
|
|
862
922
|
}
|
|
863
923
|
|
|
864
|
-
private handleSessionEvent(event: { type: string; role?: string; toolCallId?: string; usage?: { input: number; output: number } }): void {
|
|
924
|
+
private handleSessionEvent(event: { type: string; role?: string; toolCallId?: string; toolName?: string; args?: unknown; usage?: { input: number; output: number } }): void {
|
|
865
925
|
const now = this.now();
|
|
866
926
|
const active = this.status_ === "running";
|
|
867
927
|
let currentToolEvent = false;
|
|
@@ -905,6 +965,7 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
905
965
|
if (currentToolEvent) {
|
|
906
966
|
this.toolDepth++;
|
|
907
967
|
this.clearStall();
|
|
968
|
+
this.noteToolCall(event.toolName, event.args);
|
|
908
969
|
}
|
|
909
970
|
return;
|
|
910
971
|
}
|
|
@@ -921,6 +982,22 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
921
982
|
if (this.toolDepth === 0 && !this.decisionPaused) this.armStall(this.generation);
|
|
922
983
|
}
|
|
923
984
|
|
|
985
|
+
private noteToolCall(toolName: string | undefined, args: unknown): void {
|
|
986
|
+
const name = toolName ?? "";
|
|
987
|
+
const key = `${name}\0${JSON.stringify(args ?? null)}`;
|
|
988
|
+
if (this.lastToolKey === key) this.lastToolCount += 1;
|
|
989
|
+
else {
|
|
990
|
+
this.lastToolKey = key;
|
|
991
|
+
this.lastToolCount = 1;
|
|
992
|
+
}
|
|
993
|
+
if (this.lastToolCount !== 3 || !name) return;
|
|
994
|
+
try {
|
|
995
|
+
this.onToolLoop?.(this.req.childId, name, args);
|
|
996
|
+
} catch {
|
|
997
|
+
// loop observers must not break the child
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
924
1001
|
private release(terminal = true): void {
|
|
925
1002
|
const release = this.releaseSlot;
|
|
926
1003
|
if (terminal) this.releaseSlot = null;
|