@harms-haus/pi-subagents 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 +362 -0
- package/docs/architecture.md +554 -0
- package/docs/changelog.md +61 -0
- package/docs/profiles.md +546 -0
- package/docs/settings.md +52 -0
- package/docs/tools-reference.md +519 -0
- package/package.json +59 -0
- package/src/cache.ts +24 -0
- package/src/commands/profile.ts +176 -0
- package/src/format-tool-call.ts +597 -0
- package/src/format-transcript.ts +151 -0
- package/src/index.ts +117 -0
- package/src/profile-editor.ts +356 -0
- package/src/profile-formatting.ts +178 -0
- package/src/profile-types.ts +73 -0
- package/src/profiles.ts +577 -0
- package/src/schemas.ts +65 -0
- package/src/settings.ts +155 -0
- package/src/skill-discovery.ts +30 -0
- package/src/spawner.ts +523 -0
- package/src/tools/delegate-render.ts +285 -0
- package/src/tools/delegate.ts +867 -0
- package/src/tools/retrieval.ts +287 -0
- package/src/types.ts +232 -0
- package/src/utils.ts +168 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieval Tools
|
|
3
|
+
*
|
|
4
|
+
* Tool registrations for retrieving sub-agent output and session data.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { loadProfiles, profileSummary } from "../profiles";
|
|
10
|
+
import { loadMaxLinesPerWindow } from "../settings";
|
|
11
|
+
import { getLastAssistantText } from "../utils";
|
|
12
|
+
import { formatTranscript, RETRIEVAL_OPTIONS } from "../format-transcript";
|
|
13
|
+
import type { SessionRecord } from "../types";
|
|
14
|
+
import type {
|
|
15
|
+
ExtensionAPI,
|
|
16
|
+
ExtensionContext,
|
|
17
|
+
Theme,
|
|
18
|
+
AgentToolResult,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
// ── Rendering Helpers ───────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Simple renderResult that extracts and displays the first text content.
|
|
25
|
+
* Used by list_subagent_profiles (no truncation needed).
|
|
26
|
+
*/
|
|
27
|
+
function createSimpleRenderResult(defaultLabel: string = "(no output)") {
|
|
28
|
+
return (
|
|
29
|
+
result: AgentToolResult<unknown>,
|
|
30
|
+
_options: { expanded: boolean },
|
|
31
|
+
theme: Theme,
|
|
32
|
+
_context: unknown,
|
|
33
|
+
) => {
|
|
34
|
+
const text = result.content[0];
|
|
35
|
+
if (!text) return new Text(theme.fg("toolOutput", defaultLabel), 0, 0);
|
|
36
|
+
const content = text.type === "text" ? text.text : defaultLabel;
|
|
37
|
+
return new Text(theme.fg("toolOutput", content), 0, 0);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Truncating renderResult for sub-agent output/session data.
|
|
43
|
+
* Shows at most maxLinesPerWindow lines with a truncation indicator.
|
|
44
|
+
* The full content is still injected into context; only the TUI display is shortened.
|
|
45
|
+
*/
|
|
46
|
+
function createTruncatingRenderResult(defaultLabel: string = "(no output)") {
|
|
47
|
+
return (
|
|
48
|
+
result: AgentToolResult<unknown>,
|
|
49
|
+
_options: { expanded: boolean },
|
|
50
|
+
theme: Theme,
|
|
51
|
+
_context: unknown,
|
|
52
|
+
) => {
|
|
53
|
+
const text = result.content[0];
|
|
54
|
+
if (!text) return new Text(theme.fg("toolOutput", defaultLabel), 0, 0);
|
|
55
|
+
const content = text.type === "text" ? text.text : defaultLabel;
|
|
56
|
+
const lines = content.split("\n");
|
|
57
|
+
const maxLines: number = (result.details as Record<string, unknown>).maxLines as number;
|
|
58
|
+
|
|
59
|
+
if (lines.length <= maxLines) {
|
|
60
|
+
return new Text(theme.fg("toolOutput", content), 0, 0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const shown = lines.slice(0, maxLines);
|
|
64
|
+
const truncated = lines.length - maxLines;
|
|
65
|
+
const indicator = theme.fg("dim", `... (${truncated} more line${truncated !== 1 ? "s" : ""})`);
|
|
66
|
+
|
|
67
|
+
const container = new Container();
|
|
68
|
+
container.addChild(new Text(theme.fg("toolOutput", shown.join("\n")), 0, 0));
|
|
69
|
+
container.addChild(new Text(indicator, 0, 0));
|
|
70
|
+
return container;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a renderCall function for retrieval tools that take a sessionId.
|
|
76
|
+
*/
|
|
77
|
+
function createSessionRenderCall(toolName: string) {
|
|
78
|
+
return (args: { sessionId?: string }, theme: Theme, _context: unknown) => {
|
|
79
|
+
return new Text(
|
|
80
|
+
theme.fg("toolTitle", theme.bold(`${toolName} `)) +
|
|
81
|
+
theme.fg("accent", args.sessionId ?? "..."),
|
|
82
|
+
0,
|
|
83
|
+
0,
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Error Constants ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** Error message when a session ID is not found in the session store. */
|
|
91
|
+
function sessionNotFoundMessage(sessionId: string): string {
|
|
92
|
+
return `Session "${sessionId}" not found. The session may have expired or the ID is incorrect.`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Shared type for execute context parameter. */
|
|
96
|
+
type ToolExecuteContext = ExtensionContext;
|
|
97
|
+
|
|
98
|
+
/** Look up a session record, throwing if not found or has no runs. */
|
|
99
|
+
function requireSession(
|
|
100
|
+
sessionStore: Map<string, SessionRecord>,
|
|
101
|
+
sessionId: string,
|
|
102
|
+
): SessionRecord {
|
|
103
|
+
const record = sessionStore.get(sessionId);
|
|
104
|
+
if (!record || record.runs.length === 0) {
|
|
105
|
+
throw new Error(sessionNotFoundMessage(sessionId));
|
|
106
|
+
}
|
|
107
|
+
return record;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Register the get_subagent_output tool. */
|
|
111
|
+
function registerGetSubagentOutput(
|
|
112
|
+
pi: ExtensionAPI,
|
|
113
|
+
sessionStore: Map<string, SessionRecord>,
|
|
114
|
+
): void {
|
|
115
|
+
pi.registerTool({
|
|
116
|
+
name: "get_subagent_output",
|
|
117
|
+
label: "Get Sub-agent Output",
|
|
118
|
+
description: [
|
|
119
|
+
"Retrieve the last assistant text output from a completed sub-agent session.",
|
|
120
|
+
"Use this to get the results from a subagent after it finishes, without needing",
|
|
121
|
+
"the subagent to write to a file. Pass the session ID returned by delegate_to_subagents.",
|
|
122
|
+
"For resumed sessions, returns the output from the LATEST run.",
|
|
123
|
+
].join(" "),
|
|
124
|
+
parameters: Type.Object({
|
|
125
|
+
sessionId: Type.String({ description: "The session ID returned by delegate_to_subagents" }),
|
|
126
|
+
}),
|
|
127
|
+
promptSnippet: "Get the final text output from a previously completed sub-agent session",
|
|
128
|
+
promptGuidelines: [
|
|
129
|
+
"Use get_subagent_output to retrieve the final text output from a sub-agent after",
|
|
130
|
+
"delegate_to_subagents completes, instead of asking the sub-agent to write to a file.",
|
|
131
|
+
],
|
|
132
|
+
|
|
133
|
+
async execute(
|
|
134
|
+
_toolCallId: string,
|
|
135
|
+
params: { sessionId: string },
|
|
136
|
+
_signal: AbortSignal | undefined,
|
|
137
|
+
_onUpdate: unknown,
|
|
138
|
+
ctx: ToolExecuteContext,
|
|
139
|
+
) {
|
|
140
|
+
const record = requireSession(sessionStore, params.sessionId);
|
|
141
|
+
const latestRun = record.runs[record.runs.length - 1];
|
|
142
|
+
if (!latestRun) throw new Error(sessionNotFoundMessage(params.sessionId));
|
|
143
|
+
const lastText = getLastAssistantText(latestRun.messages);
|
|
144
|
+
const maxLines = await loadMaxLinesPerWindow(ctx.cwd);
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: "text", text: lastText || "(no text output from sub-agent)" }],
|
|
147
|
+
details: {
|
|
148
|
+
sessionId: params.sessionId,
|
|
149
|
+
status: latestRun.status,
|
|
150
|
+
taskName: latestRun.taskName,
|
|
151
|
+
runCount: record.runs.length,
|
|
152
|
+
maxLines,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
renderCall: createSessionRenderCall("get_subagent_output"),
|
|
158
|
+
renderResult: createTruncatingRenderResult("(no output)"),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Register the get_subagent_session tool. */
|
|
163
|
+
function registerGetSubagentSession(
|
|
164
|
+
pi: ExtensionAPI,
|
|
165
|
+
sessionStore: Map<string, SessionRecord>,
|
|
166
|
+
): void {
|
|
167
|
+
pi.registerTool({
|
|
168
|
+
name: "get_subagent_session",
|
|
169
|
+
label: "Get Sub-agent Session",
|
|
170
|
+
description: [
|
|
171
|
+
"Retrieve the complete session transcript from a sub-agent, including all messages",
|
|
172
|
+
"(assistant text, tool calls, tool results). Use this for detailed debugging or when",
|
|
173
|
+
"you need the full conversation history of a sub-agent. Pass the session ID returned",
|
|
174
|
+
"by delegate_to_subagents. For resumed sessions, returns ALL runs' data concatenated.",
|
|
175
|
+
].join(" "),
|
|
176
|
+
parameters: Type.Object({
|
|
177
|
+
sessionId: Type.String({ description: "The session ID returned by delegate_to_subagents" }),
|
|
178
|
+
}),
|
|
179
|
+
promptSnippet: "Read the full session transcript from a previously completed sub-agent session",
|
|
180
|
+
promptGuidelines: [
|
|
181
|
+
"Use get_subagent_session when you need the FULL conversation history of a sub-agent,",
|
|
182
|
+
"including all tool calls and results. Use get_subagent_output instead when you only",
|
|
183
|
+
"need the final output.",
|
|
184
|
+
],
|
|
185
|
+
|
|
186
|
+
async execute(
|
|
187
|
+
_toolCallId: string,
|
|
188
|
+
params: { sessionId: string },
|
|
189
|
+
_signal: AbortSignal | undefined,
|
|
190
|
+
_onUpdate: unknown,
|
|
191
|
+
ctx: ToolExecuteContext,
|
|
192
|
+
) {
|
|
193
|
+
const record = requireSession(sessionStore, params.sessionId);
|
|
194
|
+
const transcript = formatTranscript(record.runs, RETRIEVAL_OPTIONS);
|
|
195
|
+
const latestRun = record.runs[record.runs.length - 1];
|
|
196
|
+
if (!latestRun) throw new Error(sessionNotFoundMessage(params.sessionId));
|
|
197
|
+
const maxLines = await loadMaxLinesPerWindow(ctx.cwd);
|
|
198
|
+
return {
|
|
199
|
+
content: [{ type: "text", text: transcript || "(no messages in session)" }],
|
|
200
|
+
details: {
|
|
201
|
+
sessionId: params.sessionId,
|
|
202
|
+
status: latestRun.status,
|
|
203
|
+
taskName: latestRun.taskName,
|
|
204
|
+
messageCount: record.runs.reduce((sum, r) => sum + r.messages.length, 0),
|
|
205
|
+
exitCode: latestRun.exitCode,
|
|
206
|
+
model: latestRun.model,
|
|
207
|
+
runCount: record.runs.length,
|
|
208
|
+
maxLines,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
renderCall: createSessionRenderCall("get_subagent_session"),
|
|
214
|
+
renderResult: createTruncatingRenderResult("(no output)"),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Register the list_subagent_profiles tool. */
|
|
219
|
+
function registerListSubagentProfiles(pi: ExtensionAPI): void {
|
|
220
|
+
pi.registerTool({
|
|
221
|
+
name: "list_subagent_profiles",
|
|
222
|
+
label: "List Sub-agent Profiles",
|
|
223
|
+
description: [
|
|
224
|
+
"List all available subagent profiles that can be used with delegate_to_subagents.",
|
|
225
|
+
"Profiles are stored as .md files in ~/.pi/agent/agent-profiles/ (global) and .pi/agent-profiles/ (project-local).",
|
|
226
|
+
].join(" "),
|
|
227
|
+
parameters: Type.Object({}),
|
|
228
|
+
promptSnippet: "List available named subagent profiles and their configurations",
|
|
229
|
+
promptGuidelines: [
|
|
230
|
+
"Use list_subagent_profiles to see which profiles are available before choosing",
|
|
231
|
+
"one for delegate_to_subagents.",
|
|
232
|
+
],
|
|
233
|
+
|
|
234
|
+
async execute(
|
|
235
|
+
_toolCallId: string,
|
|
236
|
+
_params: Record<string, unknown>,
|
|
237
|
+
_signal: AbortSignal | undefined,
|
|
238
|
+
_onUpdate: unknown,
|
|
239
|
+
ctx: ToolExecuteContext,
|
|
240
|
+
) {
|
|
241
|
+
const profiles = loadProfiles(ctx.cwd);
|
|
242
|
+
const names = Object.keys(profiles);
|
|
243
|
+
if (names.length === 0) {
|
|
244
|
+
return {
|
|
245
|
+
content: [
|
|
246
|
+
{
|
|
247
|
+
type: "text",
|
|
248
|
+
text: "No subagent profiles found. Add .md files to ~/.pi/agent/agent-profiles/ or .pi/agent-profiles/.",
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
details: { count: 0 },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const summaries = names
|
|
255
|
+
.map((n) => {
|
|
256
|
+
const p = profiles[n];
|
|
257
|
+
return p ? ([n, profileSummary(n, p)] as const) : null;
|
|
258
|
+
})
|
|
259
|
+
.filter((s): s is [string, string] => s !== null);
|
|
260
|
+
return {
|
|
261
|
+
content: [{ type: "text", text: summaries.map(([, s]) => s).join("\n") }],
|
|
262
|
+
details: {
|
|
263
|
+
count: names.length,
|
|
264
|
+
profiles: Object.fromEntries(summaries),
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
renderCall(_args: unknown, theme: Theme, _context: unknown) {
|
|
270
|
+
return new Text(theme.fg("toolTitle", theme.bold("list_subagent_profiles")), 0, 0);
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
renderResult: createSimpleRenderResult("(no profiles)"),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Register the retrieval tools: get_subagent_output, get_subagent_session, and list_subagent_profiles.
|
|
279
|
+
*/
|
|
280
|
+
export function registerRetrievalTools(
|
|
281
|
+
pi: ExtensionAPI,
|
|
282
|
+
sessionStore: Map<string, SessionRecord>,
|
|
283
|
+
): void {
|
|
284
|
+
registerGetSubagentOutput(pi, sessionStore);
|
|
285
|
+
registerGetSubagentSession(pi, sessionStore);
|
|
286
|
+
registerListSubagentProfiles(pi);
|
|
287
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-subagents Extension Types
|
|
3
|
+
*
|
|
4
|
+
* Core type definitions and configuration constants for the subagent system.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
8
|
+
|
|
9
|
+
// ── Configuration ────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** Maximum number of parallel sub-agent tasks that can be spawned */
|
|
12
|
+
export const MAX_PARALLEL_TASKS = 16;
|
|
13
|
+
|
|
14
|
+
/** Maximum number of concurrent sub-agent processes */
|
|
15
|
+
export const MAX_CONCURRENCY = 4;
|
|
16
|
+
|
|
17
|
+
/** Maximum number of messages to store per session (prevents unbounded memory growth) */
|
|
18
|
+
export const MAX_MESSAGES_PER_SESSION = 500;
|
|
19
|
+
|
|
20
|
+
/** Default timeout for sub-agent tasks (in seconds) */
|
|
21
|
+
export const DEFAULT_TIMEOUT = 600;
|
|
22
|
+
|
|
23
|
+
/** Error message for loop detection kills */
|
|
24
|
+
export const LOOP_DETECTED_MESSAGE = "Loop detected: sub-agent is repeating the same tool calls";
|
|
25
|
+
|
|
26
|
+
/** Custom entry type identifier for persisting subagent session data */
|
|
27
|
+
export const CUSTOM_ENTRY_TYPE = "pi-subagents";
|
|
28
|
+
|
|
29
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Tool call part in a message (used internally for type narrowing) */
|
|
32
|
+
export interface ToolCallPart {
|
|
33
|
+
type: "toolCall";
|
|
34
|
+
name: string;
|
|
35
|
+
arguments?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A content part from a Message — text, thinking, or tool call */
|
|
39
|
+
export interface TextPart {
|
|
40
|
+
type: "text";
|
|
41
|
+
text: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ContentPart = TextPart | ToolCallPart | { type: string; [key: string]: unknown };
|
|
45
|
+
|
|
46
|
+
/** Tool result message (used internally for message processing) */
|
|
47
|
+
export interface ToolResultMessage {
|
|
48
|
+
role: "toolResult";
|
|
49
|
+
content: Array<{
|
|
50
|
+
type: string;
|
|
51
|
+
text?: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A file to inject into the sub-agent prompt, specified as a path string or an object with range options */
|
|
56
|
+
export type FileSpec =
|
|
57
|
+
| string
|
|
58
|
+
| { path: string; start?: number; end?: number }
|
|
59
|
+
| { path: string; tail: number }
|
|
60
|
+
| { path: string; head: number };
|
|
61
|
+
|
|
62
|
+
/** Task definition for spawning a sub-agent */
|
|
63
|
+
export interface SubAgentTask {
|
|
64
|
+
name: string;
|
|
65
|
+
prompt: string;
|
|
66
|
+
cwd?: string;
|
|
67
|
+
/** Named profile from an agent-profiles/*.md file */
|
|
68
|
+
profile?: string;
|
|
69
|
+
/** Timeout in seconds (default: 600) */
|
|
70
|
+
timeout?: number;
|
|
71
|
+
/** Previous session ID to resume from */
|
|
72
|
+
resume?: string;
|
|
73
|
+
/** Files to read and prepend to the prompt */
|
|
74
|
+
files?: FileSpec[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A single line in a sub-agent's rolling window */
|
|
78
|
+
export interface WindowLine {
|
|
79
|
+
text: string;
|
|
80
|
+
kind: "text" | "tool";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Shared state fields between SubAgentWindow and SubagentSessionData */
|
|
84
|
+
export interface SubagentState {
|
|
85
|
+
sessionId: string;
|
|
86
|
+
status: "running" | "completed" | "error";
|
|
87
|
+
exitCode: number | null;
|
|
88
|
+
model?: string;
|
|
89
|
+
stopReason?: string;
|
|
90
|
+
errorMessage?: string;
|
|
91
|
+
profileName?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Live window tracking a sub-agent's progress */
|
|
95
|
+
export interface SubAgentWindow extends SubagentState {
|
|
96
|
+
name: string;
|
|
97
|
+
lines: WindowLine[];
|
|
98
|
+
allMessages: WindowLine[];
|
|
99
|
+
/** Human-readable profile summary for display */
|
|
100
|
+
profileInfo?: string;
|
|
101
|
+
/** Provider from the agent profile (e.g. "anthropic", "openai") */
|
|
102
|
+
provider?: string;
|
|
103
|
+
/** Thinking level from the agent profile */
|
|
104
|
+
thinkingLevel?: string;
|
|
105
|
+
/** Date.now() timestamp when the sub-agent started */
|
|
106
|
+
startedAt: number;
|
|
107
|
+
/** Date.now() timestamp when the sub-agent completed (if applicable) */
|
|
108
|
+
completedAt?: number;
|
|
109
|
+
/** Task timeout in seconds */
|
|
110
|
+
timeout: number;
|
|
111
|
+
/** Total number of todo items written */
|
|
112
|
+
todoTotal?: number;
|
|
113
|
+
/** Number of completed todo items */
|
|
114
|
+
todoCompleted?: number;
|
|
115
|
+
/** Number of unique tools available to this sub-agent */
|
|
116
|
+
toolCount: number;
|
|
117
|
+
/** Number of files passed to this sub-agent */
|
|
118
|
+
fileCount: number;
|
|
119
|
+
/** Recent tool call signatures for loop detection (serialized name+args) */
|
|
120
|
+
recentToolCalls?: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Persistent session data stored for retrieval */
|
|
124
|
+
export interface SubagentSessionData extends SubagentState {
|
|
125
|
+
taskName: string;
|
|
126
|
+
prompt: string;
|
|
127
|
+
cwd?: string;
|
|
128
|
+
messages: Message[];
|
|
129
|
+
startedAt: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A record of all runs for a session ID (supports resume/multi-run) */
|
|
133
|
+
export interface SessionRecord {
|
|
134
|
+
/** All runs for this session, in chronological order */
|
|
135
|
+
runs: SubagentSessionData[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Helper Functions ───────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sync shared state fields from a source SubagentState to a target SubagentState.
|
|
142
|
+
* This ensures that SubAgentWindow and SubagentSessionData stay in sync.
|
|
143
|
+
*/
|
|
144
|
+
export function syncState(source: SubagentState, target: SubagentState): void {
|
|
145
|
+
target.status = source.status;
|
|
146
|
+
target.exitCode = source.exitCode;
|
|
147
|
+
target.model = source.model;
|
|
148
|
+
target.stopReason = source.stopReason;
|
|
149
|
+
target.errorMessage = source.errorMessage;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Serialize SubagentSessionData for storage in a custom entry.
|
|
154
|
+
* Data is already JSON-compatible by construction (parsed from child process stdout).
|
|
155
|
+
*/
|
|
156
|
+
export function serializeSessionData(session: SubagentSessionData): unknown {
|
|
157
|
+
return session;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Valid session status values */
|
|
161
|
+
const VALID_STATUSES = new Set(["running", "completed", "error"]);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if raw data has valid top-level session fields.
|
|
165
|
+
* Returns the typed record if valid, or null.
|
|
166
|
+
*/
|
|
167
|
+
function validateSessionShape(data: unknown): Record<string, unknown> | null {
|
|
168
|
+
if (!data || typeof data !== "object") return null;
|
|
169
|
+
const d = data as Record<string, unknown>;
|
|
170
|
+
if (
|
|
171
|
+
typeof d.sessionId !== "string" ||
|
|
172
|
+
typeof d.taskName !== "string" ||
|
|
173
|
+
typeof d.prompt !== "string" ||
|
|
174
|
+
typeof d.status !== "string" ||
|
|
175
|
+
!Array.isArray(d.messages) ||
|
|
176
|
+
d.messages.length > 1000
|
|
177
|
+
) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (!VALID_STATUSES.has(d.status)) return null;
|
|
181
|
+
return d;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Check that every message in the array has a valid string `role` field.
|
|
186
|
+
*/
|
|
187
|
+
function validateMessages(messages: unknown[]): boolean {
|
|
188
|
+
for (const msg of messages) {
|
|
189
|
+
if (
|
|
190
|
+
!msg ||
|
|
191
|
+
typeof msg !== "object" ||
|
|
192
|
+
!("role" in msg) ||
|
|
193
|
+
typeof (msg as Record<string, unknown>).role !== "string"
|
|
194
|
+
) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Deserialize and validate session data from a custom entry.
|
|
203
|
+
* Returns null if the data is malformed or missing required fields.
|
|
204
|
+
* Stale "running" sessions (from crashes) are converted to "error" status.
|
|
205
|
+
*/
|
|
206
|
+
export function deserializeSessionData(data: unknown): SubagentSessionData | null {
|
|
207
|
+
const d = validateSessionShape(data);
|
|
208
|
+
if (!d) return null;
|
|
209
|
+
if (!validateMessages(d.messages as unknown[])) return null;
|
|
210
|
+
|
|
211
|
+
const result = d as unknown as SubagentSessionData;
|
|
212
|
+
|
|
213
|
+
// Stale "running" sessions from a crash should be marked as "error"
|
|
214
|
+
if (result.status === "running") {
|
|
215
|
+
result.status = "error";
|
|
216
|
+
result.errorMessage =
|
|
217
|
+
result.errorMessage || "Session was interrupted (main agent session ended unexpectedly)";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Re-export from format-transcript to maintain backward compatibility
|
|
224
|
+
export { formatRunsForResume, getTextContent } from "./format-transcript";
|
|
225
|
+
|
|
226
|
+
/** Details passed to the UI for rendering sub-agent windows */
|
|
227
|
+
export interface WindowedSubagentDetails {
|
|
228
|
+
windows: SubAgentWindow[];
|
|
229
|
+
maxLinesPerWindow: number;
|
|
230
|
+
globalStatus: "running" | "done";
|
|
231
|
+
sessionIds: string[];
|
|
232
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-subagents Extension Utilities
|
|
3
|
+
*
|
|
4
|
+
* Helper functions for subagent processing, output formatting, and concurrency management.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { MAX_MESSAGES_PER_SESSION } from "./types";
|
|
8
|
+
import type { SubAgentWindow } from "./types";
|
|
9
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
10
|
+
|
|
11
|
+
// ── ANSI Stripping ───────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** Regex to match ANSI escape codes */
|
|
14
|
+
// eslint-disable-next-line no-control-regex
|
|
15
|
+
const ANSI_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Remove ANSI escape codes from text.
|
|
19
|
+
*/
|
|
20
|
+
export function stripAnsi(text: string): string {
|
|
21
|
+
return text.replace(ANSI_REGEX, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Rolling Buffer ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Append a line to a sub-agent window, maintaining a rolling buffer.
|
|
28
|
+
* The window keeps only the latest N lines but stores all messages separately.
|
|
29
|
+
*/
|
|
30
|
+
export function appendLineToWindow(
|
|
31
|
+
win: SubAgentWindow,
|
|
32
|
+
line: string,
|
|
33
|
+
maxLines: number,
|
|
34
|
+
kind: "text" | "tool" = "text",
|
|
35
|
+
): void {
|
|
36
|
+
const clean = stripAnsi(line).trimEnd();
|
|
37
|
+
if (!clean) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const entry = { text: clean, kind };
|
|
41
|
+
win.lines.push(entry);
|
|
42
|
+
while (win.lines.length > maxLines) {
|
|
43
|
+
win.lines.shift();
|
|
44
|
+
}
|
|
45
|
+
win.allMessages.push(entry);
|
|
46
|
+
while (win.allMessages.length > MAX_MESSAGES_PER_SESSION) {
|
|
47
|
+
win.allMessages.shift();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Session Helpers ──────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract text parts from a message's content, regardless of role.
|
|
55
|
+
* Handles string content, array-of-parts content, and gracefully returns
|
|
56
|
+
* an empty array for anything else.
|
|
57
|
+
*/
|
|
58
|
+
export function extractTextParts(msg: { content?: unknown }): string[] {
|
|
59
|
+
if (!msg.content) return [];
|
|
60
|
+
if (typeof msg.content === "string") return [msg.content];
|
|
61
|
+
if (Array.isArray(msg.content)) {
|
|
62
|
+
return msg.content
|
|
63
|
+
.filter(
|
|
64
|
+
(part): part is { type: "text"; text: string } =>
|
|
65
|
+
part != null &&
|
|
66
|
+
typeof part === "object" &&
|
|
67
|
+
(part as { type?: string }).type === "text" &&
|
|
68
|
+
typeof (part as { text?: unknown }).text === "string",
|
|
69
|
+
)
|
|
70
|
+
.map((part) => part.text);
|
|
71
|
+
}
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract all text content parts from an assistant message.
|
|
77
|
+
* Returns an array of text strings from the message content.
|
|
78
|
+
*/
|
|
79
|
+
export function getTextParts(msg: Message): string[] {
|
|
80
|
+
if (msg.role !== "assistant") return [];
|
|
81
|
+
return extractTextParts(msg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract the last assistant text from a message array.
|
|
86
|
+
* Scans backwards through messages to find the most recent assistant response.
|
|
87
|
+
*/
|
|
88
|
+
export function getLastAssistantText(messages: Message[]): string {
|
|
89
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
90
|
+
const msg = messages[i];
|
|
91
|
+
if (!msg) continue;
|
|
92
|
+
const parts = getTextParts(msg);
|
|
93
|
+
if (parts.length > 0 && parts[0] !== undefined) {
|
|
94
|
+
return parts[0];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Summary Formatting ───────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Count windows by status.
|
|
104
|
+
* Returns an object with counts for running, completed, and error states.
|
|
105
|
+
*/
|
|
106
|
+
export function countWindowStatuses(windows: SubAgentWindow[]): {
|
|
107
|
+
running: number;
|
|
108
|
+
completed: number;
|
|
109
|
+
error: number;
|
|
110
|
+
} {
|
|
111
|
+
return {
|
|
112
|
+
running: windows.filter((w) => w.status === "running").length,
|
|
113
|
+
completed: windows.filter((w) => w.status === "completed").length,
|
|
114
|
+
error: windows.filter((w) => w.status === "error").length,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Generate a one-line status summary for multiple sub-agent windows.
|
|
120
|
+
*/
|
|
121
|
+
export function getSummaryText(windows: SubAgentWindow[]): string {
|
|
122
|
+
const { running, completed: done, error: errors } = countWindowStatuses(windows);
|
|
123
|
+
const parts: string[] = [];
|
|
124
|
+
if (running > 0) {
|
|
125
|
+
parts.push(`${running} running`);
|
|
126
|
+
}
|
|
127
|
+
if (done > 0) {
|
|
128
|
+
parts.push(`${done} done`);
|
|
129
|
+
}
|
|
130
|
+
if (errors > 0) {
|
|
131
|
+
parts.push(`${errors} error${errors > 1 ? "s" : ""}`);
|
|
132
|
+
}
|
|
133
|
+
return parts.join(", ") || "processing...";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Concurrency Helper ───────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Map an array with a concurrency limit.
|
|
140
|
+
* Processes items in parallel but never more than `concurrency` at a time.
|
|
141
|
+
*/
|
|
142
|
+
export async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
143
|
+
items: TIn[],
|
|
144
|
+
concurrency: number,
|
|
145
|
+
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
146
|
+
): Promise<TOut[]> {
|
|
147
|
+
if (items.length === 0) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
151
|
+
const results: TOut[] = new Array<TOut>(items.length);
|
|
152
|
+
let nextIndex = 0;
|
|
153
|
+
const workers = new Array(limit).fill(null).map(async () => {
|
|
154
|
+
for (;;) {
|
|
155
|
+
const current = nextIndex++;
|
|
156
|
+
if (current >= items.length) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const item = items[current];
|
|
160
|
+
if (item === undefined) continue;
|
|
161
|
+
results[current] = await fn(item, current);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
await Promise.all(workers);
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Sub-Agent Spawner ────────────────────────────────────────────────
|