@gotgenes/pi-subagents 12.1.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 +24 -0
- package/dist/public.d.ts +1 -3
- package/docs/architecture/architecture.md +86 -57
- package/docs/plans/0264-remove-extension-lifecycle-control.md +275 -0
- package/docs/plans/0265-born-complete-subagent-session.md +330 -0
- package/docs/retro/0264-remove-extension-lifecycle-control.md +89 -0
- package/docs/retro/0265-born-complete-subagent-session.md +58 -0
- package/package.json +1 -1
- package/src/config/agent-types.ts +0 -2
- package/src/config/custom-agents.ts +0 -30
- package/src/config/default-agents.ts +1 -7
- package/src/config/invocation-config.ts +0 -3
- package/src/index.ts +3 -5
- package/src/lifecycle/agent-manager.ts +9 -10
- package/src/lifecycle/agent.ts +56 -55
- 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/service/service-adapter.ts +0 -1
- package/src/service/service.ts +0 -1
- package/src/session/conversation.ts +49 -0
- package/src/session/prompts.ts +2 -23
- package/src/session/session-config.ts +10 -45
- package/src/settings.ts +1 -1
- package/src/tools/agent-tool.ts +0 -5
- package/src/tools/background-spawner.ts +0 -1
- package/src/tools/foreground-runner.ts +0 -1
- package/src/tools/get-result-tool.ts +1 -1
- package/src/tools/spawn-config.ts +1 -5
- package/src/types.ts +0 -7
- package/src/ui/agent-config-editor.ts +0 -5
- package/src/ui/agent-creation-wizard.ts +0 -4
- package/src/ui/display.ts +1 -2
- package/src/lifecycle/agent-runner.ts +0 -472
- package/src/lifecycle/execution-state.ts +0 -17
- package/src/session/safe-fs.ts +0 -45
- package/src/session/skill-loader.ts +0 -104
|
@@ -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 {
|
|
@@ -66,7 +66,6 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
66
66
|
model,
|
|
67
67
|
maxTurns: options?.maxTurns,
|
|
68
68
|
thinkingLevel: options?.thinkingLevel,
|
|
69
|
-
isolated: options?.isolated,
|
|
70
69
|
inheritContext: options?.inheritContext,
|
|
71
70
|
bypassQueue: options?.bypassQueue,
|
|
72
71
|
isBackground,
|
package/src/service/service.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/session/prompts.ts
CHANGED
|
@@ -5,12 +5,6 @@
|
|
|
5
5
|
import type { EnvInfo } from "#src/session/env";
|
|
6
6
|
import type { AgentPromptConfig } from "#src/types";
|
|
7
7
|
|
|
8
|
-
/** Extra sections to inject into the system prompt (skills, etc.). */
|
|
9
|
-
export interface PromptExtras {
|
|
10
|
-
/** Preloaded skill contents to inject. */
|
|
11
|
-
skillBlocks?: { name: string; content: string }[];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
8
|
/**
|
|
15
9
|
* Build the system prompt for an agent from its config.
|
|
16
10
|
*
|
|
@@ -25,14 +19,12 @@ export interface PromptExtras {
|
|
|
25
19
|
* inherited content so the stable prefix is cacheable by the LLM's KV cache.
|
|
26
20
|
*
|
|
27
21
|
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
|
28
|
-
* @param extras Optional extra sections to inject (memory, preloaded skills).
|
|
29
22
|
*/
|
|
30
23
|
export function buildAgentPrompt(
|
|
31
24
|
config: AgentPromptConfig,
|
|
32
25
|
cwd: string,
|
|
33
26
|
env: EnvInfo,
|
|
34
27
|
parentSystemPrompt?: string,
|
|
35
|
-
extras?: PromptExtras,
|
|
36
28
|
): string {
|
|
37
29
|
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
|
|
38
30
|
|
|
@@ -41,18 +33,6 @@ Working directory: ${cwd}
|
|
|
41
33
|
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
|
|
42
34
|
Platform: ${env.platform}`;
|
|
43
35
|
|
|
44
|
-
// Build optional extras suffix
|
|
45
|
-
const extraSections: string[] = [];
|
|
46
|
-
if (extras?.skillBlocks?.length) {
|
|
47
|
-
for (const skill of extras.skillBlocks) {
|
|
48
|
-
extraSections.push(
|
|
49
|
-
`\n# Preloaded Skill: ${skill.name}\n${skill.content}`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
const extrasSuffix =
|
|
54
|
-
extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
|
|
55
|
-
|
|
56
36
|
if (config.promptMode === "append") {
|
|
57
37
|
const identity = parentSystemPrompt ?? genericBase;
|
|
58
38
|
|
|
@@ -85,8 +65,7 @@ You are operating as a sub-agent invoked to handle a specific task.
|
|
|
85
65
|
"\n\n" +
|
|
86
66
|
activeAgentTag +
|
|
87
67
|
envBlock +
|
|
88
|
-
customSection
|
|
89
|
-
extrasSuffix
|
|
68
|
+
customSection
|
|
90
69
|
);
|
|
91
70
|
}
|
|
92
71
|
|
|
@@ -97,7 +76,7 @@ You have been invoked to handle a specific task autonomously.
|
|
|
97
76
|
${envBlock}`;
|
|
98
77
|
|
|
99
78
|
return (
|
|
100
|
-
activeAgentTag + replaceHeader + "\n\n" + config.systemPrompt
|
|
79
|
+
activeAgentTag + replaceHeader + "\n\n" + config.systemPrompt
|
|
101
80
|
);
|
|
102
81
|
}
|
|
103
82
|
|