@gotgenes/pi-subagents 13.0.0 → 13.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/CHANGELOG.md +9 -0
- package/dist/public.d.ts +1 -1
- package/docs/architecture/architecture.md +74 -41
- package/docs/plans/0265-born-complete-subagent-session.md +330 -0
- package/docs/retro/0264-remove-extension-lifecycle-control.md +41 -0
- package/docs/retro/0265-born-complete-subagent-session.md +58 -0
- package/package.json +1 -1
- package/src/index.ts +3 -3
- package/src/lifecycle/agent-manager.ts +9 -8
- package/src/lifecycle/agent.ts +56 -51
- package/src/lifecycle/create-subagent-session.ts +242 -0
- package/src/lifecycle/subagent-session.ts +204 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/runtime.ts +1 -1
- package/src/session/conversation.ts +49 -0
- package/src/session/session-config.ts +8 -8
- package/src/settings.ts +1 -1
- package/src/tools/get-result-tool.ts +1 -1
- package/src/tools/spawn-config.ts +1 -1
- package/src/lifecycle/agent-runner.ts +0 -464
- package/src/lifecycle/execution-state.ts +0 -17
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create-subagent-session.ts — Assembly factory for born-complete child sessions (issue #265).
|
|
3
|
+
*
|
|
4
|
+
* `createSubagentSession()` does the assembly portion that the old runner's
|
|
5
|
+
* `runAgent()` did up front: detect the environment, assemble the session config,
|
|
6
|
+
* create the SDK session, publish `spawning`/`session-created`, bind extensions,
|
|
7
|
+
* and apply the recursion guard. It returns a fully usable `SubagentSession` —
|
|
8
|
+
* `Agent` then only coordinates (turn loop, steer, dispose).
|
|
9
|
+
*
|
|
10
|
+
* The factory takes a resolved `cwd` value, never the WorkspaceProvider: `cwd`
|
|
11
|
+
* is a value the factory consumes directly (detectEnv, assembleSessionConfig,
|
|
12
|
+
* createSession), so threading the provider through here would be a relay smell.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
16
|
+
import {
|
|
17
|
+
type AgentSession,
|
|
18
|
+
type SettingsManager,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
21
|
+
import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
|
|
22
|
+
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
23
|
+
import { SubagentSession } from "#src/lifecycle/subagent-session";
|
|
24
|
+
import type { EnvInfo } from "#src/session/env";
|
|
25
|
+
import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
|
|
26
|
+
import type { ParentSessionInfo, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
|
|
27
|
+
|
|
28
|
+
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
29
|
+
const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Apply the recursion guard: remove this extension's dispatch tools from the
|
|
33
|
+
* child's active set. Runs after `bindExtensions` so extension-registered tools
|
|
34
|
+
* are also covered. Unconditional: children always load the parent's extensions.
|
|
35
|
+
*/
|
|
36
|
+
function applyRecursionGuard(session: AgentSession): void {
|
|
37
|
+
const filtered = session
|
|
38
|
+
.getActiveToolNames()
|
|
39
|
+
.filter((t) => !EXCLUDED_TOOL_NAMES.includes(t));
|
|
40
|
+
session.setActiveToolsByName(filtered);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── IO boundary ───────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Minimal resource-loader contract used by the factory. */
|
|
46
|
+
export interface ResourceLoaderLike {
|
|
47
|
+
reload(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Minimal session-manager contract used by the factory. */
|
|
51
|
+
export interface SessionManagerLike {
|
|
52
|
+
newSession(opts: { parentSession?: string }): void;
|
|
53
|
+
getSessionFile(): string | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options passed to EnvironmentIO/SessionFactoryIO methods. */
|
|
57
|
+
export interface ResourceLoaderOptions {
|
|
58
|
+
cwd: string;
|
|
59
|
+
agentDir: string;
|
|
60
|
+
noPromptTemplates?: boolean;
|
|
61
|
+
noThemes?: boolean;
|
|
62
|
+
noContextFiles?: boolean;
|
|
63
|
+
systemPromptOverride?: () => string;
|
|
64
|
+
/** Override the append system prompt. Receives the current base value; return the replacement. */
|
|
65
|
+
appendSystemPromptOverride?: (base: string[]) => string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Options passed to SessionFactoryIO.createSession. */
|
|
69
|
+
export interface CreateSessionOptions {
|
|
70
|
+
cwd: string;
|
|
71
|
+
agentDir: string;
|
|
72
|
+
sessionManager: SessionManagerLike;
|
|
73
|
+
settingsManager: SettingsManager;
|
|
74
|
+
modelRegistry: unknown;
|
|
75
|
+
model?: unknown;
|
|
76
|
+
tools: string[];
|
|
77
|
+
resourceLoader: ResourceLoaderLike;
|
|
78
|
+
thinkingLevel?: ThinkingLevel;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Environment discovery - detect runtime context and resolve directories.
|
|
83
|
+
*
|
|
84
|
+
* Decouples the factory from direct process/SDK reads so each can be stubbed
|
|
85
|
+
* independently in tests.
|
|
86
|
+
*/
|
|
87
|
+
export interface EnvironmentIO {
|
|
88
|
+
detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
|
|
89
|
+
getAgentDir: () => string;
|
|
90
|
+
deriveSessionDir: (parentSessionFile: string | undefined, effectiveCwd: string) => string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Session factory - create SDK objects for a child agent session.
|
|
95
|
+
*
|
|
96
|
+
* Decouples the factory from direct Pi SDK imports and sibling-module IO,
|
|
97
|
+
* making it testable via plain stub objects without vi.mock().
|
|
98
|
+
*/
|
|
99
|
+
export interface SessionFactoryIO {
|
|
100
|
+
createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoaderLike;
|
|
101
|
+
createSessionManager: (cwd: string, sessionDir: string) => SessionManagerLike;
|
|
102
|
+
createSettingsManager: (cwd: string, agentDir: string) => SettingsManager;
|
|
103
|
+
createSession: (opts: CreateSessionOptions) => Promise<{ session: AgentSession }>;
|
|
104
|
+
assemblerIO: AssemblerIO;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* IO boundary injected into createSubagentSession().
|
|
109
|
+
*
|
|
110
|
+
* Intersection of EnvironmentIO and SessionFactoryIO — callers satisfy both
|
|
111
|
+
* sub-interfaces via TypeScript's structural typing.
|
|
112
|
+
*/
|
|
113
|
+
export type SubagentSessionIO = EnvironmentIO & SessionFactoryIO;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Dependencies injected at construction time — the IO boundary plus the two
|
|
117
|
+
* static domain deps (exec, registry) every creation needs.
|
|
118
|
+
*/
|
|
119
|
+
export interface SubagentSessionDeps {
|
|
120
|
+
io: SubagentSessionIO;
|
|
121
|
+
exec: ShellExec;
|
|
122
|
+
registry: AgentConfigLookup;
|
|
123
|
+
/** Publishes the child-execution lifecycle so consumers can observe it. */
|
|
124
|
+
lifecycle: ChildLifecyclePublisher;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Per-spawn parameters — the fields that vary per child session. */
|
|
128
|
+
export interface CreateSubagentSessionParams {
|
|
129
|
+
snapshot: ParentSnapshot;
|
|
130
|
+
type: SubagentType;
|
|
131
|
+
/** Resolved workspace cwd; undefined → parent cwd. */
|
|
132
|
+
cwd?: string;
|
|
133
|
+
/** Parent session identity (file path + session ID). */
|
|
134
|
+
parentSession?: ParentSessionInfo;
|
|
135
|
+
model?: Model<any>;
|
|
136
|
+
thinkingLevel?: ThinkingLevel;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build a born-complete SubagentSession: assemble config, create the SDK
|
|
141
|
+
* session, publish lifecycle events, bind extensions, apply the recursion guard.
|
|
142
|
+
*/
|
|
143
|
+
export async function createSubagentSession(
|
|
144
|
+
params: CreateSubagentSessionParams,
|
|
145
|
+
deps: SubagentSessionDeps,
|
|
146
|
+
): Promise<SubagentSession> {
|
|
147
|
+
const { snapshot, type } = params;
|
|
148
|
+
const parentSessionId = params.parentSession?.parentSessionId;
|
|
149
|
+
deps.lifecycle.spawning({ agentName: type, parentSessionId });
|
|
150
|
+
|
|
151
|
+
// Resolve working directory upfront - needed for detectEnv before assembly.
|
|
152
|
+
const effectiveCwd = params.cwd ?? snapshot.cwd;
|
|
153
|
+
const env = await deps.io.detectEnv(deps.exec, effectiveCwd);
|
|
154
|
+
|
|
155
|
+
// Assemble session configuration (synchronous, no SDK objects).
|
|
156
|
+
const cfg = assembleSessionConfig(
|
|
157
|
+
type,
|
|
158
|
+
{
|
|
159
|
+
cwd: snapshot.cwd,
|
|
160
|
+
parentSystemPrompt: snapshot.systemPrompt,
|
|
161
|
+
parentModel: snapshot.model,
|
|
162
|
+
modelRegistry: snapshot.modelRegistry,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
cwd: params.cwd,
|
|
166
|
+
model: params.model,
|
|
167
|
+
thinkingLevel: params.thinkingLevel,
|
|
168
|
+
},
|
|
169
|
+
env,
|
|
170
|
+
deps.registry,
|
|
171
|
+
deps.io.assemblerIO,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const agentDir = deps.io.getAgentDir();
|
|
175
|
+
|
|
176
|
+
// Children always load the parent's extensions and skills.
|
|
177
|
+
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md - upstream's
|
|
178
|
+
// buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
|
|
179
|
+
// would defeat prompt_mode: replace. Parent context, if wanted, reaches the
|
|
180
|
+
// subagent via prompt_mode: append (parentSystemPrompt is embedded in
|
|
181
|
+
// systemPromptOverride) or inherit_context (conversation).
|
|
182
|
+
const loader = deps.io.createResourceLoader({
|
|
183
|
+
cwd: cfg.effectiveCwd,
|
|
184
|
+
agentDir,
|
|
185
|
+
noPromptTemplates: true,
|
|
186
|
+
noThemes: true,
|
|
187
|
+
noContextFiles: true,
|
|
188
|
+
systemPromptOverride: () => cfg.systemPrompt,
|
|
189
|
+
appendSystemPromptOverride: () => [],
|
|
190
|
+
});
|
|
191
|
+
await loader.reload();
|
|
192
|
+
|
|
193
|
+
// Create a persisted SessionManager so transcripts are written in Pi's
|
|
194
|
+
// official JSONL format. Falls back to a temp directory when the parent
|
|
195
|
+
// session is not persisted (e.g. headless/API mode).
|
|
196
|
+
const sessionDir = deps.io.deriveSessionDir(params.parentSession?.parentSessionFile, cfg.effectiveCwd);
|
|
197
|
+
const sessionManager = deps.io.createSessionManager(cfg.effectiveCwd, sessionDir);
|
|
198
|
+
sessionManager.newSession({ parentSession: params.parentSession?.parentSessionId });
|
|
199
|
+
|
|
200
|
+
const { session } = await deps.io.createSession({
|
|
201
|
+
cwd: cfg.effectiveCwd,
|
|
202
|
+
agentDir,
|
|
203
|
+
sessionManager,
|
|
204
|
+
settingsManager: deps.io.createSettingsManager(cfg.effectiveCwd, agentDir),
|
|
205
|
+
modelRegistry: snapshot.modelRegistry,
|
|
206
|
+
model: cfg.model,
|
|
207
|
+
tools: cfg.toolNames,
|
|
208
|
+
resourceLoader: loader,
|
|
209
|
+
thinkingLevel: cfg.thinkingLevel,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const subagentSession = new SubagentSession(session, {
|
|
213
|
+
outputFile: sessionManager.getSessionFile(),
|
|
214
|
+
sessionDir,
|
|
215
|
+
agentName: type,
|
|
216
|
+
agentMaxTurns: cfg.agentMaxTurns,
|
|
217
|
+
parentContext: snapshot.parentContext,
|
|
218
|
+
lifecycle: deps.lifecycle,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Publish session-created before bindExtensions() so observers (e.g. the
|
|
222
|
+
// permission system) can register the child synchronously and have their
|
|
223
|
+
// entry in place for the first permission check during child extension
|
|
224
|
+
// initialization. The event bus dispatches synchronously, so a synchronous
|
|
225
|
+
// subscriber completes before this returns.
|
|
226
|
+
deps.lifecycle.sessionCreated({ sessionDir, agentName: type, parentSessionId });
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// Bind extensions so that session_start fires and extensions can initialize.
|
|
230
|
+
await session.bindExtensions({});
|
|
231
|
+
// Apply recursion guard after bindExtensions so extension-registered tools
|
|
232
|
+
// are included in the post-bind active set.
|
|
233
|
+
applyRecursionGuard(session);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
// Binding failed after session-created — dispose (emit disposed +
|
|
236
|
+
// session.dispose()) before rethrowing so registration is never leaked.
|
|
237
|
+
subagentSession.dispose();
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return subagentSession;
|
|
242
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-session.ts — The born-complete child-session value object (issue #265).
|
|
3
|
+
*
|
|
4
|
+
* A SubagentSession wraps one SDK AgentSession plus its turn-driving and teardown.
|
|
5
|
+
* It is born complete: `createSubagentSession()` returns a fully usable instance
|
|
6
|
+
* (session created, extensions bound, recursion guard applied), so the only thing
|
|
7
|
+
* left for `Agent` to do is coordinate — drive the turn loop, steer, dispose.
|
|
8
|
+
*
|
|
9
|
+
* Turn driving lives here, on the object that owns the AgentSession, rather than
|
|
10
|
+
* reaching through `subagentSession.session` from `Agent` (Law of Demeter).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type AgentSession,
|
|
15
|
+
type AgentSessionEvent,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
|
|
18
|
+
import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
|
|
19
|
+
import { extractText } from "#src/session/context";
|
|
20
|
+
|
|
21
|
+
/** Outcome of one turn loop. */
|
|
22
|
+
export interface TurnLoopResult {
|
|
23
|
+
responseText: string;
|
|
24
|
+
/** True if the agent was hard-aborted (max turns + grace exceeded). */
|
|
25
|
+
aborted: boolean;
|
|
26
|
+
/** True if the agent was steered to wrap up (soft turn limit) but finished in time. */
|
|
27
|
+
steered: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Per-call options for the initial run's turn loop. */
|
|
31
|
+
export interface TurnLoopOptions {
|
|
32
|
+
/** Per-call max-turns override — highest precedence. */
|
|
33
|
+
maxTurns?: number;
|
|
34
|
+
/** Runtime-config fallback when neither per-call nor per-agent limit is set. */
|
|
35
|
+
defaultMaxTurns?: number;
|
|
36
|
+
/** Grace turns after the soft-limit steer message before a hard abort. */
|
|
37
|
+
graceTurns?: number;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Session-level facts known at creation, supplied by the factory. */
|
|
42
|
+
export interface SubagentSessionMeta {
|
|
43
|
+
/** Path to the persisted session JSONL file, if the session was persisted. */
|
|
44
|
+
outputFile: string | undefined;
|
|
45
|
+
/** Child session directory — the registry key carried on lifecycle events. */
|
|
46
|
+
sessionDir: string;
|
|
47
|
+
agentName: string;
|
|
48
|
+
/** Per-agent max-turns from the resolved agent config — middle precedence. */
|
|
49
|
+
agentMaxTurns: number | undefined;
|
|
50
|
+
/** Parent context prepended to the run prompt, captured at spawn time. */
|
|
51
|
+
parentContext: string | undefined;
|
|
52
|
+
lifecycle: ChildLifecyclePublisher;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One child AgentSession plus its turn-driving and teardown — born complete.
|
|
57
|
+
*/
|
|
58
|
+
export class SubagentSession {
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly _session: AgentSession,
|
|
61
|
+
private readonly meta: SubagentSessionMeta,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
/** Wrapped session — exposed for observer wiring + consumers; retired by #277. */
|
|
65
|
+
get session(): AgentSession {
|
|
66
|
+
return this._session;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get outputFile(): string | undefined {
|
|
70
|
+
return this.meta.outputFile;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Drive the initial run's turn loop; emits `completed` on success. */
|
|
74
|
+
async runTurnLoop(prompt: string, opts: TurnLoopOptions): Promise<TurnLoopResult> {
|
|
75
|
+
const session = this._session;
|
|
76
|
+
|
|
77
|
+
// Track turns for graceful max_turns enforcement.
|
|
78
|
+
let turnCount = 0;
|
|
79
|
+
const maxTurns = normalizeMaxTurns(
|
|
80
|
+
opts.maxTurns ?? this.meta.agentMaxTurns ?? opts.defaultMaxTurns,
|
|
81
|
+
);
|
|
82
|
+
let softLimitReached = false;
|
|
83
|
+
let aborted = false;
|
|
84
|
+
|
|
85
|
+
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
|
|
86
|
+
if (event.type === "turn_end") {
|
|
87
|
+
turnCount++;
|
|
88
|
+
if (maxTurns != null) {
|
|
89
|
+
if (!softLimitReached && turnCount >= maxTurns) {
|
|
90
|
+
softLimitReached = true;
|
|
91
|
+
void session.steer(
|
|
92
|
+
"You have reached your turn limit. Wrap up immediately - provide your final answer now.",
|
|
93
|
+
);
|
|
94
|
+
} else if (softLimitReached && turnCount >= maxTurns + (opts.graceTurns ?? 5)) {
|
|
95
|
+
aborted = true;
|
|
96
|
+
void session.abort();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const collector = collectResponseText(session);
|
|
103
|
+
const cleanupAbort = forwardAbortSignal(session, opts.signal);
|
|
104
|
+
|
|
105
|
+
// Prepend parent context if it was captured at spawn time.
|
|
106
|
+
const effectivePrompt = this.meta.parentContext
|
|
107
|
+
? this.meta.parentContext + prompt
|
|
108
|
+
: prompt;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await session.prompt(effectivePrompt);
|
|
112
|
+
this.meta.lifecycle.completed({
|
|
113
|
+
sessionDir: this.meta.sessionDir,
|
|
114
|
+
agentName: this.meta.agentName,
|
|
115
|
+
aborted,
|
|
116
|
+
steered: softLimitReached,
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
unsubTurns();
|
|
120
|
+
collector.unsubscribe();
|
|
121
|
+
cleanupAbort();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const responseText = collector.getText().trim() || getLastAssistantText(session);
|
|
125
|
+
return { responseText, aborted, steered: softLimitReached };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Re-prompt the same session (resume); does not emit `completed`. */
|
|
129
|
+
async resumeTurnLoop(prompt: string, signal?: AbortSignal): Promise<string> {
|
|
130
|
+
const session = this._session;
|
|
131
|
+
const collector = collectResponseText(session);
|
|
132
|
+
const cleanupAbort = forwardAbortSignal(session, signal);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await session.prompt(prompt);
|
|
136
|
+
} finally {
|
|
137
|
+
collector.unsubscribe();
|
|
138
|
+
cleanupAbort();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return collector.getText().trim() || getLastAssistantText(session);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Deliver a steer to the live session. */
|
|
145
|
+
async steer(message: string): Promise<void> {
|
|
146
|
+
await this._session.steer(message);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Tear down: session.dispose() + emit `disposed` (registry unregister). */
|
|
150
|
+
dispose(): void {
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
|
|
152
|
+
this._session.dispose?.();
|
|
153
|
+
this.meta.lifecycle.disposed({ sessionDir: this.meta.sessionDir });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Private turn-loop helpers ───────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Subscribe to a session and collect the last assistant message text.
|
|
161
|
+
* Returns an object with a `getText()` getter and an `unsubscribe` function.
|
|
162
|
+
*/
|
|
163
|
+
function collectResponseText(session: AgentSession) {
|
|
164
|
+
let text = "";
|
|
165
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
166
|
+
if (event.type === "message_start") {
|
|
167
|
+
text = "";
|
|
168
|
+
}
|
|
169
|
+
if (
|
|
170
|
+
event.type === "message_update" &&
|
|
171
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
172
|
+
) {
|
|
173
|
+
text += event.assistantMessageEvent.delta;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
return { getText: () => text, unsubscribe };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Get the last assistant text from the completed session history. */
|
|
180
|
+
function getLastAssistantText(session: AgentSession): string {
|
|
181
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
182
|
+
const msg = session.messages[i];
|
|
183
|
+
if (msg.role !== "assistant") continue;
|
|
184
|
+
const text = extractText(msg.content).trim();
|
|
185
|
+
if (text) return text;
|
|
186
|
+
}
|
|
187
|
+
return "";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Wire an AbortSignal to abort a session.
|
|
192
|
+
* Returns a cleanup function to remove the listener.
|
|
193
|
+
*/
|
|
194
|
+
function forwardAbortSignal(
|
|
195
|
+
session: AgentSession,
|
|
196
|
+
signal?: AbortSignal,
|
|
197
|
+
): () => void {
|
|
198
|
+
if (!signal) return () => {};
|
|
199
|
+
const onAbort = (): void => {
|
|
200
|
+
void session.abort();
|
|
201
|
+
};
|
|
202
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
203
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
204
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-limits.ts — Pure turn-limit normalization for subagent execution.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent-runner.ts (issue #265) so the turn-counting policy has a
|
|
5
|
+
* focused home independent of session assembly. Consumed by the Agent tool's
|
|
6
|
+
* spawn-config resolution and by the turn loop in SubagentSession.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
10
|
+
export function normalizeMaxTurns(n: number | undefined): number | undefined {
|
|
11
|
+
if (n == null || n === 0) return undefined;
|
|
12
|
+
return Math.max(1, n);
|
|
13
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface WidgetLike {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Narrow config subset read by
|
|
28
|
+
* Narrow config subset read by Agent when driving the turn loop (defaultMaxTurns, graceTurns).
|
|
29
29
|
* Kept separate so callers can satisfy it without depending on the full runtime.
|
|
30
30
|
*/
|
|
31
31
|
export interface RunConfig {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation.ts — Render a subagent session's messages as formatted text.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent-runner.ts (issue #265) into the session domain, where the
|
|
5
|
+
* other message-extraction helpers (content-items, context) live. Consumed by
|
|
6
|
+
* the get_subagent_result tool's verbose output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { extractAssistantContent } from "#src/session/content-items";
|
|
11
|
+
import { extractText } from "#src/session/context";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the subagent's conversation messages as formatted text.
|
|
15
|
+
*/
|
|
16
|
+
export function getAgentConversation(session: AgentSession): string {
|
|
17
|
+
const parts: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (const msg of session.messages) {
|
|
20
|
+
if (msg.role === "user") {
|
|
21
|
+
const text =
|
|
22
|
+
typeof msg.content === "string"
|
|
23
|
+
? msg.content
|
|
24
|
+
: extractText(msg.content);
|
|
25
|
+
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
|
|
26
|
+
} else if (msg.role === "assistant") {
|
|
27
|
+
const { textParts, toolNames } = extractAssistantContent(msg.content);
|
|
28
|
+
const attribution = formatAttribution(msg);
|
|
29
|
+
if (textParts.length > 0)
|
|
30
|
+
parts.push(`[Assistant${attribution}]: ${textParts.join("\n")}`);
|
|
31
|
+
if (toolNames.length > 0)
|
|
32
|
+
parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
|
|
33
|
+
} else if (msg.role === "toolResult") {
|
|
34
|
+
const text = extractText(msg.content);
|
|
35
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
|
|
36
|
+
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return parts.join("\n\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build a `(provider/model)` attribution suffix for assistant messages. */
|
|
44
|
+
function formatAttribution(msg: { provider?: string; model?: string }): string {
|
|
45
|
+
const { provider, model } = msg;
|
|
46
|
+
if (!provider && !model) return "";
|
|
47
|
+
if (provider && model) return ` (${provider}/${model})`;
|
|
48
|
+
return ` (${provider ?? model})`;
|
|
49
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* session-config.ts — Pure configuration assembler for agent sessions.
|
|
3
3
|
*
|
|
4
|
-
* `assembleSessionConfig()` is the pure core
|
|
5
|
-
* It accepts resolved inputs (agent type, narrow
|
|
6
|
-
*
|
|
7
|
-
* importing or constructing any Pi SDK types.
|
|
4
|
+
* `assembleSessionConfig()` is the pure assembly core called by
|
|
5
|
+
* `createSubagentSession()`. It accepts resolved inputs (agent type, narrow
|
|
6
|
+
* context, run options, env info) and returns everything the factory needs to
|
|
7
|
+
* create the SDK session — without importing or constructing any Pi SDK types.
|
|
8
8
|
*
|
|
9
9
|
* The only async IO in the assembly phase (`detectEnv`) is handled by the caller
|
|
10
10
|
* before invoking this function, keeping the assembler synchronous.
|
|
@@ -22,7 +22,7 @@ import type { AgentPromptConfig, SubagentType, ThinkingLevel } from "#src/types"
|
|
|
22
22
|
* Bundling the IO-touching (or promptly testable) function into a single
|
|
23
23
|
* interface keeps the assembler free of direct module imports and makes it
|
|
24
24
|
* trivially testable without `vi.mock()` — callers inject real implementations
|
|
25
|
-
* at the edge (`
|
|
25
|
+
* at the edge (`create-subagent-session.ts`) or stubs in tests.
|
|
26
26
|
*/
|
|
27
27
|
export interface AssemblerIO {
|
|
28
28
|
buildAgentPrompt: (
|
|
@@ -57,7 +57,7 @@ export interface AssemblerContext {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Narrow slice of
|
|
60
|
+
* Narrow slice of per-spawn execution fields consumed by the assembler.
|
|
61
61
|
* All fields are optional — callers pass only what they have.
|
|
62
62
|
*/
|
|
63
63
|
export interface AssemblerOptions {
|
|
@@ -70,7 +70,7 @@ export interface AssemblerOptions {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
* Assembled configuration returned to `
|
|
73
|
+
* Assembled configuration returned to `createSubagentSession()`.
|
|
74
74
|
* Contains everything needed to create the SDK session and filter tools —
|
|
75
75
|
* with no SDK object references.
|
|
76
76
|
*/
|
|
@@ -173,7 +173,7 @@ export function assembleSessionConfig(
|
|
|
173
173
|
// Thinking level: explicit option > agent config > undefined (inherit)
|
|
174
174
|
const thinkingLevel = options.thinkingLevel ?? agentConfig.thinking;
|
|
175
175
|
|
|
176
|
-
// Per-agent max turns (combined with
|
|
176
|
+
// Per-agent max turns (combined with per-call maxTurns and defaultMaxTurns by SubagentSession.runTurnLoop)
|
|
177
177
|
const agentMaxTurns = agentConfig.maxTurns;
|
|
178
178
|
|
|
179
179
|
return {
|
package/src/settings.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface SubagentsSettings {
|
|
|
8
8
|
maxConcurrent?: number;
|
|
9
9
|
/**
|
|
10
10
|
* 0 = unlimited — the extension's single source of truth for that convention:
|
|
11
|
-
* `normalizeMaxTurns()` in
|
|
11
|
+
* `normalizeMaxTurns()` in turn-limits.ts treats 0 → `undefined`, and the
|
|
12
12
|
* `/agents` → Settings input prompt explicitly says "0 = unlimited".
|
|
13
13
|
*/
|
|
14
14
|
defaultMaxTurns?: number;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
3
|
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
4
|
-
import { getAgentConversation } from "#src/lifecycle/agent-runner";
|
|
5
4
|
import { getSessionContextPercent } from "#src/lifecycle/usage";
|
|
5
|
+
import { getAgentConversation } from "#src/session/conversation";
|
|
6
6
|
import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
|
|
7
7
|
import type { Agent } from "#src/types";
|
|
8
8
|
import { formatDuration, getDisplayName } from "#src/ui/display";
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
11
|
import type { AgentTypeRegistry } from "#src/config/agent-types";
|
|
12
12
|
import { resolveAgentInvocationConfig } from "#src/config/invocation-config";
|
|
13
|
-
import { normalizeMaxTurns } from "#src/lifecycle/
|
|
13
|
+
import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
|
|
14
14
|
import { resolveInvocationModel } from "#src/session/model-resolver";
|
|
15
15
|
import type { AgentInvocation, SubagentType, ThinkingLevel } from "#src/types";
|
|
16
16
|
import {
|