@xingular/harness 0.1.7 → 2.0.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 +14 -0
- package/README.md +29 -10
- package/dist/cli.js +5179 -2976
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +100 -10
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/index.ts +7 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +6 -0
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +46 -7
- package/src/sessions/sessions.ts +31 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +184 -8
- package/src/terminal/tui/application.ts +243 -81
- package/src/terminal/tui/commands.test.ts +69 -9
- package/src/terminal/tui/commands.ts +89 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +49 -19
- package/src/terminal/tui/keybindings.test.ts +8 -0
- package/src/terminal/tui/keybindings.ts +10 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +2 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
package/src/sessions/sessions.ts
CHANGED
|
@@ -21,11 +21,13 @@ import { FIRST_PARTY_PLUGINS, firstPartyExtensions, type InlineExtension } from
|
|
|
21
21
|
import * as registry from '../skills/registry'
|
|
22
22
|
import { ASK_USER, askUserTool } from '../tools/askUser'
|
|
23
23
|
import { PLUGIN_PROPOSE, pluginProposeTool } from '../tools/pluginPropose'
|
|
24
|
-
import {
|
|
24
|
+
import { subagentTools } from '../tools/spawnSubagent'
|
|
25
25
|
import { WIKI_TOOL_NAMES } from '../tools/definitions'
|
|
26
26
|
import { memoryTools } from '../tools/memoryTools'
|
|
27
27
|
import type { ToolProvider, ToolSession, ToolSpec } from '../tools/types'
|
|
28
28
|
import type { RunRecord } from './contracts'
|
|
29
|
+
import { SPAWN_SUBAGENT, WAIT_SUBAGENTS, subagentDepth, subagentHistory, subagentNesting, type SubagentInput, type SubagentOptions } from './subagents'
|
|
30
|
+
import type { Subagents } from './subagentService'
|
|
29
31
|
import { selectHistory } from './history'
|
|
30
32
|
import { runLineageJoin } from './lineageJoin'
|
|
31
33
|
import type { ToolPolicy } from './policy'
|
|
@@ -67,7 +69,7 @@ export interface SessionRunInput {
|
|
|
67
69
|
capabilities?: CapabilitySet
|
|
68
70
|
/** Hops from the user to this session (0 when the user started it). */
|
|
69
71
|
depth?: number
|
|
70
|
-
/**
|
|
72
|
+
/** Interactive default: native child tools. Null disables; unattended callers must opt in. */
|
|
71
73
|
subagents?: SubagentOptions | null
|
|
72
74
|
/** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
|
|
73
75
|
providers?: boolean
|
|
@@ -80,6 +82,7 @@ export interface SessionBroker {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
export interface SessionsOptions {
|
|
85
|
+
subagents: Subagents
|
|
83
86
|
env: DispatchEnv
|
|
84
87
|
accounts: Accounts
|
|
85
88
|
runs: RunRegistry
|
|
@@ -105,6 +108,7 @@ export class Sessions {
|
|
|
105
108
|
|
|
106
109
|
constructor(private readonly options: SessionsOptions) {
|
|
107
110
|
this.factory = options.factory ?? piSessionFactory
|
|
111
|
+
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
private get env(): HarnessEnv {
|
|
@@ -151,33 +155,9 @@ export class Sessions {
|
|
|
151
155
|
}
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return spawnSubagentTool({
|
|
158
|
-
root: this.env.root,
|
|
159
|
-
parentRunId: runId,
|
|
160
|
-
parentSessionId: input.run.sessionId,
|
|
161
|
-
parentTools,
|
|
162
|
-
sessionDir: input.sessionDir,
|
|
163
|
-
start: (child) => this.run({
|
|
164
|
-
...input,
|
|
165
|
-
run: { ...input.run, id: child.runId, sessionId: child.sessionId, parentRunId: runId, relation: SUBAGENT_RELATION, goal: takeChars(child.brief, 4000), resumedFromRunId: null, handoffId: null },
|
|
166
|
-
accountId,
|
|
167
|
-
poolId: null,
|
|
168
|
-
sessionDir: child.sessionDir,
|
|
169
|
-
resume: null,
|
|
170
|
-
text: child.brief,
|
|
171
|
-
images: undefined,
|
|
172
|
-
requiresImageInput: false,
|
|
173
|
-
preamble: null,
|
|
174
|
-
caller: { ...input.caller, runId: undefined, sessionId: child.sessionId },
|
|
175
|
-
tools: { allow: [...child.tools, SPAWN_SUBAGENT], gated: (input.tools.gated ?? []).filter((name) => child.tools.includes(name)) },
|
|
176
|
-
// Never wider than the parent's frozen set; the session intersects it with what is available now.
|
|
177
|
-
capabilities: { ...parent, tools: [...parent.tools.filter((name) => child.tools.includes(name)), SPAWN_SUBAGENT] },
|
|
178
|
-
depth: (input.depth ?? 0) + 1
|
|
179
|
-
})
|
|
180
|
-
})
|
|
158
|
+
/** Compatibility alias for the shared native service. */
|
|
159
|
+
spawnSubagent(request: SubagentInput): Promise<{ runId: string; nativeSessionId: string | null }> {
|
|
160
|
+
return this.options.subagents.spawn(request)
|
|
181
161
|
}
|
|
182
162
|
|
|
183
163
|
/**
|
|
@@ -187,6 +167,7 @@ export class Sessions {
|
|
|
187
167
|
async run(input: SessionRunInput): Promise<{ runId: string; nativeSessionId: string | null }> {
|
|
188
168
|
const { root } = this.env
|
|
189
169
|
if (!input?.run?.id || !input.run.sessionId || typeof input.text !== 'string') throw invalid('A session turn needs a run id, a session, and text.')
|
|
170
|
+
input = structuredClone(input)
|
|
190
171
|
const historyFile = selectHistory({ sessionDir: input.sessionDir, resume: input.resume })
|
|
191
172
|
const account = this.options.accounts.resolve(input.accountId, input.poolId)
|
|
192
173
|
const context = this.options.accounts.context(account)
|
|
@@ -194,14 +175,14 @@ export class Sessions {
|
|
|
194
175
|
const caller: CallerContext = { ...input.caller, maintenanceJobIds: input.caller.maintenanceJobIds?.slice(), runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
|
|
195
176
|
const broker = input.connections ? this.options.broker : null
|
|
196
177
|
const depth = input.depth ?? 0
|
|
197
|
-
const canSpawn =
|
|
178
|
+
const canSpawn = input.subagents !== null && (!input.automatic || input.subagents !== undefined) && subagentNesting(root, input.run) < subagentDepth(input.subagents ?? undefined)
|
|
198
179
|
const requestedTools = [...new Set([
|
|
199
|
-
...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT),
|
|
180
|
+
...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT && name !== WAIT_SUBAGENTS),
|
|
200
181
|
ASK_USER,
|
|
201
182
|
...(input.automatic ? [] : [PLUGIN_PROPOSE]),
|
|
202
183
|
...WIKI_TOOL_NAMES,
|
|
203
184
|
...(broker?.toolNames ?? []),
|
|
204
|
-
...(canSpawn ? [SPAWN_SUBAGENT] : [])
|
|
185
|
+
...(canSpawn ? [SPAWN_SUBAGENT, WAIT_SUBAGENTS] : [])
|
|
205
186
|
])]
|
|
206
187
|
const available = this.options.resolveCapabilities
|
|
207
188
|
? await this.options.resolveCapabilities(caller, requestedTools, input.cwd)
|
|
@@ -278,18 +259,19 @@ export class Sessions {
|
|
|
278
259
|
const session: ToolSession = { run, caller, allow, depth, cwd: input.cwd, capabilities }
|
|
279
260
|
const { plugins } = this.options
|
|
280
261
|
|
|
281
|
-
let pi: PiSessionLike
|
|
262
|
+
let pi: PiSessionLike | undefined
|
|
282
263
|
try {
|
|
283
264
|
const tools: ToolSpec[] = [
|
|
284
265
|
...(broker ? broker.tools(session, { readOnly: typeof input.connections === 'object' && input.connections.readOnly === true }) : []),
|
|
285
266
|
...memoryTools(this.options.env, caller, capabilities.contributions),
|
|
286
267
|
askUserTool(run),
|
|
287
268
|
pluginProposeTool(this.options.env, caller),
|
|
288
|
-
...(
|
|
269
|
+
...subagentTools(this.options.subagents, runId).filter(tool => allow.includes(tool.name))
|
|
289
270
|
]
|
|
290
271
|
if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
|
|
291
272
|
const params: TurnParams = {
|
|
292
273
|
runId,
|
|
274
|
+
restrictTools: input.run.relation === 'subagent',
|
|
293
275
|
cwd: input.cwd,
|
|
294
276
|
sessionDir: input.sessionDir,
|
|
295
277
|
historyFile,
|
|
@@ -302,7 +284,7 @@ export class Sessions {
|
|
|
302
284
|
automatic: input.automatic,
|
|
303
285
|
account: context,
|
|
304
286
|
resources: registry.resources(root, caller.projectId ?? null, caller.agentId ?? null, capabilities.contributions),
|
|
305
|
-
inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId)],
|
|
287
|
+
inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId), this.options.subagents.extension(runId)],
|
|
306
288
|
...(plugins ? { plugins: { loading: () => plugins.loading({ cwd: input.cwd, agentDir: context.configDir, install: true, caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null }, contributions: capabilities.contributions }), loaded: (extensions) => plugins.loaded(extensions) } } : {}),
|
|
307
289
|
system,
|
|
308
290
|
update
|
|
@@ -310,25 +292,35 @@ export class Sessions {
|
|
|
310
292
|
pi = await this.factory({
|
|
311
293
|
params,
|
|
312
294
|
allow,
|
|
313
|
-
gate:
|
|
295
|
+
gate: (() => {
|
|
296
|
+
const gate = this.gate(run, caller, new Set((input.tools.gated ?? []).filter((name) => capabilities.tools.includes(name))))
|
|
297
|
+
return { ...gate, check: (name: string, args: unknown) => input.run.relation === 'subagent' && !capabilities.tools.includes(name) ? Promise.resolve('This tool is outside the child capability snapshot.') : gate.check(name, args) }
|
|
298
|
+
})(),
|
|
314
299
|
tools,
|
|
315
300
|
runtime: () => this.options.accounts.runtime(context),
|
|
316
301
|
// An unattended run loads no extension, so it has nobody to relay for.
|
|
317
302
|
...(input.automatic ? {} : { extensions: { ask: (question) => run.requestAnswer(question), notice: (notice) => run.emit('extension.notice', notice) } })
|
|
318
303
|
})
|
|
319
|
-
if (
|
|
304
|
+
if (run.finished) {
|
|
320
305
|
pi.dispose()
|
|
306
|
+
return { runId, nativeSessionId: null }
|
|
307
|
+
}
|
|
308
|
+
if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
|
|
321
309
|
throw new HarnessError('image_input_unsupported', 'Choose a model that supports images, or remove the image attachments and send again.')
|
|
322
310
|
}
|
|
323
311
|
if (input.resume?.nativeSessionId && pi.sessionId !== input.resume.nativeSessionId) {
|
|
324
|
-
pi.dispose()
|
|
325
312
|
run.finish('failed', { code: 'native_resume_unavailable', message: 'The saved conversation could not be restored.' })
|
|
326
313
|
throw new HarnessError('native_resume_unavailable', 'The saved conversation could not be restored.')
|
|
327
314
|
}
|
|
328
|
-
|
|
315
|
+
const opened = pi
|
|
316
|
+
updateRun(root, runId, (record) => void (record.nativeSessionId = opened.sessionId))
|
|
317
|
+
this.options.subagents.register(input, account.id, capabilities, Boolean(pi.sessionManager))
|
|
318
|
+
if (pi.model?.id && pi.model.provider) this.options.subagents.resolvedModel(runId, `${pi.model.provider}/${pi.model.id}`, pi.thinkingLevel)
|
|
319
|
+
params.settle = () => this.options.subagents.verify(runId, opened.sessionManager ? subagentHistory(opened.sessionManager.getBranch()) : undefined)
|
|
329
320
|
void runTurn(run, pi, params)
|
|
330
321
|
return { runId, nativeSessionId: pi.sessionId }
|
|
331
322
|
} catch (error) {
|
|
323
|
+
pi?.dispose()
|
|
332
324
|
if (error instanceof HarnessError && error.code === 'native_resume_unavailable') throw error
|
|
333
325
|
const message = error instanceof Error ? error.message : String(error)
|
|
334
326
|
log('session failed to start', message)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
7
|
+
import { createHarness, type Harness } from '../harness'
|
|
8
|
+
import type { SessionRunInput } from './sessions'
|
|
9
|
+
import { SUBAGENT_OWNER, SUBAGENT_RECEIPT, subagentHistory } from './subagents'
|
|
10
|
+
import { loadRun, recordSubagent, saveRun } from './runStore'
|
|
11
|
+
|
|
12
|
+
const open: Harness[] = []
|
|
13
|
+
afterEach(async () => { for (const harness of open.splice(0)) await harness.close() })
|
|
14
|
+
|
|
15
|
+
function setup() {
|
|
16
|
+
const finish = new Map<string, (text: string) => void>()
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'native-subagents-'))
|
|
18
|
+
const harness = createHarness({ dataRoot: join(root, 'data'), stateDir: join(root, 'state'), secrets: memorySecretStore(), sessionFactory: async ({ params }) => {
|
|
19
|
+
let emit: (event: any) => void = () => {}
|
|
20
|
+
let done: () => void = () => {}
|
|
21
|
+
return { sessionId: 'native-history', subscribe: (listener) => { emit = listener; return () => {} }, sendCustomMessage: async () => {},
|
|
22
|
+
prompt: () => new Promise<void>((resolve) => { done = resolve; finish.set(params.runId, (text) => {
|
|
23
|
+
emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text }] } }); resolve()
|
|
24
|
+
}) }), abort: async () => done(), dispose: () => {} }
|
|
25
|
+
} })
|
|
26
|
+
open.push(harness)
|
|
27
|
+
harness.accounts.create('Main', 'apiKey')
|
|
28
|
+
const start = (id: string, extra: Partial<SessionRunInput> = {}) => harness.sessions.run({
|
|
29
|
+
run: { id, sessionId: 'opaque-parent' }, caller: { agentId: 'caller', actor: 'Chat' }, cwd: root,
|
|
30
|
+
sessionDir: join(root, 'history'), text: 'Parent', tools: { allow: ['read'] }, ...extra
|
|
31
|
+
})
|
|
32
|
+
return { harness, finish, start }
|
|
33
|
+
}
|
|
34
|
+
const empty = () => ({ owners: [], receipts: [] })
|
|
35
|
+
|
|
36
|
+
describe('native subagent ownership and delivery', () => {
|
|
37
|
+
it('spawns asynchronously, joins selected children, and rejects foreign handles', async () => {
|
|
38
|
+
const { harness, finish, start } = setup()
|
|
39
|
+
await start('parent')
|
|
40
|
+
const a = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'First', tools: ['read'] })
|
|
41
|
+
const b = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'Second', tools: [] })
|
|
42
|
+
expect(harness.subagents.results({ parentRunId: 'parent' }).map(result => result.status)).toEqual(['running', 'running'])
|
|
43
|
+
await expect(harness.subagents.wait({ parentRunId: 'parent', runIds: ['foreign'] })).rejects.toMatchObject({ code: 'invalid_request' })
|
|
44
|
+
let joined = false
|
|
45
|
+
const pending = harness.subagents.wait({ parentRunId: 'parent', runIds: [b.runId] }).then(results => { joined = true; return results })
|
|
46
|
+
finish.get(a.runId)!('First result')
|
|
47
|
+
await harness.runs.wait(a.runId)
|
|
48
|
+
expect(joined).toBe(false)
|
|
49
|
+
const canceled = new AbortController()
|
|
50
|
+
canceled.abort()
|
|
51
|
+
await expect(harness.subagents.wait({ parentRunId: 'parent', runIds: [b.runId] }, canceled.signal)).rejects.toMatchObject({ code: 'run_not_active' })
|
|
52
|
+
finish.get(b.runId)!('Second result')
|
|
53
|
+
expect(await pending).toMatchObject([{ runId: b.runId, status: 'completed', text: 'Second result' }])
|
|
54
|
+
expect(loadRun(harness.env.root, a.runId).sessionId).not.toContain(':subagent:')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('delivers already-settled results, retries uncommitted drafts, and trusts only typed receipts', async () => {
|
|
58
|
+
const { harness, finish, start } = setup()
|
|
59
|
+
await start('parent')
|
|
60
|
+
const child = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'Find evidence' })
|
|
61
|
+
finish.get(child.runId)!('Evidence')
|
|
62
|
+
await harness.runs.wait(child.runId)
|
|
63
|
+
const mentioned = subagentHistory([{ content: `I mentioned ${child.runId}` }])
|
|
64
|
+
const first = await harness.subagents.delivery('parent', mentioned, true)
|
|
65
|
+
expect(first?.text).toContain('Evidence')
|
|
66
|
+
expect(await harness.subagents.delivery('parent', empty(), true)).toEqual(first)
|
|
67
|
+
const committed = subagentHistory([{ type: 'custom_message', details: { [SUBAGENT_RECEIPT]: first!.receipt } }])
|
|
68
|
+
expect(await harness.subagents.delivery('parent', committed, true)).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('waits before final delivery and releases pending joins when the parent stops', async () => {
|
|
72
|
+
const { harness, start } = setup()
|
|
73
|
+
await start('parent')
|
|
74
|
+
const child = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'Wait' })
|
|
75
|
+
const pending = harness.subagents.delivery('parent', empty(), true)
|
|
76
|
+
const interrupted = expect(pending).rejects.toMatchObject({ code: 'run_not_active' })
|
|
77
|
+
await harness.runs.interrupt('parent')
|
|
78
|
+
await interrupted
|
|
79
|
+
expect(loadRun(harness.env.root, child.runId).status).toBe('interrupted')
|
|
80
|
+
expect(harness.subagents.results({ parentRunId: 'parent' })).toMatchObject([{ runId: child.runId, status: 'interrupted' }])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('recovers a child whose spawn return was lost, only from its owning history and account', async () => {
|
|
84
|
+
const { harness, finish, start } = setup()
|
|
85
|
+
await start('original')
|
|
86
|
+
const child = await harness.subagents.spawn({ parentRunId: 'original', brief: 'Recover' })
|
|
87
|
+
finish.get(child.runId)!('Retained result')
|
|
88
|
+
await harness.runs.wait(child.runId)
|
|
89
|
+
recordSubagent(harness.env.root, 'original', 'reserved-before-crash')
|
|
90
|
+
await harness.runs.interrupt('original')
|
|
91
|
+
await start('resumed')
|
|
92
|
+
expect(await harness.subagents.delivery('resumed', empty(), true)).toBeNull()
|
|
93
|
+
const branch = subagentHistory([{ type: 'custom', customType: SUBAGENT_OWNER, data: { runId: 'original' } }])
|
|
94
|
+
const recovered = await harness.subagents.delivery('resumed', branch, true)
|
|
95
|
+
expect(recovered?.text).toContain('Retained result')
|
|
96
|
+
expect(recovered?.receipt.delivered).toEqual([child.runId, 'reserved-before-crash'])
|
|
97
|
+
expect(loadRun(harness.env.root, 'reserved-before-crash')).toMatchObject({ status: 'interrupted', relation: 'subagent', parentRunId: 'original' })
|
|
98
|
+
await harness.runs.interrupt('resumed')
|
|
99
|
+
const other = harness.accounts.create('Other', 'apiKey').accounts.find(account => account.label === 'Other')!
|
|
100
|
+
await start('wrong-account', { accountId: other.id })
|
|
101
|
+
expect(await harness.subagents.delivery('wrong-account', branch, true)).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('reconstructs disk ownership after reopening without automatically restarting model work', async () => {
|
|
105
|
+
const { harness, finish, start } = setup()
|
|
106
|
+
await start('original')
|
|
107
|
+
const child = await harness.subagents.spawn({ parentRunId: 'original', brief: 'Persisted work' })
|
|
108
|
+
finish.get(child.runId)!('On-disk result')
|
|
109
|
+
await harness.runs.wait(child.runId)
|
|
110
|
+
recordSubagent(harness.env.root, 'original', 'orphan-after-crash')
|
|
111
|
+
const original = loadRun(harness.env.root, 'original')
|
|
112
|
+
saveRun(harness.env.root, { ...original, id: 'orphan-after-crash', sessionId: 'orphan-session', parentRunId: 'original', relation: 'subagent', status: 'running' })
|
|
113
|
+
const dataRoot = harness.env.root
|
|
114
|
+
const stateDir = harness.stateDir
|
|
115
|
+
await harness.close()
|
|
116
|
+
open.splice(open.indexOf(harness), 1)
|
|
117
|
+
let opened = 0
|
|
118
|
+
const restarted = createHarness({ dataRoot, stateDir, secrets: memorySecretStore(), sessionFactory: async () => {
|
|
119
|
+
opened++
|
|
120
|
+
return { sessionId: 'native-history', subscribe: () => () => {}, prompt: async () => new Promise<void>(() => {}), sendCustomMessage: async () => {}, abort: async () => {}, dispose: () => {} }
|
|
121
|
+
} })
|
|
122
|
+
open.push(restarted)
|
|
123
|
+
expect(opened).toBe(0)
|
|
124
|
+
expect(loadRun(dataRoot, 'orphan-after-crash').status).toBe('interrupted')
|
|
125
|
+
await restarted.sessions.run({ run: { id: 'explicit-resume', sessionId: original.sessionId, resumedFromRunId: original.id }, caller: { agentId: 'caller', actor: 'Chat' }, cwd: original.workingDirectory!, sessionDir: join(original.workingDirectory!, 'history'), text: 'Resume', tools: { allow: ['read'] } })
|
|
126
|
+
const delivery = await restarted.subagents.delivery('explicit-resume', { owners: ['original'], receipts: [] }, true)
|
|
127
|
+
expect(delivery?.text).toContain('On-disk result')
|
|
128
|
+
expect(delivery?.receipt.delivered).toEqual([child.runId, 'orphan-after-crash'])
|
|
129
|
+
expect(opened).toBe(1)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('enables interactive callers at a host depth without extending native nesting or unattended access', async () => {
|
|
133
|
+
const { harness, start } = setup()
|
|
134
|
+
await start('host-depth', { depth: 3 })
|
|
135
|
+
const child = await harness.subagents.spawn({ parentRunId: 'host-depth', brief: 'One native level' })
|
|
136
|
+
await expect(harness.subagents.spawn({ parentRunId: child.runId, brief: 'Too deep' })).rejects.toMatchObject({ code: 'invalid_request' })
|
|
137
|
+
await harness.runs.interrupt('host-depth')
|
|
138
|
+
await start('unattended', { automatic: true })
|
|
139
|
+
await expect(harness.subagents.spawn({ parentRunId: 'unattended', brief: 'Not enabled' })).rejects.toMatchObject({ code: 'invalid_request' })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does not acknowledge results omitted from a bounded delivery batch', async () => {
|
|
143
|
+
const { harness, finish, start } = setup()
|
|
144
|
+
await start('parent')
|
|
145
|
+
const children = await Promise.all([1, 2, 3].map(n => harness.subagents.spawn({ parentRunId: 'parent', brief: String(n) })))
|
|
146
|
+
for (const child of children) finish.get(child.runId)!('x'.repeat(16_000))
|
|
147
|
+
await harness.subagents.wait({ parentRunId: 'parent' })
|
|
148
|
+
const receipts: any[] = []
|
|
149
|
+
const delivered: string[] = []
|
|
150
|
+
for (let i = 0; i < 3; i++) {
|
|
151
|
+
const batch = await harness.subagents.delivery('parent', { owners: [], receipts }, true)
|
|
152
|
+
expect(batch).not.toBeNull()
|
|
153
|
+
delivered.push(...batch!.receipt.delivered!)
|
|
154
|
+
receipts.push(batch!.receipt)
|
|
155
|
+
}
|
|
156
|
+
expect(delivered).toEqual(children.map(child => child.runId))
|
|
157
|
+
expect(await harness.subagents.delivery('parent', { owners: [], receipts }, true)).toBeNull()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
4
|
+
|
|
5
|
+
import { loadRun as loadCapabilities } from '../capabilities/runSnapshot'
|
|
6
|
+
import type { CapabilitySet } from '../capabilities/types'
|
|
7
|
+
import type { HarnessEnv } from '../core/env'
|
|
8
|
+
import { HarnessError, invalid } from '../core/errors'
|
|
9
|
+
import { now } from '../core/fs'
|
|
10
|
+
import { takeChars } from '../core/text'
|
|
11
|
+
import type { InlineExtension } from '../plugins/firstParty'
|
|
12
|
+
import type { RunRegistry } from './registry'
|
|
13
|
+
import type { Effort } from './runner'
|
|
14
|
+
import type { SessionRunInput } from './sessions'
|
|
15
|
+
import { isActiveStatus } from './contracts'
|
|
16
|
+
import { findRun, loadRun, readEvents, recordedSubagents, recordSubagent, saveRun } from './runStore'
|
|
17
|
+
import { SPAWN_SUBAGENT, SUBAGENT_RELATION, SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory, type SubagentInput, type SubagentReceipt, type SubagentResult, type SubagentSelection } from './subagents'
|
|
18
|
+
|
|
19
|
+
type Started = { runId: string; nativeSessionId: string | null }
|
|
20
|
+
interface Parent {
|
|
21
|
+
input: SessionRunInput
|
|
22
|
+
accountId: string
|
|
23
|
+
capabilities: CapabilitySet
|
|
24
|
+
stopped: AbortController
|
|
25
|
+
recovered: Set<string>
|
|
26
|
+
launching: Map<string, Promise<Started>>
|
|
27
|
+
ready: Promise<void>
|
|
28
|
+
started: () => void
|
|
29
|
+
}
|
|
30
|
+
export interface SubagentHistory {
|
|
31
|
+
owners: string[]
|
|
32
|
+
receipts: SubagentReceipt[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function until<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
36
|
+
if (signal.aborted) {
|
|
37
|
+
void promise.catch(() => {})
|
|
38
|
+
return Promise.reject(new HarnessError('run_not_active', 'The parent run stopped.'))
|
|
39
|
+
}
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const abort = () => reject(new HarnessError('run_not_active', 'The parent run stopped.'))
|
|
42
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
43
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One owner of native child execution and delivery, shared by SDK and terminal adapters. */
|
|
48
|
+
export class Subagents {
|
|
49
|
+
private readonly parents = new Map<string, Parent>()
|
|
50
|
+
|
|
51
|
+
constructor(private readonly env: HarnessEnv, private readonly runs: RunRegistry, private readonly launch: (input: SessionRunInput) => Promise<Started>) {
|
|
52
|
+
runs.listen((event) => {
|
|
53
|
+
if (event.type !== 'completed' && event.type !== 'failed' && !(event.type === 'status.changed' && (event.payload as { status?: string }).status === 'interrupted')) return
|
|
54
|
+
this.parents.get(event.runId)?.stopped.abort()
|
|
55
|
+
this.parents.delete(event.runId)
|
|
56
|
+
if (event.type !== 'completed') for (const id of runs.activeRunIds()) {
|
|
57
|
+
const child = findRun(this.env.root, id)
|
|
58
|
+
if (child?.relation === SUBAGENT_RELATION && child.parentRunId === event.runId) void runs.interrupt(id).catch(() => {})
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Internal adapter contract: context comes from a live run, never from model arguments. */
|
|
64
|
+
register(input: SessionRunInput, accountId: string, capabilities: CapabilitySet, deferUntilPrompt = false): void {
|
|
65
|
+
if (!this.runs.active(input.run.id)) throw new HarnessError('run_not_active', 'The parent run is not active.')
|
|
66
|
+
if (this.parents.has(input.run.id)) return
|
|
67
|
+
const frozen = loadCapabilities(this.env.root, input.run.id)
|
|
68
|
+
if (!frozen) throw invalid('A native parent requires a frozen capability snapshot.')
|
|
69
|
+
const ceiling: CapabilitySet = {
|
|
70
|
+
version: 1,
|
|
71
|
+
tools: capabilities.tools.filter(name => frozen.tools.includes(name)),
|
|
72
|
+
connectors: capabilities.connectors.filter(id => frozen.connectors.includes(id)),
|
|
73
|
+
contributions: capabilities.contributions.filter(id => frozen.contributions.includes(id))
|
|
74
|
+
}
|
|
75
|
+
let started!: () => void
|
|
76
|
+
const ready = new Promise<void>(resolve => { started = resolve })
|
|
77
|
+
if (!deferUntilPrompt) started()
|
|
78
|
+
this.parents.set(input.run.id, { ready, started, input: structuredClone({ ...input, text: '', images: undefined, requiresImageInput: false, preamble: null, resume: null }), accountId, capabilities: ceiling, stopped: new AbortController(), recovered: new Set(), launching: new Map() })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
resolvedModel(runId: string, model: string, effort?: Effort): void {
|
|
82
|
+
const parent = this.parents.get(runId)
|
|
83
|
+
if (parent) {
|
|
84
|
+
parent.input.model = model
|
|
85
|
+
parent.input.effort = effort ?? parent.input.effort
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private parent(runId: string): Parent {
|
|
90
|
+
const parent = this.parents.get(runId)
|
|
91
|
+
if (!parent || !this.runs.active(runId)) throw new HarnessError('run_not_active', 'The parent run is not accepting subagents.')
|
|
92
|
+
return parent
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async spawn(request: SubagentInput): Promise<Started> {
|
|
96
|
+
const parent = this.parent(request.parentRunId)
|
|
97
|
+
const { input, accountId, capabilities } = parent
|
|
98
|
+
if (!capabilities.tools.includes(SPAWN_SUBAGENT)) throw invalid('Subagents are not enabled for this run.')
|
|
99
|
+
const brief = typeof request.brief === 'string' ? request.brief.trim() : ''
|
|
100
|
+
if (!brief) throw invalid('Give the subagent a brief.')
|
|
101
|
+
if (request.wait !== undefined && typeof request.wait !== 'boolean') throw invalid('wait must be a boolean.')
|
|
102
|
+
if (request.tools !== undefined && (!Array.isArray(request.tools) || request.tools.some((name) => typeof name !== 'string'))) throw invalid('Tools must be an array of tool names.')
|
|
103
|
+
const outside = request.tools?.filter((name) => !capabilities.tools.includes(name)) ?? []
|
|
104
|
+
if (outside.length) throw invalid(`A subagent cannot have tools you do not have: ${outside.join(', ')}.`)
|
|
105
|
+
const tools = capabilities.tools.filter((name) => !request.tools || request.tools.includes(name))
|
|
106
|
+
await until(parent.ready, parent.stopped.signal)
|
|
107
|
+
const runId = randomUUID()
|
|
108
|
+
const sessionId = randomUUID()
|
|
109
|
+
recordSubagent(this.env.root, request.parentRunId, runId)
|
|
110
|
+
const child: SessionRunInput = {
|
|
111
|
+
...input,
|
|
112
|
+
run: { ...input.run, id: runId, sessionId, parentRunId: request.parentRunId, relation: SUBAGENT_RELATION, goal: takeChars(brief, 4000), attachmentCount: 0, resumedFromRunId: null, handoffId: null },
|
|
113
|
+
accountId, poolId: null, sessionDir: join(input.sessionDir, 'subagents', runId), resume: null,
|
|
114
|
+
text: brief, images: undefined, requiresImageInput: false, preamble: null,
|
|
115
|
+
caller: { ...input.caller, runId: undefined, sessionId },
|
|
116
|
+
tools: { allow: tools, gated: (input.tools.gated ?? []).filter((name) => tools.includes(name)) },
|
|
117
|
+
capabilities: { ...capabilities, tools }, depth: (input.depth ?? 0) + 1
|
|
118
|
+
}
|
|
119
|
+
const started = this.launch(child).catch((error) => {
|
|
120
|
+
if (!findRun(this.env.root, runId)) {
|
|
121
|
+
const source = loadRun(this.env.root, request.parentRunId)
|
|
122
|
+
saveRun(this.env.root, { ...source, id: runId, sessionId, parentRunId: request.parentRunId, relation: SUBAGENT_RELATION, nativeSessionId: null, resumedFromRunId: null, handoffId: null, exitCode: null, goal: takeChars(brief, 4000), attachmentCount: 0, status: 'failed', startedAt: now(), endedAt: now(), terminalReason: error instanceof HarnessError ? error.message : 'Subagent startup failed before its session opened.' })
|
|
123
|
+
}
|
|
124
|
+
return { runId, nativeSessionId: null }
|
|
125
|
+
})
|
|
126
|
+
parent.launching.set(runId, started)
|
|
127
|
+
void started.finally(() => parent.launching.delete(runId))
|
|
128
|
+
const result = await until(started, parent.stopped.signal)
|
|
129
|
+
if (request.wait) await this.wait({ parentRunId: request.parentRunId, runIds: [runId] })
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private selected(selection: SubagentSelection, parent?: Parent): string[] {
|
|
134
|
+
const owned = new Set([...recordedSubagents(this.env.root, selection.parentRunId), ...(parent?.recovered ?? [])])
|
|
135
|
+
if (selection.runIds !== undefined && (!Array.isArray(selection.runIds) || selection.runIds.some((id) => typeof id !== 'string'))) throw invalid('runIds must be an array of child run IDs.')
|
|
136
|
+
const ids = [...new Set(selection.runIds ?? [...owned])]
|
|
137
|
+
if (ids.some((id) => !owned.has(id))) throw invalid('Select only subagents owned by this parent.')
|
|
138
|
+
return ids
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
results(selection: SubagentSelection): SubagentResult[] {
|
|
142
|
+
loadRun(this.env.root, selection.parentRunId)
|
|
143
|
+
const parent = this.parents.get(selection.parentRunId)
|
|
144
|
+
return this.selected(selection, parent).map((runId) => {
|
|
145
|
+
const run = findRun(this.env.root, runId)
|
|
146
|
+
const reply = run && readEvents(this.env.root, runId).filter((event) => event.type === 'message.completed' && (event.payload as { role?: unknown }).role === 'assistant').at(-1)
|
|
147
|
+
const payload = reply?.payload as { text?: string } | undefined
|
|
148
|
+
return { runId, status: run?.status ?? 'queued', text: takeChars(payload?.text ?? '', 16_000), terminalReason: run?.terminalReason ?? null }
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async wait(selection: SubagentSelection, signal?: AbortSignal): Promise<SubagentResult[]> {
|
|
153
|
+
const parent = this.parent(selection.parentRunId)
|
|
154
|
+
const ids = this.selected(selection, parent)
|
|
155
|
+
const abort = signal ? AbortSignal.any([signal, parent.stopped.signal]) : parent.stopped.signal
|
|
156
|
+
await until(Promise.all(ids.map(async (id) => {
|
|
157
|
+
const starting = parent.launching.get(id)
|
|
158
|
+
if (starting) await starting
|
|
159
|
+
const run = findRun(this.env.root, id)
|
|
160
|
+
if (run && isActiveStatus(run.status)) await this.runs.wait(id, abort)
|
|
161
|
+
})), abort)
|
|
162
|
+
return this.results({ ...selection, runIds: ids })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Reconcile only owners present on the selected native branch, with matching account and caller. */
|
|
166
|
+
restore(parentRunId: string, history: SubagentHistory): void {
|
|
167
|
+
const parent = this.parent(parentRunId)
|
|
168
|
+
const current = loadRun(this.env.root, parentRunId)
|
|
169
|
+
for (const owner of history.owners) {
|
|
170
|
+
if (owner === parentRunId) continue
|
|
171
|
+
const old = findRun(this.env.root, owner)
|
|
172
|
+
if (!old || old.sessionId !== current.sessionId || old.accountId !== current.accountId || old.agentId !== current.agentId || old.projectId !== current.projectId || old.workingDirectory !== current.workingDirectory || !old.nativeSessionId || old.nativeSessionId !== current.nativeSessionId) continue
|
|
173
|
+
for (const id of recordedSubagents(this.env.root, owner)) {
|
|
174
|
+
let child = findRun(this.env.root, id)
|
|
175
|
+
if (!child) {
|
|
176
|
+
// The process can die after reserving ownership but before sessions.run saves the child.
|
|
177
|
+
saveRun(this.env.root, { ...old, id, sessionId: id, parentRunId: owner, relation: SUBAGENT_RELATION, nativeSessionId: null, resumedFromRunId: null, handoffId: null, exitCode: null, goal: null, attachmentCount: 0, status: 'interrupted', startedAt: now(), endedAt: now(), terminalReason: 'Subagent startup was interrupted before its session opened.' })
|
|
178
|
+
child = findRun(this.env.root, id)
|
|
179
|
+
}
|
|
180
|
+
if (child?.parentRunId === owner && child.relation === SUBAGENT_RELATION && child.accountId === current.accountId) parent.recovered.add(id)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async delivery(parentRunId: string, history: SubagentHistory, settle: boolean, signal?: AbortSignal): Promise<{ text: string; receipt: SubagentReceipt } | null> {
|
|
186
|
+
const parent = this.parent(parentRunId)
|
|
187
|
+
this.restore(parentRunId, history)
|
|
188
|
+
if (settle) {
|
|
189
|
+
// A host may start another child while earlier children are settling.
|
|
190
|
+
for (;;) {
|
|
191
|
+
await this.wait({ parentRunId }, signal)
|
|
192
|
+
if (!parent.launching.size && this.results({ parentRunId }).every((result) => !isActiveStatus(result.status))) break
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const delivered = new Set(history.receipts.flatMap((receipt) => receipt.delivered ?? []))
|
|
196
|
+
const pending = this.results({ parentRunId }).filter((result) => !isActiveStatus(result.status) && !delivered.has(result.runId))
|
|
197
|
+
const chunks: string[] = []
|
|
198
|
+
const ids: string[] = []
|
|
199
|
+
let size = 0
|
|
200
|
+
for (const result of pending) {
|
|
201
|
+
const text = `Subagent run ${result.runId} · ${result.status}\n${result.text || result.terminalReason || 'No written reply.'}`
|
|
202
|
+
if (size + text.length > 32_000 && ids.length) break
|
|
203
|
+
chunks.push(text)
|
|
204
|
+
ids.push(result.runId)
|
|
205
|
+
size += text.length
|
|
206
|
+
}
|
|
207
|
+
return ids.length ? { text: chunks.join('\n\n'), receipt: { version: 1, ownerRunId: parentRunId, delivered: ids } } : null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A broken adapter must fail visibly rather than complete with an undelivered child result. */
|
|
211
|
+
async verify(parentRunId: string, history?: SubagentHistory): Promise<void> {
|
|
212
|
+
if (history) this.restore(parentRunId, history)
|
|
213
|
+
await this.wait({ parentRunId })
|
|
214
|
+
if (history && await this.delivery(parentRunId, history, false)) throw new HarnessError('subagent_delivery_failed', 'A child result was not persisted in this conversation. Resume this reply to recover it.')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
extension(runId: string): InlineExtension {
|
|
218
|
+
return { name: 'native-subagents', factory: (pi: ExtensionAPI) => {
|
|
219
|
+
pi.on('before_agent_start', (_event, ctx) => {
|
|
220
|
+
if (!this.parents.has(runId)) return
|
|
221
|
+
this.restore(runId, subagentHistory(ctx.sessionManager.getBranch()))
|
|
222
|
+
pi.appendEntry(SUBAGENT_OWNER, { runId })
|
|
223
|
+
this.parent(runId).started()
|
|
224
|
+
})
|
|
225
|
+
const deliver = async (event: any, ctx: any, settle: boolean) => {
|
|
226
|
+
if (!this.parents.has(runId) || (settle && event.outcome !== 'completed') || ctx.signal?.aborted) return
|
|
227
|
+
const history = subagentHistory([...ctx.sessionManager.getBranch(), ...event.entries])
|
|
228
|
+
const result = await this.delivery(runId, history, settle, ctx.signal)
|
|
229
|
+
if (!result) return
|
|
230
|
+
return { entries: [...event.entries, { type: 'custom_message' as const, customType: SUBAGENT_RESULTS, content: result.text, display: false, details: { [SUBAGENT_RECEIPT]: result.receipt } }], continue: true }
|
|
231
|
+
}
|
|
232
|
+
pi.on('turn_end', (event, ctx) => deliver(event, ctx, false))
|
|
233
|
+
pi.on('agent_before_settle', (event, ctx) => deliver(event, ctx, true))
|
|
234
|
+
} }
|
|
235
|
+
}
|
|
236
|
+
}
|