pi-optchat 0.6.5 → 0.6.6
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/README.md +2 -0
- package/package.json +1 -1
- package/src/agents.ts +119 -36
- package/src/index.ts +3 -3
package/README.md
CHANGED
|
@@ -68,9 +68,11 @@ Compression and subagents make extra model requests with your provider credentia
|
|
|
68
68
|
Ask in plain words, for example: "Spawn an agent to investigate this repository and report back."
|
|
69
69
|
|
|
70
70
|
- Children get the profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions.
|
|
71
|
+
- Children also get the Pi built-in extensions the main session loaded: MCP, codemode and tool search. Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in subagents, with the sign-ins you made in Pi. If MCP is off in the main session (`--no-mcp`, `-builtin:mcp` in settings, or an extension that replaces `/mcp`), it is off in subagents too. Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
|
|
71
72
|
- Each child reports back on its own when it finishes. The parent stays alive to receive reports; it never polls.
|
|
72
73
|
- In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages. (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
|
|
73
74
|
- The parent can send a running child guidance with `tell`, and the child can message its parent mid-run with `tell_parent` (a question, an early finding). It reaches the parent like a report, marked "still running": between tool calls if the parent is busy, or waking it if it's waiting.
|
|
75
|
+
- `tell` to a finished child resumes it: the same agent (ID, parent, model, directory) reopens its saved transcript, gets the message as a new prompt, and sends a new report. This also works for children from earlier Pi sessions. Only the agent that started the child can resume it, and the resumed child takes one of the 8 slots. Connected windows can't be resumed, and a child whose transcript is missing must be spawned fresh.
|
|
74
76
|
- Delegation goes up to three levels below the main agent (child, grandchild, great-grandchild).
|
|
75
77
|
- At most 8 agents can be active per profile, including parents waiting on descendants. Going over a limit returns an error; there is no queue.
|
|
76
78
|
- Stopping an agent stops its whole subtree. A failed parent stops its descendants.
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { join, dirname, resolve } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir, type AgentSession, type AgentSessionEvent, type ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir, type AgentSession, type AgentSessionEvent, type ExtensionAPI, type InlineExtension, type ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import * as sdk from '@earendil-works/pi-coding-agent';
|
|
6
7
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
7
9
|
import { SUBAGENT, VIEW_DOC } from './prompts.ts';
|
|
8
10
|
import { memoryTools } from './tools.ts';
|
|
9
11
|
import { type Memory } from './memory.ts';
|
|
@@ -22,6 +24,8 @@ export interface LiveRun {
|
|
|
22
24
|
pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>;
|
|
23
25
|
}
|
|
24
26
|
interface Options { parentSession?: string; usage?: UsageLedger; createSession?: typeof createAgentSession;
|
|
27
|
+
/** Names of the built-in extensions the main session loaded (see `loadedBuiltins`). */
|
|
28
|
+
builtins?: () => Iterable<string>;
|
|
25
29
|
summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[]) => Promise<string> }
|
|
26
30
|
|
|
27
31
|
// Subagents load the user's installed extensions, except any copy of OptChat itself: they get memory tools directly and must not open a profile.
|
|
@@ -40,12 +44,26 @@ export const taskDirectory = (cwd: string, path = '.', windows = process.platfor
|
|
|
40
44
|
resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
|
|
41
45
|
export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
|
|
42
46
|
const isOptchat = (path: string) => packageName(path) === 'pi-optchat';
|
|
47
|
+
|
|
48
|
+
// Pi's CLI adds its built-in extensions (MCP, codemode, tool search) to its own session; SDK sessions such as
|
|
49
|
+
// subagents must add them. Pi versions that do not export a factory simply do not get that extension.
|
|
50
|
+
const BUILTINS: Record<string, string> = { mcp: 'createMcpExtension', codemode: 'createCodemodeExtension', 'tool-search': 'createToolSearchExtension' };
|
|
51
|
+
const PREFIX = 'builtin:';
|
|
52
|
+
/** The built-in extensions a session loaded: `--no-mcp`, `-builtin:<name>` settings and replacing extensions leave them out. */
|
|
53
|
+
export const loadedBuiltins = (pi: Pick<ExtensionAPI, 'getCommands' | 'getAllTools'>) => new Set([...pi.getCommands(), ...pi.getAllTools()]
|
|
54
|
+
.map(item => item.sourceInfo?.path ?? '').filter(path => path.startsWith(PREFIX) && Object.hasOwn(BUILTINS, path.slice(PREFIX.length))).map(path => path.slice(PREFIX.length)));
|
|
55
|
+
/** Fresh built-in extensions for one session, as `builtin:<name>` resources, so the session's own settings still apply. */
|
|
56
|
+
export const builtinExtensions = (names: Iterable<string>): InlineExtension[] => [...new Set(names)].flatMap(name => {
|
|
57
|
+
const create = Object.hasOwn(BUILTINS, name) ? (sdk as unknown as Record<string, unknown>)[BUILTINS[name]] : undefined;
|
|
58
|
+
return typeof create === 'function' ? [{ name, factory: create(), replaceable: true, builtin: true }] : [];
|
|
59
|
+
});
|
|
43
60
|
export class Children {
|
|
44
61
|
private readonly running = new Map<string, LiveRun>();
|
|
45
62
|
readonly history: RunHistory;
|
|
46
63
|
private readonly listeners = new Set<() => void>();
|
|
47
64
|
private closing = false;
|
|
48
65
|
private launching = 0;
|
|
66
|
+
private readonly resuming = new Set<string>();
|
|
49
67
|
private settling = 0;
|
|
50
68
|
private readonly completions = new Set<Promise<void>>();
|
|
51
69
|
constructor(private readonly memory: Memory, private readonly registry: ModelRegistry,
|
|
@@ -122,32 +140,8 @@ export class Children {
|
|
|
122
140
|
signal?.throwIfAborted();
|
|
123
141
|
if (cancelled()) throw new Error('Parent or profile is stopping.');
|
|
124
142
|
const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
: 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
|
|
128
|
-
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
129
|
-
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
|
|
130
|
-
const loader = new DefaultResourceLoader({ cwd: directory, agentDir: getAgentDir(), settingsManager,
|
|
131
|
-
noPromptTemplates: true,
|
|
132
|
-
extensionsOverride: base => ({ ...base, extensions: base.extensions.filter(e => !isOptchat(e.resolvedPath)) }),
|
|
133
|
-
extensionFactories: [pi => {
|
|
134
|
-
const provider = this.registry.getRegisteredProviderConfig(selected.provider);
|
|
135
|
-
if (provider) pi.registerProvider(selected.provider, provider);
|
|
136
|
-
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
137
|
-
pi.on('before_agent_start', event => {
|
|
138
|
-
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}`;
|
|
139
|
-
event.systemPromptOptions.sections.instructions = instructions;
|
|
140
|
-
});
|
|
141
|
-
pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
|
|
142
|
-
}],
|
|
143
|
-
});
|
|
144
|
-
await loader.reload();
|
|
145
|
-
const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
|
|
146
|
-
model, thinkingLevel: selected.thinking, sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')),
|
|
147
|
-
customTools: [...memoryTools(() => this.memory), ...(depth < 3 ? this.delegationTools(id, directory) : []), this.parentTool(id, parentId, connected)],
|
|
148
|
-
excludeTools: depth < 3 ? [] : ['spawn', 'tell'],
|
|
149
|
-
});
|
|
150
|
-
await session.bindExtensions({});
|
|
143
|
+
const session = await this.open({ id, directory, depth, parentId, connected, provider: selected.provider, model, thinking: selected.thinking,
|
|
144
|
+
sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
|
|
151
145
|
const info: RunInfo = { id, task: task.task, cwd: directory, model: `${selected.provider}/${selected.model}`, thinking: session.thinkingLevel,
|
|
152
146
|
parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
|
|
153
147
|
const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
|
|
@@ -159,6 +153,7 @@ export class Children {
|
|
|
159
153
|
}
|
|
160
154
|
} catch (error) {
|
|
161
155
|
for (const child of launched) {
|
|
156
|
+
await this.shutdown(child.session); // Extensions such as MCP close their connections and stop their server processes.
|
|
162
157
|
this.dispose(child.session); this.running.delete(child.info.id);
|
|
163
158
|
child.info.state = 'failed'; child.info.ended = Date.now(); child.info.report = `Launch failed: ${String(error)}`;
|
|
164
159
|
if (child.info.connected) child.info.handoff = { reason: signal?.aborted ? 'disconnected' : 'failed' };
|
|
@@ -169,7 +164,7 @@ export class Children {
|
|
|
169
164
|
} finally { this.launching -= reserved; }
|
|
170
165
|
// Each child reports independently. A slow sibling must not hold back a finished result.
|
|
171
166
|
for (const live of launched) {
|
|
172
|
-
const work = this.execute(live, view).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
167
|
+
const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
173
168
|
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
174
169
|
this.completions.add(work);
|
|
175
170
|
live.completion = work;
|
|
@@ -177,15 +172,49 @@ export class Children {
|
|
|
177
172
|
this.changed();
|
|
178
173
|
return launched.map(c => c.info.id);
|
|
179
174
|
}
|
|
175
|
+
/** Builds a child session with the same prompt, tools and extensions whether it is new or resumed. */
|
|
176
|
+
private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; provider: string; model: Model<Api>;
|
|
177
|
+
thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
|
|
178
|
+
const { id, directory, depth, parentId, connected } = o;
|
|
179
|
+
const delegation = depth < 3 ? 'You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows 8 active agents total.' : 'You are at the maximum delegation depth. Complete your task with your own tools.';
|
|
180
|
+
const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
|
|
181
|
+
: 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
|
|
182
|
+
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
183
|
+
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
|
|
184
|
+
const loader = new DefaultResourceLoader({ cwd: directory, agentDir: getAgentDir(), settingsManager,
|
|
185
|
+
noPromptTemplates: true,
|
|
186
|
+
extensionsOverride: base => ({ ...base, extensions: base.extensions.filter(e => !isOptchat(e.resolvedPath)) }),
|
|
187
|
+
extensionFactories: [...builtinExtensions(this.options.builtins?.() ?? []), pi => {
|
|
188
|
+
const provider = this.registry.getRegisteredProviderConfig(o.provider);
|
|
189
|
+
if (provider) pi.registerProvider(o.provider, provider);
|
|
190
|
+
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
191
|
+
pi.on('before_agent_start', event => {
|
|
192
|
+
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}`;
|
|
193
|
+
event.systemPromptOptions.sections.instructions = instructions;
|
|
194
|
+
});
|
|
195
|
+
pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
|
|
196
|
+
}],
|
|
197
|
+
});
|
|
198
|
+
await loader.reload();
|
|
199
|
+
const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
|
|
200
|
+
model: o.model, thinkingLevel: o.thinking, sessionManager: o.sessionManager,
|
|
201
|
+
customTools: [...memoryTools(() => this.memory), ...(depth < 3 ? this.delegationTools(id, directory) : []), this.parentTool(id, parentId, connected)],
|
|
202
|
+
excludeTools: depth < 3 ? [] : ['spawn', 'tell'],
|
|
203
|
+
});
|
|
204
|
+
// Callers track the session only after this returns: clean up here if its extensions fail to start.
|
|
205
|
+
try { await session.bindExtensions({}); }
|
|
206
|
+
catch (error) { await this.shutdown(session); this.dispose(session); throw error; }
|
|
207
|
+
return session;
|
|
208
|
+
}
|
|
180
209
|
private delegationTools(parentId: string, cwd: string) {
|
|
181
210
|
return [{ name: 'spawn', label: 'Delegate task', description: 'Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. Maximum depth 3 and 8 active agents per profile.',
|
|
182
211
|
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
|
|
183
212
|
execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(`Started: ${(await this.spawn(args.tasks, cwd, signal, parentId)).join(', ')}. Results will arrive automatically.`),
|
|
184
|
-
}, { name: 'tell', label: 'Guide child', description: 'Send guidance to one of your direct children.',
|
|
213
|
+
}, { name: 'tell', label: 'Guide child', description: 'Send guidance to one of your direct children. A finished child is resumed with its earlier conversation, and its new report arrives automatically.',
|
|
185
214
|
parameters: Type.Object({ id: Type.String(), message: Type.String() }),
|
|
186
215
|
execute: async (_id: string, args: { id: string; message: string }) => {
|
|
187
216
|
if (this.history.records.get(args.id)?.parentId !== parentId) throw new Error('You can only guide your own children.');
|
|
188
|
-
return result(await this.tell(args.id, args.message));
|
|
217
|
+
return result(await this.tell(args.id, args.message, 'manager', parentId));
|
|
189
218
|
},
|
|
190
219
|
}];
|
|
191
220
|
}
|
|
@@ -207,16 +236,20 @@ export class Children {
|
|
|
207
236
|
},
|
|
208
237
|
};
|
|
209
238
|
}
|
|
239
|
+
private async shutdown(session: AgentSession) {
|
|
240
|
+
try { await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }); }
|
|
241
|
+
catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
|
|
242
|
+
}
|
|
210
243
|
private dispose(session: AgentSession) {
|
|
211
244
|
try { session.dispose(); } catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
|
|
212
245
|
}
|
|
213
246
|
private directChildren(id: string) { return [...this.running.values()].filter(c => c.info.parentId === id); }
|
|
214
|
-
private async execute(live: LiveRun,
|
|
247
|
+
private async execute(live: LiveRun, prompt: string) {
|
|
215
248
|
const { session, info } = live;
|
|
216
249
|
try {
|
|
217
250
|
if (info.connected) await this.report(`[${info.id}] User started a connected conversation in ${info.cwd}. That agent is handling this request with the user directly; don't do it yourself. Initial message: ${info.task}\n\nUse tell with this agent ID only if you know something it needs. It stays open between replies and sends a final handoff on completion or disconnect.`);
|
|
218
251
|
if (this.closing || info.handoff) throw new Error('Conversation stopped before its first request.');
|
|
219
|
-
await session.prompt(
|
|
252
|
+
await session.prompt(prompt);
|
|
220
253
|
while (info.state !== 'stopping') {
|
|
221
254
|
const last = session.messages.findLast(m => m.role === 'assistant');
|
|
222
255
|
if (last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
|
|
@@ -249,8 +282,7 @@ export class Children {
|
|
|
249
282
|
await Promise.allSettled(children.flatMap(child => child.completion ? [child.completion] : []));
|
|
250
283
|
info.ended = Date.now();
|
|
251
284
|
for (const g of info.guidance) if (g.state === 'queued') g.state = 'undelivered';
|
|
252
|
-
|
|
253
|
-
catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
|
|
285
|
+
await this.shutdown(session);
|
|
254
286
|
// A failed dispose must neither keep the slot taken nor drop the report below.
|
|
255
287
|
this.dispose(session); this.running.delete(info.id);
|
|
256
288
|
}
|
|
@@ -276,9 +308,12 @@ export class Children {
|
|
|
276
308
|
this.warn(`Subagent report saved but could not wake the parent: ${String(error)}`);
|
|
277
309
|
}
|
|
278
310
|
}
|
|
279
|
-
|
|
311
|
+
/** `caller` is the agent sending a manager message: undefined for the main agent, else the parent subagent's ID. */
|
|
312
|
+
async tell(id: string, message: string, source: 'manager' | 'user' = 'manager', caller?: string) {
|
|
280
313
|
const live = this.running.get(id);
|
|
281
|
-
if (!live
|
|
314
|
+
if (!live && source === 'manager' && this.history.records.has(id)) return this.resume(id, message, caller);
|
|
315
|
+
if (live && !['running', 'waiting'].includes(live.info.state)) throw new Error(`${id} is finishing. Its report will arrive on its own${live.info.connected || source === 'user' ? '' : '; tell it again after that to resume it'}.`);
|
|
316
|
+
if (!live) throw new Error(`No running subagent ${id}.`);
|
|
282
317
|
const text = live.info.connected && source === 'manager' ? `[Main agent guidance]\n${message.trim()}` : message.trim(); if (!message.trim()) throw new Error('Message is empty.');
|
|
283
318
|
if (source === 'user') this.memory.append('user', `Direct guidance to subagent [${id}]: ${text}`);
|
|
284
319
|
const guidance: RunInfo['guidance'][number] = { text, date: Date.now(), state: 'queued', from: source };
|
|
@@ -290,6 +325,54 @@ export class Children {
|
|
|
290
325
|
catch (error) { guidance.state = 'undelivered'; this.save(live.info); throw error; }
|
|
291
326
|
return 'Message queued for the next tool boundary.';
|
|
292
327
|
}
|
|
328
|
+
/** Reopens a finished child from its saved transcript, same ID, parent and model, and gives it a new message. */
|
|
329
|
+
private async resume(id: string, message: string, caller: string | undefined) {
|
|
330
|
+
const run = this.history.records.get(id)!, text = message.trim();
|
|
331
|
+
if (!text) throw new Error('Message is empty.');
|
|
332
|
+
if (run.connected) throw new Error(`${id} was a connected conversation with the user. It has ended and its handoff was delivered, so it cannot be resumed. Spawn a new subagent instead.`);
|
|
333
|
+
if (!['completed', 'failed', 'stopped', 'interrupted'].includes(run.state) || this.resuming.has(id)) throw new Error(`${id} is still finishing. Its report will arrive on its own.`);
|
|
334
|
+
if (run.parentId !== caller) {
|
|
335
|
+
if (!run.parentId) throw new Error(`Only the main agent can resume ${id}.`);
|
|
336
|
+
const parent = this.running.get(run.parentId);
|
|
337
|
+
throw new Error(parent && ['running', 'waiting'].includes(parent.info.state) ? `Only ${id}'s parent ${run.parentId} can resume it. Ask ${run.parentId} with tell.`
|
|
338
|
+
: `Only ${id}'s parent ${run.parentId} can resume it, and that parent is no longer running. Resume ${run.parentId} instead, or spawn a new subagent.`);
|
|
339
|
+
}
|
|
340
|
+
if (this.closing) throw new Error('Profile is closing.');
|
|
341
|
+
if (this.running.size + this.launching + 1 > 8) throw new Error(`Profile limit: at most 8 active agents, including parents and descendants. ${id} can be resumed when one finishes.`);
|
|
342
|
+
let manager: SessionManager | undefined;
|
|
343
|
+
try { if (run.sessionFile && existsSync(run.sessionFile)) manager = SessionManager.open(run.sessionFile); } catch { manager = undefined; }
|
|
344
|
+
if (!manager?.getEntries().some(e => e.type === 'message')) throw new Error(`The saved transcript of ${id} is missing or unreadable, so it cannot be resumed. Spawn a fresh subagent and give it the context it needs.`);
|
|
345
|
+
if (!existsSync(run.cwd) || !statSync(run.cwd).isDirectory()) throw new Error(`${id}'s directory ${run.cwd} no longer exists, so it cannot be resumed. Spawn a fresh subagent instead.`);
|
|
346
|
+
const slash = run.model.indexOf('/'), provider = run.model.slice(0, slash);
|
|
347
|
+
const model = slash > 0 ? this.registry.find(provider, run.model.slice(slash + 1)) : undefined;
|
|
348
|
+
if (!model) throw new Error(`${id}'s model ${run.model} is not available, so it cannot be resumed. Spawn a fresh subagent instead.`);
|
|
349
|
+
this.launching++; this.resuming.add(id);
|
|
350
|
+
let reserved = true;
|
|
351
|
+
try {
|
|
352
|
+
// No thinking level: the session restores the one it ran with.
|
|
353
|
+
const session = await this.open({ id, directory: run.cwd, depth: run.depth, parentId: run.parentId, connected: false, provider, model, sessionManager: manager });
|
|
354
|
+
// stop() cannot see this child until it is registered, so a parent stopped meanwhile must cancel it here.
|
|
355
|
+
const parent = run.parentId ? this.running.get(run.parentId) : undefined;
|
|
356
|
+
if (this.closing || run.parentId && (!parent || !['running', 'waiting'].includes(parent.info.state))) {
|
|
357
|
+
await this.shutdown(session); this.dispose(session); throw new Error('Parent or profile is stopping.');
|
|
358
|
+
}
|
|
359
|
+
// The finished record stays untouched (and resumable) unless the new one is saved.
|
|
360
|
+
const { ended: _ended, ...rest } = run;
|
|
361
|
+
const info: RunInfo = { ...rest, state: 'running', started: Date.now(), parentSession: this.options.parentSession ?? run.parentSession,
|
|
362
|
+
guidance: [...run.guidance, { text, date: Date.now(), state: 'queued', from: 'manager' }] };
|
|
363
|
+
try { this.save(info); } catch (error) { this.history.records.set(id, run); await this.shutdown(session); this.dispose(session); throw error; }
|
|
364
|
+
const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
|
|
365
|
+
this.running.set(id, live);
|
|
366
|
+
this.launching--; reserved = false;
|
|
367
|
+
session.subscribe(event => this.observe(live, event));
|
|
368
|
+
const work = this.execute(live, text).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
369
|
+
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
370
|
+
this.completions.add(work);
|
|
371
|
+
live.completion = work;
|
|
372
|
+
} finally { if (reserved) this.launching--; this.resuming.delete(id); }
|
|
373
|
+
this.changed();
|
|
374
|
+
return `${id} had finished, so I resumed it with its earlier conversation. Its new report will come back on its own.`;
|
|
375
|
+
}
|
|
293
376
|
async finish(id: string, reason: FinishReason) {
|
|
294
377
|
const live = this.running.get(id);
|
|
295
378
|
if (live?.info.connected) {
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { cachePayload, record } from './cache.ts';
|
|
|
14
14
|
import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
|
|
15
15
|
import { registerReportRenderer } from './report-message.ts';
|
|
16
16
|
import { memoryTools, result } from './tools.ts';
|
|
17
|
-
import { Children, CWD_DOC } from './agents.ts';
|
|
17
|
+
import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
|
|
18
18
|
import { exportBrowser } from './browser.ts';
|
|
19
19
|
import { Inbox } from './inbox.ts';
|
|
20
20
|
import { checkpoint } from './checkpoint.ts';
|
|
@@ -147,7 +147,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
147
147
|
const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
|
|
148
148
|
if (recovered) ctx.ui.notify(`Recovered ${recovered} unanswered inputs into ${name}'s memory. Ask to continue them when ready.`, 'info');
|
|
149
149
|
const children = new Children(memory, ctx.modelRegistry, () => config.subagent, () => `${instructions(dir)}\n\n${IMPORT_GUIDANCE}`,
|
|
150
|
-
deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage,
|
|
150
|
+
deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage, builtins: () => loadedBuiltins(pi),
|
|
151
151
|
summarizeHandoff: createHandoffSummarizer(ctx.modelRegistry, () => config.compactor, message => usage.compression(message, 'compactor', sessionId)) });
|
|
152
152
|
const loggedReports = new Set(memory.root.map(e => e.receipt));
|
|
153
153
|
reports = saved.filter((s): s is string => typeof s === 'string' && !loggedReports.has(reportReceipt(s)));
|
|
@@ -321,7 +321,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
321
321
|
return result(`Started: ${ids.join(', ')}. Reports will arrive automatically.`);
|
|
322
322
|
},
|
|
323
323
|
});
|
|
324
|
-
pi.registerTool({ name: 'tell', label: 'Tell background agent', description: 'Send a message to a running
|
|
324
|
+
pi.registerTool({ name: 'tell', label: 'Tell background agent', description: 'Send a message to a subagent. A running one gets it at its next tool boundary. A finished one you started is resumed with its earlier conversation, and its new report arrives automatically.',
|
|
325
325
|
parameters: Type.Object({ id: Type.String(), message: Type.String() }),
|
|
326
326
|
async execute(_id, args) { return result(await required().children.tell(args.id, args.message)); },
|
|
327
327
|
});
|