pi-famulus 0.2.0 → 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/package.json +8 -8
- package/src/bounded-output.ts +17 -1
- package/src/format.ts +4 -0
- package/src/index.ts +264 -7
- package/src/manager-client.ts +3 -1
- package/src/notify.ts +1 -0
- package/src/prompts.generated.ts +1 -1
- package/src/subagent/agent-records.ts +51 -2
- package/src/subagent/hosted-session.ts +57 -11
- package/src/subagent/registry.ts +78 -0
- package/src/subagent/runner.ts +75 -8
- package/src/subagent/tool.ts +10 -1
- package/src/subagent/types.ts +8 -1
- package/src/work-index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-famulus",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Keep your main agent's context clean: subagents on cheaper models, long bash in the background, and monitors that wake the agent. One Rust daemon runs the shells.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -54,12 +54,12 @@
|
|
|
54
54
|
"pi-famulus": "./bin/pi-famulus.js"
|
|
55
55
|
},
|
|
56
56
|
"optionalDependencies": {
|
|
57
|
-
"pi-famulus-linux-x64": "0.2.
|
|
58
|
-
"pi-famulus-linux-arm64": "0.2.
|
|
59
|
-
"pi-famulus-darwin-x64": "0.2.
|
|
60
|
-
"pi-famulus-darwin-arm64": "0.2.
|
|
61
|
-
"pi-famulus-win-x64": "0.2.
|
|
62
|
-
"pi-famulus-win-arm64": "0.2.
|
|
57
|
+
"pi-famulus-linux-x64": "0.2.1",
|
|
58
|
+
"pi-famulus-linux-arm64": "0.2.1",
|
|
59
|
+
"pi-famulus-darwin-x64": "0.2.1",
|
|
60
|
+
"pi-famulus-darwin-arm64": "0.2.1",
|
|
61
|
+
"pi-famulus-win-x64": "0.2.1",
|
|
62
|
+
"pi-famulus-win-arm64": "0.2.1"
|
|
63
63
|
},
|
|
64
64
|
"pi": {
|
|
65
65
|
"extensions": [
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"deps": "nub install --frozen-lockfile --node-linker hoisted",
|
|
71
71
|
"prompts": "node scripts/build-prompts.ts",
|
|
72
72
|
"test": "vitest run",
|
|
73
|
-
"test:compat": "vitest run tests/unit/impl-child-resource-isolation.test.ts tests/unit/impl-child-tools.test.ts tests/unit/impl-child-guidelines.test.ts tests/unit/impl-subagent-lifecycle.test.ts tests/unit/impl-steering-drain.test.ts tests/unit/impl-model-spec.test.ts tests/unit/impl-eval-fixes.test.ts tests/integration/behavior-guidelines.test.ts",
|
|
73
|
+
"test:compat": "vitest run tests/unit/impl-child-resource-isolation.test.ts tests/unit/impl-child-tools.test.ts tests/unit/impl-child-guidelines.test.ts tests/unit/impl-subagent-lifecycle.test.ts tests/unit/impl-steering-drain.test.ts tests/unit/impl-model-spec.test.ts tests/unit/impl-eval-fixes.test.ts tests/unit/impl-hosted-child-failure.test.ts tests/unit/impl-hosted-child-rate-limit.test.ts tests/unit/impl-hosted-child-stuck.test.ts tests/unit/impl-hosted-child-tool-allowlist.test.ts tests/unit/impl-hosted-child-tool-activity.test.ts tests/unit/impl-hosted-child-ui-dialog.test.ts tests/unit/impl-hosted-child-steering.test.ts tests/unit/impl-hosted-docs.test.ts tests/integration/behavior-guidelines.test.ts",
|
|
74
74
|
"typecheck": "tsc --noEmit"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
package/src/bounded-output.ts
CHANGED
|
@@ -7,11 +7,26 @@
|
|
|
7
7
|
* buffer or string on the event loop.
|
|
8
8
|
*/
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
-
import { createWriteStream, type WriteStream } from "node:fs";
|
|
10
|
+
import { createWriteStream, unlinkSync, type WriteStream } from "node:fs";
|
|
11
11
|
import type { Readable } from "node:stream";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
|
+
/** Spill files created this process; unlinked on session shutdown. */
|
|
16
|
+
const sessionSpillFiles = new Set<string>();
|
|
17
|
+
|
|
18
|
+
/** Forget and delete every overflow file this session wrote. */
|
|
19
|
+
export function cleanupSessionSpillFiles(): void {
|
|
20
|
+
for (const path of sessionSpillFiles) {
|
|
21
|
+
try {
|
|
22
|
+
unlinkSync(path);
|
|
23
|
+
} catch {
|
|
24
|
+
// already gone, or never created
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
sessionSpillFiles.clear();
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
export interface BoundedOutputOptions {
|
|
16
31
|
/** Spill to a temp file once total bytes exceed this. */
|
|
17
32
|
maxBytes: number;
|
|
@@ -122,6 +137,7 @@ export class BoundedOutput {
|
|
|
122
137
|
|
|
123
138
|
private openSpill(): void {
|
|
124
139
|
this.path = join(tmpdir(), `${this.opts.tempFilePrefix ?? "pi-famulus-bash"}-${randomUUID()}.log`);
|
|
140
|
+
sessionSpillFiles.add(this.path);
|
|
125
141
|
const stream = createWriteStream(this.path);
|
|
126
142
|
stream.on("error", () => {
|
|
127
143
|
this.streamFailed = true;
|
package/src/format.ts
CHANGED
|
@@ -228,6 +228,8 @@ export interface SubagentChildInfo {
|
|
|
228
228
|
status: "pending" | "running" | "completed" | "failed" | "interrupted";
|
|
229
229
|
text: string;
|
|
230
230
|
error?: string;
|
|
231
|
+
endReason?: "model-error" | "rate-limit";
|
|
232
|
+
retryAfter?: number;
|
|
231
233
|
/** Non-fatal caveat from the child run (e.g. an adapted model spec). */
|
|
232
234
|
warning?: string;
|
|
233
235
|
/** Task prompt that was sent to this child. */
|
|
@@ -287,6 +289,8 @@ export function formatSubagentNotification(info: SubagentNotificationInfo): Form
|
|
|
287
289
|
prompt: capPrompt(child.prompt ?? ""),
|
|
288
290
|
result: capTail(child.text),
|
|
289
291
|
...(child.error ? { error: child.error } : {}),
|
|
292
|
+
...(child.endReason ? { endReason: child.endReason } : {}),
|
|
293
|
+
...(child.retryAfter !== undefined ? { retryAfter: child.retryAfter } : {}),
|
|
290
294
|
...(child.warning ? { warning: child.warning } : {}),
|
|
291
295
|
})),
|
|
292
296
|
});
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-w
|
|
|
19
19
|
import { resolveAgent as resolveAgentDef } from "./agents/definition";
|
|
20
20
|
import { createAgentLoader, type AgentLoader } from "./agents/loader";
|
|
21
21
|
import { createBashOverride } from "./bash-override";
|
|
22
|
+
import { cleanupSessionSpillFiles } from "./bounded-output";
|
|
22
23
|
import { createComms, type CommsWithOrigin } from "./comms/comms";
|
|
23
24
|
import { createRegistryCommsHost } from "./comms/registry-host";
|
|
24
25
|
import { createAgentMessageTool } from "./comms/tools";
|
|
@@ -35,6 +36,8 @@ import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
|
|
|
35
36
|
import {
|
|
36
37
|
agentEndReason,
|
|
37
38
|
headOf,
|
|
39
|
+
loadAgentChildRecords,
|
|
40
|
+
findAgentChildRecord,
|
|
38
41
|
tailOf,
|
|
39
42
|
updateAgentChildMetrics,
|
|
40
43
|
writeAgentChildRecord,
|
|
@@ -42,8 +45,9 @@ import {
|
|
|
42
45
|
} from "./subagent/agent-records";
|
|
43
46
|
import { TranscriptWriter } from "./subagent/transcript";
|
|
44
47
|
import { FleetWidget } from "./subagent/fleet-widget";
|
|
45
|
-
import { WorkIndex, type WorkItem } from "./work-index";
|
|
48
|
+
import { printModeDropWarning, WORK_RETAIN_MS, WorkIndex, type WorkItem } from "./work-index";
|
|
46
49
|
import { fileURLToPath } from "node:url";
|
|
50
|
+
import { AGENT_WORK_KINDS, type AgentWorkKind } from "./subagent/types";
|
|
47
51
|
import { isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
|
|
48
52
|
import { createHostedSessionFn } from "./subagent/hosted-session";
|
|
49
53
|
import { isHostedChildProcess, registerChildMode } from "./child-mode";
|
|
@@ -113,7 +117,8 @@ export function readPreview(outputPath: string | undefined, maxChars: number): s
|
|
|
113
117
|
return tail.text.length <= maxChars ? tail.text : tail.text.slice(-maxChars);
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
|
|
120
|
+
/** Map a manager exit event to the background-wake status. Exported for tests. */
|
|
121
|
+
export function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
|
|
117
122
|
// The manager died without shutting down; its runner took the task down.
|
|
118
123
|
if (event.end_reason === "manager-crash") return "orphaned";
|
|
119
124
|
if (event.signal) return "killed";
|
|
@@ -219,6 +224,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
219
224
|
* is mis-labeled as a parent "Background command" wake (§4.2 / §4.6).
|
|
220
225
|
*/
|
|
221
226
|
const notifyOnExit = new Set<string>();
|
|
227
|
+
/** Sessions that already received the crash-recovery notice. */
|
|
228
|
+
const recoveredSessions = new Set<string>();
|
|
222
229
|
const exitGate = new ExitNotifyGate<ManagerEvent>({ clock });
|
|
223
230
|
const workIndex = new WorkIndex({ clock });
|
|
224
231
|
const patchExited = (taskId: string, event: ManagerEvent): void => {
|
|
@@ -288,6 +295,136 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
288
295
|
}
|
|
289
296
|
return out;
|
|
290
297
|
};
|
|
298
|
+
/**
|
|
299
|
+
* Parent-owned shells this process did not start (the previous pi died
|
|
300
|
+
* while the daemon stayed up). Running ones join the index and get an
|
|
301
|
+
* exit wake; recent finished ones are delivered the same way.
|
|
302
|
+
* manager-shutdown / manager-crash leftovers stay for the crash notice.
|
|
303
|
+
*/
|
|
304
|
+
const adoptParentShells = (tasks: TaskRecord[]): void => {
|
|
305
|
+
const sid = ctx?.sessionManager.getSessionId() ?? "";
|
|
306
|
+
const now = clock.now();
|
|
307
|
+
for (const task of tasks) {
|
|
308
|
+
if (task.kind !== "shell") continue;
|
|
309
|
+
if (task.origin?.via === "child-bash") continue;
|
|
310
|
+
if (sid && task.session_id && task.session_id !== sid) continue;
|
|
311
|
+
if (workIndex.get(task.task_id) || taskMeta.has(task.task_id)) continue;
|
|
312
|
+
const startedAt = task.started_at ?? now;
|
|
313
|
+
taskMeta.set(task.task_id, {
|
|
314
|
+
kind: "shell",
|
|
315
|
+
command: task.command ?? "",
|
|
316
|
+
cwd: task.cwd,
|
|
317
|
+
startedAt,
|
|
318
|
+
});
|
|
319
|
+
if (task.status === "running") {
|
|
320
|
+
upsertBackgroundTask(task.task_id, startedAt);
|
|
321
|
+
markNotifyOnExit(task.task_id);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (task.end_reason === "manager-shutdown" || task.end_reason === "manager-crash") continue;
|
|
325
|
+
if (task.ended_at != null && now - task.ended_at > WORK_RETAIN_MS) continue;
|
|
326
|
+
if (task.status !== "completed" && task.status !== "failed" && task.status !== "killed") continue;
|
|
327
|
+
upsertBackgroundTask(task.task_id, startedAt);
|
|
328
|
+
deliverExit(task.task_id, exitEventFromRecord(task));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* A running disk record whose hosted process is gone: interrupt the live
|
|
333
|
+
* handle when this process still holds it (daemon crash), otherwise
|
|
334
|
+
* write the record. Warm children stay for resume.
|
|
335
|
+
*/
|
|
336
|
+
const settleDeadHostedChildren = async (
|
|
337
|
+
sessionId: string,
|
|
338
|
+
opts: { interruptLive?: boolean } = {},
|
|
339
|
+
): Promise<AgentChildRecord[]> => {
|
|
340
|
+
const manager = client;
|
|
341
|
+
const registry = subagentRegistry;
|
|
342
|
+
const center = notifyCenter;
|
|
343
|
+
const stale = loadAgentChildRecords(home, { sessionId, includeTerminal: false });
|
|
344
|
+
const lost: AgentChildRecord[] = [];
|
|
345
|
+
for (const rec of stale) {
|
|
346
|
+
if (rec.status !== "running" && rec.status !== "pending") continue;
|
|
347
|
+
const handle = registry?.handle(rec.child_id);
|
|
348
|
+
// A local admission queue has no hosted process to look up yet.
|
|
349
|
+
if (handle?.status() === "pending") continue;
|
|
350
|
+
let live = false;
|
|
351
|
+
if (manager?.isAvailable() && manager.protocolLevel() >= 6) {
|
|
352
|
+
const found =
|
|
353
|
+
await manager.hostedLookup({ sessionId: rec.child_id }).catch(() => null);
|
|
354
|
+
if (client !== manager || subagentRegistry !== registry || notifyCenter !== center) return [];
|
|
355
|
+
live = Boolean(found && (found.residency === "warm" || found.activity === "running" || found.activity === "starting"));
|
|
356
|
+
}
|
|
357
|
+
if (live) continue;
|
|
358
|
+
if (handle && (handle.status() === "running" || handle.status() === "pending")) {
|
|
359
|
+
if (!opts.interruptLive) continue;
|
|
360
|
+
await handle.interrupt().catch(() => {});
|
|
361
|
+
if (client !== manager || subagentRegistry !== registry || notifyCenter !== center) return [];
|
|
362
|
+
} else {
|
|
363
|
+
writeAgentChildRecord(home, {
|
|
364
|
+
...rec,
|
|
365
|
+
status: "interrupted",
|
|
366
|
+
ended_at: rec.ended_at ?? clock.now(),
|
|
367
|
+
end_reason: "interrupted",
|
|
368
|
+
error: rec.error ?? "parent session ended",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
lost.push(rec);
|
|
372
|
+
}
|
|
373
|
+
return lost;
|
|
374
|
+
};
|
|
375
|
+
/**
|
|
376
|
+
* The previous process died. Settle stale running agent records (the child
|
|
377
|
+
* is gone) and tell the model about that work and about commands the
|
|
378
|
+
* daemon already ended as manager-shutdown / manager-crash.
|
|
379
|
+
* A hosted child that is still warm is left for resume.
|
|
380
|
+
*/
|
|
381
|
+
const recoverCrashedSession = async (sessionId: string): Promise<void> => {
|
|
382
|
+
if (!sessionId || recoveredSessions.has(sessionId)) return;
|
|
383
|
+
const manager = client;
|
|
384
|
+
const center = notifyCenter;
|
|
385
|
+
recoveredSessions.add(sessionId);
|
|
386
|
+
const interrupted =
|
|
387
|
+
await settleDeadHostedChildren(sessionId);
|
|
388
|
+
if (client !== manager || notifyCenter !== center) return;
|
|
389
|
+
let killed: TaskRecord[] = [];
|
|
390
|
+
if (manager?.isAvailable()) {
|
|
391
|
+
try {
|
|
392
|
+
const tasks = await manager.list(true);
|
|
393
|
+
if (client !== manager || notifyCenter !== center) return;
|
|
394
|
+
killed = tasks.filter((task) => {
|
|
395
|
+
if (task.session_id && task.session_id !== sessionId) return false;
|
|
396
|
+
if (task.kind !== "shell") return false;
|
|
397
|
+
return task.end_reason === "manager-shutdown" || task.end_reason === "manager-crash";
|
|
398
|
+
});
|
|
399
|
+
} catch {
|
|
400
|
+
killed = [];
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (interrupted.length === 0 && killed.length === 0) {
|
|
404
|
+
recoveredSessions.delete(sessionId);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const lines = [
|
|
408
|
+
"System wake — not a new user message.",
|
|
409
|
+
"",
|
|
410
|
+
"This session was reopened after the previous process ended.",
|
|
411
|
+
];
|
|
412
|
+
for (const rec of interrupted) {
|
|
413
|
+
lines.push(`Interrupted subagent ${rec.child_id} (${rec.name}).`);
|
|
414
|
+
}
|
|
415
|
+
for (const task of killed) {
|
|
416
|
+
lines.push(`Command ${task.task_id} ended while the session was gone (${task.end_reason ?? task.status}).`);
|
|
417
|
+
}
|
|
418
|
+
center?.notify({
|
|
419
|
+
customType: "pi-famulus-wake",
|
|
420
|
+
content: lines.join("\n"),
|
|
421
|
+
details: {
|
|
422
|
+
kind: "session-recovery",
|
|
423
|
+
children: interrupted.map((rec) => rec.child_id),
|
|
424
|
+
tasks: killed.map((task) => task.task_id),
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
};
|
|
291
428
|
/**
|
|
292
429
|
* Settle everything the manager reports as ended that we still show live:
|
|
293
430
|
* monitors in the registry and shell/monitor rows in the index. Runs when
|
|
@@ -299,6 +436,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
299
436
|
*/
|
|
300
437
|
const syncWithManager = async (): Promise<void> => {
|
|
301
438
|
const c = client;
|
|
439
|
+
const center = notifyCenter;
|
|
302
440
|
let tasks: TaskRecord[];
|
|
303
441
|
if (c && c.isAvailable()) {
|
|
304
442
|
try {
|
|
@@ -313,7 +451,9 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
313
451
|
if (!disk) return;
|
|
314
452
|
tasks = disk;
|
|
315
453
|
}
|
|
454
|
+
if (client !== c || notifyCenter !== center) return;
|
|
316
455
|
monitorRegistry?.reconcile(tasks);
|
|
456
|
+
adoptParentShells(tasks);
|
|
317
457
|
for (const task of workIndex.staleLive(tasks)) {
|
|
318
458
|
const event = exitEventFromRecord(task);
|
|
319
459
|
// Route through the gate: if the real task_exited is processed after
|
|
@@ -533,6 +673,40 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
533
673
|
if (!loader) return resolveAgentDef([], name); // builtins-only fallback
|
|
534
674
|
return resolveAgentDef(loader.reload().definitions, name);
|
|
535
675
|
},
|
|
676
|
+
recoverChild: async (childId) => {
|
|
677
|
+
const registry = subagentRegistry;
|
|
678
|
+
if (!registry) return undefined;
|
|
679
|
+
const rec = findAgentChildRecord(home, childId);
|
|
680
|
+
if (!rec) return undefined;
|
|
681
|
+
if (registry.handle(rec.child_id)) {
|
|
682
|
+
return registry.get(rec.run_id);
|
|
683
|
+
}
|
|
684
|
+
const workKind = AGENT_WORK_KINDS.includes(rec.work_kind as AgentWorkKind)
|
|
685
|
+
? rec.work_kind as AgentWorkKind
|
|
686
|
+
: undefined;
|
|
687
|
+
const loader = agentLoader;
|
|
688
|
+
const agent = resolveAgentDef(loader ? loader.reload().definitions : [], rec.agent);
|
|
689
|
+
registry.adoptExisting({
|
|
690
|
+
runId: rec.run_id,
|
|
691
|
+
childId: rec.child_id,
|
|
692
|
+
name: rec.name,
|
|
693
|
+
agent: rec.agent,
|
|
694
|
+
workKind,
|
|
695
|
+
startedAt: rec.started_at,
|
|
696
|
+
prompt: rec.prompt_head,
|
|
697
|
+
});
|
|
698
|
+
await registry.attachChild({
|
|
699
|
+
childId: rec.child_id,
|
|
700
|
+
runId: rec.run_id,
|
|
701
|
+
name: rec.name,
|
|
702
|
+
prompt: rec.prompt_head ?? "",
|
|
703
|
+
agent,
|
|
704
|
+
timeoutMs: subagentConfig.timeoutMs,
|
|
705
|
+
depth: 1,
|
|
706
|
+
...(workKind ? { workKind } : {}),
|
|
707
|
+
});
|
|
708
|
+
return registry.get(rec.run_id);
|
|
709
|
+
},
|
|
536
710
|
listModels: () => {
|
|
537
711
|
const current = ctx?.model;
|
|
538
712
|
return modelCandidates({
|
|
@@ -618,7 +792,13 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
618
792
|
// still carry <shell>.
|
|
619
793
|
const childShells = new ChildShellTracker();
|
|
620
794
|
const parentSessionId = startCtx.sessionManager.getSessionId();
|
|
795
|
+
// Bind this session's notify centre and client. After /new /resume /fork
|
|
796
|
+
// the outer `notifyCenter` is replaced; late events on the old client
|
|
797
|
+
// must not follow that rebinding.
|
|
798
|
+
const sessionNotify = notifyCenter;
|
|
799
|
+
const sessionClient = client;
|
|
621
800
|
client.onEvent((event) => {
|
|
801
|
+
if (client !== sessionClient) return;
|
|
622
802
|
const hosted = event as ManagerEvent & {
|
|
623
803
|
child_id?: string;
|
|
624
804
|
reason?: string;
|
|
@@ -628,6 +808,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
628
808
|
residency?: string;
|
|
629
809
|
driver?: string;
|
|
630
810
|
last_text?: string;
|
|
811
|
+
request?: { type?: string; id?: string; method?: string; title?: string; message?: string };
|
|
631
812
|
};
|
|
632
813
|
if (hosted.event === "agent_comms" && hosted.child_id && hosted.request_id) {
|
|
633
814
|
const reason = hosted.reason === "need_decision" ? "need_decision" : "progress_update";
|
|
@@ -636,8 +817,31 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
636
817
|
}).catch(() => {});
|
|
637
818
|
return;
|
|
638
819
|
}
|
|
820
|
+
if (hosted.event === "agent_ui" && hosted.child_id) {
|
|
821
|
+
const request = hosted.request ?? {};
|
|
822
|
+
const method = request.method;
|
|
823
|
+
if (method !== "select" && method !== "confirm" && method !== "input" && method !== "editor") return;
|
|
824
|
+
const title = typeof request.title === "string" ? request.title : "";
|
|
825
|
+
const body = typeof request.message === "string" ? request.message : "";
|
|
826
|
+
sessionNotify?.notify({
|
|
827
|
+
customType: "pi-famulus-wake",
|
|
828
|
+
content:
|
|
829
|
+
`System wake — not a new user message.\n\n` +
|
|
830
|
+
`Subagent ${hosted.child_id} is waiting for a ${method}` +
|
|
831
|
+
(title ? `: ${title}` : ".") +
|
|
832
|
+
(body ? `\n${body}` : ""),
|
|
833
|
+
details: {
|
|
834
|
+
kind: "supervisor-request",
|
|
835
|
+
from: hosted.child_id,
|
|
836
|
+
method,
|
|
837
|
+
...(title ? { title } : {}),
|
|
838
|
+
...(body ? { message: body } : {}),
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
639
843
|
if (hosted.event === "agent_detach" && hosted.child_id) {
|
|
640
|
-
|
|
844
|
+
sessionNotify?.notify({
|
|
641
845
|
customType: "pi-famulus-wake",
|
|
642
846
|
content: `System wake — not a new user message.\n\nThe user talked to ${hosted.name ?? hosted.child_id} directly.\n${hosted.last_text ?? ""}`,
|
|
643
847
|
details: { kind: "supervisor-update", from: hosted.child_id, name: hosted.name ?? hosted.child_id, message: hosted.last_text ?? "" },
|
|
@@ -703,20 +907,39 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
703
907
|
}
|
|
704
908
|
});
|
|
705
909
|
client.onReconnect(() => {
|
|
910
|
+
if (client !== sessionClient) return;
|
|
706
911
|
// A daemon restart may drop permits; a same-session rebind may retain
|
|
707
912
|
// them. Keep each kind for idempotent re-registration and mark all local
|
|
708
913
|
// leases pending until the daemon confirms them.
|
|
709
914
|
for (const childId of globalAgentLeases.keys()) pendingAgentLeases.add(childId);
|
|
710
|
-
void monitorRegistry?.rewatchAll().then(() =>
|
|
915
|
+
void monitorRegistry?.rewatchAll().then(() => {
|
|
916
|
+
if (client === sessionClient) return syncWithManager();
|
|
917
|
+
});
|
|
711
918
|
void (async () => {
|
|
712
|
-
const manager =
|
|
713
|
-
if (
|
|
919
|
+
const manager = sessionClient;
|
|
920
|
+
if (manager.protocolLevel() < 4) {
|
|
714
921
|
capacityNotice("daemon too old");
|
|
715
922
|
return;
|
|
716
923
|
}
|
|
717
924
|
// The registry and its child ids survive socket reconnects in this
|
|
718
925
|
// extension process; held ids include resumable interrupted children.
|
|
719
926
|
await reconcileAgentLeases(manager);
|
|
927
|
+
if (client !== manager) return;
|
|
928
|
+
const sid = parentSessionId;
|
|
929
|
+
const lost =
|
|
930
|
+
await settleDeadHostedChildren(sid, { interruptLive: true });
|
|
931
|
+
if (lost.length > 0) {
|
|
932
|
+
sessionNotify?.notify({
|
|
933
|
+
customType: "pi-famulus-wake",
|
|
934
|
+
content: [
|
|
935
|
+
"System wake — not a new user message.",
|
|
936
|
+
"",
|
|
937
|
+
"The daemon restarted. Hosted children that were running are gone.",
|
|
938
|
+
...lost.map((rec) => `Interrupted subagent ${rec.child_id} (${rec.name}).`),
|
|
939
|
+
].join("\n"),
|
|
940
|
+
details: { kind: "session-recovery", children: lost.map((rec) => rec.child_id) },
|
|
941
|
+
});
|
|
942
|
+
}
|
|
720
943
|
})().catch(() => {});
|
|
721
944
|
});
|
|
722
945
|
|
|
@@ -811,6 +1034,23 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
811
1034
|
// One event per stall detection: an auto-resume follows unless the
|
|
812
1035
|
// retry budget (stallRetries) is already spent.
|
|
813
1036
|
logEvent("agent.stall", { child_id: childId, attempt });
|
|
1037
|
+
notifyCenter?.notify({
|
|
1038
|
+
customType: "pi-famulus-wake",
|
|
1039
|
+
content:
|
|
1040
|
+
`System wake — not a new user message.\n\n` +
|
|
1041
|
+
`Subagent ${childId} stalled with no activity (attempt ${attempt}); retrying.`,
|
|
1042
|
+
details: { kind: "subagent-stall", child_id: childId, attempt },
|
|
1043
|
+
});
|
|
1044
|
+
},
|
|
1045
|
+
onToolLoop: (childId, toolName, args) => {
|
|
1046
|
+
const argText = args && typeof args === "object" ? JSON.stringify(args) : String(args ?? "");
|
|
1047
|
+
notifyCenter?.notify({
|
|
1048
|
+
customType: "pi-famulus-wake",
|
|
1049
|
+
content:
|
|
1050
|
+
`System wake — not a new user message.\n\n` +
|
|
1051
|
+
`Subagent ${childId} called ${toolName} three times in a row with the same arguments${argText ? `: ${argText}` : "."}`,
|
|
1052
|
+
details: { kind: "subagent-loop", child_id: childId, tool: toolName, args },
|
|
1053
|
+
});
|
|
814
1054
|
},
|
|
815
1055
|
// Soft deadline: the child keeps running; the parent decides.
|
|
816
1056
|
onOverrun: createOverrunNotifier({
|
|
@@ -898,6 +1138,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
898
1138
|
};
|
|
899
1139
|
const endReason = agentEndReason(c.status, c.result);
|
|
900
1140
|
if (endReason) rec.end_reason = endReason;
|
|
1141
|
+
if (c.result?.retryAfter !== undefined) rec.retry_after = c.result.retryAfter;
|
|
901
1142
|
const transcript = syncTranscript(c.childId);
|
|
902
1143
|
if (transcript) {
|
|
903
1144
|
rec.transcript = transcript;
|
|
@@ -927,12 +1168,16 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
927
1168
|
}
|
|
928
1169
|
});
|
|
929
1170
|
startFleetWidget(startCtx);
|
|
1171
|
+
if (client?.isAvailable()) {
|
|
1172
|
+
await recoverCrashedSession(startCtx.sessionManager.getSessionId());
|
|
1173
|
+
}
|
|
930
1174
|
|
|
931
1175
|
// Connect in the background: a cold manager spawn must not stall the
|
|
932
1176
|
// first prompt. The cache install, if one is running, finishes first so a
|
|
933
1177
|
// missing optional package is not reported before it can be restored.
|
|
934
1178
|
// Tools call ensureAvailable() lazily before use.
|
|
935
|
-
|
|
1179
|
+
if (client !== sessionClient) return;
|
|
1180
|
+
const c = sessionClient;
|
|
936
1181
|
void nativeRestore
|
|
937
1182
|
.then((path) => {
|
|
938
1183
|
if (client !== c || !c) return null;
|
|
@@ -946,6 +1191,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
946
1191
|
const file = startCtx.sessionManager.getSessionFile?.();
|
|
947
1192
|
const sid = startCtx.sessionManager.getSessionId();
|
|
948
1193
|
void promptIfHostedSession(c, () => ctx, { sessionFile: file, sessionId: sid }, "start").catch(() => {});
|
|
1194
|
+
void syncWithManager();
|
|
1195
|
+
void recoverCrashedSession(sid);
|
|
949
1196
|
return;
|
|
950
1197
|
}
|
|
951
1198
|
// A /reload may have replaced startCtx by now; report to the live one.
|
|
@@ -975,6 +1222,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
975
1222
|
agentLoader = null;
|
|
976
1223
|
monitorRegistry?.disposeAll();
|
|
977
1224
|
notifyCenter?.dispose();
|
|
1225
|
+
recoveredSessions.clear();
|
|
1226
|
+
cleanupSessionSpillFiles();
|
|
978
1227
|
const current = client;
|
|
979
1228
|
client = null;
|
|
980
1229
|
if (current) {
|
|
@@ -1010,6 +1259,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
1010
1259
|
parkRuntime({ version: EXTENSION_VERSION, runtime, dispose: teardown });
|
|
1011
1260
|
return;
|
|
1012
1261
|
}
|
|
1262
|
+
if (String((ctx as { mode?: string } | null)?.mode) === "print") {
|
|
1263
|
+
const ids = [
|
|
1264
|
+
...workIndex.list().filter((item) => item.status === "running" || item.status === "pending").map((item) => item.id),
|
|
1265
|
+
...(subagentRegistry?.activeChildren() ?? []).map((child) => child.childId),
|
|
1266
|
+
];
|
|
1267
|
+
const warning = printModeDropWarning([...new Set(ids)]);
|
|
1268
|
+
if (warning) process.stderr.write(warning);
|
|
1269
|
+
}
|
|
1013
1270
|
await teardown();
|
|
1014
1271
|
});
|
|
1015
1272
|
|
package/src/manager-client.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { famulusPaths } from "./config";
|
|
|
18
18
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
19
19
|
|
|
20
20
|
const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
|
|
21
|
-
const EXTENSION_VERSION = "0.2.
|
|
21
|
+
const EXTENSION_VERSION = "0.2.1";
|
|
22
22
|
export { EXTENSION_VERSION };
|
|
23
23
|
// 5: speaks per-kind agent admission and queued acquire.
|
|
24
24
|
const PROTOCOL = 6;
|
|
@@ -637,6 +637,8 @@ export class ManagerClient {
|
|
|
637
637
|
async close(): Promise<void> {
|
|
638
638
|
this.intentionalClose = true;
|
|
639
639
|
this.state = "disconnected";
|
|
640
|
+
this.eventHandlers.clear();
|
|
641
|
+
this.reconnectHandlers.clear();
|
|
640
642
|
this.failAllPending(new Error("manager client closed"));
|
|
641
643
|
const socket = this.socket;
|
|
642
644
|
this.socket = null;
|
package/src/notify.ts
CHANGED
package/src/prompts.generated.ts
CHANGED
|
@@ -49,7 +49,7 @@ export const PROMPTS = {
|
|
|
49
49
|
"tools.subagent.rules": ["When a <pi-famulus-wake kind=\"subagent-handover\"> arrives, read <prompt> and <result> immediately and continue: subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.","Subagent runs that exceed the foreground budget continue in the background; you are notified per finished child and again when the run completes — do not poll.","A failed subagent does not fail the whole run; inspect per-subagent sections in the result.","<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run."],
|
|
50
50
|
"tools.subagent.param.tasks": "Start new subagents in parallel. Use only when action is omitted. For any action call, omit tasks entirely; do not supply placeholder tasks.",
|
|
51
51
|
"tools.subagent.param.tasks.agent": "Agent definition name (default: worker)",
|
|
52
|
-
"tools.subagent.param.tasks.prompt": "Task prompt for this subagent",
|
|
52
|
+
"tools.subagent.param.tasks.prompt": "Task prompt for this subagent. The child is a new process: it cannot see this conversation, so the prompt must be self-contained.",
|
|
53
53
|
"tools.subagent.param.tasks.name": "Display name (default: agent name + ordinal)",
|
|
54
54
|
"tools.subagent.param.work_kind": "Explicit planned workload class for machine-wide admission (default: other)",
|
|
55
55
|
"tools.subagent.param.chain": "Start a new sequential chain (always awaited). Use only when action is omitted. For any action call, omit chain entirely.",
|
|
@@ -32,6 +32,8 @@ export interface AgentChildRecord {
|
|
|
32
32
|
error?: string;
|
|
33
33
|
/** Why the child ended (terminal statuses only). */
|
|
34
34
|
end_reason?: AgentEndReason;
|
|
35
|
+
/** Seconds until a rate-limited provider may be retried, when known. */
|
|
36
|
+
retry_after?: number;
|
|
35
37
|
/** Task prompt without the agent preamble, capped. */
|
|
36
38
|
prompt_head?: string;
|
|
37
39
|
/** Tail of the final result text, capped. */
|
|
@@ -58,6 +60,7 @@ export type AgentEndReason =
|
|
|
58
60
|
| "completed"
|
|
59
61
|
| "failed"
|
|
60
62
|
| "model-error"
|
|
63
|
+
| "rate-limit"
|
|
61
64
|
| "stalled"
|
|
62
65
|
| "timeout"
|
|
63
66
|
| "interrupted"
|
|
@@ -66,10 +69,11 @@ export type AgentEndReason =
|
|
|
66
69
|
/** Map a terminal child status + result to the end_reason recorded on disk. */
|
|
67
70
|
export function agentEndReason(
|
|
68
71
|
status: AgentChildRecord["status"],
|
|
69
|
-
result: { error?: string; endReason?: "model-error" } | undefined,
|
|
72
|
+
result: { error?: string; endReason?: "model-error" | "rate-limit" } | undefined,
|
|
70
73
|
): AgentEndReason | undefined {
|
|
71
74
|
if (status === "completed") return "completed";
|
|
72
75
|
if (status === "failed") {
|
|
76
|
+
if (result?.endReason === "rate-limit") return "rate-limit";
|
|
73
77
|
if (result?.endReason === "model-error") return "model-error";
|
|
74
78
|
return result?.error === "stalled" ? "stalled" : "failed";
|
|
75
79
|
}
|
|
@@ -174,6 +178,42 @@ function parseRecord(raw: string): AgentChildRecord | null {
|
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
/** Load agent child records from disk (all sessions under home). */
|
|
181
|
+
/** Hosted children the daemon still has warm. They outlive a gone parent session. */
|
|
182
|
+
export function warmHostedChildIds(home: string): Set<string> {
|
|
183
|
+
const out = new Set<string>();
|
|
184
|
+
try {
|
|
185
|
+
const { pid } = JSON.parse(readFileSync(join(home, "manager.pid"), "utf8")) as { pid?: unknown };
|
|
186
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return out;
|
|
187
|
+
try {
|
|
188
|
+
process.kill(pid, 0);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if ((error as NodeJS.ErrnoException).code !== "EPERM") return out;
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
let files: string[];
|
|
196
|
+
try {
|
|
197
|
+
files = readdirSync(join(home, "agents")).filter((name) => name.endsWith(".json"));
|
|
198
|
+
} catch {
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
try {
|
|
203
|
+
const value = JSON.parse(readFileSync(join(home, "agents", file), "utf8")) as {
|
|
204
|
+
residency?: string;
|
|
205
|
+
child_id?: string;
|
|
206
|
+
};
|
|
207
|
+
if (value.residency === "warm" && typeof value.child_id === "string") {
|
|
208
|
+
out.add(value.child_id);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// skip bad hosted records
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
177
217
|
export function loadAgentChildRecords(
|
|
178
218
|
home: string,
|
|
179
219
|
opts: { sessionId?: string; includeTerminal?: boolean; connectedSessionIds?: ReadonlySet<string> } = {},
|
|
@@ -189,6 +229,7 @@ export function loadAgentChildRecords(
|
|
|
189
229
|
} catch {
|
|
190
230
|
return [];
|
|
191
231
|
}
|
|
232
|
+
const warm = opts.connectedSessionIds === undefined ? new Set<string>() : warmHostedChildIds(home);
|
|
192
233
|
const out: AgentChildRecord[] = [];
|
|
193
234
|
for (const sid of sessionIds) {
|
|
194
235
|
const dir = agentRecordsDir(home, sid);
|
|
@@ -205,7 +246,9 @@ export function loadAgentChildRecords(
|
|
|
205
246
|
const disconnected =
|
|
206
247
|
opts.connectedSessionIds !== undefined && !opts.connectedSessionIds.has(rec.session_id);
|
|
207
248
|
const shown: AgentChildRecord =
|
|
208
|
-
disconnected &&
|
|
249
|
+
disconnected &&
|
|
250
|
+
(rec.status === "pending" || rec.status === "running") &&
|
|
251
|
+
!warm.has(rec.child_id)
|
|
209
252
|
? { ...rec, status: "interrupted" }
|
|
210
253
|
: rec;
|
|
211
254
|
const terminal =
|
|
@@ -220,6 +263,12 @@ export function loadAgentChildRecords(
|
|
|
220
263
|
return out;
|
|
221
264
|
}
|
|
222
265
|
|
|
266
|
+
/** One child by id or name, across every session under home. */
|
|
267
|
+
export function findAgentChildRecord(home: string, childIdOrName: string): AgentChildRecord | undefined {
|
|
268
|
+
const recs = loadAgentChildRecords(home, { includeTerminal: true });
|
|
269
|
+
return recs.find((rec) => rec.child_id === childIdOrName) ?? recs.find((rec) => rec.name === childIdOrName);
|
|
270
|
+
}
|
|
271
|
+
|
|
223
272
|
export function isAgentStatusActive(status: AgentChildRecord["status"]): boolean {
|
|
224
273
|
return status === "pending" || status === "running";
|
|
225
274
|
}
|
|
@@ -66,10 +66,10 @@ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn
|
|
|
66
66
|
"--thinking", String(thinking),
|
|
67
67
|
"--append-system-prompt", promptFile,
|
|
68
68
|
...(trustInherited ? [] : [deps.getProjectTrusted?.() ? "-a" : "-na"]),
|
|
69
|
-
...(req.agent.tools ? ["--tools", req.agent.tools.join(",")] : []),
|
|
69
|
+
...(req.agent.tools ? ["--tools", hostedChildTools(req.agent.tools).join(",")] : []),
|
|
70
70
|
];
|
|
71
71
|
const env = { ...process.env } as Record<string, string>;
|
|
72
|
-
if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = req.agent.tools.join(",");
|
|
72
|
+
if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = hostedChildTools(req.agent.tools).join(",");
|
|
73
73
|
env.PI_FAMULUS_CHILD_RUN = req.runId;
|
|
74
74
|
env.PI_FAMULUS_CHILD_NAME = req.name;
|
|
75
75
|
const spawned = await client.agentSpawn({
|
|
@@ -80,14 +80,7 @@ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn
|
|
|
80
80
|
cwd,
|
|
81
81
|
env,
|
|
82
82
|
});
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
await client.agentInput(req.childId, "set_steering_mode", "all", "parent");
|
|
86
|
-
} catch (err) {
|
|
87
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
88
|
-
const note = `Steering mode was not set (${message}); queued input may wait until the current turn ends.`;
|
|
89
|
-
warning = warning ? `${warning} ${note}` : note;
|
|
90
|
-
}
|
|
83
|
+
const warning = model.warning;
|
|
91
84
|
await client.agentWatch(req.childId);
|
|
92
85
|
const session = new HostedRpcSession(client, req.childId, {
|
|
93
86
|
warning,
|
|
@@ -108,6 +101,7 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
108
101
|
private readonly unsubscribe: () => void;
|
|
109
102
|
private streaming = false;
|
|
110
103
|
private lastText = "";
|
|
104
|
+
private lastFailure: { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
|
|
111
105
|
private turns: { role: string; text: string }[] = [];
|
|
112
106
|
private usage: ChildTokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
113
107
|
private settle: (() => void) | null = null;
|
|
@@ -172,6 +166,10 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
172
166
|
return this.lastText || undefined;
|
|
173
167
|
}
|
|
174
168
|
|
|
169
|
+
getLastAssistantFailure(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined {
|
|
170
|
+
return this.lastFailure;
|
|
171
|
+
}
|
|
172
|
+
|
|
175
173
|
getConversation() {
|
|
176
174
|
return this.turns.map((turn) => ({ ...turn }));
|
|
177
175
|
}
|
|
@@ -216,6 +214,9 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
216
214
|
};
|
|
217
215
|
if (event.activity === "running") this.streaming = true;
|
|
218
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
|
+
}
|
|
219
220
|
this.streaming = false;
|
|
220
221
|
this.settle?.();
|
|
221
222
|
this.settle = null;
|
|
@@ -223,7 +224,20 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
223
224
|
return;
|
|
224
225
|
}
|
|
225
226
|
if (event.event !== "agent_rpc" || !event.record || typeof event.record !== "object") return;
|
|
226
|
-
const record = event.record as {
|
|
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
|
+
};
|
|
227
241
|
const type = record.type ?? "";
|
|
228
242
|
if (type === "agent_start") this.streaming = true;
|
|
229
243
|
if (type === "agent_settled") {
|
|
@@ -237,6 +251,18 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
237
251
|
this.lastText = text;
|
|
238
252
|
this.turns.push({ role: "assistant", text });
|
|
239
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
|
+
}
|
|
240
266
|
const usage = record.message.usage;
|
|
241
267
|
if (usage) {
|
|
242
268
|
this.usage.input += num(usage.input);
|
|
@@ -249,10 +275,21 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
249
275
|
const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
|
|
250
276
|
if (text) this.turns.push({ role: "user", text });
|
|
251
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
|
+
}
|
|
252
286
|
const adapted: ChildSessionEvent = {
|
|
253
287
|
type,
|
|
254
288
|
...(record.message?.role ? { role: record.message.role } : {}),
|
|
255
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 } : {}),
|
|
256
293
|
};
|
|
257
294
|
for (const listener of this.listeners) listener(adapted);
|
|
258
295
|
}
|
|
@@ -262,6 +299,15 @@ function num(value: unknown): number {
|
|
|
262
299
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
263
300
|
}
|
|
264
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
|
+
|
|
265
311
|
/** Request id for a child→parent decision. */
|
|
266
312
|
export function newCommsRequestId(): string {
|
|
267
313
|
return `rq_${randomUUID().slice(0, 8)}`;
|
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. */
|
|
@@ -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_})`);
|
|
@@ -480,11 +528,10 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
480
528
|
text: this.status_ === "pending" ? "" : this.partialText(),
|
|
481
529
|
durationMs: this.now() - this.startedAt,
|
|
482
530
|
});
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
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;
|
|
488
535
|
}
|
|
489
536
|
|
|
490
537
|
dispose(): void | Promise<void> {
|
|
@@ -690,11 +737,14 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
690
737
|
if (!this.isCurrent(gen)) return;
|
|
691
738
|
const failure = session?.getLastAssistantFailure?.();
|
|
692
739
|
if (failure) {
|
|
740
|
+
const error = failure.errorMessage?.trim() || `Model stopped with ${failure.stopReason}`;
|
|
741
|
+
const classified = classifyModelFailure(error);
|
|
693
742
|
this.settle(gen, {
|
|
694
743
|
status: "failed",
|
|
695
744
|
text: this.partialText(),
|
|
696
|
-
error
|
|
697
|
-
endReason:
|
|
745
|
+
error,
|
|
746
|
+
endReason: classified.endReason,
|
|
747
|
+
...(classified.retryAfter !== undefined ? { retryAfter: classified.retryAfter } : {}),
|
|
698
748
|
durationMs: this.now() - this.startedAt,
|
|
699
749
|
});
|
|
700
750
|
return;
|
|
@@ -871,7 +921,7 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
871
921
|
this.toolStartedAt = null;
|
|
872
922
|
}
|
|
873
923
|
|
|
874
|
-
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 {
|
|
875
925
|
const now = this.now();
|
|
876
926
|
const active = this.status_ === "running";
|
|
877
927
|
let currentToolEvent = false;
|
|
@@ -915,6 +965,7 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
915
965
|
if (currentToolEvent) {
|
|
916
966
|
this.toolDepth++;
|
|
917
967
|
this.clearStall();
|
|
968
|
+
this.noteToolCall(event.toolName, event.args);
|
|
918
969
|
}
|
|
919
970
|
return;
|
|
920
971
|
}
|
|
@@ -931,6 +982,22 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
931
982
|
if (this.toolDepth === 0 && !this.decisionPaused) this.armStall(this.generation);
|
|
932
983
|
}
|
|
933
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
|
+
|
|
934
1001
|
private release(terminal = true): void {
|
|
935
1002
|
const release = this.releaseSlot;
|
|
936
1003
|
if (terminal) this.releaseSlot = null;
|
package/src/subagent/tool.ts
CHANGED
|
@@ -142,6 +142,8 @@ export interface SubagentToolDeps {
|
|
|
142
142
|
/** Selectable models for action:"models" (§4.6); absent → action errors. */
|
|
143
143
|
/** Receives the watcher for turns started by agent_message send. */
|
|
144
144
|
registerResumeWatch?: (watch: (childId: string) => void) => void;
|
|
145
|
+
/** Rebuild a child this session no longer holds so resume can use child_id. */
|
|
146
|
+
recoverChild?: (childId: string) => Promise<RunRecord | undefined>;
|
|
145
147
|
listModels?: () => {
|
|
146
148
|
provider: string;
|
|
147
149
|
id: string;
|
|
@@ -228,6 +230,8 @@ function toNotificationInfo(record: RunRecord, now: number) {
|
|
|
228
230
|
status: c.status,
|
|
229
231
|
text: c.result?.text ?? "",
|
|
230
232
|
error: c.result?.error,
|
|
233
|
+
...(c.result?.endReason !== undefined ? { endReason: c.result.endReason } : {}),
|
|
234
|
+
...(c.result?.retryAfter !== undefined ? { retryAfter: c.result.retryAfter } : {}),
|
|
231
235
|
...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
|
|
232
236
|
...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
|
|
233
237
|
})),
|
|
@@ -563,11 +567,16 @@ export function createSubagentTool(
|
|
|
563
567
|
};
|
|
564
568
|
}
|
|
565
569
|
|
|
566
|
-
|
|
570
|
+
let record = params.run_id
|
|
567
571
|
? requireRun(registry, params.run_id)
|
|
568
572
|
: params.child_id
|
|
569
573
|
? resolveRunByChild(registry, params.child_id)
|
|
570
574
|
: undefined;
|
|
575
|
+
if (params.child_id && !params.run_id && action === "resume" && deps.recoverChild &&
|
|
576
|
+
(!record || !registry.findChild(record.runId, params.child_id))) {
|
|
577
|
+
record =
|
|
578
|
+
await deps.recoverChild(params.child_id);
|
|
579
|
+
}
|
|
571
580
|
if (!record) {
|
|
572
581
|
if (params.child_id && !params.run_id) {
|
|
573
582
|
throw new Error(`unknown child "${params.child_id}" (no run in this session holds it)`);
|
package/src/subagent/types.ts
CHANGED
|
@@ -46,7 +46,9 @@ export interface ChildResult {
|
|
|
46
46
|
/** Non-fatal caveat, e.g. agent-definition model fell back to parent model. */
|
|
47
47
|
warning?: string;
|
|
48
48
|
/** Set when the model itself stopped with an error (provider failure). */
|
|
49
|
-
endReason?: "model-error";
|
|
49
|
+
endReason?: "model-error" | "rate-limit";
|
|
50
|
+
/** Seconds until a rate-limited provider may be retried, when known. */
|
|
51
|
+
retryAfter?: number;
|
|
50
52
|
/**
|
|
51
53
|
* Total generations run (1 + resumes + stall retries). Omitted when 1.
|
|
52
54
|
* Note: a user resume() also increments this; use `stalls` to count
|
|
@@ -102,6 +104,9 @@ export interface ChildSessionEvent {
|
|
|
102
104
|
role?: string;
|
|
103
105
|
/** Pi's stable tool-call id, used to pair execution start/end events. */
|
|
104
106
|
toolCallId?: string;
|
|
107
|
+
toolName?: string;
|
|
108
|
+
args?: unknown;
|
|
109
|
+
isError?: boolean;
|
|
105
110
|
usage?: ChildTokenUsage;
|
|
106
111
|
}
|
|
107
112
|
|
|
@@ -178,6 +183,8 @@ export type CreateSessionFn = (req: ChildRunRequest) => Promise<ChildSessionAdap
|
|
|
178
183
|
|
|
179
184
|
export interface ChildRunner {
|
|
180
185
|
start(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
|
|
186
|
+
/** Reattach a child the daemon still hosts, without sending its first prompt. */
|
|
187
|
+
attach?(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
/**
|
package/src/work-index.ts
CHANGED
|
@@ -11,6 +11,12 @@ export type WorkKind = "shell" | "monitor" | "agent";
|
|
|
11
11
|
export const WORK_RETAIN_MS = 10 * 60 * 1000;
|
|
12
12
|
export const WORK_FINISHED_CAP = 50;
|
|
13
13
|
|
|
14
|
+
/** stderr line when print mode drops live work and still exits 0. */
|
|
15
|
+
export function printModeDropWarning(ids: string[]): string | null {
|
|
16
|
+
if (ids.length === 0) return null;
|
|
17
|
+
return `pi-famulus: print mode is ending; stopping ${ids.length} background item(s) (${ids.join(", ")}). The process still exits 0.\n`;
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
export interface WorkItem {
|
|
15
21
|
id: string;
|
|
16
22
|
kind: WorkKind;
|