pi-agenticoding 0.1.0
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/LICENSE +21 -0
- package/README.md +240 -0
- package/agenticoding.test.ts +2079 -0
- package/handoff/command.ts +36 -0
- package/handoff/compact.ts +35 -0
- package/handoff/tool.ts +151 -0
- package/index.ts +149 -0
- package/ledger/rehydration.ts +94 -0
- package/ledger/store.ts +82 -0
- package/ledger/tools.ts +166 -0
- package/package.json +21 -0
- package/spawn/index.ts +487 -0
- package/spawn/renderer.ts +809 -0
- package/spawn/shared.ts +34 -0
- package/state.ts +108 -0
- package/system-prompt.ts +59 -0
- package/test-loader.mjs +32 -0
- package/watchdog.ts +65 -0
package/ledger/tools.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger tool definitions for the agenticoding extension.
|
|
3
|
+
*
|
|
4
|
+
* Three tools: ledger_add (sequential, serialized write), ledger_get, ledger_list.
|
|
5
|
+
* All read from the in-memory state.ledger Map and always return the current
|
|
6
|
+
* list of entry names in both result text and details.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import type { AgenticodingState } from "../state.js";
|
|
12
|
+
import { formatEntryList, getEntryNames, saveLedgerEntry } from "./store.js";
|
|
13
|
+
|
|
14
|
+
// ── Factory ───────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates ledger tool definitions (ledger_add, ledger_get, ledger_list).
|
|
18
|
+
*
|
|
19
|
+
* Shared by parent registration (withPromptHints=true) and child spawn
|
|
20
|
+
* sessions (withPromptHints=false). The prompt hints (snippet, guidelines)
|
|
21
|
+
* are only included for the parent — child agents don't need them.
|
|
22
|
+
*/
|
|
23
|
+
export function createLedgerToolDefinitions(
|
|
24
|
+
pi: ExtensionAPI,
|
|
25
|
+
state: AgenticodingState,
|
|
26
|
+
options?: { withPromptHints?: boolean; isStale?: () => boolean },
|
|
27
|
+
): ToolDefinition[] {
|
|
28
|
+
const withHints = options?.withPromptHints ?? false;
|
|
29
|
+
const assertFresh = () => {
|
|
30
|
+
if (options?.isStale?.()) {
|
|
31
|
+
throw new Error("Spawn invalidated by reset.");
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const ledgerAdd: ToolDefinition = {
|
|
36
|
+
name: "ledger_add",
|
|
37
|
+
label: "Ledger Add",
|
|
38
|
+
description:
|
|
39
|
+
"Save or refine a compact continuity entry. " +
|
|
40
|
+
"Same name overwrites the previous entry (refinement). " +
|
|
41
|
+
"Writes are serialized via a process-local lock; same-name writes overwrite in completion order. " +
|
|
42
|
+
"Always returns the current list of up to date entries.",
|
|
43
|
+
...(withHints
|
|
44
|
+
? {
|
|
45
|
+
promptSnippet: "Save or refine a compact continuity entry",
|
|
46
|
+
promptGuidelines: [
|
|
47
|
+
"Continuously maintain the ledger while you work. After meaningful reads, research, analysis, decisions, or milestones, either refine an existing entry, create a compact reusable entry, or consciously skip because nothing reusable was learned.",
|
|
48
|
+
"Prefer refining existing entries over creating many tiny ones. Do not try to make the ledger complete.",
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
executionMode: "sequential",
|
|
53
|
+
parameters: Type.Object({
|
|
54
|
+
name: Type.String({
|
|
55
|
+
description:
|
|
56
|
+
"Kebab-case entry identifier. Using an existing name overwrites that entry (refinement).",
|
|
57
|
+
}),
|
|
58
|
+
content: Type.String({
|
|
59
|
+
description:
|
|
60
|
+
"Compact markdown. Capture only reusable facts, decisions, " +
|
|
61
|
+
"constraints, progress, and expensive discoveries. " +
|
|
62
|
+
"Truncated at 50KB / 2000 lines.",
|
|
63
|
+
}),
|
|
64
|
+
}),
|
|
65
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
66
|
+
assertFresh();
|
|
67
|
+
const names = await saveLedgerEntry(pi, state, params.name, params.content, assertFresh);
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text",
|
|
72
|
+
text: `Saved ledger entry "${params.name}".` +
|
|
73
|
+
`\n\nEntries:\n${formatEntryList(state) || "(empty)"}`,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
details: { entries: names },
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const ledgerGet: ToolDefinition = {
|
|
82
|
+
name: "ledger_get",
|
|
83
|
+
label: "Ledger Get",
|
|
84
|
+
description:
|
|
85
|
+
"Retrieve a ledger entry's full body by name. " +
|
|
86
|
+
"Always returns the current list of entry names.",
|
|
87
|
+
...(withHints
|
|
88
|
+
? { promptSnippet: "Fetch a ledger entry by name" }
|
|
89
|
+
: {}),
|
|
90
|
+
parameters: Type.Object({
|
|
91
|
+
name: Type.String({
|
|
92
|
+
description: "Entry name to retrieve.",
|
|
93
|
+
}),
|
|
94
|
+
}),
|
|
95
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
96
|
+
assertFresh();
|
|
97
|
+
const content = state.ledger.get(params.name);
|
|
98
|
+
const names = getEntryNames(state);
|
|
99
|
+
|
|
100
|
+
if (content === undefined) {
|
|
101
|
+
return {
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: "text",
|
|
105
|
+
text:
|
|
106
|
+
`Entry "${params.name}" not found.` +
|
|
107
|
+
`\n\nEntries:\n${formatEntryList(state) || "(empty)"}`,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
details: { entries: names, found: false },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: "text",
|
|
118
|
+
text:
|
|
119
|
+
`--- ${params.name} ---\n${content}\n` +
|
|
120
|
+
`---\nEntries:\n${formatEntryList(state) || "(empty)"}`,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
details: { entries: names, found: true },
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const ledgerList: ToolDefinition = {
|
|
129
|
+
name: "ledger_list",
|
|
130
|
+
label: "Ledger List",
|
|
131
|
+
description:
|
|
132
|
+
"List all ledger entries as name + first-line preview. " +
|
|
133
|
+
"Always returns the current list of entry names.",
|
|
134
|
+
...(withHints
|
|
135
|
+
? { promptSnippet: "List all ledger entries" }
|
|
136
|
+
: {}),
|
|
137
|
+
parameters: Type.Object({}),
|
|
138
|
+
async execute() {
|
|
139
|
+
assertFresh();
|
|
140
|
+
const names = getEntryNames(state);
|
|
141
|
+
return {
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
type: "text",
|
|
145
|
+
text: `Entries:\n${formatEntryList(state) || "(empty)"}`,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
details: { entries: names },
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return [ledgerAdd, ledgerGet, ledgerList];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Registration ──────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
export function registerLedgerTools(
|
|
159
|
+
pi: ExtensionAPI,
|
|
160
|
+
state: AgenticodingState,
|
|
161
|
+
): void {
|
|
162
|
+
const tools = createLedgerToolDefinitions(pi, state, { withPromptHints: true });
|
|
163
|
+
for (const tool of tools) {
|
|
164
|
+
pi.registerTool(tool);
|
|
165
|
+
}
|
|
166
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-agenticoding",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Context management primitives for the pi coding agent — spawn, ledger, handoff",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": ["pi-package"],
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/earendil-works/pi-agenticoding.git"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"@earendil-works/pi-ai": "*",
|
|
13
|
+
"@earendil-works/pi-agent-core": "*",
|
|
14
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
15
|
+
"@earendil-works/pi-tui": "*",
|
|
16
|
+
"typebox": "*"
|
|
17
|
+
},
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": ["./index.ts"]
|
|
20
|
+
}
|
|
21
|
+
}
|
package/spawn/index.ts
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn tool for the agenticoding extension.
|
|
3
|
+
*
|
|
4
|
+
* Creates an isolated in-memory child AgentSession for focused subtask execution.
|
|
5
|
+
* Children inherit the parent's model, thinking level, cwd, and ledger access.
|
|
6
|
+
* Max nesting depth: 1 edge (parent → child only).
|
|
7
|
+
*
|
|
8
|
+
* Spawn is context isolation, not a security boundary. Child agents are trusted
|
|
9
|
+
* extensions of the parent and inherit parent authority by design.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
ExtensionAPI,
|
|
14
|
+
ExtensionContext,
|
|
15
|
+
ToolDefinition,
|
|
16
|
+
ToolInfo,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import {
|
|
19
|
+
AuthStorage,
|
|
20
|
+
createAgentSession,
|
|
21
|
+
ModelRegistry,
|
|
22
|
+
SessionManager,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
25
|
+
import { Type } from "typebox";
|
|
26
|
+
import type { AgenticodingState } from "../state.js";
|
|
27
|
+
import { formatEntryList } from "../ledger/store.js";
|
|
28
|
+
import { createLedgerToolDefinitions } from "../ledger/tools.js";
|
|
29
|
+
import {
|
|
30
|
+
renderSpawnCall,
|
|
31
|
+
renderSpawnResult,
|
|
32
|
+
} from "./renderer.js";
|
|
33
|
+
import {
|
|
34
|
+
getLastAssistantText,
|
|
35
|
+
type SpawnOutcome,
|
|
36
|
+
type SpawnResultDetails,
|
|
37
|
+
type ThinkingValue,
|
|
38
|
+
} from "./shared.js";
|
|
39
|
+
|
|
40
|
+
// ── Constants ─────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const MAX_SPAWN_DEPTH = 1;
|
|
43
|
+
const CHILD_MAX_LINES = 2000;
|
|
44
|
+
const CHILD_MAX_BYTES = 50 * 1024;
|
|
45
|
+
|
|
46
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
type AssistantMessageLike = {
|
|
49
|
+
role: string;
|
|
50
|
+
content?: { type: string; text?: string }[];
|
|
51
|
+
stopReason?: unknown;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function getLastAssistantMessage(messages: AssistantMessageLike[]): AssistantMessageLike | undefined {
|
|
55
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
56
|
+
const msg = messages[i];
|
|
57
|
+
if (msg.role === "assistant") return msg;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getLastAssistantOutcome(messages: AssistantMessageLike[]): SpawnOutcome {
|
|
63
|
+
const stopReason = getLastAssistantMessage(messages)?.stopReason;
|
|
64
|
+
if (stopReason === "aborted") return "aborted";
|
|
65
|
+
if (stopReason === "error") return "error";
|
|
66
|
+
return "success";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Truncates text to stay within maxLines/maxBytes.
|
|
71
|
+
* Line-count limit is applied first, then byte limit.
|
|
72
|
+
* May end mid-line if the byte limit is the tighter constraint.
|
|
73
|
+
*/
|
|
74
|
+
function truncateText(text: string, maxLines: number, maxBytes: number): string {
|
|
75
|
+
const lines = text.split("\n");
|
|
76
|
+
let truncated = lines.slice(0, maxLines).join("\n");
|
|
77
|
+
if (new TextEncoder().encode(truncated).length > maxBytes) {
|
|
78
|
+
truncated = new TextDecoder().decode(
|
|
79
|
+
new TextEncoder().encode(truncated).slice(0, maxBytes),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return truncated;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Truncates child agent output to CHILD_MAX_LINES lines / CHILD_MAX_BYTES bytes.
|
|
87
|
+
* Appends a "[Result truncated...]" advisory when truncation occurs.
|
|
88
|
+
* Returns { text, truncated }.
|
|
89
|
+
*/
|
|
90
|
+
function truncateResult(text: string): { text: string; truncated: boolean } {
|
|
91
|
+
const lines = text.split("\n");
|
|
92
|
+
const bytes = new TextEncoder().encode(text).length;
|
|
93
|
+
|
|
94
|
+
if (lines.length <= CHILD_MAX_LINES && bytes <= CHILD_MAX_BYTES) {
|
|
95
|
+
return { text, truncated: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const truncated = truncateText(text, CHILD_MAX_LINES, CHILD_MAX_BYTES);
|
|
99
|
+
return {
|
|
100
|
+
text:
|
|
101
|
+
truncated +
|
|
102
|
+
`\n\n[Result truncated to ${CHILD_MAX_LINES} lines / ${(CHILD_MAX_BYTES / 1024).toFixed(0)}KB. ` +
|
|
103
|
+
`Ask the child to summarize further if needed.]`,
|
|
104
|
+
truncated: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the final list of tool names for a child session.
|
|
111
|
+
*
|
|
112
|
+
* Child sessions inherit the parent's active built-in tools plus the local
|
|
113
|
+
* child custom tools defined here. Parent-only custom tools are intentionally
|
|
114
|
+
* excluded so the child never advertises a tool it cannot execute.
|
|
115
|
+
*
|
|
116
|
+
* handoff never carries into children, and spawn is only re-added from
|
|
117
|
+
* childTools when the current depth still allows nesting.
|
|
118
|
+
*/
|
|
119
|
+
function getInheritableParentToolNames(parentToolNames: string[], availableTools: Pick<ToolInfo, "name" | "sourceInfo">[]): string[] {
|
|
120
|
+
const activeToolNames = new Set(parentToolNames);
|
|
121
|
+
return availableTools
|
|
122
|
+
.filter((tool) => activeToolNames.has(tool.name) && tool.sourceInfo?.source === "builtin")
|
|
123
|
+
.map((tool) => tool.name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildChildToolNames(
|
|
127
|
+
parentToolNames: string[],
|
|
128
|
+
childTools: ToolDefinition[],
|
|
129
|
+
availableTools?: Pick<ToolInfo, "name" | "sourceInfo">[],
|
|
130
|
+
): string[] {
|
|
131
|
+
const inheritableParentToolNames = availableTools
|
|
132
|
+
? getInheritableParentToolNames(parentToolNames, availableTools)
|
|
133
|
+
: parentToolNames;
|
|
134
|
+
const inheritedTools = inheritableParentToolNames.filter((name) => name !== "spawn" && name !== "handoff");
|
|
135
|
+
return [...new Set([...inheritedTools, ...childTools.map((tool) => tool.name)])];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Shared spawn tool metadata (used by both parent and child tool definitions) ──
|
|
139
|
+
|
|
140
|
+
const SPAWN_DESCRIPTION =
|
|
141
|
+
"Spawn an isolated child agent for a focused subtask. " +
|
|
142
|
+
"Child inherits parent model, thinking level, cwd, supported built-in tools, and shared ledger tools; spawn is only exposed when depth allows. " +
|
|
143
|
+
"Reference ledger entries by name — child will ledger_get them on demand.";
|
|
144
|
+
|
|
145
|
+
const SPAWN_PROMPT_SNIPPET = "Spawn a focused subtask agent";
|
|
146
|
+
|
|
147
|
+
const SPAWN_PROMPT_GUIDELINES = [
|
|
148
|
+
"Use spawn to delegate isolated work to child agents. They are trusted extensions of you with their own context and the same authority. Only condensed results are returned.",
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const SPAWN_PARAMETERS = Type.Object({
|
|
152
|
+
prompt: Type.String({
|
|
153
|
+
description:
|
|
154
|
+
"Self-contained task description. Reference ledger entries by name — " +
|
|
155
|
+
"child will ledger_get them on demand.",
|
|
156
|
+
}),
|
|
157
|
+
thinking: StringEnum(
|
|
158
|
+
["off", "minimal", "low", "medium", "high", "xhigh"] as const,
|
|
159
|
+
{
|
|
160
|
+
description:
|
|
161
|
+
"Override child thinking level. Inherits parent by default.",
|
|
162
|
+
},
|
|
163
|
+
),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the custom tool set for child agent sessions.
|
|
170
|
+
*
|
|
171
|
+
* Produces ledger tools (add/get/list) and conditionally includes the spawn
|
|
172
|
+
* tool when currentDepth is below MAX_SPAWN_DEPTH. The spawn tool is omitted
|
|
173
|
+
* at max depth to prevent the LLM from attempting illegal recursion.
|
|
174
|
+
*
|
|
175
|
+
* All tools read/write the shared parent state so ledger entries are visible
|
|
176
|
+
* across parent and child contexts.
|
|
177
|
+
*
|
|
178
|
+
* @param sessionFactory - Test seam for dependency-injecting createAgentSession.
|
|
179
|
+
*/
|
|
180
|
+
export function createChildTools(
|
|
181
|
+
pi: ExtensionAPI,
|
|
182
|
+
state: AgenticodingState,
|
|
183
|
+
defaultThinking: ThinkingValue,
|
|
184
|
+
currentDepth: number,
|
|
185
|
+
sessionFactory: typeof createAgentSession = createAgentSession,
|
|
186
|
+
options?: { isStale?: () => boolean },
|
|
187
|
+
): ToolDefinition[] {
|
|
188
|
+
// Child sessions inherit only executable parent tools via
|
|
189
|
+
// buildChildToolNames(). Only built-in parent tools are carried through.
|
|
190
|
+
// handoff never carries into children, and spawn is only re-added here
|
|
191
|
+
// while depth allows it.
|
|
192
|
+
|
|
193
|
+
const childSpawnTool: ToolDefinition = {
|
|
194
|
+
name: "spawn",
|
|
195
|
+
label: "Spawn",
|
|
196
|
+
description: SPAWN_DESCRIPTION,
|
|
197
|
+
promptSnippet: SPAWN_PROMPT_SNIPPET,
|
|
198
|
+
promptGuidelines: SPAWN_PROMPT_GUIDELINES,
|
|
199
|
+
parameters: SPAWN_PARAMETERS,
|
|
200
|
+
async execute(
|
|
201
|
+
toolCallId: string,
|
|
202
|
+
params: { prompt: string; thinking?: ThinkingValue },
|
|
203
|
+
signal: AbortSignal | undefined,
|
|
204
|
+
onUpdate:
|
|
205
|
+
| ((result: {
|
|
206
|
+
content: { type: string; text: string }[];
|
|
207
|
+
details?: unknown;
|
|
208
|
+
}) => void)
|
|
209
|
+
| undefined,
|
|
210
|
+
ctx: ExtensionContext,
|
|
211
|
+
) {
|
|
212
|
+
return executeSpawn(toolCallId, pi, ctx, state, params, signal, onUpdate, defaultThinking, currentDepth, sessionFactory);
|
|
213
|
+
},
|
|
214
|
+
renderCall: renderSpawnCall,
|
|
215
|
+
renderResult(result, { expanded }, theme, context) {
|
|
216
|
+
return renderSpawnResult(result, expanded, theme, context, state);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const childLedgerTools = createLedgerToolDefinitions(pi, state, { isStale: options?.isStale });
|
|
221
|
+
|
|
222
|
+
return [
|
|
223
|
+
...(currentDepth < MAX_SPAWN_DEPTH ? [childSpawnTool] : []),
|
|
224
|
+
...childLedgerTools,
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
// ── Shared spawn execution logic ──────────────────────────────────────
|
|
231
|
+
// Used by both the parent-registered spawn tool and child custom spawn tools.
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Creates an isolated child agent session, runs the given prompt, and returns
|
|
235
|
+
* the result with usage stats.
|
|
236
|
+
*
|
|
237
|
+
* Errors (all thrown, not returned):
|
|
238
|
+
* - "Max spawn depth reached" → currentDepth >= MAX_SPAWN_DEPTH
|
|
239
|
+
* - "No model configured..." → ctx.model is undefined
|
|
240
|
+
* - "Child agent produced no output." → no assistant text after prompt
|
|
241
|
+
*
|
|
242
|
+
* Side effects on state:
|
|
243
|
+
* - state.childSessions.set(toolCallId, session) on creation
|
|
244
|
+
* - state.liveChildSessions.set(toolCallId, session) on creation
|
|
245
|
+
* - both registries delete(toolCallId) on error and completion paths
|
|
246
|
+
*
|
|
247
|
+
* @param onUpdate - Callback that fires once after session creation with
|
|
248
|
+
* empty content + initial details (depth, model, thinking). Pi uses this
|
|
249
|
+
* to render the component before the child produces output.
|
|
250
|
+
* @param sessionFactory - Test seam for mocking createAgentSession.
|
|
251
|
+
*/
|
|
252
|
+
export async function executeSpawn(
|
|
253
|
+
toolCallId: string,
|
|
254
|
+
pi: ExtensionAPI,
|
|
255
|
+
ctx: ExtensionContext,
|
|
256
|
+
state: AgenticodingState,
|
|
257
|
+
params: { prompt: string; thinking?: ThinkingValue },
|
|
258
|
+
signal: AbortSignal | undefined,
|
|
259
|
+
onUpdate:
|
|
260
|
+
| ((result: {
|
|
261
|
+
content: { type: string; text: string }[];
|
|
262
|
+
details?: unknown;
|
|
263
|
+
}) => void)
|
|
264
|
+
| undefined,
|
|
265
|
+
defaultThinking: ThinkingValue,
|
|
266
|
+
currentDepth: number,
|
|
267
|
+
sessionFactory: typeof createAgentSession = createAgentSession,
|
|
268
|
+
) {
|
|
269
|
+
if (currentDepth >= MAX_SPAWN_DEPTH) {
|
|
270
|
+
throw new Error(`Max spawn depth (${MAX_SPAWN_DEPTH}) reached. Cannot spawn further children.`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const childModel = ctx.model;
|
|
274
|
+
if (!childModel) {
|
|
275
|
+
throw new Error("No model configured. Cannot spawn child agent.");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const childThinking: ThinkingValue = params.thinking ?? defaultThinking;
|
|
279
|
+
const depth = currentDepth + 1;
|
|
280
|
+
|
|
281
|
+
const listing = formatEntryList(state);
|
|
282
|
+
const ledgerListing = listing
|
|
283
|
+
? "Available ledger entries:\n" + listing
|
|
284
|
+
: "No ledger entries.";
|
|
285
|
+
const fullPrompt =
|
|
286
|
+
`You are a focused child agent spawned by a parent agent. ` +
|
|
287
|
+
`You have the same authority as the parent. ` +
|
|
288
|
+
`You inherit the parent's supported built-in tools plus shared ledger tools, and spawn is only exposed when depth allows it. ` +
|
|
289
|
+
`Your result will be read by the parent, so be concise and complete.\n\n` +
|
|
290
|
+
`${ledgerListing}\n\n` +
|
|
291
|
+
`## Task\n\n${params.prompt}\n\n` +
|
|
292
|
+
`When complete, provide a concise summary of findings. ` +
|
|
293
|
+
`Keep the result under ${CHILD_MAX_LINES} lines / ${(CHILD_MAX_BYTES / 1024).toFixed(0)}KB.`;
|
|
294
|
+
|
|
295
|
+
const authStorage = AuthStorage.create();
|
|
296
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
297
|
+
const childSessionEpoch = state.childSessionEpoch;
|
|
298
|
+
const isStale = () => state.childSessionEpoch !== childSessionEpoch;
|
|
299
|
+
const childTools = createChildTools(pi, state, childThinking, depth, sessionFactory, { isStale });
|
|
300
|
+
const parentToolNames = pi.getActiveTools();
|
|
301
|
+
const childToolNames = buildChildToolNames(parentToolNames, childTools, pi.getAllTools());
|
|
302
|
+
|
|
303
|
+
const { session } = await sessionFactory({
|
|
304
|
+
sessionManager: SessionManager.inMemory(),
|
|
305
|
+
model: childModel,
|
|
306
|
+
thinkingLevel: childThinking,
|
|
307
|
+
cwd: ctx.cwd,
|
|
308
|
+
tools: childToolNames,
|
|
309
|
+
customTools: childTools,
|
|
310
|
+
authStorage,
|
|
311
|
+
modelRegistry,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const invalidatedError = new Error("Spawn invalidated by reset.");
|
|
315
|
+
let wasAborted = false;
|
|
316
|
+
const abortChild = () => {
|
|
317
|
+
wasAborted = true;
|
|
318
|
+
session.abort().catch(e => console.error("[spawn] abort failed:", toolCallId, e));
|
|
319
|
+
};
|
|
320
|
+
const clearChildSession = () => {
|
|
321
|
+
if (state.childSessions.get(toolCallId) === session) {
|
|
322
|
+
state.childSessions.delete(toolCallId);
|
|
323
|
+
}
|
|
324
|
+
if (state.liveChildSessions.get(toolCallId) === session) {
|
|
325
|
+
state.liveChildSessions.delete(toolCallId);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const abortAndInvalidate = async () => {
|
|
329
|
+
clearChildSession();
|
|
330
|
+
await session.abort().catch(e => console.error("[spawn] abort failed:", toolCallId, e));
|
|
331
|
+
throw invalidatedError;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
if (isStale()) {
|
|
335
|
+
await abortAndInvalidate();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// liveChildSessions must be set first — renderSpawnResult checks it to decide
|
|
339
|
+
// whether to pass the live registry to attachSession for stale detection.
|
|
340
|
+
state.liveChildSessions.set(toolCallId, session);
|
|
341
|
+
state.childSessions.set(toolCallId, session);
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
if (signal?.aborted) {
|
|
345
|
+
wasAborted = true;
|
|
346
|
+
await session.abort();
|
|
347
|
+
throw signal.reason instanceof Error
|
|
348
|
+
? signal.reason
|
|
349
|
+
: new Error("Spawn aborted before child session started.");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (isStale()) {
|
|
353
|
+
await abortAndInvalidate();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
onUpdate?.({
|
|
357
|
+
content: [],
|
|
358
|
+
details: {
|
|
359
|
+
depth,
|
|
360
|
+
model: childModel.id,
|
|
361
|
+
thinking: childThinking,
|
|
362
|
+
truncated: false,
|
|
363
|
+
outcome: "running",
|
|
364
|
+
} satisfies SpawnResultDetails,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
signal?.addEventListener("abort", abortChild, { once: true });
|
|
368
|
+
await session.prompt(fullPrompt);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
clearChildSession();
|
|
371
|
+
if (isStale()) {
|
|
372
|
+
throw invalidatedError;
|
|
373
|
+
}
|
|
374
|
+
throw error;
|
|
375
|
+
} finally {
|
|
376
|
+
signal?.removeEventListener("abort", abortChild);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (isStale()) {
|
|
380
|
+
clearChildSession();
|
|
381
|
+
throw invalidatedError;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const resultText = getLastAssistantText(session.messages);
|
|
385
|
+
if (!resultText) {
|
|
386
|
+
clearChildSession();
|
|
387
|
+
throw new Error("Child agent produced no output.");
|
|
388
|
+
}
|
|
389
|
+
const outcome = wasAborted ? "aborted" : getLastAssistantOutcome(session.messages);
|
|
390
|
+
const { text: finalText, truncated } = truncateResult(resultText);
|
|
391
|
+
|
|
392
|
+
// Execution should not retain live children after completion. If the TUI
|
|
393
|
+
// already rendered the child, it still owns the session object itself.
|
|
394
|
+
// Clearing here intentionally makes the component's dispose() a no-op for
|
|
395
|
+
// liveChildSessions — the child already completed so there's nothing to abort.
|
|
396
|
+
clearChildSession();
|
|
397
|
+
|
|
398
|
+
let stats: Record<string, number> | undefined;
|
|
399
|
+
let statsUnavailable = false;
|
|
400
|
+
try {
|
|
401
|
+
const sessionStats = session.getSessionStats();
|
|
402
|
+
if (sessionStats) {
|
|
403
|
+
stats = {
|
|
404
|
+
inputTokens: sessionStats.tokens?.input ?? 0,
|
|
405
|
+
outputTokens: sessionStats.tokens?.output ?? 0,
|
|
406
|
+
cacheReadTokens: sessionStats.tokens?.cacheRead ?? 0,
|
|
407
|
+
cacheWriteTokens: sessionStats.tokens?.cacheWrite ?? 0,
|
|
408
|
+
totalTokens: sessionStats.tokens?.total ?? 0,
|
|
409
|
+
cost: sessionStats.cost ?? 0,
|
|
410
|
+
turns: sessionStats.assistantMessages ?? 0,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
} catch (error: unknown) {
|
|
414
|
+
statsUnavailable = true;
|
|
415
|
+
console.warn("[spawn] Failed to collect child session stats:", error, toolCallId);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (isStale()) {
|
|
419
|
+
throw invalidatedError;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const details: SpawnResultDetails = {
|
|
423
|
+
depth,
|
|
424
|
+
model: childModel.id,
|
|
425
|
+
thinking: childThinking,
|
|
426
|
+
truncated,
|
|
427
|
+
outcome,
|
|
428
|
+
};
|
|
429
|
+
if (stats) {
|
|
430
|
+
details.stats = stats;
|
|
431
|
+
} else if (statsUnavailable) {
|
|
432
|
+
details.statsUnavailable = true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
content: [{ type: "text" as const, text: finalText }],
|
|
437
|
+
details,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Register the spawn tool with pi's tool system.
|
|
443
|
+
*
|
|
444
|
+
* Creates a ToolDefinition that spawns an isolated child AgentSession
|
|
445
|
+
* for focused subtasks. Children inherit the parent model, thinking
|
|
446
|
+
* level, cwd, and ledger access.
|
|
447
|
+
*
|
|
448
|
+
* @param pi - Extension API instance for tool registration
|
|
449
|
+
* @param state - Shared session state (child sessions, epoch, ledger)
|
|
450
|
+
* @param sessionFactory - Optional test seam for mocking createAgentSession
|
|
451
|
+
*/
|
|
452
|
+
export function registerSpawnTool(
|
|
453
|
+
pi: ExtensionAPI,
|
|
454
|
+
state: AgenticodingState,
|
|
455
|
+
sessionFactory: typeof createAgentSession = createAgentSession,
|
|
456
|
+
): void {
|
|
457
|
+
pi.registerTool({
|
|
458
|
+
name: "spawn",
|
|
459
|
+
label: "Spawn",
|
|
460
|
+
description: SPAWN_DESCRIPTION,
|
|
461
|
+
promptSnippet: SPAWN_PROMPT_SNIPPET,
|
|
462
|
+
promptGuidelines: SPAWN_PROMPT_GUIDELINES,
|
|
463
|
+
parameters: SPAWN_PARAMETERS,
|
|
464
|
+
|
|
465
|
+
async execute(
|
|
466
|
+
_toolCallId: string,
|
|
467
|
+
params: { prompt: string; thinking?: ThinkingValue },
|
|
468
|
+
signal: AbortSignal | undefined,
|
|
469
|
+
onUpdate:
|
|
470
|
+
| ((result: {
|
|
471
|
+
content: { type: string; text: string }[];
|
|
472
|
+
details?: unknown;
|
|
473
|
+
}) => void)
|
|
474
|
+
| undefined,
|
|
475
|
+
ctx: ExtensionContext,
|
|
476
|
+
) {
|
|
477
|
+
const parentThinking: ThinkingValue = pi.getThinkingLevel();
|
|
478
|
+
return executeSpawn(_toolCallId, pi, ctx, state, params, signal, onUpdate, parentThinking, 0, sessionFactory);
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
renderCall: renderSpawnCall,
|
|
482
|
+
|
|
483
|
+
renderResult(result, { expanded }, theme, context) {
|
|
484
|
+
return renderSpawnResult(result, expanded, theme, context, state);
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
}
|