@mars.liu/dsh-cybernetics 0.2.0 → 0.7.8

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/src/controller.ts CHANGED
@@ -22,6 +22,8 @@ export interface RoundResult {
22
22
  verdict?: unknown
23
23
  triage?: unknown
24
24
  failed?: string
25
+ /** Execution substrate used for this round (v0.6 audit). */
26
+ executor?: 'ephemeral' | 'persistent'
25
27
  }
26
28
 
27
29
  /** LLM target overrides for one sub-agent role; workflow `agent()` opts subset. */
@@ -30,6 +32,24 @@ export interface RoleSpec {
30
32
  model?: string
31
33
  }
32
34
 
35
+ /**
36
+ * Parse a flat agent-model field ("provider/model" or a bare "model" name)
37
+ * into a workflow `agent()` override. Empty/whitespace/undefined → undefined,
38
+ * which the caller must treat as「与 session agent 一致」(no override handed
39
+ * to the round script — the workflow inherits the calling model).
40
+ */
41
+ export function parseAgentModelSpec(value?: string): RoleSpec | undefined {
42
+ const raw = (value ?? '').trim()
43
+ if (!raw) return undefined
44
+ const slash = raw.indexOf('/')
45
+ if (slash > 0 && slash < raw.length - 1) {
46
+ const provider = raw.slice(0, slash).trim()
47
+ const model = raw.slice(slash + 1).trim()
48
+ if (provider && model) return { provider, model }
49
+ }
50
+ return { model: raw }
51
+ }
52
+
33
53
  /** Per-role sub-agent configuration: 'default'/'verify'/'triage' plus named task roles. */
34
54
  export interface RolesConfig {
35
55
  default?: RoleSpec
@@ -70,9 +90,122 @@ export interface LoopState {
70
90
  role: string
71
91
  /** Detailed per-round work order extracted by the calling model; passed to the task agent verbatim. */
72
92
  brief?: string
73
- /** Lifecycle: running (a round in flight) / paused / met (verify passed, awaits user) / cancelled. */
74
- status?: 'running' | 'paused' | 'met' | 'cancelled'
93
+ /** Round depth: 'quick' = task agent only (lightweight analysis); 'full' = task→verify→triage (default). */
94
+ depth?: 'quick' | 'full'
95
+ /** Execution substrate: 'ephemeral' (workflow subagents, default) or 'persistent' (Agent Teams teammate). */
96
+ executor?: 'ephemeral' | 'persistent'
97
+ /** Persistent teammate name, set on the first persistent round and reused afterwards. */
98
+ memberName?: string
99
+ /**
100
+ * Lifecycle: running (a round in flight) / paused / met (verify passed, awaits
101
+ * user) / cancelled / superseded (a revision created a successor goal; this
102
+ * goal keeps its final state and is never driven again).
103
+ */
104
+ status?: 'running' | 'paused' | 'met' | 'cancelled' | 'superseded'
75
105
  paused?: { reason: ResumableReason; note: string }
106
+ /**
107
+ * Append-only goal lineage (v0.6, user 2026-11-02): a revision never edits the
108
+ * objective in place — it creates a NEW goal, and the old one keeps its final
109
+ * state. `parentGoalId` = the goal this one was revised from.
110
+ */
111
+ parentGoalId?: string
112
+ /** Set on a superseded goal: the id of the successor that replaced it. */
113
+ supersededBy?: string
114
+ /**
115
+ * In-place revision log kept for wire compatibility (pre-append-only shape).
116
+ * v0.6 append-only model: a revision creates a successor goal instead, so new
117
+ * revisions are recorded as `parentGoalId`/`supersededBy` plus the
118
+ * `.cybernetics/revisions-<newGoalId>.jsonl` audit line, and this stays empty.
119
+ */
120
+ revisions?: Array<{ version: number; reason: string; at: number }>
121
+ }
122
+
123
+ /** One `revise` transition as written to `.cybernetics/revisions-<newGoalId>.jsonl`. */
124
+ export interface GoalRevisionRecord {
125
+ /** Superseded goal id. */
126
+ from: string
127
+ /** Newly created successor goal id. */
128
+ to: string
129
+ /** Why the goal changed (required on the `revise` action). */
130
+ reason: string
131
+ /** Epoch milliseconds of the revision. */
132
+ at: number
133
+ /**
134
+ * v0.7: how the old goal's in-flight round was handled, and why. Present on
135
+ * every revision so the audit trail records the decision basis, not just the
136
+ * outcome — see `decideAbort` for the decision tree.
137
+ */
138
+ abort?: { abort: boolean; mode: 'auto' | 'explicit'; basis: string }
139
+ }
140
+
141
+ /**
142
+ * Whether `revise` aborts the old goal's in-flight task agent round, and on
143
+ * what grounds. Pure — the caller supplies the "is a round in flight" fact
144
+ * from its own `running` map (no new orchestration mechanism; this reads the
145
+ * state the controller already maintains).
146
+ *
147
+ * Decision tree (v0.7 all-goals mode; `abortPrevious` explicitly passed keeps
148
+ * the v0.6 manual semantics):
149
+ * 1. `abortPrevious` given explicitly → honor it verbatim (mode 'explicit').
150
+ * 2. Old goal has NO round in flight → no abort, start the successor directly.
151
+ * 3. Old round in flight AND the revision rewrites the goal itself (a new
152
+ * `objective`, or a different `depth`) → the old round is working toward a
153
+ * target that no longer exists — contradictory goals / write-domain
154
+ * conflict → abort it (the cancel path) so it cannot interleave writes.
155
+ * 4. Old round in flight, correction only (new brief / title / role on the
156
+ * same objective) → the old work is still valuable and its result can be
157
+ * reused → let the round finish naturally, no abort.
158
+ */
159
+ export interface AbortDecision {
160
+ abort: boolean
161
+ mode: 'auto' | 'explicit'
162
+ basis: string
163
+ }
164
+
165
+ export function decideAbort(
166
+ previous: LoopState,
167
+ roundInFlight: boolean,
168
+ args: { abortPrevious?: boolean; objective?: string; depth?: 'quick' | 'full' },
169
+ ): AbortDecision {
170
+ if (args.abortPrevious !== undefined) {
171
+ return {
172
+ abort: args.abortPrevious,
173
+ mode: 'explicit',
174
+ basis: args.abortPrevious
175
+ ? '调用方显式要求中止旧轮(abortPrevious: true,复用 cancel 路径)'
176
+ : '调用方显式要求保留旧轮(abortPrevious: false,跑完自然结束)',
177
+ }
178
+ }
179
+ if (!roundInFlight) {
180
+ return { abort: false, mode: 'auto', basis: '旧 task agent 无在跑轮(查 goal 板 running 状态),直接启动新 goal,无需中止' }
181
+ }
182
+ const retargets = args.objective !== undefined || (args.depth !== undefined && args.depth !== (previous.depth ?? 'full'))
183
+ if (retargets) {
184
+ return {
185
+ abort: true,
186
+ mode: 'auto',
187
+ basis: '旧轮在跑且修订改写了目标本身(objective/depth 变化,目标矛盾/写域冲突)——自动中止旧轮,避免旧任务继续写入已被取代的方向',
188
+ }
189
+ }
190
+ return {
191
+ abort: false,
192
+ mode: 'auto',
193
+ basis: '旧轮在跑但任务目标未变(仅 brief/title/role 修正,旧工作仍可复用/可并行)——等待旧轮自然结束,结果由新 goal 继承',
194
+ }
195
+ }
196
+
197
+ /** Input for the append-only goal creation path. */
198
+ export interface GoalSeed {
199
+ goalId: string
200
+ title?: string
201
+ /** Overrides the inherited objective when present. */
202
+ objective?: string
203
+ /** Goal this one was revised from (lineage link). */
204
+ parentGoalId?: string
205
+ depth?: 'quick' | 'full'
206
+ executor?: 'ephemeral' | 'persistent'
207
+ role?: string
208
+ subgoal?: string
76
209
  }
77
210
 
78
211
  /** Append one round's structured result to the per-workspace audit log. */
@@ -85,6 +218,60 @@ async function writeRoundLog(root: string, goalId: string | undefined, round: nu
85
218
  await writeFile(join(root, STATE_DIR, `round-${slug}-${round}.json`), JSON.stringify(result, null, 2))
86
219
  }
87
220
 
221
+ /**
222
+ * Append one revision line to `.cybernetics/revisions-<newGoalId>.jsonl`.
223
+ * One line per transition, so a revision chain is readable (and greppable)
224
+ * without reconstructing it from per-goal fields. The directory is created
225
+ * lazily; the caller treats a write failure as non-fatal (see `revise`).
226
+ */
227
+ export async function writeRevisionLog(root: string, newGoalId: string, record: GoalRevisionRecord): Promise<string> {
228
+ await mkdir(join(root, STATE_DIR), { recursive: true })
229
+ const slug = newGoalId.replace(/[^a-zA-Z0-9._-]/g, '_')
230
+ const file = join(root, STATE_DIR, `revisions-${slug}.jsonl`)
231
+ await writeFile(file, `${JSON.stringify(record)}\n`, { flag: 'a' })
232
+ return file
233
+ }
234
+
235
+ /**
236
+ * Build the successor goal of an append-only revision. Everything the old goal
237
+ * owned that is not per-round state — title, objective, depth, executor, role —
238
+ * is inherited unless a seed field overrides it; the new goal starts at round 0
239
+ * and carries `parentGoalId`. Pure: it neither mutates nor registers anything.
240
+ */
241
+ export function createGoal(previous: LoopState | undefined, seed: GoalSeed): LoopState {
242
+ const objective = seed.objective ?? previous?.objective ?? previous?.title ?? ''
243
+ const title = seed.title ?? previous?.title ?? objective.split('\n')[0].slice(0, 60)
244
+ const goal: LoopState = {
245
+ goalId: seed.goalId,
246
+ title,
247
+ objective,
248
+ round: 0,
249
+ doneSummary: '',
250
+ subgoal: seed.subgoal ?? (objective.length > 0 ? objective : title),
251
+ role: seed.role ?? previous?.role ?? 'default',
252
+ status: 'running',
253
+ }
254
+ const depth = seed.depth ?? previous?.depth
255
+ if (depth !== undefined) goal.depth = depth
256
+ const executor = seed.executor ?? previous?.executor
257
+ if (executor !== undefined) goal.executor = executor
258
+ const parentGoalId = seed.parentGoalId ?? previous?.goalId
259
+ if (parentGoalId !== undefined) goal.parentGoalId = parentGoalId
260
+ return goal
261
+ }
262
+
263
+ /**
264
+ * Freeze a goal as superseded by a successor. Mutates the loop in place (the
265
+ * registry holds the same object); the paused marker is dropped so the view
266
+ * does not mix "awaiting human" with "replaced".
267
+ */
268
+ export function supersedeGoal(previous: LoopState, newGoalId: string): LoopState {
269
+ previous.status = 'superseded'
270
+ previous.supersededBy = newGoalId
271
+ previous.paused = undefined
272
+ return previous
273
+ }
274
+
88
275
  /**
89
276
  * Start one round's workflow run without awaiting it. Returns the round
90
277
  * number and the live run; the caller either awaits `run.result` itself
@@ -109,6 +296,7 @@ export async function startRound(
109
296
  state: { round, doneSummary: state.doneSummary },
110
297
  subgoal: state.subgoal,
111
298
  brief: state.brief,
299
+ depth: state.depth ?? 'full',
112
300
  role: state.role,
113
301
  roles: config.roles ?? {},
114
302
  },
@@ -137,7 +325,9 @@ export async function foldRound(
137
325
  } else {
138
326
  result = settled.value as RoundResult
139
327
  }
140
- await writeRoundLog(root, state.goalId, round, result)
328
+ // v0.6 audit: record which substrate ran the round.
329
+ const logged: RoundResult = { ...result, executor: state.executor ?? 'ephemeral' }
330
+ await writeRoundLog(root, state.goalId, round, logged)
141
331
  return { result, decision: decide(result, state, config) }
142
332
  }
143
333
 
@@ -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
+ }