@gotgenes/pi-subagents 4.0.0 → 4.1.1
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/CHANGELOG.md +26 -0
- package/docs/plans/0053-extract-model-resolution-from-execute.md +181 -0
- package/docs/plans/0054-decompose-index-into-modules.md +302 -0
- package/docs/retro/0053-extract-model-resolution-from-execute.md +30 -0
- package/package.json +2 -2
- package/src/index.ts +87 -1443
- package/src/model-resolver.ts +39 -0
- package/src/notification.ts +188 -0
- package/src/renderer.ts +67 -0
- package/src/tools/agent-tool.ts +634 -0
- package/src/tools/get-result-tool.ts +99 -0
- package/src/tools/helpers.ts +21 -0
- package/src/tools/steer-tool.ts +83 -0
- package/src/ui/agent-menu.ts +685 -0
package/src/model-resolver.ts
CHANGED
|
@@ -14,6 +14,45 @@ export interface ModelRegistry {
|
|
|
14
14
|
getAvailable?(): any[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Successful model resolution — `model` is the resolved or inherited model instance. */
|
|
18
|
+
export interface ModelResolutionResult {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
model: any;
|
|
21
|
+
error?: undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Failed model resolution when the model was user-specified (params) — surface the error. */
|
|
25
|
+
export interface ModelResolutionError {
|
|
26
|
+
model?: undefined;
|
|
27
|
+
error: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Discriminated union returned by `resolveInvocationModel`. */
|
|
31
|
+
export type ModelResolution = ModelResolutionResult | ModelResolutionError;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the effective model for an agent invocation.
|
|
35
|
+
*
|
|
36
|
+
* Encapsulates the three-branch fallback policy used in `Agent.execute`:
|
|
37
|
+
* 1. No `modelInput` → inherit `parentModel`.
|
|
38
|
+
* 2. `modelInput` resolves → return the resolved model.
|
|
39
|
+
* 3. `modelInput` fails:
|
|
40
|
+
* - `modelFromParams` true → return `{ error }` so the caller can surface it.
|
|
41
|
+
* - `modelFromParams` false → silent fallback to `parentModel`.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveInvocationModel(
|
|
44
|
+
parentModel: unknown,
|
|
45
|
+
modelInput: string | undefined,
|
|
46
|
+
modelFromParams: boolean,
|
|
47
|
+
registry: ModelRegistry,
|
|
48
|
+
): ModelResolution {
|
|
49
|
+
if (!modelInput) return { model: parentModel };
|
|
50
|
+
const resolved = resolveModel(modelInput, registry);
|
|
51
|
+
if (typeof resolved !== "string") return { model: resolved };
|
|
52
|
+
if (modelFromParams) return { error: resolved };
|
|
53
|
+
return { model: parentModel };
|
|
54
|
+
}
|
|
55
|
+
|
|
17
56
|
/**
|
|
18
57
|
* Resolve a model string to a Model instance.
|
|
19
58
|
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { AgentRecord, NotificationDetails } from "./types.js";
|
|
2
|
+
import type { AgentActivity } from "./ui/agent-widget.js";
|
|
3
|
+
import { getLifetimeTotal, getSessionContextPercent } from "./usage.js";
|
|
4
|
+
|
|
5
|
+
// ---- Pure helpers (exported for unit testing) ----
|
|
6
|
+
|
|
7
|
+
/** Escape XML special characters to prevent injection in structured notifications. */
|
|
8
|
+
export function escapeXml(s: string): string {
|
|
9
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Human-readable status label for agent completion. */
|
|
13
|
+
export function getStatusLabel(status: string, error?: string): string {
|
|
14
|
+
switch (status) {
|
|
15
|
+
case "error":
|
|
16
|
+
return `Error: ${error ?? "unknown"}`;
|
|
17
|
+
case "aborted":
|
|
18
|
+
return "Aborted (max turns exceeded)";
|
|
19
|
+
case "steered":
|
|
20
|
+
return "Wrapped up (turn limit)";
|
|
21
|
+
case "stopped":
|
|
22
|
+
return "Stopped";
|
|
23
|
+
default:
|
|
24
|
+
return "Done";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Format a structured task notification matching Claude Code's <task-notification> XML. */
|
|
29
|
+
export function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string {
|
|
30
|
+
const status = getStatusLabel(record.status, record.error);
|
|
31
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
32
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
33
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
34
|
+
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
35
|
+
const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
|
|
36
|
+
|
|
37
|
+
const resultPreview = record.result
|
|
38
|
+
? record.result.length > resultMaxLen
|
|
39
|
+
? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
|
|
40
|
+
: record.result
|
|
41
|
+
: "No output.";
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
"<task-notification>",
|
|
45
|
+
`<task-id>${record.id}</task-id>`,
|
|
46
|
+
record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
|
|
47
|
+
record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
|
|
48
|
+
`<status>${escapeXml(status)}</status>`,
|
|
49
|
+
`<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
|
|
50
|
+
`<result>${escapeXml(resultPreview)}</result>`,
|
|
51
|
+
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
52
|
+
"</task-notification>",
|
|
53
|
+
]
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build notification details for the custom message renderer. */
|
|
59
|
+
export function buildNotificationDetails(
|
|
60
|
+
record: AgentRecord,
|
|
61
|
+
resultMaxLen: number,
|
|
62
|
+
activity?: AgentActivity,
|
|
63
|
+
): NotificationDetails {
|
|
64
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
id: record.id,
|
|
68
|
+
description: record.description,
|
|
69
|
+
status: record.status,
|
|
70
|
+
toolUses: record.toolUses,
|
|
71
|
+
turnCount: activity?.turnCount ?? 0,
|
|
72
|
+
maxTurns: activity?.maxTurns,
|
|
73
|
+
totalTokens,
|
|
74
|
+
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
75
|
+
outputFile: record.outputFile,
|
|
76
|
+
error: record.error,
|
|
77
|
+
resultPreview: record.result
|
|
78
|
+
? record.result.length > resultMaxLen
|
|
79
|
+
? record.result.slice(0, resultMaxLen) + "…"
|
|
80
|
+
: record.result
|
|
81
|
+
: "No output.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build event data for lifecycle events from an AgentRecord. */
|
|
86
|
+
export function buildEventData(record: AgentRecord) {
|
|
87
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
88
|
+
const u = record.lifetimeUsage;
|
|
89
|
+
const total = getLifetimeTotal(u);
|
|
90
|
+
const tokens =
|
|
91
|
+
total > 0
|
|
92
|
+
? { input: u.input, output: u.output, total }
|
|
93
|
+
: undefined;
|
|
94
|
+
return {
|
|
95
|
+
id: record.id,
|
|
96
|
+
type: record.type,
|
|
97
|
+
description: record.description,
|
|
98
|
+
result: record.result,
|
|
99
|
+
error: record.error,
|
|
100
|
+
status: record.status,
|
|
101
|
+
toolUses: record.toolUses,
|
|
102
|
+
durationMs,
|
|
103
|
+
tokens,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- Notification system factory ----
|
|
108
|
+
|
|
109
|
+
/** Narrow deps for the notification system — only the methods it actually calls. */
|
|
110
|
+
export interface NotificationDeps {
|
|
111
|
+
sendMessage: (msg: unknown, opts: unknown) => void;
|
|
112
|
+
agentActivity: Map<string, AgentActivity>;
|
|
113
|
+
markFinished: (id: string) => void;
|
|
114
|
+
updateWidget: () => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface NotificationSystem {
|
|
118
|
+
cancelNudge: (key: string) => void;
|
|
119
|
+
sendCompletion: (record: AgentRecord) => void;
|
|
120
|
+
cleanupCompleted: (id: string) => void;
|
|
121
|
+
dispose: () => void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const NUDGE_HOLD_MS = 200;
|
|
125
|
+
|
|
126
|
+
export function createNotificationSystem(deps: NotificationDeps): NotificationSystem {
|
|
127
|
+
const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
|
|
128
|
+
|
|
129
|
+
function cancelNudge(key: string) {
|
|
130
|
+
const timer = pendingNudges.get(key);
|
|
131
|
+
if (timer != null) {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
pendingNudges.delete(key);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
|
|
138
|
+
cancelNudge(key);
|
|
139
|
+
pendingNudges.set(
|
|
140
|
+
key,
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
pendingNudges.delete(key);
|
|
143
|
+
try {
|
|
144
|
+
send();
|
|
145
|
+
} catch {
|
|
146
|
+
/* ignore stale completion side-effect errors */
|
|
147
|
+
}
|
|
148
|
+
}, delay),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function emitIndividualNudge(record: AgentRecord) {
|
|
153
|
+
if (record.resultConsumed) return;
|
|
154
|
+
|
|
155
|
+
const notification = formatTaskNotification(record, 500);
|
|
156
|
+
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : "";
|
|
157
|
+
|
|
158
|
+
deps.sendMessage(
|
|
159
|
+
{
|
|
160
|
+
customType: "subagent-notification",
|
|
161
|
+
content: notification + footer,
|
|
162
|
+
display: true,
|
|
163
|
+
details: buildNotificationDetails(record, 500, deps.agentActivity.get(record.id)),
|
|
164
|
+
},
|
|
165
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sendCompletion(record: AgentRecord) {
|
|
170
|
+
deps.agentActivity.delete(record.id);
|
|
171
|
+
deps.markFinished(record.id);
|
|
172
|
+
scheduleNudge(record.id, () => emitIndividualNudge(record));
|
|
173
|
+
deps.updateWidget();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function cleanupCompleted(id: string) {
|
|
177
|
+
deps.agentActivity.delete(id);
|
|
178
|
+
deps.markFinished(id);
|
|
179
|
+
deps.updateWidget();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function dispose() {
|
|
183
|
+
for (const timer of pendingNudges.values()) clearTimeout(timer);
|
|
184
|
+
pendingNudges.clear();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { cancelNudge, sendCompletion, cleanupCompleted, dispose };
|
|
188
|
+
}
|
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { NotificationDetails } from "./types.js";
|
|
3
|
+
import { formatMs, formatTokens, formatTurns } from "./ui/agent-widget.js";
|
|
4
|
+
|
|
5
|
+
/** Narrow theme interface — only the methods the renderer actually calls. */
|
|
6
|
+
interface RendererTheme {
|
|
7
|
+
fg(style: string, text: string): string;
|
|
8
|
+
bold(text: string): string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Narrow message interface — only the fields the renderer reads. */
|
|
12
|
+
interface RendererMessage {
|
|
13
|
+
details?: NotificationDetails;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Narrow render options — only the fields the renderer reads. */
|
|
17
|
+
interface RenderOptions {
|
|
18
|
+
expanded: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create the notification renderer callback for `pi.registerMessageRenderer`.
|
|
23
|
+
* Returns a factory so the renderer is independently testable without the Pi SDK.
|
|
24
|
+
*/
|
|
25
|
+
export function createNotificationRenderer() {
|
|
26
|
+
return (message: RendererMessage, { expanded }: RenderOptions, theme: RendererTheme): Text | undefined => {
|
|
27
|
+
const d = message.details;
|
|
28
|
+
if (!d) return undefined;
|
|
29
|
+
|
|
30
|
+
const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
|
|
31
|
+
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
32
|
+
const statusText = isError
|
|
33
|
+
? d.status
|
|
34
|
+
: d.status === "steered"
|
|
35
|
+
? "completed (steered)"
|
|
36
|
+
: "completed";
|
|
37
|
+
|
|
38
|
+
// Line 1: icon + agent description + status
|
|
39
|
+
let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
|
|
40
|
+
|
|
41
|
+
// Line 2: stats
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
44
|
+
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
45
|
+
if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
|
|
46
|
+
if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
|
|
47
|
+
if (parts.length) {
|
|
48
|
+
line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Line 3: result preview (collapsed) or full (expanded)
|
|
52
|
+
if (expanded) {
|
|
53
|
+
const lines = d.resultPreview.split("\n").slice(0, 30);
|
|
54
|
+
for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
|
|
55
|
+
} else {
|
|
56
|
+
const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
|
|
57
|
+
line += "\n " + theme.fg("dim", `⎿ ${preview}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Line 4: output file link (if present)
|
|
61
|
+
if (d.outputFile) {
|
|
62
|
+
line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Text(line, 0, 0);
|
|
66
|
+
};
|
|
67
|
+
}
|