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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-famulus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Subagents, monitoring, and auto-backgrounding bash for pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,19 +9,36 @@
|
|
|
9
9
|
"url": "https://github.com/Leechael/pi-famulus",
|
|
10
10
|
"directory": "extension"
|
|
11
11
|
},
|
|
12
|
-
"publishConfig": {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"registry": "https://registry.npmjs.org"
|
|
15
|
+
},
|
|
16
|
+
"os": [
|
|
17
|
+
"linux",
|
|
18
|
+
"darwin"
|
|
19
|
+
],
|
|
20
|
+
"cpu": [
|
|
21
|
+
"x64",
|
|
22
|
+
"arm64"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"bin",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"pi-famulus": "./bin/pi-famulus.js"
|
|
31
|
+
},
|
|
17
32
|
"optionalDependencies": {
|
|
18
|
-
"pi-famulus-linux-x64": "0.1.
|
|
19
|
-
"pi-famulus-linux-arm64": "0.1.
|
|
20
|
-
"pi-famulus-darwin-x64": "0.1.
|
|
21
|
-
"pi-famulus-darwin-arm64": "0.1.
|
|
33
|
+
"pi-famulus-linux-x64": "0.1.3",
|
|
34
|
+
"pi-famulus-linux-arm64": "0.1.3",
|
|
35
|
+
"pi-famulus-darwin-x64": "0.1.3",
|
|
36
|
+
"pi-famulus-darwin-arm64": "0.1.3"
|
|
22
37
|
},
|
|
23
38
|
"pi": {
|
|
24
|
-
"extensions": [
|
|
39
|
+
"extensions": [
|
|
40
|
+
"./src/index.ts"
|
|
41
|
+
]
|
|
25
42
|
},
|
|
26
43
|
"scripts": {
|
|
27
44
|
"test": "vitest run",
|
package/src/agents/definition.ts
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* Body becomes the system prompt (trimmed; may be empty).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import {
|
|
19
|
+
isValidThinkingLevel,
|
|
20
|
+
VALID_THINKING_LEVELS,
|
|
21
|
+
type ThinkingLevel,
|
|
22
|
+
} from "../thinking-levels";
|
|
23
|
+
|
|
18
24
|
export interface AgentDefinition {
|
|
19
25
|
/** Must match ^[a-z][a-z0-9-]*$ */
|
|
20
26
|
name: string;
|
|
@@ -24,7 +30,8 @@ export interface AgentDefinition {
|
|
|
24
30
|
tools: string[];
|
|
25
31
|
/** "provider:id" or bare id. */
|
|
26
32
|
model?: string;
|
|
27
|
-
|
|
33
|
+
/** Thinking level (pi core parity; src/thinking-levels.ts). */
|
|
34
|
+
thinking?: ThinkingLevel;
|
|
28
35
|
/** Frontmatter body, trimmed. Empty body → "". */
|
|
29
36
|
systemPrompt: string;
|
|
30
37
|
source: "builtin" | "user" | "project";
|
|
@@ -35,8 +42,6 @@ export interface AgentDefinition {
|
|
|
35
42
|
export const DEFAULT_AGENT_TOOLS: readonly string[] = ["read", "bash", "edit", "write"];
|
|
36
43
|
|
|
37
44
|
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
38
|
-
const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh"] as const;
|
|
39
|
-
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
40
45
|
|
|
41
46
|
type Fail = (reason: string) => never;
|
|
42
47
|
|
|
@@ -178,12 +183,13 @@ export function parseAgentMarkdown(
|
|
|
178
183
|
let thinking: ThinkingLevel | undefined;
|
|
179
184
|
if (fields.thinking !== undefined) {
|
|
180
185
|
const value = parseScalar(fields.thinking, "thinking", fail);
|
|
181
|
-
if (
|
|
186
|
+
if (isValidThinkingLevel(value)) {
|
|
187
|
+
thinking = value;
|
|
188
|
+
} else {
|
|
182
189
|
fail(
|
|
183
|
-
`invalid thinking level "${value}" (expected one of: ${
|
|
190
|
+
`invalid thinking level "${value}" (expected one of: ${VALID_THINKING_LEVELS.join(", ")})`,
|
|
184
191
|
);
|
|
185
192
|
}
|
|
186
|
-
thinking = value as ThinkingLevel;
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
return {
|
|
@@ -22,6 +22,7 @@ export const BEHAVIOR_GUIDELINES = `## Background tasks and notifications (pi-fa
|
|
|
22
22
|
- When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. Do not wait for further user input when the next step is clear; do not merely acknowledge and stop.
|
|
23
23
|
- Never fabricate or assume a background task's result before its notification arrives.
|
|
24
24
|
- Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind="subagent-handover"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:"resume", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind="subagent-done"> arrives — read each <child>, synthesize, and continue.
|
|
25
|
+
- A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind="subagent-overrun"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. Pick one: subagent({action:"extend", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:"interrupt", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again.
|
|
25
26
|
- Use agent_message to steer running subagents and to reply to <pi-famulus-wake kind="supervisor-request"> (action:"reply"; the <reply-with> child is the call shape). Do not use agent_message to resume a finished child — that is subagent({action:"resume"}). Supervisor wakes are not user messages — still act on them.`;
|
|
26
27
|
|
|
27
28
|
export interface GuidelinePromptOptions {
|
package/src/clock.ts
CHANGED
|
@@ -43,6 +43,11 @@ export class ManualClock implements Clock {
|
|
|
43
43
|
return this.currentTime;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Timers still scheduled: lets tests assert that settle left nothing armed. */
|
|
47
|
+
get pendingTimers(): number {
|
|
48
|
+
return this.timers.size;
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
setTimeout(callback: () => void, delayMs: number): ClockTimer {
|
|
47
52
|
return this.schedule(callback, delayMs, null);
|
|
48
53
|
}
|
package/src/config.ts
CHANGED
|
@@ -25,8 +25,15 @@ export interface FamulusConfig {
|
|
|
25
25
|
export interface FamulusSubagentConfig {
|
|
26
26
|
/** Sync-wait budget before a run is moved to background (ms). Overrides top-level subagentBudgetMs. */
|
|
27
27
|
budgetMs?: number;
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Default per-child soft budget per turn (ms). Reaching it wakes the parent
|
|
30
|
+
* (subagent-overrun); the child keeps running.
|
|
31
|
+
*/
|
|
29
32
|
timeoutMs?: number;
|
|
33
|
+
/** Repeat interval of the overrun wake while a child stays past its budget (ms). Default 10 min. */
|
|
34
|
+
overrunRepeatMs?: number;
|
|
35
|
+
/** Opt-in ceiling per turn that aborts the child (ms). 0 or absent = off (default). */
|
|
36
|
+
hardTimeoutMs?: number;
|
|
30
37
|
/** Stall watchdog: abort a child with no events for this long (ms). Paused during tools and need_decision. */
|
|
31
38
|
stallMs?: number;
|
|
32
39
|
/** Auto-resume attempts after a stall before the run settles failed (stalled). Default 1. */
|
|
@@ -47,6 +54,9 @@ export interface FamulusSubagentConfig {
|
|
|
47
54
|
export interface ResolvedSubagentConfig {
|
|
48
55
|
budgetMs: number;
|
|
49
56
|
timeoutMs: number;
|
|
57
|
+
overrunRepeatMs: number;
|
|
58
|
+
/** 0 = no hard ceiling. */
|
|
59
|
+
hardTimeoutMs: number;
|
|
50
60
|
stallMs: number;
|
|
51
61
|
stallRetries: number;
|
|
52
62
|
stallRetryDelayMs: number;
|
|
@@ -69,6 +79,8 @@ function capTimerDelay(value: number): number {
|
|
|
69
79
|
export const DEFAULT_SUBAGENT_CONFIG: ResolvedSubagentConfig = {
|
|
70
80
|
budgetMs: 45000,
|
|
71
81
|
timeoutMs: 1_800_000,
|
|
82
|
+
overrunRepeatMs: 600_000,
|
|
83
|
+
hardTimeoutMs: 0,
|
|
72
84
|
stallMs: 300_000,
|
|
73
85
|
stallRetries: 1,
|
|
74
86
|
stallRetryDelayMs: 5_000,
|
|
@@ -85,6 +97,12 @@ export function resolveSubagentConfig(config: FamulusConfig): ResolvedSubagentCo
|
|
|
85
97
|
resolved.budgetMs = config.subagentBudgetMs > 0 ? config.subagentBudgetMs : resolved.budgetMs;
|
|
86
98
|
if (typeof section.budgetMs === "number" && section.budgetMs > 0) resolved.budgetMs = section.budgetMs;
|
|
87
99
|
if (typeof section.timeoutMs === "number" && section.timeoutMs > 0) resolved.timeoutMs = section.timeoutMs;
|
|
100
|
+
if (typeof section.overrunRepeatMs === "number" && section.overrunRepeatMs > 0) {
|
|
101
|
+
resolved.overrunRepeatMs = capTimerDelay(section.overrunRepeatMs);
|
|
102
|
+
}
|
|
103
|
+
if (typeof section.hardTimeoutMs === "number" && section.hardTimeoutMs >= 0) {
|
|
104
|
+
resolved.hardTimeoutMs = capTimerDelay(section.hardTimeoutMs);
|
|
105
|
+
}
|
|
88
106
|
if (typeof section.stallMs === "number" && section.stallMs > 0) {
|
|
89
107
|
resolved.stallMs = capTimerDelay(section.stallMs);
|
|
90
108
|
}
|
|
@@ -175,6 +193,12 @@ export function loadConfig(home: string = getFamulusHome()): FamulusConfig {
|
|
|
175
193
|
const subagent: FamulusSubagentConfig = {};
|
|
176
194
|
if (typeof section.budgetMs === "number" && section.budgetMs > 0) subagent.budgetMs = section.budgetMs;
|
|
177
195
|
if (typeof section.timeoutMs === "number" && section.timeoutMs > 0) subagent.timeoutMs = section.timeoutMs;
|
|
196
|
+
if (typeof section.overrunRepeatMs === "number" && section.overrunRepeatMs > 0) {
|
|
197
|
+
subagent.overrunRepeatMs = capTimerDelay(section.overrunRepeatMs);
|
|
198
|
+
}
|
|
199
|
+
if (typeof section.hardTimeoutMs === "number" && section.hardTimeoutMs >= 0) {
|
|
200
|
+
subagent.hardTimeoutMs = capTimerDelay(section.hardTimeoutMs);
|
|
201
|
+
}
|
|
178
202
|
if (typeof section.stallMs === "number" && section.stallMs > 0) {
|
|
179
203
|
subagent.stallMs = capTimerDelay(section.stallMs);
|
|
180
204
|
}
|
package/src/format.ts
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
* These functions are the shared contract between implementation and tests.
|
|
5
5
|
* Signatures must match Appendix A exactly.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
formatFamulusWake,
|
|
9
|
+
shellWakeTitle,
|
|
10
|
+
wakeDuration,
|
|
11
|
+
type FormattedWake,
|
|
12
|
+
type OverrunShell,
|
|
13
|
+
type WakeItem,
|
|
14
|
+
} from "./wake";
|
|
8
15
|
|
|
9
16
|
export { FAMULUS_WAKE_LEAD_IN } from "./wake";
|
|
10
17
|
export type { FormattedWake, WakeItem } from "./wake";
|
|
@@ -195,6 +202,8 @@ export interface SubagentChildInfo {
|
|
|
195
202
|
status: "pending" | "running" | "completed" | "failed" | "interrupted";
|
|
196
203
|
text: string;
|
|
197
204
|
error?: string;
|
|
205
|
+
/** Non-fatal caveat from the child run (e.g. an adapted model spec). */
|
|
206
|
+
warning?: string;
|
|
198
207
|
/** Task prompt that was sent to this child. */
|
|
199
208
|
prompt?: string;
|
|
200
209
|
}
|
|
@@ -230,6 +239,7 @@ export interface SubagentHandoverInfo {
|
|
|
230
239
|
prompt: string;
|
|
231
240
|
text: string;
|
|
232
241
|
error?: string;
|
|
242
|
+
warning?: string;
|
|
233
243
|
/** Children that are still pending or running. Title is the agent name. */
|
|
234
244
|
stillRunning: WakeItem[];
|
|
235
245
|
}
|
|
@@ -251,6 +261,7 @@ export function formatSubagentNotification(info: SubagentNotificationInfo): Form
|
|
|
251
261
|
prompt: capPrompt(child.prompt ?? ""),
|
|
252
262
|
result: capTail(child.text),
|
|
253
263
|
...(child.error ? { error: child.error } : {}),
|
|
264
|
+
...(child.warning ? { warning: child.warning } : {}),
|
|
254
265
|
})),
|
|
255
266
|
});
|
|
256
267
|
}
|
|
@@ -272,5 +283,55 @@ export function formatSubagentHandover(info: SubagentHandoverInfo): FormattedWak
|
|
|
272
283
|
prompt: capPrompt(info.prompt),
|
|
273
284
|
result: capTail(info.text),
|
|
274
285
|
...(info.error ? { error: info.error } : {}),
|
|
286
|
+
...(info.warning ? { warning: info.warning } : {}),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Soft deadline: a running child passed its budget (design doc §4.6 lifecycle)
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
export interface SubagentOverrunInfo {
|
|
295
|
+
runId: string;
|
|
296
|
+
childId: string;
|
|
297
|
+
name: string;
|
|
298
|
+
elapsedMs: number;
|
|
299
|
+
budgetMs: number;
|
|
300
|
+
reminder: number;
|
|
301
|
+
nextReminderMs: number;
|
|
302
|
+
/** Time left until the opt-in hard ceiling aborts the child. Absent when none is set. */
|
|
303
|
+
hardCeilingMs?: number;
|
|
304
|
+
lastActivity: { agoMs: number; text: string };
|
|
305
|
+
shell?: OverrunShell;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Last-activity text cap: enough to see what the child was doing. */
|
|
309
|
+
const OVERRUN_ACTIVITY_CHARS = 300;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A child is past its soft budget and still running. The parent decides:
|
|
313
|
+
* extend, steer, or interrupt; doing nothing leaves it running.
|
|
314
|
+
*/
|
|
315
|
+
export function formatSubagentOverrun(info: SubagentOverrunInfo): FormattedWake {
|
|
316
|
+
const shellBit = info.shell ? `; it is waiting on a shell command that has run ${wakeDuration(info.shell.elapsedMs)}` : "";
|
|
317
|
+
const summary =
|
|
318
|
+
`${info.name} has run ${wakeDuration(info.elapsedMs)} in this turn, past its ` +
|
|
319
|
+
`${wakeDuration(info.budgetMs)} budget, and is still running${shellBit}.`;
|
|
320
|
+
const oneLine = info.lastActivity.text.replace(/\s+/g, " ").trim();
|
|
321
|
+
const text =
|
|
322
|
+
oneLine.length > OVERRUN_ACTIVITY_CHARS ? `…${oneLine.slice(-OVERRUN_ACTIVITY_CHARS)}` : oneLine;
|
|
323
|
+
return formatFamulusWake({
|
|
324
|
+
kind: "subagent-overrun",
|
|
325
|
+
runId: info.runId,
|
|
326
|
+
childId: info.childId,
|
|
327
|
+
name: info.name,
|
|
328
|
+
elapsedMs: info.elapsedMs,
|
|
329
|
+
budgetMs: info.budgetMs,
|
|
330
|
+
reminder: info.reminder,
|
|
331
|
+
nextReminderMs: info.nextReminderMs,
|
|
332
|
+
...(info.hardCeilingMs !== undefined ? { hardCeilingMs: info.hardCeilingMs } : {}),
|
|
333
|
+
summary,
|
|
334
|
+
lastActivity: { agoMs: info.lastActivity.agoMs, text: text || "(no output yet)" },
|
|
335
|
+
...(info.shell ? { shell: { ...info.shell, command: shellWakeTitle(info.shell.command) } } : {}),
|
|
275
336
|
});
|
|
276
337
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { ExitNotifyGate } from "./exit-notify-gate";
|
|
|
11
11
|
import { ExitWatchdog } from "./exit-watchdog";
|
|
12
12
|
import { createExtensionEventLog } from "./events";
|
|
13
13
|
import { readFileTail } from "./file-tail";
|
|
14
|
+
import { statSync } from "node:fs";
|
|
14
15
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
15
16
|
import { homedir } from "node:os";
|
|
16
17
|
import { join } from "node:path";
|
|
@@ -27,7 +28,9 @@ import type { TaskExitInfo } from "./format";
|
|
|
27
28
|
import { ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
28
29
|
import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
|
|
29
30
|
import { NotifyCenter } from "./notify";
|
|
31
|
+
import { onWakeMessageEnd, registryStatusLookup } from "./wake-delivery";
|
|
30
32
|
import { createChildBashTool } from "./subagent/child-bash";
|
|
33
|
+
import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
|
|
31
34
|
import {
|
|
32
35
|
agentEndReason,
|
|
33
36
|
headOf,
|
|
@@ -504,6 +507,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
504
507
|
"warning",
|
|
505
508
|
);
|
|
506
509
|
}
|
|
510
|
+
// The foreground shell each child is blocked on, for its overrun wake.
|
|
511
|
+
const childShells = new ChildShellTracker();
|
|
507
512
|
const createSession = createPiSessionFn({
|
|
508
513
|
getModelRegistry: () => ctx?.modelRegistry ?? null,
|
|
509
514
|
getModelRuntime: () => {
|
|
@@ -533,6 +538,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
533
538
|
childId: req.childId,
|
|
534
539
|
runId: req.runId,
|
|
535
540
|
clock,
|
|
541
|
+
shells: childShells,
|
|
536
542
|
}),
|
|
537
543
|
);
|
|
538
544
|
}
|
|
@@ -551,7 +557,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
551
557
|
stallMs: subagentConfig.stallMs,
|
|
552
558
|
stallRetries: subagentConfig.stallRetries,
|
|
553
559
|
stallRetryDelayMs: subagentConfig.stallRetryDelayMs,
|
|
554
|
-
|
|
560
|
+
overrunRepeatMs: subagentConfig.overrunRepeatMs,
|
|
561
|
+
hardTimeoutMs: subagentConfig.hardTimeoutMs,
|
|
562
|
+
acquire: (req, ticket) => registry.admitChild(req.childId, ticket),
|
|
555
563
|
onActivity: (childId) => {
|
|
556
564
|
syncTranscript(childId);
|
|
557
565
|
},
|
|
@@ -560,6 +568,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
560
568
|
// retry budget (stallRetries) is already spent.
|
|
561
569
|
logEvent("agent.stall", { child_id: childId, attempt });
|
|
562
570
|
},
|
|
571
|
+
// Soft deadline: the child keeps running; the parent decides.
|
|
572
|
+
onOverrun: createOverrunNotifier({
|
|
573
|
+
now: () => clock.now(),
|
|
574
|
+
registry,
|
|
575
|
+
shells: childShells,
|
|
576
|
+
stat: (path) => {
|
|
577
|
+
try {
|
|
578
|
+
const st = statSync(path);
|
|
579
|
+
return { size: st.size, mtimeMs: st.mtimeMs };
|
|
580
|
+
} catch {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
notify: (wake) => notifyCenter?.notify(wake),
|
|
585
|
+
logEvent,
|
|
586
|
+
}),
|
|
563
587
|
});
|
|
564
588
|
registry.setRunner(runner);
|
|
565
589
|
subagentRegistry = registry;
|
|
@@ -581,6 +605,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
581
605
|
}
|
|
582
606
|
if (["completed", "failed", "interrupted"].includes(c.status) && previousStatus !== c.status) {
|
|
583
607
|
const error = c.result?.error;
|
|
608
|
+
// Only the opt-in hard ceiling settles with error=timeout now.
|
|
584
609
|
if (error === "timeout") logEvent("agent.timeout", { child_id: c.childId });
|
|
585
610
|
logEvent("agent.settle", {
|
|
586
611
|
child_id: c.childId,
|
|
@@ -694,6 +719,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
694
719
|
notifyCenter?.settled();
|
|
695
720
|
});
|
|
696
721
|
|
|
722
|
+
// A wake entering the model's context (steer / triggerTurn); see wake-delivery.ts.
|
|
723
|
+
pi.on("message_end", async (event) => {
|
|
724
|
+
const message = onWakeMessageEnd(event.message, {
|
|
725
|
+
now: () => clock.now(),
|
|
726
|
+
logEvent,
|
|
727
|
+
lookup: () => (subagentRegistry ? registryStatusLookup(subagentRegistry) : null),
|
|
728
|
+
});
|
|
729
|
+
return message ? { message } : undefined;
|
|
730
|
+
});
|
|
731
|
+
|
|
697
732
|
pi.on("before_agent_start", async (event) => {
|
|
698
733
|
applyBehaviorGuidelines(event.systemPromptOptions as { sections?: Record<string, string> });
|
|
699
734
|
});
|
package/src/manager-client.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { famulusPaths } from "./config";
|
|
|
16
16
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
17
17
|
|
|
18
18
|
const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
|
|
19
|
-
const EXTENSION_VERSION = "0.1.
|
|
19
|
+
const EXTENSION_VERSION = "0.1.3";
|
|
20
20
|
// 3: speaks in-place upgrade (resends on reconnect, start keys).
|
|
21
21
|
const PROTOCOL = 3;
|
|
22
22
|
const HELLO_TIMEOUT_MS = 5000;
|
package/src/notify.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* ever delivered once.
|
|
15
15
|
*/
|
|
16
16
|
import { formatMonitorEvent, formatTaskNotification, type TaskExitInfo } from "./format";
|
|
17
|
-
import { FAMULUS_WAKE_CUSTOM_TYPE, type WakeItem } from "./wake";
|
|
17
|
+
import { FAMULUS_WAKE_CUSTOM_TYPE, stampWakeAsOf, wakeIds, type WakeItem } from "./wake";
|
|
18
18
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
19
19
|
|
|
20
20
|
|
|
@@ -88,6 +88,8 @@ export class NotifyCenter {
|
|
|
88
88
|
*/
|
|
89
89
|
notify(message: NotifyMessage, opts: { passive?: boolean; quietEvents?: boolean } = {}): void {
|
|
90
90
|
if (this.disposed) return;
|
|
91
|
+
// Stamped on arrival: a held passive notice keeps the time it was built.
|
|
92
|
+
message = stampWakeAsOf(message, this.clock.now());
|
|
91
93
|
const wake = message.details as { kind?: string; id?: string } | undefined;
|
|
92
94
|
if (wake?.kind === "monitor" && typeof wake.id === "string") this.flushMonitor(wake.id, opts.quietEvents);
|
|
93
95
|
if (!opts.passive) {
|
|
@@ -203,14 +205,12 @@ export class NotifyCenter {
|
|
|
203
205
|
}
|
|
204
206
|
|
|
205
207
|
private deliver(message: NotifyMessage, passive = false): void {
|
|
208
|
+
// Wakes built here (task batches, monitor events) are stamped now.
|
|
209
|
+
message = stampWakeAsOf(message, this.clock.now());
|
|
206
210
|
const msg = { ...message, display: true };
|
|
207
|
-
const details = message.details as { kind?: string;
|
|
211
|
+
const details = message.details as { kind?: string; eventCount?: number } | undefined;
|
|
208
212
|
if (details?.kind) {
|
|
209
|
-
const ids = details
|
|
210
|
-
? (details.tasks ?? []).map((task) => task.id)
|
|
211
|
-
: details.kind === "subagent-done"
|
|
212
|
-
? (details.children ?? []).map((child) => child.childId)
|
|
213
|
-
: [details.id ?? details.taskId ?? details.childId ?? details.from].filter((id): id is string => Boolean(id));
|
|
213
|
+
const ids = wakeIds(details);
|
|
214
214
|
this.deps.logEvent?.("wake.emit", {
|
|
215
215
|
kind: details.kind,
|
|
216
216
|
ids,
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
import { taskOutputPath } from "../config";
|
|
26
26
|
import { realClock, type Clock } from "../clock";
|
|
27
27
|
import type { ManagerClient } from "../manager-client";
|
|
28
|
+
import type { ChildShellTracker } from "./overrun";
|
|
28
29
|
import {
|
|
29
30
|
appendStatus,
|
|
30
31
|
bareSleepError,
|
|
@@ -66,6 +67,8 @@ export interface ChildBashDeps {
|
|
|
66
67
|
childId?: string;
|
|
67
68
|
runId?: string;
|
|
68
69
|
clock?: Clock;
|
|
70
|
+
/** Records the shell this child is blocked on, for its overrun wake. */
|
|
71
|
+
shells?: Pick<ChildShellTracker, "start" | "end">;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
const CHILD_SLEEP_GUIDANCE =
|
|
@@ -132,52 +135,66 @@ export function createChildBashTool(
|
|
|
132
135
|
const outputPath = taskOutputPath(deps.home, deps.sessionId(), start.task_id);
|
|
133
136
|
|
|
134
137
|
const clock = deps.clock ?? realClock;
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
138
|
+
const childId = deps.childId;
|
|
139
|
+
if (childId) {
|
|
140
|
+
deps.shells?.start(childId, {
|
|
141
|
+
taskId: start.task_id,
|
|
142
|
+
command: input.command,
|
|
143
|
+
startedAt: clock.now(),
|
|
144
|
+
outputPath,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const deadline = timeoutMs !== null ? clock.now() + timeoutMs : null;
|
|
149
|
+
let waitResult: { done: boolean; exit_code?: number | null } | null = null;
|
|
150
|
+
for (;;) {
|
|
151
|
+
const budget =
|
|
152
|
+
deadline === null
|
|
153
|
+
? WAIT_SLICE_MS
|
|
154
|
+
: Math.min(WAIT_SLICE_MS, Math.max(1, deadline - clock.now()));
|
|
155
|
+
try {
|
|
156
|
+
waitResult = await withAbort(client.wait(start.task_id, budget), signal, () => {
|
|
157
|
+
client.stop(start.task_id, "tool").catch(() => {});
|
|
158
|
+
});
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if ((err as Error).message === "aborted") {
|
|
161
|
+
throw new Error("Command aborted (task stopped)");
|
|
162
|
+
}
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
|
|
165
|
+
`${(err as Error).message}. Output so far: ${outputPath}.`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (waitResult.done) break;
|
|
169
|
+
if (deadline !== null && clock.now() >= deadline) {
|
|
170
|
+
await client.stop(start.task_id, "timeout").catch(() => {});
|
|
171
|
+
const collected = await collectOutput(client, start.task_id).catch(() => null);
|
|
172
|
+
const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
|
|
173
|
+
throw new Error(
|
|
174
|
+
appendStatus(text, timedOutStatus(input.timeout!)),
|
|
175
|
+
);
|
|
149
176
|
}
|
|
150
|
-
throw new Error(
|
|
151
|
-
`Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
|
|
152
|
-
`${(err as Error).message}. Output so far: ${outputPath}.`,
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
if (waitResult.done) break;
|
|
156
|
-
if (deadline !== null && clock.now() >= deadline) {
|
|
157
|
-
await client.stop(start.task_id, "timeout").catch(() => {});
|
|
158
|
-
const collected = await collectOutput(client, start.task_id).catch(() => null);
|
|
159
|
-
const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
|
|
160
|
-
throw new Error(
|
|
161
|
-
appendStatus(text, timedOutStatus(input.timeout!)),
|
|
162
|
-
);
|
|
163
177
|
}
|
|
164
|
-
}
|
|
165
178
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
const collected = await collectOutput(client, start.task_id);
|
|
180
|
+
const { text, details } = formatFinishedOutput(collected, outputPath);
|
|
181
|
+
const exitCode = waitResult.exit_code ?? null;
|
|
182
|
+
if (exitCode !== 0 && exitCode !== null) {
|
|
183
|
+
throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
|
|
184
|
+
}
|
|
185
|
+
// No exit code: killed (timeout, stop, crash), unless the manager
|
|
186
|
+
// finished it as completed with its runner status unobservable.
|
|
187
|
+
if (exitCode === null && collected.status !== "completed") {
|
|
188
|
+
throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
|
|
189
|
+
}
|
|
190
|
+
const finalDetails = details
|
|
191
|
+
? { ...details, task_id: start.task_id }
|
|
192
|
+
: ({ task_id: start.task_id } as FamulusChildBashDetails);
|
|
193
|
+
return { content: [{ type: "text", text }], details: finalDetails };
|
|
194
|
+
} finally {
|
|
195
|
+
// The overrun wake must not name a shell that already returned.
|
|
196
|
+
if (childId) deps.shells?.end(childId, start.task_id);
|
|
176
197
|
}
|
|
177
|
-
const finalDetails = details
|
|
178
|
-
? { ...details, task_id: start.task_id }
|
|
179
|
-
: ({ task_id: start.task_id } as FamulusChildBashDetails);
|
|
180
|
-
return { content: [{ type: "text", text }], details: finalDetails };
|
|
181
198
|
},
|
|
182
199
|
};
|
|
183
200
|
}
|