@xingular/harness 0.1.6 → 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 +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -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 +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -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/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- 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 +43 -2
- 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/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -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 +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- package/src/tools/types.ts +2 -0
package/src/sessions/sessions.ts
CHANGED
|
@@ -21,10 +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 { subagentTools } from '../tools/spawnSubagent'
|
|
24
25
|
import { WIKI_TOOL_NAMES } from '../tools/definitions'
|
|
25
26
|
import { memoryTools } from '../tools/memoryTools'
|
|
26
27
|
import type { ToolProvider, ToolSession, ToolSpec } from '../tools/types'
|
|
27
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'
|
|
28
31
|
import { selectHistory } from './history'
|
|
29
32
|
import { runLineageJoin } from './lineageJoin'
|
|
30
33
|
import type { ToolPolicy } from './policy'
|
|
@@ -66,6 +69,10 @@ export interface SessionRunInput {
|
|
|
66
69
|
capabilities?: CapabilitySet
|
|
67
70
|
/** Hops from the user to this session (0 when the user started it). */
|
|
68
71
|
depth?: number
|
|
72
|
+
/** Interactive default: native child tools. Null disables; unattended callers must opt in. */
|
|
73
|
+
subagents?: SubagentOptions | null
|
|
74
|
+
/** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
|
|
75
|
+
providers?: boolean
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/** The connected-apps broker, as a session sees it. */
|
|
@@ -75,6 +82,7 @@ export interface SessionBroker {
|
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
export interface SessionsOptions {
|
|
85
|
+
subagents: Subagents
|
|
78
86
|
env: DispatchEnv
|
|
79
87
|
accounts: Accounts
|
|
80
88
|
runs: RunRegistry
|
|
@@ -100,6 +108,7 @@ export class Sessions {
|
|
|
100
108
|
|
|
101
109
|
constructor(private readonly options: SessionsOptions) {
|
|
102
110
|
this.factory = options.factory ?? piSessionFactory
|
|
111
|
+
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
private get env(): HarnessEnv {
|
|
@@ -126,9 +135,15 @@ export class Sessions {
|
|
|
126
135
|
} catch (error) {
|
|
127
136
|
return error instanceof Error ? error.message : 'Tool unavailable in the app settings.'
|
|
128
137
|
}
|
|
129
|
-
if (scope.allowed && (scope.automatic || !gated.has(tool))) return null
|
|
130
|
-
// A call outside the scope is approved one at a time; a gated tool may be remembered.
|
|
131
|
-
const decision = await run.requestApproval({
|
|
138
|
+
if (scope.allowed && !scope.humanOnly && (scope.automatic || !gated.has(tool))) return null
|
|
139
|
+
// A call outside the scope is approved one at a time; a gated tool may be remembered, unless only a person may decide it.
|
|
140
|
+
const decision = await run.requestApproval({
|
|
141
|
+
target: tool,
|
|
142
|
+
action: summarize(args),
|
|
143
|
+
operation: { tool, args: (args && typeof args === 'object' ? args : {}) as Record<string, unknown> },
|
|
144
|
+
rememberable: scope.allowed && !scope.humanOnly,
|
|
145
|
+
standing: scope.humanOnly ? (scope.allowGlobalApproval ? 'switch' : 'none') : undefined
|
|
146
|
+
})
|
|
132
147
|
if (decision !== 'approved') return 'The user denied this action. Ask how they would like to proceed.'
|
|
133
148
|
try {
|
|
134
149
|
await policy.authorize?.(call)
|
|
@@ -140,6 +155,11 @@ export class Sessions {
|
|
|
140
155
|
}
|
|
141
156
|
}
|
|
142
157
|
|
|
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)
|
|
161
|
+
}
|
|
162
|
+
|
|
143
163
|
/**
|
|
144
164
|
* Starts one turn. Returns once the Pi session is open; the reply arrives as run events. A
|
|
145
165
|
* session that cannot be opened ends its run as failed and returns no native session id.
|
|
@@ -147,18 +167,22 @@ export class Sessions {
|
|
|
147
167
|
async run(input: SessionRunInput): Promise<{ runId: string; nativeSessionId: string | null }> {
|
|
148
168
|
const { root } = this.env
|
|
149
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)
|
|
150
171
|
const historyFile = selectHistory({ sessionDir: input.sessionDir, resume: input.resume })
|
|
151
172
|
const account = this.options.accounts.resolve(input.accountId, input.poolId)
|
|
152
173
|
const context = this.options.accounts.context(account)
|
|
153
174
|
const runId = input.run.id
|
|
154
|
-
const caller: CallerContext = { ...input.caller, runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
|
|
175
|
+
const caller: CallerContext = { ...input.caller, maintenanceJobIds: input.caller.maintenanceJobIds?.slice(), runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
|
|
155
176
|
const broker = input.connections ? this.options.broker : null
|
|
177
|
+
const depth = input.depth ?? 0
|
|
178
|
+
const canSpawn = input.subagents !== null && (!input.automatic || input.subagents !== undefined) && subagentNesting(root, input.run) < subagentDepth(input.subagents ?? undefined)
|
|
156
179
|
const requestedTools = [...new Set([
|
|
157
|
-
...input.tools.allow,
|
|
180
|
+
...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT && name !== WAIT_SUBAGENTS),
|
|
158
181
|
ASK_USER,
|
|
159
182
|
...(input.automatic ? [] : [PLUGIN_PROPOSE]),
|
|
160
183
|
...WIKI_TOOL_NAMES,
|
|
161
|
-
...(broker?.toolNames ?? [])
|
|
184
|
+
...(broker?.toolNames ?? []),
|
|
185
|
+
...(canSpawn ? [SPAWN_SUBAGENT, WAIT_SUBAGENTS] : [])
|
|
162
186
|
])]
|
|
163
187
|
const available = this.options.resolveCapabilities
|
|
164
188
|
? await this.options.resolveCapabilities(caller, requestedTools, input.cwd)
|
|
@@ -232,20 +256,22 @@ export class Sessions {
|
|
|
232
256
|
if (run.finished) return { runId, nativeSessionId: null }
|
|
233
257
|
// What every session has, after the tools the caller switched on and in a fixed order.
|
|
234
258
|
const allow = capabilities.tools
|
|
235
|
-
const session: ToolSession = { run, caller, allow, depth
|
|
259
|
+
const session: ToolSession = { run, caller, allow, depth, cwd: input.cwd, capabilities }
|
|
236
260
|
const { plugins } = this.options
|
|
237
261
|
|
|
238
|
-
let pi: PiSessionLike
|
|
262
|
+
let pi: PiSessionLike | undefined
|
|
239
263
|
try {
|
|
240
264
|
const tools: ToolSpec[] = [
|
|
241
265
|
...(broker ? broker.tools(session, { readOnly: typeof input.connections === 'object' && input.connections.readOnly === true }) : []),
|
|
242
266
|
...memoryTools(this.options.env, caller, capabilities.contributions),
|
|
243
267
|
askUserTool(run),
|
|
244
|
-
pluginProposeTool(this.options.env, caller)
|
|
268
|
+
pluginProposeTool(this.options.env, caller),
|
|
269
|
+
...subagentTools(this.options.subagents, runId).filter(tool => allow.includes(tool.name))
|
|
245
270
|
]
|
|
246
|
-
for (const provider of this.providers) tools.push(...(await provider(session)))
|
|
271
|
+
if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
|
|
247
272
|
const params: TurnParams = {
|
|
248
273
|
runId,
|
|
274
|
+
restrictTools: input.run.relation === 'subagent',
|
|
249
275
|
cwd: input.cwd,
|
|
250
276
|
sessionDir: input.sessionDir,
|
|
251
277
|
historyFile,
|
|
@@ -258,7 +284,7 @@ export class Sessions {
|
|
|
258
284
|
automatic: input.automatic,
|
|
259
285
|
account: context,
|
|
260
286
|
resources: registry.resources(root, caller.projectId ?? null, caller.agentId ?? null, capabilities.contributions),
|
|
261
|
-
inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId)],
|
|
287
|
+
inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId), this.options.subagents.extension(runId)],
|
|
262
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) } } : {}),
|
|
263
289
|
system,
|
|
264
290
|
update
|
|
@@ -266,25 +292,35 @@ export class Sessions {
|
|
|
266
292
|
pi = await this.factory({
|
|
267
293
|
params,
|
|
268
294
|
allow,
|
|
269
|
-
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
|
+
})(),
|
|
270
299
|
tools,
|
|
271
300
|
runtime: () => this.options.accounts.runtime(context),
|
|
272
301
|
// An unattended run loads no extension, so it has nobody to relay for.
|
|
273
302
|
...(input.automatic ? {} : { extensions: { ask: (question) => run.requestAnswer(question), notice: (notice) => run.emit('extension.notice', notice) } })
|
|
274
303
|
})
|
|
275
|
-
if (
|
|
304
|
+
if (run.finished) {
|
|
276
305
|
pi.dispose()
|
|
306
|
+
return { runId, nativeSessionId: null }
|
|
307
|
+
}
|
|
308
|
+
if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
|
|
277
309
|
throw new HarnessError('image_input_unsupported', 'Choose a model that supports images, or remove the image attachments and send again.')
|
|
278
310
|
}
|
|
279
311
|
if (input.resume?.nativeSessionId && pi.sessionId !== input.resume.nativeSessionId) {
|
|
280
|
-
pi.dispose()
|
|
281
312
|
run.finish('failed', { code: 'native_resume_unavailable', message: 'The saved conversation could not be restored.' })
|
|
282
313
|
throw new HarnessError('native_resume_unavailable', 'The saved conversation could not be restored.')
|
|
283
314
|
}
|
|
284
|
-
|
|
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)
|
|
285
320
|
void runTurn(run, pi, params)
|
|
286
321
|
return { runId, nativeSessionId: pi.sessionId }
|
|
287
322
|
} catch (error) {
|
|
323
|
+
pi?.dispose()
|
|
288
324
|
if (error instanceof HarnessError && error.code === 'native_resume_unavailable') throw error
|
|
289
325
|
const message = error instanceof Error ? error.message : String(error)
|
|
290
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
|
+
}
|