@mars.liu/dsh-cybernetics 0.2.0 → 0.6.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/README.md +40 -2
- package/cordis.patch.yml +18 -1
- package/docs/protocol.md +219 -3
- package/lib/client.js +231 -13
- package/package.json +13 -1
- package/src/controller.ts +113 -3
- package/src/executor.ts +414 -0
- package/src/index.ts +610 -42
- package/src/projection.ts +763 -0
- package/src/upstream.d.ts +22 -0
- package/workflows/round.js +101 -1
- package/test/v03-offline.mjs +0 -203
- package/tsconfig.json +0 -16
package/src/executor.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round executors (v0.6): the substrate that runs one goal round's task work.
|
|
3
|
+
*
|
|
4
|
+
* Two substrates, one contract. `WorkflowExecutor` is the v0.5 path: the
|
|
5
|
+
* controller starts `workflows/round.js` through the agent's workflowEngine and
|
|
6
|
+
* the whole task→verify→triage loop runs as ephemeral subagents. `TeamExecutor`
|
|
7
|
+
* replaces only the TASK agent with a persistent Agent Teams teammate
|
|
8
|
+
* (context:'fresh' — identity persists, conversation history does not), while
|
|
9
|
+
* verify/triage stay ephemeral workflow agents fed with the teammate's report
|
|
10
|
+
* (`args.taskReport`) so verification never inherits the task narrative.
|
|
11
|
+
*
|
|
12
|
+
* Capture seam: a teammate's final output for one turn is delivered by the
|
|
13
|
+
* host as `subagent/end` (info.id = the child session id, info.lastAssistantMessage
|
|
14
|
+
* = ContentBlock[] — the host's `SubagentRunEndInfo` field; the legacy `output`
|
|
15
|
+
* shape is still accepted as a fallback). The event can settle before
|
|
16
|
+
* `spawnTeammate` resolves, so the listener is installed before the first spawn
|
|
17
|
+
* and every settled child is buffered by id until consumed.
|
|
18
|
+
*
|
|
19
|
+
* Soft integration: the Agent Teams service is probed at call time through
|
|
20
|
+
* `agentTeamsOf()`. Two seams, in priority order:
|
|
21
|
+
*
|
|
22
|
+
* 1. `ctx.reflect.get('agentTeams')` — Cordis' reflection read ("Read a
|
|
23
|
+
* service from the store without the inject requirement") returns the
|
|
24
|
+
* service value or `undefined` and needs no `inject` declaration. This is
|
|
25
|
+
* the correct seam for a soft/optional integration: it neither pends boot
|
|
26
|
+
* nor throws when nothing provides the service.
|
|
27
|
+
* 2. `ctx.agentTeams` — an exception-safe fallback for older hosts and for
|
|
28
|
+
* hand-built test contexts that have no `reflect` seam. Cordis' context
|
|
29
|
+
* proxy THROWS on an un-injected service property ("cannot get property
|
|
30
|
+
* ... without inject"), which would surface as an internal proxy error, so
|
|
31
|
+
* the read is wrapped.
|
|
32
|
+
*
|
|
33
|
+
* Whichever seam answers, "not available" is reported as `undefined`: a
|
|
34
|
+
* session without Agent Teams keeps working with executor 'ephemeral';
|
|
35
|
+
* requesting 'persistent' there fails with a pointed, structured error
|
|
36
|
+
* (pointing at ephemeral) instead of a Cordis proxy exception or a hang.
|
|
37
|
+
* @module dsh-cybernetics/executor
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { ControllerConfig, LoopState, RoundResult } from './controller.ts'
|
|
41
|
+
|
|
42
|
+
/** Everything one round needs from the goal board (a plain snapshot). */
|
|
43
|
+
export interface TeamRoundInput {
|
|
44
|
+
goalId: string
|
|
45
|
+
title?: string
|
|
46
|
+
objective: string
|
|
47
|
+
round: number
|
|
48
|
+
subgoal: string
|
|
49
|
+
brief?: string
|
|
50
|
+
depth: 'quick' | 'full'
|
|
51
|
+
doneSummary: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Run-like handle shared by both executors' consumers. */
|
|
55
|
+
export interface TeamRunHandle {
|
|
56
|
+
round: number
|
|
57
|
+
memberName: string
|
|
58
|
+
result: Promise<{ stopReason: string; value?: unknown; error?: unknown }>
|
|
59
|
+
cancel(reason?: string): void
|
|
60
|
+
dispose(): Promise<void>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read a service through Cordis' reflection seam, if this context has one.
|
|
65
|
+
*
|
|
66
|
+
* `ctx.reflect.get(name)` is the documented read that bypasses the `inject`
|
|
67
|
+
* requirement (vendor/cordis/src/reflect.ts): it returns the service value, or
|
|
68
|
+
* `undefined` when no fiber currently provides it. It is deliberately called
|
|
69
|
+
* with the default `strict = true`, so only an ACTIVE provider counts — a
|
|
70
|
+
* service that is mid-teardown must not be handed to an executor.
|
|
71
|
+
*
|
|
72
|
+
* Contexts without a reflection seam (older hosts, plain test doubles) have no
|
|
73
|
+
* `reflect` at all; a malformed or throwing seam is treated the same way.
|
|
74
|
+
* Every failure mode means "no answer from this seam", never an error.
|
|
75
|
+
*/
|
|
76
|
+
function reflectedService(ctx: unknown, name: string): unknown {
|
|
77
|
+
try {
|
|
78
|
+
const reflect = (ctx as { reflect?: { get?: (name: string) => unknown } } | undefined)?.reflect
|
|
79
|
+
if (reflect === undefined || reflect === null || typeof reflect.get !== 'function') return undefined
|
|
80
|
+
return reflect.get(name)
|
|
81
|
+
} catch {
|
|
82
|
+
// Un-audited reflection implementation: soft integration ⇒ no answer.
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the Agent Teams service without tripping Cordis' proxy guard.
|
|
89
|
+
*
|
|
90
|
+
* The reflection seam is tried first — it answers "available" and "absent"
|
|
91
|
+
* without an `inject`, which is what a soft integration needs. The property
|
|
92
|
+
* read is only the compatibility fallback: Cordis' context proxy throws
|
|
93
|
+
* `cannot get property "X" without inject` for a service that was not injected,
|
|
94
|
+
* so "not available" must be reported as `undefined`, not as a proxy error.
|
|
95
|
+
* Returns `any` on purpose — the service shape is only known to the host that
|
|
96
|
+
* mounts it.
|
|
97
|
+
*/
|
|
98
|
+
export function agentTeamsOf(ctx: unknown): any {
|
|
99
|
+
const reflected = reflectedService(ctx, 'agentTeams')
|
|
100
|
+
if (reflected !== undefined) return reflected
|
|
101
|
+
try {
|
|
102
|
+
return (ctx as { agentTeams?: unknown } | undefined)?.agentTeams
|
|
103
|
+
} catch {
|
|
104
|
+
// Un-injected service: Cordis threw. Soft integration ⇒ unavailable.
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether this context can host persistent (teammate) executors. */
|
|
110
|
+
export function teamExecutorAvailable(ctx: unknown): boolean {
|
|
111
|
+
const teams = agentTeamsOf(ctx) as { spawnTeammate?: unknown } | undefined
|
|
112
|
+
return teams !== undefined && typeof teams.spawnTeammate === 'function'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The pointed, structured error for a persistent round without Agent Teams. */
|
|
116
|
+
export function teamExecutorUnavailableError(): Error {
|
|
117
|
+
return new Error(
|
|
118
|
+
'cybernetics: executor "persistent" needs Agent Teams (agentTeams) in this session — ' +
|
|
119
|
+
'the service is not injected here; fall back to executor "ephemeral" for this goal',
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** ContentBlock text join (the host's output blocks are already text-only here). */
|
|
124
|
+
function outputText(output: unknown): string {
|
|
125
|
+
if (!Array.isArray(output)) return ''
|
|
126
|
+
const parts: string[] = []
|
|
127
|
+
for (const block of output) {
|
|
128
|
+
if (block !== null && typeof block === 'object' && (block as { type?: string }).type === 'text') {
|
|
129
|
+
const text = (block as { text?: unknown }).text
|
|
130
|
+
if (typeof text === 'string' && text.length > 0) parts.push(text)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return parts.join('\n')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse the task agent's output contract: the final message ends with one
|
|
138
|
+
* single-line JSON object `{"done": true|false, "summary": "...", ...}`.
|
|
139
|
+
* Missing/invalid marker → not done (the controller then continues or pauses
|
|
140
|
+
* instead of pretending a turn succeeded).
|
|
141
|
+
*/
|
|
142
|
+
export function parseTaskOutput(text: string): { done: boolean; summary: string; evidence: string } {
|
|
143
|
+
const marker = /\{[^{}]*"done"\s*:[^{}]*\}/gu
|
|
144
|
+
let match: RegExpExecArray | null = null
|
|
145
|
+
for (let m = marker.exec(text); m !== null; m = marker.exec(text)) match = m
|
|
146
|
+
if (match !== null) {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(match[0]) as { done?: unknown; summary?: unknown; evidence?: unknown }
|
|
149
|
+
if (typeof parsed.done === 'boolean') {
|
|
150
|
+
return {
|
|
151
|
+
done: parsed.done,
|
|
152
|
+
summary: typeof parsed.summary === 'string' && parsed.summary.length > 0 ? parsed.summary : text.slice(0, 2000),
|
|
153
|
+
evidence: typeof parsed.evidence === 'string' && parsed.evidence.length > 0 ? parsed.evidence : text,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// fall through to the not-done default
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { done: false, summary: text.slice(0, 2000), evidence: text }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The work order a persistent task agent receives on its first round. */
|
|
164
|
+
export function buildWorkOrder(input: TeamRoundInput, roles: ControllerConfig['roles']): string {
|
|
165
|
+
const lines = [
|
|
166
|
+
'你是一名持久 task agent(teammate):你不继承任何会话历史,本委托书是你获得的全部任务信息;',
|
|
167
|
+
'你在本轮结束后保持存活,后续会收到「委托书修订」,那些消息是同一 goal 的增量要求。',
|
|
168
|
+
'',
|
|
169
|
+
'【本轮委托书】',
|
|
170
|
+
`目标:${input.objective}`,
|
|
171
|
+
`本轮子目标:${input.subgoal}`,
|
|
172
|
+
...(input.brief && input.brief.trim().length > 0 ? ['', input.brief] : []),
|
|
173
|
+
...(input.doneSummary && input.doneSummary.trim().length > 0 ? ['', '【既往轮次摘要(不要重做,可依赖其产物)】', input.doneSummary] : []),
|
|
174
|
+
'',
|
|
175
|
+
'【执行纪律】',
|
|
176
|
+
'- 你不是在写计划:必须实际调用工具完成工作,并给出可复现的证据。',
|
|
177
|
+
'- 完成判定必须可验证:每项交付附上证据(命令输出、文件路径、查询结果)。',
|
|
178
|
+
'- 被环境阻塞时如实停止,不要编造结果。',
|
|
179
|
+
'',
|
|
180
|
+
'【输出契约(必须遵守)】',
|
|
181
|
+
'你的最终回复必须是结构化的,并且最后一行必须是单行 JSON:',
|
|
182
|
+
'{"done": true|false, "summary": "一句话结果摘要", "evidence": "证据说明"}',
|
|
183
|
+
'done 只在交付物确实完成且可验证时为 true。',
|
|
184
|
+
]
|
|
185
|
+
return lines.join('\n')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Per-context capture state: buffered child outputs + waiters. */
|
|
189
|
+
interface CaptureState {
|
|
190
|
+
buffer: Map<string, unknown[]>
|
|
191
|
+
waiters: Map<string, Array<{ resolve: (value: unknown) => void; live: boolean }>>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const captures = new WeakMap<object, CaptureState>()
|
|
195
|
+
const installed = new WeakSet<object>()
|
|
196
|
+
|
|
197
|
+
function captureState(ctx: object): CaptureState {
|
|
198
|
+
let state = captures.get(ctx)
|
|
199
|
+
if (state === undefined) {
|
|
200
|
+
state = { buffer: new Map(), waiters: new Map() }
|
|
201
|
+
captures.set(ctx, state)
|
|
202
|
+
}
|
|
203
|
+
if (!installed.has(ctx)) {
|
|
204
|
+
installed.add(ctx)
|
|
205
|
+
;(ctx as { on?: (event: string, fn: (info: unknown) => void) => (() => void) | void }).on?.('subagent/end', (info: unknown) => {
|
|
206
|
+
const id = (info as { id?: unknown })?.id
|
|
207
|
+
if (typeof id !== 'string') return
|
|
208
|
+
const pending = state!.waiters.get(id)
|
|
209
|
+
while (pending !== undefined && pending.length > 0) {
|
|
210
|
+
const waiter = pending.shift()!
|
|
211
|
+
if (waiter.live) { waiter.resolve(info); return }
|
|
212
|
+
}
|
|
213
|
+
const queue = state!.buffer.get(id) ?? []
|
|
214
|
+
queue.push(info)
|
|
215
|
+
state!.buffer.set(id, queue)
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
return state
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Consume the next settled output for one child id (buffer first). The
|
|
223
|
+
* returned dispose() marks the waiter dead and resolves it, so a cancelled
|
|
224
|
+
* round can never steal the next turn's subagent/end from its successor.
|
|
225
|
+
*/
|
|
226
|
+
function nextSettled(ctx: object, childId: string): { promise: Promise<unknown>; dispose: () => void } {
|
|
227
|
+
const state = captureState(ctx)
|
|
228
|
+
const buffered = state.buffer.get(childId)
|
|
229
|
+
if (buffered !== undefined && buffered.length > 0) {
|
|
230
|
+
const value = buffered.shift()!
|
|
231
|
+
return { promise: Promise.resolve(value), dispose: () => {} }
|
|
232
|
+
}
|
|
233
|
+
let waiter: { resolve: (value: unknown) => void; live: boolean }
|
|
234
|
+
const promise = new Promise<unknown>((resolve) => {
|
|
235
|
+
waiter = { resolve, live: true }
|
|
236
|
+
const queue = state.waiters.get(childId) ?? []
|
|
237
|
+
queue.push(waiter)
|
|
238
|
+
state.waiters.set(childId, queue)
|
|
239
|
+
})
|
|
240
|
+
return {
|
|
241
|
+
promise,
|
|
242
|
+
dispose: () => {
|
|
243
|
+
waiter!.live = false
|
|
244
|
+
waiter!.resolve(undefined)
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Start one persistent round: spawn the teammate on the goal's first round,
|
|
251
|
+
* otherwise steer a brief amendment into the same member; await its settled
|
|
252
|
+
* output; then either derive the verdict (quick) or run verify+triage through
|
|
253
|
+
* round.js with the report as `taskReport` (full).
|
|
254
|
+
*/
|
|
255
|
+
export async function startTeamRound(
|
|
256
|
+
ctx: any,
|
|
257
|
+
parent: any,
|
|
258
|
+
state: LoopState,
|
|
259
|
+
config: ControllerConfig,
|
|
260
|
+
signal?: AbortSignal,
|
|
261
|
+
): Promise<TeamRunHandle> {
|
|
262
|
+
const input: TeamRoundInput = {
|
|
263
|
+
goalId: state.goalId ?? 'cyb',
|
|
264
|
+
title: state.title,
|
|
265
|
+
objective: state.objective ?? '',
|
|
266
|
+
round: state.round + 1,
|
|
267
|
+
subgoal: state.subgoal,
|
|
268
|
+
brief: state.brief,
|
|
269
|
+
depth: state.depth === 'quick' ? 'quick' : 'full',
|
|
270
|
+
doneSummary: state.doneSummary,
|
|
271
|
+
}
|
|
272
|
+
const workOrder = buildWorkOrder(input, config.roles)
|
|
273
|
+
// Soft integration probe (same accessor as teamExecutorAvailable): a missing
|
|
274
|
+
// service is a structured, actionable failure — the caller/protocol falls
|
|
275
|
+
// back to executor 'ephemeral' — never a Cordis proxy exception.
|
|
276
|
+
const teams = agentTeamsOf(ctx)
|
|
277
|
+
if (teams === undefined || typeof teams.spawnTeammate !== 'function') {
|
|
278
|
+
throw teamExecutorUnavailableError()
|
|
279
|
+
}
|
|
280
|
+
// Install the subagent/end listener BEFORE the first spawn: a teammate can
|
|
281
|
+
// settle before spawnTeammate resolves, and the buffered output must not be
|
|
282
|
+
// lost. (Registration is idempotent per ctx.)
|
|
283
|
+
captureState(ctx as object)
|
|
284
|
+
const memberName = state.memberName ?? `cyb-${input.goalId.replace(/^cyb-/, '')}`
|
|
285
|
+
|
|
286
|
+
let cancelled = false
|
|
287
|
+
let resolveCancel: (() => void) | undefined
|
|
288
|
+
let disposeWait: (() => void) | undefined
|
|
289
|
+
const cancelGate = new Promise<void>((resolve) => { resolveCancel = resolve })
|
|
290
|
+
|
|
291
|
+
const roundPromise = (async (): Promise<{ stopReason: string; value?: unknown; error?: unknown }> => {
|
|
292
|
+
try {
|
|
293
|
+
let childId: string
|
|
294
|
+
if (state.memberName === undefined) {
|
|
295
|
+
const spawned = await teams.spawnTeammate(parent, {
|
|
296
|
+
name: memberName,
|
|
297
|
+
description: (input.title ?? input.subgoal).slice(0, 200),
|
|
298
|
+
prompt: [{ type: 'text', text: workOrder }],
|
|
299
|
+
context: 'fresh',
|
|
300
|
+
provider: 'spawn',
|
|
301
|
+
signal: signal ?? new AbortController().signal,
|
|
302
|
+
})
|
|
303
|
+
childId = String(spawned?.member?.id ?? '')
|
|
304
|
+
state.memberName = memberName
|
|
305
|
+
if (childId.length === 0) throw new Error('TeamExecutor: spawnTeammate returned no member id')
|
|
306
|
+
} else {
|
|
307
|
+
await teams.sendMessage(parent, {
|
|
308
|
+
target: state.memberName,
|
|
309
|
+
content: [{ type: 'text', text: `【委托书修订(第 ${input.round} 轮)】\n本轮子目标:${input.subgoal}\n${state.brief ?? '(承接上一轮的委托书与已验证结果)'}` }],
|
|
310
|
+
signal: signal ?? new AbortController().signal,
|
|
311
|
+
})
|
|
312
|
+
childId = ''
|
|
313
|
+
}
|
|
314
|
+
// For round 2+ we need the child id: listMembers resolves the durable member.
|
|
315
|
+
if (childId.length === 0) {
|
|
316
|
+
const members = teams.listMembers(parent) as Array<{ name?: string; id?: string }>
|
|
317
|
+
childId = String(members.find((m) => m.name === state.memberName)?.id ?? '')
|
|
318
|
+
if (childId.length === 0) throw new Error(`TeamExecutor: teammate "${state.memberName}" not found in the roster`)
|
|
319
|
+
}
|
|
320
|
+
if (cancelled) return { stopReason: 'cancelled', error: 'interrupted by session agent' }
|
|
321
|
+
|
|
322
|
+
const wait = nextSettled(ctx, childId)
|
|
323
|
+
disposeWait = wait.dispose
|
|
324
|
+
const info = await wait.promise
|
|
325
|
+
disposeWait = undefined
|
|
326
|
+
if (cancelled) return { stopReason: 'cancelled', error: 'interrupted by session agent' }
|
|
327
|
+
// The host's SubagentRunEndInfo carries the child's final output as
|
|
328
|
+
// `lastAssistantMessage` (ContentBlock[]) — NOT `output`, which does not
|
|
329
|
+
// exist on that payload. Reading the wrong field silently produced an
|
|
330
|
+
// empty report, so read the real field first and keep `output` only as a
|
|
331
|
+
// compatibility fallback for older/test shapes; an empty real field also
|
|
332
|
+
// falls through instead of shadowing usable legacy text.
|
|
333
|
+
const settledInfo = info as { lastAssistantMessage?: unknown; output?: unknown } | undefined
|
|
334
|
+
const text = outputText(settledInfo?.lastAssistantMessage) || outputText(settledInfo?.output)
|
|
335
|
+
const parsed = parseTaskOutput(text)
|
|
336
|
+
const report = {
|
|
337
|
+
summary: parsed.summary,
|
|
338
|
+
done: parsed.done,
|
|
339
|
+
artifacts: [],
|
|
340
|
+
evidence: parsed.evidence,
|
|
341
|
+
nextStepThoughts: [],
|
|
342
|
+
}
|
|
343
|
+
if (input.depth === 'quick') {
|
|
344
|
+
const result: RoundResult = {
|
|
345
|
+
round: input.round,
|
|
346
|
+
report,
|
|
347
|
+
verdict: {
|
|
348
|
+
met: parsed.done,
|
|
349
|
+
quick: true,
|
|
350
|
+
criteria: [],
|
|
351
|
+
gaps: parsed.done ? [] : ['task agent 未报告 done=true(quick 轮无 verify,详见 report)'],
|
|
352
|
+
goalItselfQuestionable: false,
|
|
353
|
+
},
|
|
354
|
+
triage: { auto: [], defer: [], reject: [], human: [] },
|
|
355
|
+
}
|
|
356
|
+
return { stopReason: 'completed', value: result }
|
|
357
|
+
}
|
|
358
|
+
// full depth: verify + triage stay ephemeral and independent of the
|
|
359
|
+
// teammate's narrative — they receive the report as a data input.
|
|
360
|
+
const engine = ctx.agentPresets?.serviceFor(parent, 'workflowEngine')
|
|
361
|
+
if (!engine) throw new Error('TeamExecutor: workflowEngine unavailable for verify/triage')
|
|
362
|
+
const run = engine.start({
|
|
363
|
+
script: await (await import('node:fs/promises')).readFile(config.roundScriptPath, 'utf8'),
|
|
364
|
+
meta: { name: 'cybernetics-verify', description: `verify round ${input.round}: ${input.subgoal}` },
|
|
365
|
+
args: {
|
|
366
|
+
goal: { objective: input.objective },
|
|
367
|
+
state: { round: input.round, doneSummary: input.doneSummary },
|
|
368
|
+
subgoal: input.subgoal,
|
|
369
|
+
brief: state.brief,
|
|
370
|
+
depth: 'full',
|
|
371
|
+
role: state.role,
|
|
372
|
+
roles: config.roles ?? {},
|
|
373
|
+
taskReport: report,
|
|
374
|
+
},
|
|
375
|
+
parent,
|
|
376
|
+
signal,
|
|
377
|
+
})
|
|
378
|
+
try {
|
|
379
|
+
const settled = await run.result
|
|
380
|
+
if (settled.stopReason !== 'completed') {
|
|
381
|
+
return { stopReason: 'completed', value: { round: input.round, failed: `verify run ${settled.stopReason}${settled.error ? `: ${settled.error}` : ''}` } }
|
|
382
|
+
}
|
|
383
|
+
return { stopReason: 'completed', value: settled.value }
|
|
384
|
+
} finally {
|
|
385
|
+
await run.dispose()
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
return { stopReason: 'error', error: error instanceof Error ? error.message : String(error) }
|
|
389
|
+
}
|
|
390
|
+
})()
|
|
391
|
+
|
|
392
|
+
const result = Promise.race([
|
|
393
|
+
roundPromise,
|
|
394
|
+
cancelGate.then(() => ({ stopReason: 'cancelled', error: 'interrupted by session agent' })),
|
|
395
|
+
])
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
round: input.round,
|
|
399
|
+
memberName,
|
|
400
|
+
result,
|
|
401
|
+
cancel(_reason?: string) {
|
|
402
|
+
cancelled = true
|
|
403
|
+
// Drop this round's waiter first: a cancelled round must not consume the
|
|
404
|
+
// next turn's subagent/end (it would hang the successor round).
|
|
405
|
+
disposeWait?.()
|
|
406
|
+
disposeWait = undefined
|
|
407
|
+
resolveCancel?.()
|
|
408
|
+
try { teams.interrupt(parent, state.memberName ?? memberName) } catch { /* best effort */ }
|
|
409
|
+
},
|
|
410
|
+
async dispose() {
|
|
411
|
+
// The member persists across rounds by design; nothing to release here.
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
}
|