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/src/subagent/tool.ts
CHANGED
|
@@ -15,15 +15,17 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { VALID_THINKING_LEVELS } from "../thinking-levels";
|
|
18
19
|
import { formatSubagentHandover, formatSubagentNotification, truncateTail } from "../format";
|
|
19
20
|
import { realClock, type Clock, type ClockTimer } from "../clock";
|
|
20
21
|
|
|
21
22
|
import type { NotifyCenter } from "../notify";
|
|
22
23
|
import { statusGlyph, toolComponent } from "../tui/tool-component";
|
|
23
24
|
import type { WorkIndex } from "../work-index";
|
|
25
|
+
import { fill, PROMPTS } from "../prompts.generated";
|
|
24
26
|
import { runChain, runTasks, validateChainSteps } from "./pool";
|
|
25
27
|
import type { RunRecord, SubagentRegistry } from "./registry";
|
|
26
|
-
import type
|
|
28
|
+
import { AGENT_WORK_KINDS, type AgentDefinition, type AgentWorkKind, type ChildHandle, type ChildResult, type ChildRunRequest } from "./types";
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
/** Overall result text cap (§4.6: truncateTail 512 lines / 48KB). */
|
|
@@ -37,49 +39,48 @@ const MIN_TIMEOUT_MS = 1_000;
|
|
|
37
39
|
// Schema (§4.6 field-name contract)
|
|
38
40
|
// ---------------------------------------------------------------------------
|
|
39
41
|
|
|
42
|
+
const workKindSchema = Type.Union(AGENT_WORK_KINDS.map((kind) => Type.Literal(kind)), {
|
|
43
|
+
description: PROMPTS["tools.subagent.param.work_kind"],
|
|
44
|
+
});
|
|
45
|
+
|
|
40
46
|
const taskItem = Type.Object({
|
|
41
|
-
agent: Type.Optional(Type.String({ description: "
|
|
42
|
-
prompt: Type.String({ description: "
|
|
43
|
-
name: Type.Optional(Type.String({ description: "
|
|
47
|
+
agent: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.tasks.agent"] })),
|
|
48
|
+
prompt: Type.String({ description: PROMPTS["tools.subagent.param.tasks.prompt"] }),
|
|
49
|
+
name: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.tasks.name"] })),
|
|
50
|
+
work_kind: Type.Optional(workKindSchema),
|
|
44
51
|
});
|
|
45
52
|
|
|
46
53
|
const chainItem = Type.Object({
|
|
47
|
-
agent: Type.Optional(Type.String({ description: "
|
|
48
|
-
prompt: Type.String({
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
label: Type.Optional(Type.String({ description: "Label for referencing this step's output later" })),
|
|
54
|
+
agent: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.chain.agent"] })),
|
|
55
|
+
prompt: Type.String({ description: PROMPTS["tools.subagent.param.chain.prompt"] }),
|
|
56
|
+
label: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.chain.label"] })),
|
|
57
|
+
work_kind: Type.Optional(workKindSchema),
|
|
52
58
|
});
|
|
53
59
|
|
|
54
60
|
const subagentParameters = Type.Object({
|
|
55
61
|
tasks: Type.Optional(
|
|
56
|
-
Type.Array(taskItem, { minItems: 1, maxItems: 10, description: "
|
|
62
|
+
Type.Array(taskItem, { minItems: 1, maxItems: 10, description: PROMPTS["tools.subagent.param.tasks"] }),
|
|
57
63
|
),
|
|
58
64
|
chain: Type.Optional(
|
|
59
|
-
Type.Array(chainItem, { minItems: 1, description: "
|
|
65
|
+
Type.Array(chainItem, { minItems: 1, description: PROMPTS["tools.subagent.param.chain"] }),
|
|
60
66
|
),
|
|
61
67
|
async: Type.Optional(
|
|
62
|
-
Type.Boolean({ description: "
|
|
68
|
+
Type.Boolean({ description: PROMPTS["tools.subagent.param.async"] }),
|
|
63
69
|
),
|
|
64
70
|
concurrency: Type.Optional(
|
|
65
|
-
Type.Number({ minimum: 1, maximum: 8, description: "
|
|
71
|
+
Type.Number({ minimum: 1, maximum: 8, description: PROMPTS["tools.subagent.param.concurrency"] }),
|
|
66
72
|
),
|
|
67
73
|
fail_fast: Type.Optional(
|
|
68
|
-
Type.Boolean({
|
|
69
|
-
description: "Cancel not-yet-started subagents on first failure (already-started ones finish)",
|
|
70
|
-
}),
|
|
74
|
+
Type.Boolean({ description: PROMPTS["tools.subagent.param.fail_fast"] }),
|
|
71
75
|
),
|
|
72
76
|
model: Type.Optional(
|
|
73
77
|
Type.String({
|
|
74
|
-
description:
|
|
75
|
-
'Model override for all subagents: fuzzy ("haiku"), qualified ("provider/id"), ' +
|
|
76
|
-
'optionally with ":<thinking>" suffix. Default: current model. ' +
|
|
77
|
-
'Use action:"models" to list selectable values.',
|
|
78
|
+
description: fill("tools.subagent.param.model", { thinkingLevels: VALID_THINKING_LEVELS.join(", ") }),
|
|
78
79
|
}),
|
|
79
80
|
),
|
|
80
81
|
timeout_ms: Type.Optional(
|
|
81
82
|
Type.Number({
|
|
82
|
-
description:
|
|
83
|
+
description: fill("tools.subagent.param.timeout_ms", { maxMs: MAX_TIMEOUT_MS }),
|
|
83
84
|
maximum: MAX_TIMEOUT_MS,
|
|
84
85
|
}),
|
|
85
86
|
),
|
|
@@ -92,25 +93,28 @@ const subagentParameters = Type.Object({
|
|
|
92
93
|
Type.Literal("interrupt"),
|
|
93
94
|
Type.Literal("resume"),
|
|
94
95
|
Type.Literal("steer"),
|
|
96
|
+
Type.Literal("extend"),
|
|
95
97
|
Type.Literal("models"),
|
|
96
98
|
],
|
|
97
|
-
{ description: "
|
|
99
|
+
{ description: PROMPTS["tools.subagent.param.action"] },
|
|
98
100
|
),
|
|
99
101
|
),
|
|
100
|
-
run_id: Type.Optional(Type.String({ description: "
|
|
101
|
-
child_id: Type.Optional(
|
|
102
|
-
|
|
102
|
+
run_id: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.run_id"] })),
|
|
103
|
+
child_id: Type.Optional(
|
|
104
|
+
Type.String({ description: PROMPTS["tools.subagent.param.child_id"] }),
|
|
105
|
+
),
|
|
106
|
+
message: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.message"] })),
|
|
103
107
|
});
|
|
104
108
|
|
|
105
109
|
type SubagentParams = {
|
|
106
|
-
tasks?: { agent?: string; prompt: string; name?: string }[];
|
|
107
|
-
chain?: { agent?: string; prompt: string; label?: string }[];
|
|
110
|
+
tasks?: { agent?: string; prompt: string; name?: string; work_kind?: AgentWorkKind }[];
|
|
111
|
+
chain?: { agent?: string; prompt: string; label?: string; work_kind?: AgentWorkKind }[];
|
|
108
112
|
async?: boolean;
|
|
109
113
|
concurrency?: number;
|
|
110
114
|
fail_fast?: boolean;
|
|
111
115
|
model?: string;
|
|
112
116
|
timeout_ms?: number;
|
|
113
|
-
action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "models";
|
|
117
|
+
action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "extend" | "models";
|
|
114
118
|
run_id?: string;
|
|
115
119
|
child_id?: string;
|
|
116
120
|
message?: string;
|
|
@@ -147,8 +151,7 @@ export interface SubagentToolDeps {
|
|
|
147
151
|
/** M3 stopgap resolver until M5 wires the real agent loader (§4.8). */
|
|
148
152
|
const BUILTIN_WORKER: AgentDefinition = {
|
|
149
153
|
name: "worker",
|
|
150
|
-
description: "General-purpose subagent with
|
|
151
|
-
tools: ["read", "bash", "edit", "write"],
|
|
154
|
+
description: "General-purpose subagent with user-configured tools",
|
|
152
155
|
systemPrompt: "",
|
|
153
156
|
source: "builtin",
|
|
154
157
|
};
|
|
@@ -222,6 +225,7 @@ function toNotificationInfo(record: RunRecord, now: number) {
|
|
|
222
225
|
status: c.status,
|
|
223
226
|
text: c.result?.text ?? "",
|
|
224
227
|
error: c.result?.error,
|
|
228
|
+
...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
|
|
225
229
|
...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
|
|
226
230
|
})),
|
|
227
231
|
};
|
|
@@ -285,9 +289,23 @@ export function createSubagentTool(
|
|
|
285
289
|
return registry;
|
|
286
290
|
};
|
|
287
291
|
|
|
292
|
+
/** Last terminal state a subagent-done was sent for, per run. */
|
|
293
|
+
const runDoneSent = new Map<string, string>();
|
|
288
294
|
const notifyRunCompleted = (registry: SubagentRegistry, runId: string): void => {
|
|
289
295
|
const record = registry.get(runId);
|
|
290
|
-
if (!record)
|
|
296
|
+
if (!record) {
|
|
297
|
+
runDoneSent.delete(runId);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// A resumed child can outlive the run's first-launch completion. The
|
|
301
|
+
// run is not done while it runs; its own settle sends this wake.
|
|
302
|
+
if (record.children.some((c) => c.status === "pending" || c.status === "running")) return;
|
|
303
|
+
// Several observers (first-launch completion, each resumed turn's settle)
|
|
304
|
+
// can see the same all-terminal state. One subagent-done per terminal
|
|
305
|
+
// state, identified by every child's turn number.
|
|
306
|
+
const terminalKey = record.children.map((c) => `${c.childId}:${c.turn ?? 1}`).join("|");
|
|
307
|
+
if (runDoneSent.get(runId) === terminalKey) return;
|
|
308
|
+
runDoneSent.set(runId, terminalKey);
|
|
291
309
|
deps.getNotifyCenter()?.notify(formatSubagentNotification(toNotificationInfo(record, clock.now())));
|
|
292
310
|
};
|
|
293
311
|
|
|
@@ -320,6 +338,7 @@ export function createSubagentTool(
|
|
|
320
338
|
prompt: child.prompt ?? "",
|
|
321
339
|
text: child.result?.text ?? "",
|
|
322
340
|
...(child.result?.error !== undefined ? { error: child.result.error } : {}),
|
|
341
|
+
...(child.result?.warning !== undefined ? { warning: child.result.warning } : {}),
|
|
323
342
|
stillRunning,
|
|
324
343
|
}),
|
|
325
344
|
);
|
|
@@ -348,12 +367,12 @@ export function createSubagentTool(
|
|
|
348
367
|
const label = "name" in item ? item.name : undefined;
|
|
349
368
|
const chainLabel = "label" in item ? item.label : undefined;
|
|
350
369
|
const name = label ?? chainLabel ?? `${agent.name}-${i + 1}`;
|
|
351
|
-
return { agent, name, prompt: item.prompt };
|
|
370
|
+
return { agent, name, prompt: item.prompt, workKind: item.work_kind ?? "other" };
|
|
352
371
|
});
|
|
353
372
|
|
|
354
373
|
const run = registry.createRun(kind);
|
|
355
374
|
const childIds = resolved.map((r) =>
|
|
356
|
-
registry.addChild(run.runId, { name: r.name, agent: r.agent.name }),
|
|
375
|
+
registry.addChild(run.runId, { name: r.name, agent: r.agent.name, workKind: r.workKind }),
|
|
357
376
|
);
|
|
358
377
|
|
|
359
378
|
const makeRequest = (ordinal: number, prompt: string): ChildRunRequest => ({
|
|
@@ -364,6 +383,7 @@ export function createSubagentTool(
|
|
|
364
383
|
taskPrompt: prompt,
|
|
365
384
|
agent: resolved[ordinal].agent,
|
|
366
385
|
model: params.model,
|
|
386
|
+
workKind: resolved[ordinal].workKind,
|
|
367
387
|
timeoutMs,
|
|
368
388
|
depth: 1,
|
|
369
389
|
});
|
|
@@ -427,13 +447,7 @@ export function createSubagentTool(
|
|
|
427
447
|
content: [
|
|
428
448
|
{
|
|
429
449
|
type: "text",
|
|
430
|
-
text:
|
|
431
|
-
`Started ${items.length} subagent(s) in run ${run.runId}. ${reason}\n` +
|
|
432
|
-
`While others are still running, each finished subagent arrives as <pi-famulus-wake kind="subagent-handover"> ` +
|
|
433
|
-
`with that child's prompt and result. Read it and continue: subagent({action:"resume", run_id, child_id, message}) for that child, ` +
|
|
434
|
-
`or agent_message to steer the ones still running. Do not wait for the whole run. Do not poll. ` +
|
|
435
|
-
`<pi-famulus-wake kind="subagent-done"> arrives when every subagent in the run has finished. ` +
|
|
436
|
-
`Use subagent({action:"get", run_id:"${run.runId}"}) if you need the full record.`,
|
|
450
|
+
text: fill("tools.subagent.result.backgrounded", { count: items.length, runId: run.runId, reason }),
|
|
437
451
|
},
|
|
438
452
|
],
|
|
439
453
|
details: { run_id: run.runId, status: "backgrounded" },
|
|
@@ -590,12 +604,44 @@ export function createSubagentTool(
|
|
|
590
604
|
};
|
|
591
605
|
}
|
|
592
606
|
|
|
607
|
+
if (action === "extend") {
|
|
608
|
+
const handle = resolveSingleActiveChild(registry, record, params.child_id, "extend");
|
|
609
|
+
const child = record.children.find((c) => c.childId === handle.childId);
|
|
610
|
+
const ms = params.timeout_ms === undefined ? undefined : clampTimeout(params.timeout_ms, deps.defaultTimeoutMs);
|
|
611
|
+
const { deadlineAt, hardDeadlineAt } = handle.extend(ms);
|
|
612
|
+
const now = clock.now();
|
|
613
|
+
const hard =
|
|
614
|
+
hardDeadlineAt === null
|
|
615
|
+
? ""
|
|
616
|
+
: ` ${fill("tools.subagent.result.extended.hard-ceiling", { remaining: formatDurationMs(Math.max(0, hardDeadlineAt - now)) })}`;
|
|
617
|
+
return {
|
|
618
|
+
content: [
|
|
619
|
+
{
|
|
620
|
+
type: "text",
|
|
621
|
+
text:
|
|
622
|
+
fill("tools.subagent.result.extended", {
|
|
623
|
+
name: child?.name ?? handle.childId,
|
|
624
|
+
childId: handle.childId,
|
|
625
|
+
runId: record.runId,
|
|
626
|
+
next: formatDurationMs(deadlineAt - now),
|
|
627
|
+
}) + hard,
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
details: { run_id: record.runId, child_id: handle.childId, deadline_at: deadlineAt, hard_deadline_at: hardDeadlineAt },
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
593
634
|
// resume
|
|
594
635
|
if (!params.message) throw new Error("message is required for resume");
|
|
595
636
|
const child = resolveSingleTerminalChild(record, params.child_id);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
637
|
+
// A resume is a new turn with its own soft budget: timeout_ms when given,
|
|
638
|
+
// else the child's spawn budget (the runner's default). Returns once the
|
|
639
|
+
// request is accepted; it never waits for an admission slot.
|
|
640
|
+
const { queuedBehind } = await registry.resumeChild(
|
|
641
|
+
child.childId,
|
|
642
|
+
params.message,
|
|
643
|
+
params.timeout_ms === undefined ? {} : { timeoutMs: clampTimeout(params.timeout_ms, deps.defaultTimeoutMs) },
|
|
644
|
+
);
|
|
599
645
|
// Resume is asynchronous. If siblings are still running, hand this child
|
|
600
646
|
// back as soon as it finishes; otherwise the run-complete wake covers it.
|
|
601
647
|
const handedOver = new Set<string>();
|
|
@@ -608,32 +654,24 @@ export function createSubagentTool(
|
|
|
608
654
|
content: [
|
|
609
655
|
{
|
|
610
656
|
type: "text",
|
|
611
|
-
text:
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
657
|
+
text: fill("tools.subagent.result.resumed", {
|
|
658
|
+
name: child.name,
|
|
659
|
+
childId: child.childId,
|
|
660
|
+
runId: record.runId,
|
|
661
|
+
queued: queuedText(queuedBehind),
|
|
662
|
+
}),
|
|
615
663
|
},
|
|
616
664
|
],
|
|
617
|
-
details: { run_id: record.runId, child_id: child.childId },
|
|
665
|
+
details: { run_id: record.runId, child_id: child.childId, queued_behind: queuedBehind },
|
|
618
666
|
};
|
|
619
667
|
};
|
|
620
668
|
|
|
621
669
|
return {
|
|
622
670
|
name: "subagent",
|
|
623
671
|
label: "Subagent",
|
|
624
|
-
description:
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
"continue in the background. Each child that finishes while others are still running wakes you with " +
|
|
628
|
-
"<pi-famulus-wake kind=\"subagent-handover\"> (its prompt and result). The whole run wakes you with <pi-famulus-wake kind=\"subagent-done\">. " +
|
|
629
|
-
"Never poll or sleep to wait. Use action=list/get/status/interrupt/resume/steer to manage existing runs.",
|
|
630
|
-
promptSnippet: "Fan out subagents in parallel or sequence them in a chain",
|
|
631
|
-
promptGuidelines: [
|
|
632
|
-
'When a <pi-famulus-wake kind="subagent-handover"> arrives, read <prompt> and <result> immediately and continue: subagent({action:"resume", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.',
|
|
633
|
-
"Subagent runs that exceed the foreground budget continue in the background; you are notified per finished child and again when the run completes — do not poll.",
|
|
634
|
-
"A failed subagent does not fail the whole run; inspect per-subagent sections in the result.",
|
|
635
|
-
"<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run.",
|
|
636
|
-
],
|
|
672
|
+
description: PROMPTS["tools.subagent.description"],
|
|
673
|
+
promptSnippet: PROMPTS["tools.subagent.snippet"],
|
|
674
|
+
promptGuidelines: [...PROMPTS["tools.subagent.rules"]],
|
|
637
675
|
parameters: subagentParameters,
|
|
638
676
|
renderResult(result, { expanded }, theme, context) {
|
|
639
677
|
const details = result.details as { run_id?: string; status?: string } | undefined;
|
|
@@ -694,6 +732,13 @@ export function createSubagentTool(
|
|
|
694
732
|
};
|
|
695
733
|
}
|
|
696
734
|
|
|
735
|
+
/** Resume result clause for a turn waiting for an admission slot ("" when it started at once). */
|
|
736
|
+
function queuedText(queuedBehind: number | null): string {
|
|
737
|
+
if (queuedBehind === null) return "";
|
|
738
|
+
const ahead = queuedBehind === 0 ? "" : ` and ${queuedBehind} subagent(s) are waiting ahead of it`;
|
|
739
|
+
return `Every subagent slot is busy${ahead}, so it is queued and starts when a slot frees. `;
|
|
740
|
+
}
|
|
741
|
+
|
|
697
742
|
function resolveSingleActiveChild(
|
|
698
743
|
registry: SubagentRegistry,
|
|
699
744
|
record: RunRecord,
|
|
@@ -728,6 +773,11 @@ function resolveSingleTerminalChild(record: RunRecord, childIdOrName: string | u
|
|
|
728
773
|
const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
|
|
729
774
|
throw new Error(`no child "${childIdOrName}" in run ${record.runId} (children: ${known})`);
|
|
730
775
|
}
|
|
776
|
+
if (child.status === "pending") {
|
|
777
|
+
// Queued for an admission slot (a launch or an earlier resume): steer
|
|
778
|
+
// would fail too, and a second resume adds nothing.
|
|
779
|
+
throw new Error(fill("tools.subagent.error.resume-queued", { name: child.name, childId: child.childId }));
|
|
780
|
+
}
|
|
731
781
|
if (!matches(child)) {
|
|
732
782
|
throw new Error(`subagent ${child.name} (${child.childId}) is still ${child.status}; use steer instead`);
|
|
733
783
|
}
|
package/src/subagent/types.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* consumer stays testable with plain fakes.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { OverrunTick } from "./overrun";
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Mirror of the M5 `AgentDefinition` (design doc Appendix B, src/agents/).
|
|
11
13
|
* Structurally compatible with the M5 definition, so the real agent loader
|
|
@@ -14,9 +16,11 @@
|
|
|
14
16
|
export interface AgentDefinition {
|
|
15
17
|
name: string; // ^[a-z][a-z0-9-]*$
|
|
16
18
|
description: string; // required, non-empty
|
|
17
|
-
tools
|
|
19
|
+
tools?: string[]; // omitted: inherit user config; explicit list: allowlist; []: no user-configured tools (injected communication tools remain)
|
|
18
20
|
model?: string; // "provider:id" | bare id
|
|
19
|
-
|
|
21
|
+
// Mirror of src/agents/definition.ts — keep in sync with
|
|
22
|
+
// src/thinking-levels.ts (VALID_THINKING_LEVELS), the single source.
|
|
23
|
+
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
20
24
|
systemPrompt: string; // frontmatter body, trimmed
|
|
21
25
|
source: "builtin" | "user" | "project";
|
|
22
26
|
path?: string; // builtins have no path
|
|
@@ -24,6 +28,17 @@ export interface AgentDefinition {
|
|
|
24
28
|
|
|
25
29
|
export type ChildStatus = "pending" | "running" | "completed" | "failed" | "interrupted";
|
|
26
30
|
|
|
31
|
+
export const AGENT_WORK_KINDS = [
|
|
32
|
+
"test-suite",
|
|
33
|
+
"test",
|
|
34
|
+
"build",
|
|
35
|
+
"lint/type",
|
|
36
|
+
"other",
|
|
37
|
+
"git",
|
|
38
|
+
"read/search",
|
|
39
|
+
] as const;
|
|
40
|
+
export type AgentWorkKind = (typeof AGENT_WORK_KINDS)[number];
|
|
41
|
+
|
|
27
42
|
export interface ChildResult {
|
|
28
43
|
status: "completed" | "failed" | "interrupted";
|
|
29
44
|
text: string; // getLastAssistantText() or "(no output)"
|
|
@@ -41,6 +56,8 @@ export interface ChildResult {
|
|
|
41
56
|
/** Stall detections in the user turn that produced this result. */
|
|
42
57
|
stalls?: number;
|
|
43
58
|
durationMs: number;
|
|
59
|
+
/** Time waiting for local and machine-wide admission in this turn. */
|
|
60
|
+
queueMs?: number;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
/** One turn of a child session, for the /tasks conversation view. */
|
|
@@ -58,10 +75,36 @@ export interface ChildRunRequest {
|
|
|
58
75
|
taskPrompt?: string;
|
|
59
76
|
agent: AgentDefinition; // already resolved
|
|
60
77
|
model?: string; // subagent() parameter-level override
|
|
78
|
+
/** Explicit planned workload class; natural-language prompts are not classified. */
|
|
79
|
+
workKind?: AgentWorkKind;
|
|
80
|
+
/** Soft budget per turn: reaching it wakes the parent; it does not abort. */
|
|
61
81
|
timeoutMs: number;
|
|
62
82
|
depth: number; // main session = 0, child = 1
|
|
63
83
|
}
|
|
64
84
|
|
|
85
|
+
export interface ChildTokenUsage {
|
|
86
|
+
input: number;
|
|
87
|
+
output: number;
|
|
88
|
+
cacheRead: number;
|
|
89
|
+
cacheWrite: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ChildWallUsage {
|
|
93
|
+
llmMs: number;
|
|
94
|
+
toolMs: number;
|
|
95
|
+
queueMs: number;
|
|
96
|
+
otherMs: number;
|
|
97
|
+
approximate: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ChildSessionEvent {
|
|
101
|
+
type: string;
|
|
102
|
+
role?: string;
|
|
103
|
+
/** Pi's stable tool-call id, used to pair execution start/end events. */
|
|
104
|
+
toolCallId?: string;
|
|
105
|
+
usage?: ChildTokenUsage;
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
export interface ChildHandle {
|
|
66
109
|
readonly childId: string;
|
|
67
110
|
readonly result: Promise<ChildResult>; // resolves exactly once per generation;
|
|
@@ -70,14 +113,28 @@ export interface ChildHandle {
|
|
|
70
113
|
// current generation's promise).
|
|
71
114
|
steer(message: string): Promise<void>; // while running; terminal -> throw
|
|
72
115
|
followUp(message: string): Promise<void>; // same, queued delivery
|
|
73
|
-
|
|
116
|
+
/** terminal -> continue running; opts.timeoutMs is the new turn's soft budget (default: spawn budget). */
|
|
117
|
+
resume(message: string, opts?: { timeoutMs?: number }): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* running -> soft deadline = now + timeoutMs (default: spawn budget);
|
|
120
|
+
* otherwise throws. Returns the new deadline and the unmoved hard ceiling.
|
|
121
|
+
*/
|
|
122
|
+
extend(timeoutMs?: number): { deadlineAt: number; hardDeadlineAt: number | null };
|
|
74
123
|
interrupt(): Promise<void>; // abort; result resolves as interrupted
|
|
75
124
|
status(): ChildStatus;
|
|
76
125
|
lastEventAt(): number; // for the stall watchdog / status display
|
|
77
126
|
/** Resolved `provider/id` once the child session has been constructed. */
|
|
78
127
|
resolvedModel(): string | undefined;
|
|
128
|
+
/** Effective thinking level applied when the child session was constructed. */
|
|
129
|
+
effectiveThinkingLevel?(): string | undefined;
|
|
130
|
+
/** Collect and reschedule a reminder if this child is overdue at the given time. */
|
|
131
|
+
collectDueOverrun?(now: number): OverrunTick | undefined;
|
|
79
132
|
/** Live child transcript. Empty when the session never started. */
|
|
80
133
|
conversation(): ConversationTurn[];
|
|
134
|
+
/** Cumulative provider input/output and cache counts for this child. */
|
|
135
|
+
tokenUsage(): ChildTokenUsage;
|
|
136
|
+
/** Cumulative observed LLM, tool, admission-queue and approximate wall time. */
|
|
137
|
+
wallUsage(): ChildWallUsage;
|
|
81
138
|
}
|
|
82
139
|
|
|
83
140
|
/**
|
|
@@ -92,6 +149,8 @@ export interface ChildSessionAdapter {
|
|
|
92
149
|
* Used for fleet/ls persistence and so the child prompt can name its model.
|
|
93
150
|
*/
|
|
94
151
|
readonly resolvedModel?: string;
|
|
152
|
+
/** Effective thinking level passed to createAgentSession, after model fallbacks. */
|
|
153
|
+
readonly effectiveThinkingLevel?: string;
|
|
95
154
|
prompt(text: string): Promise<void>;
|
|
96
155
|
steer(text: string): Promise<void>;
|
|
97
156
|
followUp(text: string): Promise<void>;
|
|
@@ -101,18 +160,22 @@ export interface ChildSessionAdapter {
|
|
|
101
160
|
/** Provider/model failure outcome of the last assistant turn, when any. */
|
|
102
161
|
getLastAssistantFailure?(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
|
|
103
162
|
getConversation(): ConversationTurn[];
|
|
163
|
+
tokenUsage(): ChildTokenUsage;
|
|
104
164
|
/** Introspection for child-session orchestration and contract tests. */
|
|
105
165
|
getActiveToolNames?(): string[];
|
|
106
166
|
getSystemPrompt?(): string;
|
|
107
167
|
isStreaming(): boolean;
|
|
108
|
-
|
|
109
|
-
|
|
168
|
+
/** Current queued steering drain mode, when supported by the runtime. */
|
|
169
|
+
getSteeringMode?(): "all" | "one-at-a-time";
|
|
170
|
+
subscribe(listener: (event: ChildSessionEvent) => void): () => void;
|
|
171
|
+
/** Await extension cleanup before releasing the underlying session. */
|
|
172
|
+
dispose(): void | Promise<void>;
|
|
110
173
|
}
|
|
111
174
|
|
|
112
175
|
export type CreateSessionFn = (req: ChildRunRequest) => Promise<ChildSessionAdapter>;
|
|
113
176
|
|
|
114
177
|
export interface ChildRunner {
|
|
115
|
-
start(req: ChildRunRequest): Promise<ChildHandle>;
|
|
178
|
+
start(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
|
|
116
179
|
}
|
|
117
180
|
|
|
118
181
|
/**
|
|
@@ -121,5 +184,6 @@ export interface ChildRunner {
|
|
|
121
184
|
* duck-types this to release sessions in disposeRun().
|
|
122
185
|
*/
|
|
123
186
|
export interface DisposableChildHandle extends ChildHandle {
|
|
124
|
-
|
|
187
|
+
/** Settles immediately; an optional promise tracks asynchronous session cleanup. */
|
|
188
|
+
dispose(): void | Promise<void>;
|
|
125
189
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface TokenUsage {
|
|
2
|
+
input: number;
|
|
3
|
+
output: number;
|
|
4
|
+
cacheRead: number;
|
|
5
|
+
cacheWrite: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Normalize provider usage counts while keeping missing values at zero. */
|
|
9
|
+
export function normalizedTokenUsage(usage: {
|
|
10
|
+
input?: unknown;
|
|
11
|
+
output?: unknown;
|
|
12
|
+
cacheRead?: unknown;
|
|
13
|
+
cacheWrite?: unknown;
|
|
14
|
+
}): TokenUsage {
|
|
15
|
+
const count = (value: unknown): number => {
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
input: count(usage.input),
|
|
21
|
+
output: count(usage.output),
|
|
22
|
+
cacheRead: count(usage.cacheRead),
|
|
23
|
+
cacheWrite: count(usage.cacheWrite),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Add a provider message's usage to the child's cumulative totals. */
|
|
28
|
+
export function accumulateTokenUsage(total: TokenUsage, delta: TokenUsage): TokenUsage {
|
|
29
|
+
return {
|
|
30
|
+
input: total.input + delta.input,
|
|
31
|
+
output: total.output + delta.output,
|
|
32
|
+
cacheRead: total.cacheRead + delta.cacheRead,
|
|
33
|
+
cacheWrite: total.cacheWrite + delta.cacheWrite,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createTokenUsageAccumulator() {
|
|
38
|
+
let total: TokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
39
|
+
return {
|
|
40
|
+
add(usage: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown }): TokenUsage {
|
|
41
|
+
total = accumulateTokenUsage(total, normalizedTokenUsage(usage));
|
|
42
|
+
return { ...total };
|
|
43
|
+
},
|
|
44
|
+
snapshot(): TokenUsage {
|
|
45
|
+
return { ...total };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/task-tools.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Type } from "typebox";
|
|
|
5
5
|
import { realClock, type Clock } from "./clock";
|
|
6
6
|
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import type { ManagerClient, TaskRecord } from "./manager-client";
|
|
8
|
+
import { fill, PROMPTS } from "./prompts.generated";
|
|
8
9
|
import {
|
|
9
10
|
formatAgentCommand,
|
|
10
11
|
isAgentStatusActive,
|
|
@@ -85,14 +86,12 @@ function formatTaskLine(task: TaskRecord, now: number): string {
|
|
|
85
86
|
);
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
const END_TURN_HINT =
|
|
89
|
-
|
|
90
|
-
const LIST_END_TURN_HINT =
|
|
91
|
-
"Running work wakes you with a <pi-famulus-wake> when it finishes. Reply to the user now with no tool call instead of checking again.";
|
|
89
|
+
const END_TURN_HINT = PROMPTS["tools.task_output.hint.still-running"];
|
|
90
|
+
const LIST_END_TURN_HINT = PROMPTS["tools.task_list.hint.running"];
|
|
92
91
|
|
|
93
92
|
const taskListParameters = Type.Object({
|
|
94
93
|
all: Type.Optional(
|
|
95
|
-
Type.Boolean({ description: "
|
|
94
|
+
Type.Boolean({ description: PROMPTS["tools.task_list.param.all"] }),
|
|
96
95
|
),
|
|
97
96
|
});
|
|
98
97
|
|
|
@@ -102,10 +101,8 @@ export function createTaskListTool(
|
|
|
102
101
|
return {
|
|
103
102
|
name: "task_list",
|
|
104
103
|
label: "Task List",
|
|
105
|
-
description:
|
|
106
|
-
|
|
107
|
-
"Work from other pi sessions is never shown.",
|
|
108
|
-
promptSnippet: "List background shell/monitor tasks and subagents",
|
|
104
|
+
description: PROMPTS["tools.task_list.description"],
|
|
105
|
+
promptSnippet: PROMPTS["tools.task_list.snippet"],
|
|
109
106
|
parameters: taskListParameters,
|
|
110
107
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
111
108
|
const client = await requireClient(deps);
|
|
@@ -180,15 +177,12 @@ export function createTaskListTool(
|
|
|
180
177
|
}
|
|
181
178
|
|
|
182
179
|
const taskOutputParameters = Type.Object({
|
|
183
|
-
task_id: Type.String({ description: "
|
|
180
|
+
task_id: Type.String({ description: PROMPTS["tools.task_output.param.task_id"] }),
|
|
184
181
|
cursor: Type.Optional(
|
|
185
|
-
Type.Number({
|
|
186
|
-
description:
|
|
187
|
-
"Byte offset to read from (for incremental reads). Omit to read the tail of the output.",
|
|
188
|
-
}),
|
|
182
|
+
Type.Number({ description: PROMPTS["tools.task_output.param.cursor"] }),
|
|
189
183
|
),
|
|
190
184
|
max_bytes: Type.Optional(
|
|
191
|
-
Type.Number({ description: "
|
|
185
|
+
Type.Number({ description: PROMPTS["tools.task_output.param.max_bytes"] }),
|
|
192
186
|
),
|
|
193
187
|
});
|
|
194
188
|
|
|
@@ -211,7 +205,7 @@ export function createTaskOutputTool(
|
|
|
211
205
|
const lastRunningSize = new Map<string, number>();
|
|
212
206
|
const guardPoll = (id: string, running: boolean, size: number) => {
|
|
213
207
|
if (running && lastRunningSize.get(id) === size) {
|
|
214
|
-
throw new Error(
|
|
208
|
+
throw new Error(fill("tools.task_output.error.no-new-output", { taskId: id, hint: END_TURN_HINT }));
|
|
215
209
|
}
|
|
216
210
|
if (running) lastRunningSize.set(id, size);
|
|
217
211
|
else lastRunningSize.delete(id);
|
|
@@ -219,11 +213,8 @@ export function createTaskOutputTool(
|
|
|
219
213
|
return {
|
|
220
214
|
name: "task_output",
|
|
221
215
|
label: "Task Output",
|
|
222
|
-
description:
|
|
223
|
-
|
|
224
|
-
"plus the current file pointer; pass the returned next_cursor as cursor for incremental reads. " +
|
|
225
|
-
"Not a way to wait: a running task wakes you when it finishes, so reply with no tool call instead of calling this again.",
|
|
226
|
-
promptSnippet: "Read background task output",
|
|
216
|
+
description: PROMPTS["tools.task_output.description"],
|
|
217
|
+
promptSnippet: PROMPTS["tools.task_output.snippet"],
|
|
227
218
|
parameters: taskOutputParameters,
|
|
228
219
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
229
220
|
const agentText = agentTextFor(deps, params.task_id);
|
|
@@ -280,7 +271,7 @@ export function createTaskOutputTool(
|
|
|
280
271
|
}
|
|
281
272
|
|
|
282
273
|
const taskStopParameters = Type.Object({
|
|
283
|
-
task_id: Type.String({ description: "
|
|
274
|
+
task_id: Type.String({ description: PROMPTS["tools.task_stop.param.task_id"] }),
|
|
284
275
|
});
|
|
285
276
|
|
|
286
277
|
export function createTaskStopTool(
|
|
@@ -289,9 +280,8 @@ export function createTaskStopTool(
|
|
|
289
280
|
return {
|
|
290
281
|
name: "task_stop",
|
|
291
282
|
label: "Task Stop",
|
|
292
|
-
description:
|
|
293
|
-
|
|
294
|
-
promptSnippet: "Stop a background task",
|
|
283
|
+
description: PROMPTS["tools.task_stop.description"],
|
|
284
|
+
promptSnippet: PROMPTS["tools.task_stop.snippet"],
|
|
295
285
|
parameters: taskStopParameters,
|
|
296
286
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx: ExtensionContext) {
|
|
297
287
|
const agent = deps.getRegistry?.()?.handle(params.task_id);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinking levels pi core accepts — the single source of truth for the
|
|
3
|
+
* extension. The pi SDK does not export its list (pi's internal
|
|
4
|
+
* VALID_THINKING_LEVELS), so this is a maintained copy; parity tests pin
|
|
5
|
+
* every level (impl-model-spec.test.ts, impl-agents.test.ts).
|
|
6
|
+
*
|
|
7
|
+
* Pure module: zero pi dependencies, zero npm dependencies. Both src/agents/
|
|
8
|
+
* and src/subagent/ consume this — neither layer imports the other (see
|
|
9
|
+
* design doc Appendix B).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const VALID_THINKING_LEVELS = [
|
|
13
|
+
"off",
|
|
14
|
+
"minimal",
|
|
15
|
+
"low",
|
|
16
|
+
"medium",
|
|
17
|
+
"high",
|
|
18
|
+
"xhigh",
|
|
19
|
+
"max",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number];
|
|
23
|
+
|
|
24
|
+
export function isValidThinkingLevel(level: string): level is ThinkingLevel {
|
|
25
|
+
return (VALID_THINKING_LEVELS as readonly string[]).includes(level);
|
|
26
|
+
}
|