@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/README.md +133 -2
- package/cordis.patch.yml +18 -1
- package/docs/issue-browser-connection-pool.md +79 -0
- package/docs/listBundles-hang-investigation-20261103.md +93 -0
- package/docs/protocol.md +253 -3
- package/lib/client.js +517 -15
- package/package.json +18 -2
- package/src/controller.ts +193 -3
- package/src/executor.ts +414 -0
- package/src/index.ts +776 -49
- 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/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
|
-
/**
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|