@maplezzk/pi-interactive-subagents 3.7.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.
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Extension loaded into sub-agents.
3
+ * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+J)
4
+ * - Provides a `subagent_done` tool for autonomous agents to self-terminate
5
+ * - Nudges any agent that forgets to call subagent_done after generating
6
+ *
7
+ * auto-exit 历史背景:
8
+ * 早期设计中 PI_SUBAGENT_AUTO_EXIT=1 会让 agent_end 短路退出 — agent 正常结束
9
+ * turn 时直接写 { type: "done" } 到 .exit sidecar,绕过 subagent_done 工具。
10
+ * 这与 workflow 系统启用 PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA 冲突:LLM 即使
11
+ * 决定不调 subagent_done 也会被短路退出,workflow 永远拿不到 structuredOutput
12
+ * → "Subagent finished without calling structured_output"。
13
+ * 现在不论 autoExit env 如何,agent 都必须主动调用 subagent_done 或 caller_ping
14
+ * 才能结束。如果 agent 不调,会被 nudge 提醒。
15
+ */
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { Box, Text } from "@earendil-works/pi-tui";
18
+ import { Type } from "@sinclair/typebox";
19
+ import { writeFileSync } from "node:fs";
20
+ import Ajv from "ajv";
21
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
22
+ import { createSubagentActivityRecorder } from "./activity.ts";
23
+
24
+ const i18n = createTranslator(loadCatalog(new URL("../../locales/index.json", import.meta.url)));
25
+
26
+ export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
27
+ return agentStarted;
28
+ }
29
+
30
+ export function shouldAutoExitOnAgentEnd(
31
+ _userTookOver: boolean,
32
+ messages: any[] | undefined,
33
+ ): boolean {
34
+ // Manual input should not strand an auto-exit subagent. If the latest agent
35
+ // turn completed normally, close the session. Escape/abort still leaves it
36
+ // open for inspection or another prompt.
37
+ if (messages) {
38
+ for (let i = messages.length - 1; i >= 0; i--) {
39
+ const msg = messages[i];
40
+ if (msg?.role === "assistant") {
41
+ return msg.stopReason !== "aborted";
42
+ }
43
+ }
44
+ }
45
+
46
+ return true;
47
+ }
48
+
49
+ export function parseDeniedTools(rawValue: string | undefined): string[] {
50
+ return (rawValue ?? "")
51
+ .split(",")
52
+ .map((value) => value.trim())
53
+ .filter(Boolean);
54
+ }
55
+
56
+ export default function (pi: ExtensionAPI) {
57
+ let toolNames: string[] = [];
58
+ let denied: string[] = [];
59
+ let expanded = false;
60
+
61
+ // Read subagent identity from env vars (set by parent orchestrator)
62
+ const subagentName = process.env.PI_SUBAGENT_NAME ?? "";
63
+ const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? "";
64
+ const deniedToolsValue = process.env.PI_DENY_TOOLS;
65
+ const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1";
66
+ const recorder = createSubagentActivityRecorder({
67
+ runningChildId: process.env.PI_SUBAGENT_ID,
68
+ activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE,
69
+ });
70
+
71
+ // ── Agent completion nudge configuration ──
72
+ /** Delay (ms) before sending a nudge after agent_end. Configurable via env var. */
73
+ const NUDGE_DELAY_MS = Math.max(
74
+ 1000,
75
+ parseInt(process.env.PI_SUBAGENT_NUDGE_DELAY_MS ?? "5000", 10) || 5000,
76
+ );
77
+ /** Set to "1" to disable the nudge entirely. */
78
+ const NUDGE_DISABLED = process.env.PI_SUBAGENT_NUDGE_DISABLE === "1";
79
+
80
+ let doneCalled = false;
81
+ let userInputAfterAgentEnd = false;
82
+ let nudgeTimer: ReturnType<typeof setTimeout> | null = null;
83
+
84
+ function clearNudgeTimer(): void {
85
+ if (nudgeTimer !== null) {
86
+ clearTimeout(nudgeTimer);
87
+ nudgeTimer = null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * After a non-auto-exit subagent finishes generating, schedule a nudge
93
+ * reminding it to call subagent_done if it hasn't already.
94
+ *
95
+ * Each call replaces any pending nudge, so repeated agent_end events
96
+ * (e.g. during multi-turn tool use) automatically reset the timer.
97
+ * The nudge only fires if no new agent activity or user input arrives
98
+ * within NUDGE_DELAY_MS.
99
+ */
100
+ function scheduleAgentEndNudge(): void {
101
+ clearNudgeTimer();
102
+ // 不论 autoExit 是否启用,都必须 nudge — autoExit 已被移除,
103
+ // agent 结束 turn 后只能靠主动调用 subagent_done 才能真正退出。
104
+ if (NUDGE_DISABLED || doneCalled) return;
105
+
106
+ nudgeTimer = setTimeout(() => {
107
+ nudgeTimer = null;
108
+ if (doneCalled || userInputAfterAgentEnd) return;
109
+
110
+ pi.sendUserMessage(
111
+ i18n.t("agentEndNudge"),
112
+ { deliverAs: "followUp" },
113
+ );
114
+ }, NUDGE_DELAY_MS);
115
+ }
116
+
117
+ function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) {
118
+ ctx.ui.setWidget(
119
+ "subagent-tools",
120
+ (_tui: any, theme: any) => {
121
+ const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text));
122
+
123
+ const label = subagentAgent || subagentName;
124
+ const agentTag = label ? theme.bold(theme.fg("accent", `[${label}]`)) : "";
125
+
126
+ if (expanded) {
127
+ // Expanded: full tool list + denied
128
+ const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
129
+ const hint = theme.fg("muted", " (Ctrl+J to collapse)");
130
+
131
+ const toolList = toolNames
132
+ .map((name: string) => theme.fg("dim", name))
133
+ .join(theme.fg("muted", ", "));
134
+
135
+ let deniedLine = "";
136
+ if (denied.length > 0) {
137
+ const deniedList = denied
138
+ .map((name: string) => theme.fg("error", name))
139
+ .join(theme.fg("muted", ", "));
140
+ deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList;
141
+ }
142
+
143
+ const content = new Text(
144
+ `${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`,
145
+ 0,
146
+ 0,
147
+ );
148
+ box.addChild(content);
149
+ } else {
150
+ // Collapsed: one-line summary
151
+ const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`);
152
+ const deniedInfo =
153
+ denied.length > 0
154
+ ? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`)
155
+ : "";
156
+ const hint = theme.fg("muted", " (Ctrl+J to expand)");
157
+
158
+ const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0);
159
+ box.addChild(content);
160
+ }
161
+
162
+ return box;
163
+ },
164
+ { placement: "aboveEditor" },
165
+ );
166
+ }
167
+
168
+ let userTookOver = false;
169
+ let agentStarted = false;
170
+
171
+ // Show widget + status bar on session start
172
+ pi.on("session_start", (_event, ctx) => {
173
+ recorder.sessionStart();
174
+ doneCalled = false;
175
+ userInputAfterAgentEnd = false;
176
+ clearNudgeTimer();
177
+ const tools = pi.getAllTools();
178
+ toolNames = tools.map((t) => t.name).sort();
179
+ denied = parseDeniedTools(deniedToolsValue);
180
+
181
+ renderWidget(ctx, null);
182
+ });
183
+
184
+ pi.on("input", () => {
185
+ recorder.input();
186
+ // User typed something — they are in control, cancel any pending nudge.
187
+ userInputAfterAgentEnd = true;
188
+ clearNudgeTimer();
189
+ // Ignore the initial task message that starts an autonomous subagent.
190
+ // Only inputs after the first agent run has started count as user takeover.
191
+ if (!shouldMarkUserTookOver(agentStarted)) return;
192
+ userTookOver = true;
193
+ });
194
+
195
+ pi.on("before_agent_start", () => {
196
+ recorder.beforeAgentStart();
197
+ // Agent is about to generate — clear any pending nudge; the AI is active.
198
+ clearNudgeTimer();
199
+ });
200
+
201
+ pi.on("agent_start", () => {
202
+ agentStarted = true;
203
+ recorder.agentStart();
204
+ // Agent has started a new generation cycle — clear any pending nudge.
205
+ userInputAfterAgentEnd = false;
206
+ clearNudgeTimer();
207
+ });
208
+
209
+ pi.on("agent_end", (event, ctx) => {
210
+ // auto-exit 已彻底移除:agent 必须主动调用 subagent_done 工具才能结束 session。
211
+ // 之前的 autoExit 短路会在 agent 正常结束 turn 时直接写 { type: "done" } 到
212
+ // .exit 文件,绕过 subagent_done 工具。这会让 workflow 系统(启用
213
+ // PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA 时)拿到 undefined 的 structuredOutput
214
+ // → "Subagent finished without calling structured_output"。
215
+ // 现在不论 autoExit env 如何,都不自动退出 — 只能靠 agent 自己调
216
+ // subagent_done(或 caller_ping)。如果 agent 不调,下面会有 nudge 提醒。
217
+ recorder.agentEndWaiting();
218
+
219
+ // For non-auto-exit agents: schedule a nudge in case the AI forgot to call
220
+ // subagent_done. This is automatically cleared/reset on any subsequent
221
+ // agent activity or user input.
222
+ scheduleAgentEndNudge();
223
+ });
224
+
225
+ pi.on("turn_start", (event) => {
226
+ recorder.turnStart((event as any).turnIndex);
227
+ });
228
+
229
+ pi.on("turn_end", (event) => {
230
+ recorder.turnEnd((event as any).turnIndex);
231
+ });
232
+
233
+ pi.on("before_provider_request", () => {
234
+ recorder.beforeProviderRequest();
235
+ });
236
+
237
+ pi.on("after_provider_response", () => {
238
+ recorder.afterProviderResponse();
239
+ });
240
+
241
+ pi.on("message_update", (event) => {
242
+ recorder.messageUpdate((event as any).assistantMessageEvent?.type);
243
+ });
244
+
245
+ pi.on("tool_execution_start", (event) => {
246
+ recorder.toolExecutionStart((event as any).toolCallId, (event as any).toolName);
247
+ });
248
+
249
+ pi.on("tool_call", (event) => {
250
+ recorder.toolCall((event as any).toolCallId, (event as any).toolName);
251
+ });
252
+
253
+ pi.on("tool_execution_update", (event) => {
254
+ recorder.toolExecutionUpdate((event as any).toolCallId, (event as any).toolName);
255
+ });
256
+
257
+ pi.on("tool_result", (event) => {
258
+ recorder.toolResult((event as any).toolCallId, (event as any).toolName);
259
+ });
260
+
261
+ pi.on("tool_execution_end", (event) => {
262
+ recorder.toolExecutionEnd((event as any).toolCallId, (event as any).toolName);
263
+ });
264
+
265
+ pi.on("session_shutdown", (event) => {
266
+ clearNudgeTimer();
267
+ recorder.sessionShutdown((event as any).reason);
268
+ });
269
+
270
+ // Toggle expand/collapse with Ctrl+J
271
+ pi.registerShortcut("ctrl+j", {
272
+ description: "Toggle subagent tools widget",
273
+ handler: (ctx) => {
274
+ expanded = !expanded;
275
+ renderWidget(ctx, null);
276
+ },
277
+ });
278
+
279
+ pi.registerTool({
280
+ name: "caller_ping",
281
+ label: "Caller Ping",
282
+ description:
283
+ "Send a help request to the parent agent and exit this session. " +
284
+ "The parent will be notified with your message and can resume this session with a response. " +
285
+ "Use when you're stuck, need clarification, or need the parent to take action.",
286
+ parameters: Type.Object({
287
+ message: Type.String({ description: "What you need help with" }),
288
+ }),
289
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
290
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
291
+ if (!sessionFile) {
292
+ throw new Error(
293
+ "caller_ping is only available in subagent contexts. " +
294
+ "PI_SUBAGENT_SESSION environment variable is not set.",
295
+ );
296
+ }
297
+
298
+ doneCalled = true;
299
+ clearNudgeTimer();
300
+ recorder.callerPing();
301
+ const exitData = {
302
+ type: "ping" as const,
303
+ name: process.env.PI_SUBAGENT_NAME ?? "subagent",
304
+ message: params.message,
305
+ };
306
+ try {
307
+ writeFileSync(`${sessionFile}.exit`, JSON.stringify(exitData));
308
+ } catch (writeErr: any) {
309
+ process.stderr.write(
310
+ `[subagent-done] caller_ping: .exit 写入失败 file=${sessionFile}.exit err=${writeErr?.message ?? String(writeErr)}\n`,
311
+ );
312
+ }
313
+
314
+ ctx.shutdown();
315
+ return {
316
+ content: [{ type: "text", text: "Ping sent. Session will exit and parent will be notified." }],
317
+ details: {},
318
+ };
319
+ },
320
+ });
321
+
322
+ // ── subagent_done ──
323
+ // When PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA is set, the `result` parameter
324
+ // is required and ajv-validated against the JSON Schema before writing to
325
+ // the .exit sidecar. Otherwise `result` is optional — the subagent's last
326
+ // assistant message is used as the summary.
327
+ (() => {
328
+ let structuredOutputSchema: object | null = null;
329
+ try {
330
+ const raw = process.env.PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA;
331
+ if (raw) structuredOutputSchema = JSON.parse(raw);
332
+ } catch {
333
+ // Schema parse failure — result will be optional.
334
+ }
335
+
336
+ const hasSchema = !!structuredOutputSchema;
337
+ let validate: ReturnType<Ajv["compile"]> | null = null;
338
+ if (hasSchema) {
339
+ const ajv = new Ajv({ allErrors: true });
340
+ validate = ajv.compile(structuredOutputSchema!);
341
+ }
342
+
343
+ // result 的类型直接用真实 schema(Type.Unsafe 透传 JSON Schema),让 AI 看到
344
+ // 完整的 properties/required。旧实现用空 Type.Object 占位 + 把 schema 藏在
345
+ // description 文本里,导致 AI 不知道要把字段包进 result,直接放到顶层参数。
346
+ const resultParam = hasSchema
347
+ ? Type.Unsafe({
348
+ ...(structuredOutputSchema as object),
349
+ description:
350
+ `Required structured result. Must match this JSON Schema:\n` +
351
+ JSON.stringify(structuredOutputSchema, null, 2),
352
+ })
353
+ : Type.Optional(
354
+ Type.Any({
355
+ description:
356
+ "Optional structured result for the parent session. Pass your structured output here.",
357
+ }),
358
+ );
359
+
360
+ const descriptionBase =
361
+ "Call this tool when you have completed your task. " +
362
+ "It will close this session and return your results to the main session.";
363
+ const description = hasSchema
364
+ ? `${descriptionBase} You MUST pass your final output as the \`result\` argument — it will be validated against a JSON Schema. If validation fails you will see the errors and can retry.`
365
+ : `${descriptionBase} Your LAST assistant message before calling this becomes the summary returned to the caller.`;
366
+
367
+ pi.registerTool({
368
+ name: "subagent_done",
369
+ label: "Subagent Done",
370
+ description,
371
+ parameters: Type.Object({ result: resultParam }),
372
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
373
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
374
+
375
+ // Validate structured result if schema is active
376
+ if (hasSchema) {
377
+ if (!params.result) {
378
+ return {
379
+ content: [
380
+ {
381
+ type: "text",
382
+ text: "Validation failed — `result` is required for this task. Pass your structured output as the `result` argument.",
383
+ },
384
+ ],
385
+ details: { error: "validation_failed", errors: [{ message: "result is required" }] },
386
+ };
387
+ }
388
+ if (validate && !validate(params.result)) {
389
+ const errors = (validate.errors ?? [])
390
+ .map((e) => ` - ${e.instancePath || "/"}: ${e.message}`)
391
+ .join("\n");
392
+ return {
393
+ content: [
394
+ {
395
+ type: "text",
396
+ text: `Validation failed — your \`result\` does not match the required schema:\n${errors}\n\nFix your arguments and call subagent_done again.`,
397
+ },
398
+ ],
399
+ details: { error: "validation_failed", errors: validate.errors },
400
+ };
401
+ }
402
+ }
403
+
404
+ doneCalled = true;
405
+ clearNudgeTimer();
406
+ recorder.subagentDone();
407
+
408
+ if (sessionFile) {
409
+ const exitFile = `${sessionFile}.exit`;
410
+ try {
411
+ if (params.result) {
412
+ writeFileSync(exitFile, JSON.stringify({ type: "structured_output", value: params.result }));
413
+ } else {
414
+ writeFileSync(exitFile, JSON.stringify({ type: "done" }));
415
+ }
416
+ } catch (writeErr: any) {
417
+ process.stderr.write(
418
+ `[subagent-done] subagent_done: .exit 写入失败 file=${exitFile} err=${writeErr?.message ?? String(writeErr)}\n`,
419
+ );
420
+ }
421
+ }
422
+
423
+ ctx.shutdown();
424
+ return {
425
+ content: [{ type: "text", text: "Shutting down subagent session." }],
426
+ details: params.result ?? {},
427
+ };
428
+ },
429
+ });
430
+ })();
431
+ }