pi-famulus 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +31 -12
- package/src/agents/builtins.ts +5 -22
- package/src/agents/definition.ts +16 -12
- package/src/bash-override.ts +8 -21
- package/src/behavior-guidelines.ts +40 -15
- package/src/clock.ts +5 -0
- package/src/comms/comms.ts +7 -3
- package/src/comms/tools.ts +11 -36
- package/src/config.ts +25 -1
- package/src/format.ts +83 -5
- package/src/index.ts +155 -30
- package/src/manager-client.ts +265 -114
- package/src/monitor.ts +14 -33
- package/src/notify.ts +7 -7
- package/src/prompts.generated.ts +183 -0
- package/src/shell-exec.ts +6 -2
- package/src/subagent/admission.ts +224 -0
- package/src/subagent/agent-records.ts +66 -3
- package/src/subagent/child-bash.ts +65 -56
- package/src/subagent/model-spec.ts +134 -16
- package/src/subagent/overrun.ts +191 -0
- package/src/subagent/pi-runtime.ts +118 -33
- package/src/subagent/registry.ts +235 -34
- package/src/subagent/runner.ts +565 -99
- package/src/subagent/tool.ts +111 -61
- package/src/subagent/types.ts +71 -7
- package/src/subagent/usage.ts +48 -0
- package/src/task-tools.ts +15 -25
- package/src/thinking-levels.ts +26 -0
- package/src/tui/message-renderers.ts +52 -2
- package/src/wake-delivery.ts +137 -0
- package/src/wake.ts +270 -7
- package/src/work-index.ts +4 -0
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# pi-famulus
|
|
2
2
|
|
|
3
|
-
Subagent orchestration, auto-backgrounding bash, monitoring, and agent-to-agent communication for pi.
|
|
3
|
+
Subagent orchestration, auto-backgrounding bash, monitoring, and agent-to-agent communication for pi 1.0.0 or newer.
|
|
4
|
+
|
|
5
|
+
Workers inherit user/global and trusted-project pi configuration and resources, including codemode and MCP, then receive famulus communication tools. An explicit agent `tools` list remains an allowlist; omitting it inherits the user’s default tools. Parent-only CLI resource overrides are not copied.
|
|
4
6
|
|
|
5
7
|
After the first public release:
|
|
6
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-famulus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Subagents, monitoring, and auto-backgrounding bash for pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,21 +9,40 @@
|
|
|
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.4",
|
|
34
|
+
"pi-famulus-linux-arm64": "0.1.4",
|
|
35
|
+
"pi-famulus-darwin-x64": "0.1.4",
|
|
36
|
+
"pi-famulus-darwin-arm64": "0.1.4"
|
|
22
37
|
},
|
|
23
38
|
"pi": {
|
|
24
|
-
"extensions": [
|
|
39
|
+
"extensions": [
|
|
40
|
+
"./src/index.ts"
|
|
41
|
+
]
|
|
25
42
|
},
|
|
26
43
|
"scripts": {
|
|
44
|
+
"deps": "nub install --frozen-lockfile --node-linker hoisted",
|
|
45
|
+
"prompts": "node scripts/build-prompts.ts",
|
|
27
46
|
"test": "vitest run",
|
|
28
47
|
"typecheck": "tsc --noEmit"
|
|
29
48
|
},
|
|
@@ -36,7 +55,7 @@
|
|
|
36
55
|
"vitest": "^3.0.0"
|
|
37
56
|
},
|
|
38
57
|
"peerDependencies": {
|
|
39
|
-
"@earendil-works/pi-coding-agent": "
|
|
58
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0",
|
|
40
59
|
"typebox": "*",
|
|
41
60
|
"@earendil-works/pi-tui": "*"
|
|
42
61
|
},
|
package/src/agents/builtins.ts
CHANGED
|
@@ -4,38 +4,21 @@
|
|
|
4
4
|
* These form the lowest precedence layer: user and project definitions with
|
|
5
5
|
* the same name replace them wholesale.
|
|
6
6
|
*/
|
|
7
|
+
import { PROMPTS } from "../prompts.generated";
|
|
7
8
|
import type { AgentDefinition } from "./definition";
|
|
8
9
|
|
|
9
10
|
export const BUILTIN_AGENTS: AgentDefinition[] = [
|
|
10
11
|
{
|
|
11
12
|
name: "explorer",
|
|
12
|
-
description:
|
|
13
|
-
"Fast read-only codebase exploration — finds files, symbols, and answers structure questions",
|
|
13
|
+
description: PROMPTS["agents.explorer.description"],
|
|
14
14
|
tools: ["read", "grep", "find", "ls", "bash"],
|
|
15
|
-
systemPrompt: [
|
|
16
|
-
"You are an explorer agent. Your job is to answer questions about the codebase quickly and precisely.",
|
|
17
|
-
"",
|
|
18
|
-
"Rules:",
|
|
19
|
-
"- Return findings, never change code. You are strictly read-only: do not edit, write, or create files.",
|
|
20
|
-
"- Report concrete locations: cite specific file paths with line numbers (path:line) for every claim.",
|
|
21
|
-
"- Control your search scope: start narrow (targeted grep/find), widen only when needed, and stop once the question is answered.",
|
|
22
|
-
"- Prefer a short, factual summary over exhaustive dumps. List what you found and where.",
|
|
23
|
-
].join("\n"),
|
|
15
|
+
systemPrompt: PROMPTS["agents.explorer.system-prompt"],
|
|
24
16
|
source: "builtin",
|
|
25
17
|
},
|
|
26
18
|
{
|
|
27
19
|
name: "worker",
|
|
28
|
-
description: "
|
|
29
|
-
|
|
30
|
-
systemPrompt: [
|
|
31
|
-
"You are a worker agent. Your job is to complete the concrete task you were given, end to end.",
|
|
32
|
-
"",
|
|
33
|
-
"Rules:",
|
|
34
|
-
"- Do the task, don't just describe it. Make the actual edits and run the actual commands.",
|
|
35
|
-
"- Self-verify before finishing: run the relevant tests, typecheck, or command that proves the task is done.",
|
|
36
|
-
"- Stay within the task's scope; do not refactor unrelated code.",
|
|
37
|
-
"- Report concisely: what you changed, how you verified it, and anything the supervisor should know.",
|
|
38
|
-
].join("\n"),
|
|
20
|
+
description: PROMPTS["agents.worker.description"],
|
|
21
|
+
systemPrompt: PROMPTS["agents.worker.system-prompt"],
|
|
39
22
|
source: "builtin",
|
|
40
23
|
},
|
|
41
24
|
];
|
package/src/agents/definition.ts
CHANGED
|
@@ -15,16 +15,23 @@
|
|
|
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;
|
|
21
27
|
/** Required, non-empty. */
|
|
22
28
|
description: string;
|
|
23
|
-
/**
|
|
24
|
-
tools
|
|
29
|
+
/** Omitted: inherit user-configured tools. Explicit list: allowlist; []: disables user-configured tools, but injected Famulus tools remain. */
|
|
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";
|
|
@@ -32,11 +39,7 @@ export interface AgentDefinition {
|
|
|
32
39
|
path?: string;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
export const DEFAULT_AGENT_TOOLS: readonly string[] = ["read", "bash", "edit", "write"];
|
|
36
|
-
|
|
37
42
|
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
43
|
|
|
41
44
|
type Fail = (reason: string) => never;
|
|
42
45
|
|
|
@@ -163,7 +166,7 @@ export function parseAgentMarkdown(
|
|
|
163
166
|
const description = parseScalar(fields.description, "description", fail);
|
|
164
167
|
if (description === "") fail("field 'description' must be non-empty");
|
|
165
168
|
|
|
166
|
-
let tools
|
|
169
|
+
let tools: string[] | undefined;
|
|
167
170
|
if (fields.tools !== undefined) {
|
|
168
171
|
tools = parseStringArray(fields.tools, "tools", fail);
|
|
169
172
|
}
|
|
@@ -178,18 +181,19 @@ export function parseAgentMarkdown(
|
|
|
178
181
|
let thinking: ThinkingLevel | undefined;
|
|
179
182
|
if (fields.thinking !== undefined) {
|
|
180
183
|
const value = parseScalar(fields.thinking, "thinking", fail);
|
|
181
|
-
if (
|
|
184
|
+
if (isValidThinkingLevel(value)) {
|
|
185
|
+
thinking = value;
|
|
186
|
+
} else {
|
|
182
187
|
fail(
|
|
183
|
-
`invalid thinking level "${value}" (expected one of: ${
|
|
188
|
+
`invalid thinking level "${value}" (expected one of: ${VALID_THINKING_LEVELS.join(", ")})`,
|
|
184
189
|
);
|
|
185
190
|
}
|
|
186
|
-
thinking = value as ThinkingLevel;
|
|
187
191
|
}
|
|
188
192
|
|
|
189
193
|
return {
|
|
190
194
|
name,
|
|
191
195
|
description,
|
|
192
|
-
tools,
|
|
196
|
+
...(tools !== undefined ? { tools } : {}),
|
|
193
197
|
...(model !== undefined ? { model } : {}),
|
|
194
198
|
...(thinking !== undefined ? { thinking } : {}),
|
|
195
199
|
systemPrompt: body.trim(),
|
package/src/bash-override.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { backgroundRowText, formatBackgroundNotice, truncateTail } from "./forma
|
|
|
30
30
|
import { toolComponent } from "./tui/tool-component";
|
|
31
31
|
import type { WorkIndex } from "./work-index";
|
|
32
32
|
import type { ManagerClient } from "./manager-client";
|
|
33
|
+
import { fill, PROMPTS } from "./prompts.generated";
|
|
33
34
|
import {
|
|
34
35
|
appendStatus,
|
|
35
36
|
bareSleepError,
|
|
@@ -48,15 +49,12 @@ const MAX_LINES = SHELL_MAX_LINES;
|
|
|
48
49
|
const MAX_BYTES = SHELL_MAX_BYTES;
|
|
49
50
|
|
|
50
51
|
const bashParameters = Type.Object({
|
|
51
|
-
command: Type.String({ description: "
|
|
52
|
+
command: Type.String({ description: PROMPTS["tools.bash.param.command"] }),
|
|
52
53
|
timeout: Type.Optional(
|
|
53
|
-
Type.Number({ description: "
|
|
54
|
+
Type.Number({ description: PROMPTS["tools.bash.param.timeout"] }),
|
|
54
55
|
),
|
|
55
56
|
run_in_background: Type.Optional(
|
|
56
|
-
Type.Boolean({
|
|
57
|
-
description:
|
|
58
|
-
"Start the command in the background and return immediately. You will be notified when it completes.",
|
|
59
|
-
}),
|
|
57
|
+
Type.Boolean({ description: PROMPTS["tools.bash.param.run_in_background"] }),
|
|
60
58
|
),
|
|
61
59
|
});
|
|
62
60
|
|
|
@@ -135,11 +133,8 @@ function isActiveStatus(status: string | undefined): boolean {
|
|
|
135
133
|
return status === undefined || status === "running" || status === "pending";
|
|
136
134
|
}
|
|
137
135
|
|
|
138
|
-
const BARE_SLEEP_GUIDANCE =
|
|
139
|
-
"Do not sleep to wait for background work; completion is delivered via notification. Use the monitor tool to watch for a condition, or run_in_background for long commands.";
|
|
140
|
-
|
|
141
136
|
function rejectBareSleep(command: string): string | null {
|
|
142
|
-
return bareSleepError(command,
|
|
137
|
+
return bareSleepError(command, "tools.bash.error.bare-sleep");
|
|
143
138
|
}
|
|
144
139
|
|
|
145
140
|
function resolveTimeoutMs(timeoutSeconds: number | undefined): number | null {
|
|
@@ -289,17 +284,9 @@ export function createBashOverride(
|
|
|
289
284
|
return {
|
|
290
285
|
name: "bash",
|
|
291
286
|
label: "Bash",
|
|
292
|
-
description:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"Foreground commands that exceed the foreground budget are automatically moved to the background; " +
|
|
296
|
-
"you will be notified when they complete. Optionally provide a timeout in seconds (hard kill limit), " +
|
|
297
|
-
"or run_in_background to background immediately.",
|
|
298
|
-
promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
|
|
299
|
-
promptGuidelines: [
|
|
300
|
-
"You can inspect PI_* environment variables for current model and session details.",
|
|
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.",
|
|
302
|
-
],
|
|
287
|
+
description: fill("tools.bash.description", { maxLines: MAX_LINES, maxKb: MAX_BYTES / 1024 }),
|
|
288
|
+
promptSnippet: PROMPTS["tools.bash.snippet"],
|
|
289
|
+
promptGuidelines: [...PROMPTS["tools.bash.rules"]],
|
|
303
290
|
parameters: bashParameters,
|
|
304
291
|
outputSchema: bashOutputSchema,
|
|
305
292
|
renderResult(result, { expanded }, theme, context) {
|
|
@@ -4,25 +4,22 @@
|
|
|
4
4
|
* Returning `systemPrompt` from before_agent_start forces the whole prompt for
|
|
5
5
|
* that user-prompt run only (and clobbers other extensions). Wake turns started
|
|
6
6
|
* by sendMessage({ triggerTurn: true }) do not call before_agent_start again.
|
|
7
|
-
* A named section is
|
|
7
|
+
* A named section is recorded on user turns. Pi can rebuild the base prompt
|
|
8
|
+
* after a wake's tool result and remove that run-local section, so the request
|
|
9
|
+
* projection restores only our section whenever it is missing or stale.
|
|
8
10
|
*/
|
|
9
|
-
|
|
11
|
+
import type { ContextWithSystemEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
// .ts suffix: eval/ loads this module with Node's own TypeScript support.
|
|
13
|
+
import { fill, PROMPTS } from "./prompts.generated.ts";
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
export const CHILD_BEHAVIOR_GUIDELINES = `## Child agent behavior (pi-famulus)
|
|
15
|
+
export const BEHAVIOR_GUIDELINES_SECTION = "pi-famulus";
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
- When blocked, use contact_supervisor with need_decision; use progress_update for status.
|
|
16
|
-
- Use agent_message only to communicate with siblings in this same run.
|
|
17
|
-
- Your final message is your result.`;
|
|
17
|
+
export const MONITOR_IDLE_INSTRUCTION = PROMPTS["guidelines.monitor-idle"];
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** Child-only instructions injected through the contact_supervisor tool guideline. */
|
|
20
|
+
export const CHILD_BEHAVIOR_GUIDELINES = PROMPTS["guidelines.child"];
|
|
20
21
|
|
|
21
|
-
|
|
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
|
-
- Never fabricate or assume a background task's result before its notification arrives.
|
|
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
|
-
- 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.`;
|
|
22
|
+
export const BEHAVIOR_GUIDELINES = fill("guidelines.parent", { monitorIdle: MONITOR_IDLE_INSTRUCTION });
|
|
26
23
|
|
|
27
24
|
export interface GuidelinePromptOptions {
|
|
28
25
|
sections?: Record<string, string>;
|
|
@@ -34,7 +31,35 @@ export function applyBehaviorGuidelines(options: GuidelinePromptOptions): void {
|
|
|
34
31
|
options.sections[BEHAVIOR_GUIDELINES_SECTION] = BEHAVIOR_GUIDELINES;
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
/**
|
|
34
|
+
/** Register parent behavior without relying on command-only context methods. */
|
|
35
|
+
export function registerBehaviorGuidelines(pi: Pick<ExtensionAPI, "on">): void {
|
|
36
|
+
pi.on("before_agent_start", (event) => {
|
|
37
|
+
applyBehaviorGuidelines(event.systemPromptOptions);
|
|
38
|
+
});
|
|
39
|
+
pi.on("context_with_system", (event) => restoreBehaviorGuidelines(event.messages));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Repair the model request, not the persisted transcript or other sections. */
|
|
43
|
+
export function restoreBehaviorGuidelines(messages: ContextWithSystemEvent["messages"]) {
|
|
44
|
+
const expected = `<${BEHAVIOR_GUIDELINES_SECTION}>\n${BEHAVIOR_GUIDELINES}\n</${BEHAVIOR_GUIDELINES_SECTION}>`;
|
|
45
|
+
let current: string | null | undefined;
|
|
46
|
+
for (const message of messages) {
|
|
47
|
+
if (message.role === "system" && message.sections && BEHAVIOR_GUIDELINES_SECTION in message.sections) {
|
|
48
|
+
current = message.sections[BEHAVIOR_GUIDELINES_SECTION];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (current === expected) return undefined;
|
|
52
|
+
return {
|
|
53
|
+
messages: [...messages, {
|
|
54
|
+
role: "system" as const,
|
|
55
|
+
content: "",
|
|
56
|
+
sections: { [BEHAVIOR_GUIDELINES_SECTION]: expected },
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
}],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Render named sections without replacing unrelated instructions. */
|
|
38
63
|
export function wakePromptFromSections(sections: Record<string, string>): string {
|
|
39
64
|
return Object.entries(sections)
|
|
40
65
|
.map(([name, content]) => `<${name}>\n${content}\n</${name}>`)
|
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/comms/comms.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type MailboxOptions,
|
|
11
11
|
} from "./mailbox";
|
|
12
12
|
import type { Comms, CommsHost } from "./types";
|
|
13
|
+
import { fill } from "../prompts.generated";
|
|
13
14
|
import { formatFamulusWake, type FormattedWake } from "../wake";
|
|
14
15
|
import type { Clock } from "../clock";
|
|
15
16
|
|
|
@@ -149,9 +150,12 @@ export function createComms(host: CommsHost, options: CommsOptions = {}): CommsW
|
|
|
149
150
|
// Lifecycle (resume) belongs to the subagent tool. Resuming here never
|
|
150
151
|
// wired a completion wake, so the parent hung.
|
|
151
152
|
throw new Error(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
fill("tools.agent_message.error.finished-child", {
|
|
154
|
+
childId: toChildId,
|
|
155
|
+
name: child.name,
|
|
156
|
+
status: child.status,
|
|
157
|
+
runId: child.runId,
|
|
158
|
+
}),
|
|
155
159
|
);
|
|
156
160
|
} else {
|
|
157
161
|
throw new Error(
|
package/src/comms/tools.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { realClock, type Clock } from "../clock";
|
|
|
13
13
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import type { CommsWithOrigin } from "./comms";
|
|
15
15
|
import { assertSiblingAllowed } from "./routing";
|
|
16
|
+
import { PROMPTS } from "../prompts.generated";
|
|
16
17
|
import type { ChildStatus, Comms, CommsHost, MailboxEntry } from "./types";
|
|
17
18
|
|
|
18
19
|
export type AgentMessageSender =
|
|
@@ -25,11 +26,9 @@ export type AgentMessageSender =
|
|
|
25
26
|
|
|
26
27
|
const contactSupervisorParameters = Type.Object({
|
|
27
28
|
reason: Type.Union([Type.Literal("need_decision"), Type.Literal("progress_update")], {
|
|
28
|
-
description:
|
|
29
|
-
'"need_decision" blocks until the supervisor (parent agent) replies; ' +
|
|
30
|
-
'"progress_update" is fire-and-forget.',
|
|
29
|
+
description: PROMPTS["tools.child.contact_supervisor.param.reason"],
|
|
31
30
|
}),
|
|
32
|
-
message: Type.String({ description: "
|
|
31
|
+
message: Type.String({ description: PROMPTS["tools.child.contact_supervisor.param.message"] }),
|
|
33
32
|
});
|
|
34
33
|
|
|
35
34
|
export interface ContactSupervisorDetails {
|
|
@@ -45,13 +44,8 @@ export function createContactSupervisorTool(
|
|
|
45
44
|
return {
|
|
46
45
|
name: "contact_supervisor",
|
|
47
46
|
label: "Contact Supervisor",
|
|
48
|
-
description:
|
|
49
|
-
|
|
50
|
-
'Use reason "need_decision" when you are blocked and need the parent to decide — ' +
|
|
51
|
-
"the call blocks until the parent replies (10 minute timeout, after which you must " +
|
|
52
|
-
"decide yourself). " +
|
|
53
|
-
'Use reason "progress_update" for a fire-and-forget status note.',
|
|
54
|
-
promptSnippet: "Ask the parent agent for a decision or report progress",
|
|
47
|
+
description: PROMPTS["tools.child.contact_supervisor.description"],
|
|
48
|
+
promptSnippet: PROMPTS["tools.child.contact_supervisor.snippet"],
|
|
55
49
|
parameters: contactSupervisorParameters,
|
|
56
50
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
57
51
|
try {
|
|
@@ -79,24 +73,18 @@ const agentMessageParameters = Type.Object({
|
|
|
79
73
|
action: Type.Union(
|
|
80
74
|
[Type.Literal("send"), Type.Literal("reply"), Type.Literal("broadcast"), Type.Literal("list")],
|
|
81
75
|
{
|
|
82
|
-
description:
|
|
83
|
-
'"send" a message to a child, "reply" to a pending decision request, ' +
|
|
84
|
-
'"broadcast" to all running children of a run, "list" pending requests and recent traffic.',
|
|
76
|
+
description: PROMPTS["tools.agent_message.param.action"],
|
|
85
77
|
},
|
|
86
78
|
),
|
|
87
79
|
to: Type.Optional(
|
|
88
80
|
Type.String({
|
|
89
|
-
description:
|
|
90
|
-
'Target child_id or name ("send"/"reply"). For "broadcast" from the parent session: run_id. ' +
|
|
91
|
-
"Child sessions broadcast to their own run and must omit this.",
|
|
81
|
+
description: PROMPTS["tools.agent_message.param.to"],
|
|
92
82
|
}),
|
|
93
83
|
),
|
|
94
|
-
message: Type.Optional(Type.String({ description: "
|
|
84
|
+
message: Type.Optional(Type.String({ description: PROMPTS["tools.agent_message.param.message"] })),
|
|
95
85
|
delivery: Type.Optional(
|
|
96
86
|
Type.Union([Type.Literal("steer"), Type.Literal("queue")], {
|
|
97
|
-
description:
|
|
98
|
-
'"steer" (default) injects into a running child immediately; "queue" delivers after ' +
|
|
99
|
-
'its current turn. Sending to a finished child is an error — resume with subagent({action:"resume"}).',
|
|
87
|
+
description: PROMPTS["tools.agent_message.param.delivery"],
|
|
100
88
|
}),
|
|
101
89
|
),
|
|
102
90
|
});
|
|
@@ -209,21 +197,8 @@ export function createAgentMessageTool(
|
|
|
209
197
|
return {
|
|
210
198
|
name: "agent_message",
|
|
211
199
|
label: "Agent Message",
|
|
212
|
-
description: isChild
|
|
213
|
-
|
|
214
|
-
'"send" delivers to a running sibling. Sending to a finished sibling errors and does not resume it; ' +
|
|
215
|
-
'resume with subagent({ action: "resume", run_id, child_id, message }). ' +
|
|
216
|
-
'"broadcast" steers every running sibling in your run; "list" shows pending ' +
|
|
217
|
-
"decision requests and recent traffic. Cross-run messaging is rejected."
|
|
218
|
-
: "Message your subagents. " +
|
|
219
|
-
'"send" steers or queues a running child. Sending to a finished child errors ' +
|
|
220
|
-
'(it does not resume) and points at subagent({ action: "resume", run_id, child_id, message }). ' +
|
|
221
|
-
'"reply" answers a child\'s pending decision request; "broadcast" steers every ' +
|
|
222
|
-
'running child of a run (to = run_id); "list" shows children, pending decision ' +
|
|
223
|
-
"requests, and recent traffic.",
|
|
224
|
-
promptSnippet: isChild
|
|
225
|
-
? "Message sibling subagents in your run"
|
|
226
|
-
: "Send/reply/broadcast messages to subagents",
|
|
200
|
+
description: isChild ? PROMPTS["tools.child.agent_message.description"] : PROMPTS["tools.agent_message.description"],
|
|
201
|
+
promptSnippet: isChild ? PROMPTS["tools.child.agent_message.snippet"] : PROMPTS["tools.agent_message.snippet"],
|
|
227
202
|
parameters: agentMessageParameters,
|
|
228
203
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
229
204
|
const action = params.action;
|
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,17 @@
|
|
|
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
|
+
type FamulusWake,
|
|
11
|
+
wakeDuration,
|
|
12
|
+
type FormattedWake,
|
|
13
|
+
type OverrunShell,
|
|
14
|
+
type SubagentOverrunChild,
|
|
15
|
+
type WakeItem,
|
|
16
|
+
} from "./wake";
|
|
17
|
+
import { fill } from "./prompts.generated";
|
|
8
18
|
|
|
9
19
|
export { FAMULUS_WAKE_LEAD_IN } from "./wake";
|
|
10
20
|
export type { FormattedWake, WakeItem } from "./wake";
|
|
@@ -142,10 +152,7 @@ export function formatBackgroundNotice(
|
|
|
142
152
|
outputPath: string,
|
|
143
153
|
): string {
|
|
144
154
|
// Model-facing. The transcript row is drawn by the bash tool's renderResult.
|
|
145
|
-
return
|
|
146
|
-
`Command "${displayCommand(command)}" moved to background (task_id: ${taskId}). Output: ${outputPath}.`,
|
|
147
|
-
'You will be notified when it completes, even if other commands are still running. Do not poll or sleep: reply to the user now with no tool call, and continue from the <pi-famulus-wake kind="task"> when it arrives.',
|
|
148
|
-
].join("\n");
|
|
155
|
+
return fill("tools.bash.result.backgrounded", { command: displayCommand(command), taskId, outputPath });
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
/** Transcript row for a backgrounded command (UI only; never sent to the model). */
|
|
@@ -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,72 @@ 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
|
+
function formatOverrunChild(info: SubagentOverrunInfo): SubagentOverrunChild {
|
|
316
|
+
const oneLine = info.lastActivity.text.replace(/\s+/g, " ").trim();
|
|
317
|
+
const text = oneLine.length > OVERRUN_ACTIVITY_CHARS ? `…${oneLine.slice(-OVERRUN_ACTIVITY_CHARS)}` : oneLine;
|
|
318
|
+
return {
|
|
319
|
+
runId: info.runId,
|
|
320
|
+
childId: info.childId,
|
|
321
|
+
name: info.name,
|
|
322
|
+
elapsedMs: info.elapsedMs,
|
|
323
|
+
budgetMs: info.budgetMs,
|
|
324
|
+
reminder: info.reminder,
|
|
325
|
+
nextReminderMs: info.nextReminderMs,
|
|
326
|
+
...(info.hardCeilingMs !== undefined ? { hardCeilingMs: info.hardCeilingMs } : {}),
|
|
327
|
+
lastActivity: { agoMs: info.lastActivity.agoMs, text: text || "(no output yet)" },
|
|
328
|
+
...(info.shell ? { shell: { ...info.shell, command: shellWakeTitle(info.shell.command) } } : {}),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function formatSubagentOverrunDetails(info: SubagentOverrunInfo): Extract<FamulusWake, { kind: "subagent-overrun" }> {
|
|
333
|
+
const child = formatOverrunChild(info);
|
|
334
|
+
const shell = child.shell ? fill("wakes.overrun.summary-shell", { elapsed: wakeDuration(child.shell.elapsedMs) }) : "";
|
|
335
|
+
const summary = fill("wakes.overrun.summary", {
|
|
336
|
+
name: child.name,
|
|
337
|
+
elapsed: wakeDuration(child.elapsedMs),
|
|
338
|
+
budget: wakeDuration(child.budgetMs),
|
|
339
|
+
shell,
|
|
340
|
+
});
|
|
341
|
+
return { kind: "subagent-overrun", ...child, summary };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function formatSubagentOverrun(info: SubagentOverrunInfo): FormattedWake {
|
|
345
|
+
return formatFamulusWake(formatSubagentOverrunDetails(info));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function formatSubagentOverrunBatch(infos: SubagentOverrunInfo[]): FormattedWake {
|
|
349
|
+
if (infos.length === 1) return formatSubagentOverrun(infos[0]);
|
|
350
|
+
return formatFamulusWake({
|
|
351
|
+
...formatSubagentOverrunDetails(infos[0]),
|
|
352
|
+
additional: infos.slice(1).map(formatOverrunChild),
|
|
275
353
|
});
|
|
276
354
|
}
|