@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.
@@ -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
+ }