@gotgenes/pi-subagents 13.0.0 → 13.2.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.
@@ -1,464 +0,0 @@
1
- /**
2
- * agent-runner.ts - Core execution engine: creates sessions, runs agents, collects results.
3
- */
4
-
5
- import type { Model } from "@earendil-works/pi-ai";
6
- import {
7
- type AgentSession,
8
- type AgentSessionEvent,
9
- type SettingsManager,
10
- } from "@earendil-works/pi-coding-agent";
11
- import type { AgentConfigLookup } from "#src/config/agent-types";
12
- import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
13
- import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
14
- import { extractAssistantContent } from "#src/session/content-items";
15
- import { extractText } from "#src/session/context";
16
- import type { EnvInfo } from "#src/session/env";
17
- import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
18
- import type { ParentSessionInfo, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
19
-
20
- /** Names of tools registered by this extension that subagents must NOT inherit. */
21
- const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];
22
-
23
- /**
24
- * Filter the session's active tool names: remove recursion-guard tools.
25
- *
26
- * Run once after `bindExtensions` so extension-registered tools (added during
27
- * `bindExtensions`) are also covered by the guard.
28
- *
29
- * @param activeTools Names currently active on the session.
30
- */
31
- function filterActiveTools(activeTools: string[]): string[] {
32
- return activeTools.filter((t) => !EXCLUDED_TOOL_NAMES.includes(t));
33
- }
34
-
35
- /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
36
- export function normalizeMaxTurns(n: number | undefined): number | undefined {
37
- if (n == null || n === 0) return undefined;
38
- return Math.max(1, n);
39
- }
40
-
41
- // ── IO boundary ───────────────────────────────────────────────────────────────
42
-
43
- /** Minimal resource-loader contract used by the runner. */
44
- export interface ResourceLoaderLike {
45
- reload(): Promise<void>;
46
- }
47
-
48
- /** Minimal session-manager contract used by the runner. */
49
- export interface SessionManagerLike {
50
- newSession(opts: { parentSession?: string }): void;
51
- getSessionFile(): string | undefined;
52
- }
53
-
54
- /** Options passed to EnvironmentIO/SessionFactoryIO methods. */
55
- export interface ResourceLoaderOptions {
56
- cwd: string;
57
- agentDir: string;
58
- noPromptTemplates?: boolean;
59
- noThemes?: boolean;
60
- noContextFiles?: boolean;
61
- systemPromptOverride?: () => string;
62
- /** Override the append system prompt. Receives the current base value; return the replacement. */
63
- appendSystemPromptOverride?: (base: string[]) => string[];
64
- }
65
-
66
- /** Options passed to SessionFactoryIO.createSession. */
67
- export interface CreateSessionOptions {
68
- cwd: string;
69
- agentDir: string;
70
- sessionManager: SessionManagerLike;
71
- settingsManager: SettingsManager;
72
- modelRegistry: unknown;
73
- model?: unknown;
74
- tools: string[];
75
- resourceLoader: ResourceLoaderLike;
76
- thinkingLevel?: ThinkingLevel;
77
- }
78
-
79
- /**
80
- * Environment discovery - detect runtime context and resolve directories.
81
- *
82
- * Decouples the runner from direct process/SDK reads so each can be stubbed
83
- * independently in tests.
84
- */
85
- export interface EnvironmentIO {
86
- detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
87
- getAgentDir: () => string;
88
- deriveSessionDir: (parentSessionFile: string | undefined, effectiveCwd: string) => string;
89
- }
90
-
91
- /**
92
- * Session factory - create SDK objects for a child agent session.
93
- *
94
- * Decouples the runner from direct Pi SDK imports and sibling-module IO,
95
- * making it testable via plain stub objects without vi.mock().
96
- */
97
- export interface SessionFactoryIO {
98
- createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoaderLike;
99
- createSessionManager: (cwd: string, sessionDir: string) => SessionManagerLike;
100
- createSettingsManager: (cwd: string, agentDir: string) => SettingsManager;
101
- createSession: (opts: CreateSessionOptions) => Promise<{ session: AgentSession }>;
102
- assemblerIO: AssemblerIO;
103
- }
104
-
105
- /**
106
- * IO boundary injected into runAgent().
107
- *
108
- * Backward-compatible intersection of EnvironmentIO and SessionFactoryIO.
109
- * Callers that previously constructed a RunnerIO object continue to satisfy
110
- * both sub-interfaces via TypeScript's structural typing.
111
- */
112
- export type RunnerIO = EnvironmentIO & SessionFactoryIO;
113
-
114
- /**
115
- * Dependencies owned by the runner — injected at construction time.
116
- *
117
- * Groups the IO boundary with the two static domain deps (exec, registry)
118
- * that every run() call needs but that do not vary per call.
119
- */
120
- export interface RunnerDeps {
121
- io: RunnerIO;
122
- exec: ShellExec;
123
- registry: AgentConfigLookup;
124
- /** Publishes the child-execution lifecycle so consumers can observe it. */
125
- lifecycle: ChildLifecyclePublisher;
126
- }
127
-
128
- // ── Public interfaces ─────────────────────────────────────────────────────────
129
-
130
- /**
131
- * Per-call execution context — fields that vary per spawn.
132
- *
133
- * Static dependencies (exec, registry) live on RunnerDeps; this interface
134
- * carries only the two per-call fields that AgentManager supplies at spawn time.
135
- */
136
- export interface RunContext {
137
- /** Override working directory (e.g. for worktree isolation). */
138
- cwd?: string;
139
- /** Parent session identity (file path + session ID). */
140
- parentSession?: ParentSessionInfo;
141
- }
142
-
143
- export interface RunOptions {
144
- /** Parent execution context - where/who is running. */
145
- context: RunContext;
146
- model?: Model<any>;
147
- maxTurns?: number;
148
- signal?: AbortSignal;
149
- thinkingLevel?: ThinkingLevel;
150
- /** Called once after session creation - session delivery mechanism. */
151
- onSessionCreated?: (session: AgentSession) => void;
152
- /**
153
- * Default max turns from runtime config. Falls back to the module-scope
154
- * `defaultMaxTurns` during the lift-and-shift migration; superseded by
155
- * per-call `maxTurns` and per-agent `agentConfig.maxTurns`.
156
- */
157
- defaultMaxTurns?: number;
158
- /**
159
- * Grace turns after the soft-limit steer message. Falls back to the
160
- * module-scope `graceTurns` during migration.
161
- */
162
- graceTurns?: number;
163
- }
164
-
165
- export interface RunResult {
166
- responseText: string;
167
- session: AgentSession;
168
- /** True if the agent was hard-aborted (max_turns + grace exceeded). */
169
- aborted: boolean;
170
- /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
171
- steered: boolean;
172
- /** Path to the persisted session JSONL file, if the session was persisted. */
173
- sessionFile?: string;
174
- }
175
-
176
- /** Options for resuming an existing agent session. */
177
- export interface ResumeOptions {
178
- signal?: AbortSignal;
179
- }
180
-
181
- /**
182
- * Execution boundary: decouples AgentManager (lifecycle management) from the
183
- * SDK session orchestration in runAgent/resumeAgent.
184
- */
185
- export interface AgentRunner {
186
- run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
187
- resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string>;
188
- }
189
-
190
- /**
191
- * Concrete AgentRunner backed by RunnerDeps.
192
- *
193
- * Captures IO, exec, and registry at construction time so AgentManager
194
- * remains unaware of runner-internal dependencies.
195
- */
196
- export class ConcreteAgentRunner implements AgentRunner {
197
- constructor(private readonly deps: RunnerDeps) {}
198
-
199
- run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult> {
200
- return runAgent(snapshot, type, prompt, options, this.deps);
201
- }
202
-
203
- resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string> {
204
- return resumeAgent(session, prompt, options);
205
- }
206
- }
207
-
208
-
209
- // ── Private helpers ───────────────────────────────────────────────────────────
210
-
211
- /**
212
- * Subscribe to a session and collect the last assistant message text.
213
- * Returns an object with a `getText()` getter and an `unsubscribe` function.
214
- */
215
- function collectResponseText(session: AgentSession) {
216
- let text = "";
217
- const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
218
- if (event.type === "message_start") {
219
- text = "";
220
- }
221
- if (
222
- event.type === "message_update" &&
223
- event.assistantMessageEvent.type === "text_delta"
224
- ) {
225
- text += event.assistantMessageEvent.delta;
226
- }
227
- });
228
- return { getText: () => text, unsubscribe };
229
- }
230
-
231
- /** Get the last assistant text from the completed session history. */
232
- function getLastAssistantText(session: AgentSession): string {
233
- for (let i = session.messages.length - 1; i >= 0; i--) {
234
- const msg = session.messages[i];
235
- if (msg.role !== "assistant") continue;
236
- const text = extractText(msg.content).trim();
237
- if (text) return text;
238
- }
239
- return "";
240
- }
241
-
242
- /**
243
- * Wire an AbortSignal to abort a session.
244
- * Returns a cleanup function to remove the listener.
245
- */
246
- function forwardAbortSignal(
247
- session: AgentSession,
248
- signal?: AbortSignal,
249
- ): () => void {
250
- if (!signal) return () => {};
251
- const onAbort = (): void => { void session.abort(); };
252
- signal.addEventListener("abort", onAbort, { once: true });
253
- return () => signal.removeEventListener("abort", onAbort);
254
- }
255
-
256
- // ── Public functions ──────────────────────────────────────────────────────────
257
-
258
- export async function runAgent(
259
- snapshot: ParentSnapshot,
260
- type: SubagentType,
261
- prompt: string,
262
- options: RunOptions,
263
- deps: RunnerDeps,
264
- ): Promise<RunResult> {
265
- const parentSessionId = options.context.parentSession?.parentSessionId;
266
- deps.lifecycle.spawning({ agentName: type, parentSessionId });
267
-
268
- // Resolve working directory upfront - needed for detectEnv before assembly.
269
- const effectiveCwd = options.context.cwd ?? snapshot.cwd;
270
- const env = await deps.io.detectEnv(deps.exec, effectiveCwd);
271
-
272
- // Assemble session configuration (synchronous, no SDK objects).
273
- const cfg = assembleSessionConfig(
274
- type,
275
- {
276
- cwd: snapshot.cwd,
277
- parentSystemPrompt: snapshot.systemPrompt,
278
- parentModel: snapshot.model,
279
- modelRegistry: snapshot.modelRegistry,
280
- },
281
- {
282
- cwd: options.context.cwd,
283
- model: options.model,
284
- thinkingLevel: options.thinkingLevel,
285
- },
286
- env,
287
- deps.registry,
288
- deps.io.assemblerIO,
289
- );
290
-
291
- const agentDir = deps.io.getAgentDir();
292
-
293
- // Children always load the parent's extensions and skills.
294
- // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md - upstream's
295
- // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
296
- // would defeat prompt_mode: replace. Parent context, if
297
- // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
298
- // is embedded in systemPromptOverride) or inherit_context (conversation).
299
- const loader = deps.io.createResourceLoader({
300
- cwd: cfg.effectiveCwd,
301
- agentDir,
302
- noPromptTemplates: true,
303
- noThemes: true,
304
- noContextFiles: true,
305
- systemPromptOverride: () => cfg.systemPrompt,
306
- appendSystemPromptOverride: () => [],
307
- });
308
- await loader.reload();
309
-
310
- // Create a persisted SessionManager so transcripts are written in Pi's
311
- // official JSONL format. Falls back to a temp directory when the parent
312
- // session is not persisted (e.g. headless/API mode).
313
- const sessionDir = deps.io.deriveSessionDir(options.context.parentSession?.parentSessionFile, cfg.effectiveCwd);
314
- const sessionManager = deps.io.createSessionManager(cfg.effectiveCwd, sessionDir);
315
- sessionManager.newSession({ parentSession: options.context.parentSession?.parentSessionId });
316
-
317
- const { session } = await deps.io.createSession({
318
- cwd: cfg.effectiveCwd,
319
- agentDir,
320
- sessionManager,
321
- settingsManager: deps.io.createSettingsManager(cfg.effectiveCwd, agentDir),
322
- modelRegistry: snapshot.modelRegistry,
323
- model: cfg.model,
324
- tools: cfg.toolNames,
325
- resourceLoader: loader,
326
- thinkingLevel: cfg.thinkingLevel,
327
- });
328
-
329
- // Publish session-created before bindExtensions() so observers (e.g. the
330
- // permission system) can register the child synchronously and have their
331
- // entry in place for the first permission check during child extension
332
- // initialization. The event bus dispatches synchronously, so a synchronous
333
- // subscriber completes before this returns. Paired with disposed() in the
334
- // finally block below to guarantee cleanup on both success and error paths.
335
- deps.lifecycle.sessionCreated({ sessionDir, agentName: type, parentSessionId });
336
-
337
- // Bind extensions so that session_start fires and extensions can initialize
338
- // (e.g. loading credentials, setting up state). Placed after tool filtering
339
- // so extension-provided skills/prompts from extendResourcesFromExtensions()
340
- // respect the active tool set. All ExtensionBindings fields are optional.
341
- await session.bindExtensions({});
342
-
343
- // Apply recursion guard: remove our own tools from the child's active set.
344
- // Runs after bindExtensions so extension-registered tools are included in the
345
- // post-bind active set. Unconditional: children always load the parent's
346
- // extensions, so the guard must always strip our dispatch tools.
347
- const filtered = filterActiveTools(session.getActiveToolNames());
348
- session.setActiveToolsByName(filtered);
349
-
350
- options.onSessionCreated?.(session);
351
-
352
- // Track turns for graceful max_turns enforcement
353
- let turnCount = 0;
354
- const maxTurns = normalizeMaxTurns(
355
- options.maxTurns ?? cfg.agentMaxTurns ?? options.defaultMaxTurns,
356
- );
357
- let softLimitReached = false;
358
- let aborted = false;
359
-
360
- const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
361
- if (event.type === "turn_end") {
362
- turnCount++;
363
- if (maxTurns != null) {
364
- if (!softLimitReached && turnCount >= maxTurns) {
365
- softLimitReached = true;
366
- void session.steer(
367
- "You have reached your turn limit. Wrap up immediately - provide your final answer now.",
368
- );
369
- } else if (softLimitReached && turnCount >= maxTurns + (options.graceTurns ?? 5)) {
370
- aborted = true;
371
- void session.abort();
372
- }
373
- }
374
- }
375
- });
376
-
377
- const collector = collectResponseText(session);
378
- const cleanupAbort = forwardAbortSignal(session, options.signal);
379
-
380
- // Prepend parent context if it was captured at spawn time
381
- let effectivePrompt = prompt;
382
- if (snapshot.parentContext) {
383
- effectivePrompt = snapshot.parentContext + prompt;
384
- }
385
-
386
- try {
387
- await session.prompt(effectivePrompt);
388
- deps.lifecycle.completed({ sessionDir, agentName: type, aborted, steered: softLimitReached });
389
- } finally {
390
- unsubTurns();
391
- collector.unsubscribe();
392
- cleanupAbort();
393
- deps.lifecycle.disposed({ sessionDir });
394
- }
395
-
396
- const responseText =
397
- collector.getText().trim() || getLastAssistantText(session);
398
- return {
399
- responseText,
400
- session,
401
- aborted,
402
- steered: softLimitReached,
403
- sessionFile: sessionManager.getSessionFile(),
404
- };
405
- }
406
-
407
- /**
408
- * Send a new prompt to an existing session (resume).
409
- */
410
- export async function resumeAgent(
411
- session: AgentSession,
412
- prompt: string,
413
- options: ResumeOptions = {},
414
- ): Promise<string> {
415
- const collector = collectResponseText(session);
416
- const cleanupAbort = forwardAbortSignal(session, options.signal);
417
-
418
- try {
419
- await session.prompt(prompt);
420
- } finally {
421
- collector.unsubscribe();
422
- cleanupAbort();
423
- }
424
-
425
- return collector.getText().trim() || getLastAssistantText(session);
426
- }
427
-
428
- /**
429
- * Get the subagent's conversation messages as formatted text.
430
- */
431
- export function getAgentConversation(session: AgentSession): string {
432
- const parts: string[] = [];
433
-
434
- for (const msg of session.messages) {
435
- if (msg.role === "user") {
436
- const text =
437
- typeof msg.content === "string"
438
- ? msg.content
439
- : extractText(msg.content);
440
- if (text.trim()) parts.push(`[User]: ${text.trim()}`);
441
- } else if (msg.role === "assistant") {
442
- const { textParts, toolNames } = extractAssistantContent(msg.content);
443
- const attribution = formatAttribution(msg);
444
- if (textParts.length > 0)
445
- parts.push(`[Assistant${attribution}]: ${textParts.join("\n")}`);
446
- if (toolNames.length > 0)
447
- parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
448
- } else if (msg.role === "toolResult") {
449
- const text = extractText(msg.content);
450
- const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
451
- parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
452
- }
453
- }
454
-
455
- return parts.join("\n\n");
456
- }
457
-
458
- /** Build a `(provider/model)` attribution suffix for assistant messages. */
459
- function formatAttribution(msg: { provider?: string; model?: string }): string {
460
- const { provider, model } = msg;
461
- if (!provider && !model) return "";
462
- if (provider && model) return ` (${provider}/${model})`;
463
- return ` (${provider ?? model})`;
464
- }
@@ -1,17 +0,0 @@
1
- /**
2
- * execution-state.ts — ExecutionState: execution-phase state for a running agent.
3
- *
4
- * Constructed and attached to Agent when onSessionCreated fires inside startAgent().
5
- * Contains the session and output file — the two fields that become known once the
6
- * runner creates the session. promise stays as a separate Agent field because
7
- * it is set at a different moment (after runner.run() returns).
8
- */
9
-
10
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
11
-
12
- export interface ExecutionState {
13
- /** The active agent session — available from the moment the session is created. */
14
- readonly session: AgentSession;
15
- /** Path to the agent's session JSONL file, or undefined if not yet available. */
16
- readonly outputFile: string | undefined;
17
- }