pi-famulus 0.1.1 → 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 +32 -16
- package/src/agents/definition.ts +12 -6
- package/src/bash-override.ts +89 -9
- 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/fleet-widget.ts +46 -6
- package/src/subagent/model-spec.ts +134 -16
- package/src/subagent/overrun.ts +176 -0
- package/src/subagent/pi-runtime.ts +7 -5
- 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/tui/tasks-command.ts +36 -6
- package/src/wake-delivery.ts +137 -0
- package/src/wake.ts +238 -4
- package/src/work-index.ts +16 -0
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,36 +9,52 @@
|
|
|
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",
|
|
28
45
|
"typecheck": "tsc --noEmit"
|
|
29
46
|
},
|
|
30
|
-
"dependencies": {
|
|
31
|
-
"typebox": "^1.3.7"
|
|
32
|
-
},
|
|
33
47
|
"devDependencies": {
|
|
34
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
35
|
-
"@earendil-works/pi-tui": "^0.
|
|
48
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
49
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
36
50
|
"@types/node": "^24.0.0",
|
|
51
|
+
"typebox": "1.3.27",
|
|
37
52
|
"typescript": "7.0.2",
|
|
38
53
|
"vitest": "^3.0.0"
|
|
39
54
|
},
|
|
40
55
|
"peerDependencies": {
|
|
41
56
|
"@earendil-works/pi-coding-agent": "*",
|
|
57
|
+
"typebox": "*",
|
|
42
58
|
"@earendil-works/pi-tui": "*"
|
|
43
59
|
},
|
|
44
60
|
"peerDependenciesMeta": {
|
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 {
|
package/src/bash-override.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { randomUUID } from "node:crypto";
|
|
|
17
17
|
import { writeFileSync } from "node:fs";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
-
import { Type } from "typebox";
|
|
20
|
+
import { Type, type Static } from "typebox";
|
|
21
21
|
import type {
|
|
22
22
|
AgentToolResult,
|
|
23
23
|
BashToolDetails,
|
|
@@ -62,6 +62,46 @@ const bashParameters = Type.Object({
|
|
|
62
62
|
|
|
63
63
|
type BashParams = { command: string; timeout?: number; run_in_background?: boolean };
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Mirrors the built-in bash outputSchema (pi ≥0.99) so codemode scripts calling
|
|
67
|
+
* `bash()` resolve to the same structured shape the built-in tool returns.
|
|
68
|
+
* pi does not export `bashOutputSchema`, so keep this in sync with BashToolOutput.
|
|
69
|
+
*/
|
|
70
|
+
const bashOutputSchema = Type.Object({
|
|
71
|
+
output: Type.String(),
|
|
72
|
+
truncated: Type.Boolean(),
|
|
73
|
+
full_output_path: Type.Optional(Type.String()),
|
|
74
|
+
exit_code: Type.Number(),
|
|
75
|
+
wall_time_seconds: Type.Number(),
|
|
76
|
+
});
|
|
77
|
+
type BashToolOutput = Static<typeof bashOutputSchema>;
|
|
78
|
+
|
|
79
|
+
/** Same cap as the built-in structured output (1 MiB). */
|
|
80
|
+
const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
/** Full-output slice for structuredContent, keeping the tail like the built-in. */
|
|
83
|
+
function structuredOutput(fullText: string): { output: string; truncated: boolean } {
|
|
84
|
+
if (Buffer.byteLength(fullText, "utf8") <= STRUCTURED_OUTPUT_MAX_BYTES) {
|
|
85
|
+
return { output: fullText, truncated: false };
|
|
86
|
+
}
|
|
87
|
+
// Keep the largest tail that fits the cap without materializing the whole
|
|
88
|
+
// output as a second buffer: search a UTF-16 index near the end (each code
|
|
89
|
+
// unit encodes to at least one byte, so the tail starts within `cap` units
|
|
90
|
+
// of the end) and only encode that slice.
|
|
91
|
+
let lo = Math.max(0, fullText.length - STRUCTURED_OUTPUT_MAX_BYTES);
|
|
92
|
+
let hi = fullText.length;
|
|
93
|
+
while (lo < hi) {
|
|
94
|
+
const mid = (lo + hi) >> 1;
|
|
95
|
+
if (Buffer.byteLength(fullText.slice(mid), "utf8") <= STRUCTURED_OUTPUT_MAX_BYTES) hi = mid;
|
|
96
|
+
else lo = mid + 1;
|
|
97
|
+
}
|
|
98
|
+
return { output: fullText.slice(lo), truncated: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function wallSeconds(startedAtMs: number, endedAtMs: number): number {
|
|
102
|
+
return Math.round((endedAtMs - startedAtMs) / 100) / 10;
|
|
103
|
+
}
|
|
104
|
+
|
|
65
105
|
/** Details shape returned by this override; superset of BashToolDetails. */
|
|
66
106
|
export interface FamulusBashDetails extends BashToolDetails {
|
|
67
107
|
backgrounded?: boolean;
|
|
@@ -130,6 +170,7 @@ async function executeLocal(
|
|
|
130
170
|
clock: Clock,
|
|
131
171
|
): Promise<AgentToolResult<FamulusBashDetails | undefined>> {
|
|
132
172
|
const timeoutMs = resolveTimeoutMs(params.timeout);
|
|
173
|
+
const startedAtMs = clock.now();
|
|
133
174
|
const shell = process.env.SHELL && process.env.SHELL.length > 0 ? process.env.SHELL : "/bin/bash";
|
|
134
175
|
|
|
135
176
|
const output = await new Promise<{ text: string; exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean }>(
|
|
@@ -216,11 +257,26 @@ async function executeLocal(
|
|
|
216
257
|
if (output.timedOut && params.timeout !== undefined) {
|
|
217
258
|
throw new Error(appendStatus(text, timedOutStatus(params.timeout)));
|
|
218
259
|
}
|
|
219
|
-
if (output.exitCode !== 0 && output.exitCode !== null) {
|
|
220
|
-
throw new Error(appendStatus(text, `Command exited with code ${output.exitCode}`));
|
|
221
|
-
}
|
|
222
260
|
if (output.exitCode === null) throw new Error(appendStatus(text, killedBy(output.signal)));
|
|
223
|
-
|
|
261
|
+
const { output: structuredText, truncated: structuredTruncated } = structuredOutput(output.text);
|
|
262
|
+
const structuredContent: BashToolOutput = {
|
|
263
|
+
output: structuredText,
|
|
264
|
+
truncated: structuredTruncated,
|
|
265
|
+
...(details?.fullOutputPath ? { full_output_path: details.fullOutputPath } : {}),
|
|
266
|
+
exit_code: output.exitCode,
|
|
267
|
+
wall_time_seconds: wallSeconds(startedAtMs, clock.now()),
|
|
268
|
+
};
|
|
269
|
+
if (output.exitCode !== 0) {
|
|
270
|
+
// Mirror the built-in bash tool: non-zero exits resolve to an isError
|
|
271
|
+
// result carrying structuredContent instead of rejecting.
|
|
272
|
+
return {
|
|
273
|
+
content: [{ type: "text", text: appendStatus(text, `Command exited with code ${output.exitCode}`) }],
|
|
274
|
+
details,
|
|
275
|
+
structuredContent,
|
|
276
|
+
isError: true,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return { content: [{ type: "text", text }], details, structuredContent };
|
|
224
280
|
}
|
|
225
281
|
|
|
226
282
|
// ---------------------------------------------------------------------------
|
|
@@ -245,6 +301,7 @@ export function createBashOverride(
|
|
|
245
301
|
"Long-running bash commands are moved to the background automatically; do not poll or sleep to wait for them. End your turn (a reply with no tool call) and resume from the task wake when it arrives.",
|
|
246
302
|
],
|
|
247
303
|
parameters: bashParameters,
|
|
304
|
+
outputSchema: bashOutputSchema,
|
|
248
305
|
renderResult(result, { expanded }, theme, context) {
|
|
249
306
|
const details = result.details as FamulusBashDetails | undefined;
|
|
250
307
|
if (details?.backgrounded && details.task_id) {
|
|
@@ -291,6 +348,7 @@ export function createBashOverride(
|
|
|
291
348
|
}
|
|
292
349
|
|
|
293
350
|
const timeoutMs = resolveTimeoutMs(input.timeout);
|
|
351
|
+
const startedAtMs = (deps.clock ?? realClock).now();
|
|
294
352
|
let start;
|
|
295
353
|
try {
|
|
296
354
|
start = await client.start({
|
|
@@ -358,15 +416,37 @@ export function createBashOverride(
|
|
|
358
416
|
const collected = await collectOutput(client, start.task_id);
|
|
359
417
|
const { text, details } = formatFinishedOutput(collected, outputPath);
|
|
360
418
|
const exitCode = waitResult.exit_code ?? null;
|
|
361
|
-
if (exitCode !== 0 && exitCode !== null) {
|
|
362
|
-
throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
|
|
363
|
-
}
|
|
364
419
|
// No exit code: killed (timeout, stop, crash), unless the manager
|
|
365
420
|
// finished it as completed with its runner status unobservable.
|
|
366
421
|
if (exitCode === null && collected.status !== "completed") {
|
|
367
422
|
throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
|
|
368
423
|
}
|
|
369
|
-
|
|
424
|
+
if (exitCode === null) {
|
|
425
|
+
// Completed but the exit code was unobservable: no honest
|
|
426
|
+
// structured shape (exit_code is required, and a sentinel like -1
|
|
427
|
+
// would misclassify success as failure), so omit structuredContent
|
|
428
|
+
// and let codemode scripts fall back to the text content.
|
|
429
|
+
return { content: [{ type: "text", text }], details };
|
|
430
|
+
}
|
|
431
|
+
const { output: structuredText, truncated: structuredTruncated } = structuredOutput(collected.text);
|
|
432
|
+
const structuredContent: BashToolOutput = {
|
|
433
|
+
output: structuredText,
|
|
434
|
+
truncated: structuredTruncated || collected.windowed,
|
|
435
|
+
full_output_path: outputPath,
|
|
436
|
+
exit_code: exitCode,
|
|
437
|
+
wall_time_seconds: wallSeconds(startedAtMs, (deps.clock ?? realClock).now()),
|
|
438
|
+
};
|
|
439
|
+
if (exitCode !== 0) {
|
|
440
|
+
// Mirror the built-in bash tool: non-zero exits resolve to an isError
|
|
441
|
+
// result carrying structuredContent instead of rejecting.
|
|
442
|
+
return {
|
|
443
|
+
content: [{ type: "text", text: appendStatus(text, `Command exited with code ${exitCode}`) }],
|
|
444
|
+
details,
|
|
445
|
+
structuredContent,
|
|
446
|
+
isError: true,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return { content: [{ type: "text", text }], details, structuredContent };
|
|
370
450
|
},
|
|
371
451
|
};
|
|
372
452
|
}
|
|
@@ -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,
|