@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/index.ts
CHANGED
|
@@ -22,9 +22,18 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { randomUUID } from 'node:crypto'
|
|
25
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
26
|
+
import { join } from 'node:path'
|
|
25
27
|
import { fileURLToPath } from 'node:url'
|
|
26
|
-
import
|
|
27
|
-
import
|
|
28
|
+
import z from '@deepseek-ai/schemastery'
|
|
29
|
+
import {
|
|
30
|
+
STATE_DIR, createGoal, decideAbort, foldRound, parseAgentModelSpec, runRound, startRound, supersedeGoal, writeRevisionLog,
|
|
31
|
+
} from './controller.ts'
|
|
32
|
+
import type {
|
|
33
|
+
ControllerConfig, ControllerDecision, LoopState, ResumableReason, RoundResult, RolesConfig,
|
|
34
|
+
} from './controller.ts'
|
|
35
|
+
import { startTeamRound, teamExecutorAvailable, teamExecutorUnavailableError } from './executor.ts'
|
|
36
|
+
import { cyberneticsGoalsProjection, goalsFromBoard, publishGoals, setPublishDebugRoot } from './projection.ts'
|
|
28
37
|
|
|
29
38
|
export const name = 'dsh-cybernetics'
|
|
30
39
|
|
|
@@ -34,9 +43,14 @@ export const inject = [
|
|
|
34
43
|
'goals',
|
|
35
44
|
'workingDirectory',
|
|
36
45
|
'agentPresets',
|
|
46
|
+
'agents',
|
|
47
|
+
'sessionProjections',
|
|
37
48
|
] as const
|
|
38
|
-
// All
|
|
39
|
-
// are top-level loader entries) — static
|
|
49
|
+
// All seven are ROOT-level services (goal / working-directory / agent-preset-registry
|
|
50
|
+
// / agents / session-projection registry are top-level loader entries) — static
|
|
51
|
+
// inject cannot pend at boot. `sessionProjections` is the registry dsh-goal
|
|
52
|
+
// itself injects at root; this plugin registers the `cyberneticsGoals` unit
|
|
53
|
+
// there so every goal on the board reaches the「目标」tab (v0.6).
|
|
40
54
|
// `workflowEngine` is different: it is published inside each Agent's preset
|
|
41
55
|
// mount (the `delegation` group with isolate workflowEngine: true), invisible
|
|
42
56
|
// to the root scope. It is resolved per call via
|
|
@@ -55,12 +69,108 @@ export interface Config extends ControllerConfig {
|
|
|
55
69
|
implicitTrigger?: boolean
|
|
56
70
|
/** Per-role sub-agent model/provider overrides. */
|
|
57
71
|
roles?: RolesConfig
|
|
72
|
+
/** Flat task-agent model override ("provider/model" or bare model); empty = inherit session agent. */
|
|
73
|
+
taskAgentModel?: string
|
|
74
|
+
/** Flat verify-agent model override (same format); empty = inherit session agent. */
|
|
75
|
+
verifyAgentModel?: string
|
|
76
|
+
/**
|
|
77
|
+
* Hard orchestration gate (default false): hide execution tools from
|
|
78
|
+
* TOP-LEVEL session agents at prompt-assembly time, so the session agent
|
|
79
|
+
* structurally cannot do task work itself — every task must go through a
|
|
80
|
+
* cybernetics goal + task agent. Task/verify/triage subagents (never in
|
|
81
|
+
* `agents.roots()`) are unaffected and keep full tool access.
|
|
82
|
+
*/
|
|
83
|
+
hardGate?: boolean
|
|
84
|
+
/** Tool names hidden from gated agents (default ['bash', 'edit', 'write']). */
|
|
85
|
+
gatedTools?: string[]
|
|
86
|
+
/**
|
|
87
|
+
* Gate semantics: 'denylist' hides `gatedTools`; 'allowlist' keeps ONLY
|
|
88
|
+
* `gateAllowlist` (default DEFAULT_GATE_ALLOWLIST) plus the cybernetics
|
|
89
|
+
* tool itself — the robust stance against the long tail of mutation tools
|
|
90
|
+
* (farm_*, taskboard_*, knowledge_save, schedule_*, ...).
|
|
91
|
+
*/
|
|
92
|
+
gateMode?: 'denylist' | 'allowlist'
|
|
93
|
+
/** Allowlist-mode survival set; defaults to DEFAULT_GATE_ALLOWLIST. */
|
|
94
|
+
gateAllowlist?: string[]
|
|
95
|
+
/** Extra tool names unioned onto the allowlist (default or gateAllowlist). */
|
|
96
|
+
gateAllowlistExtra?: string[]
|
|
97
|
+
/**
|
|
98
|
+
* All-goals mode (v0.7, default false): EVERY user request — including
|
|
99
|
+
* trivial read-only questions and lightweight analysis — must go through a
|
|
100
|
+
* cybernetics goal first; in-place analysis by the session agent is banned.
|
|
101
|
+
* Off (default) keeps the v0.6 implicit-trigger behavior exactly unchanged.
|
|
102
|
+
*/
|
|
103
|
+
allGoalsMode?: boolean
|
|
58
104
|
}
|
|
59
105
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
106
|
+
// Under the host's 0.1.7 settings model, an exported schemastery `Config`
|
|
107
|
+
// schema IS the plugin entry's settings page: the Web editor derives the
|
|
108
|
+
// plugin config form from it and serves it through the shared configuration
|
|
109
|
+
// forms (see @linxin666/dsh-ssh and @linxin666/dsh-usage for the same
|
|
110
|
+
// pattern). At least one field must be `.volatile()` for the page to be
|
|
111
|
+
// served at all; a volatile edit is committed into the running fiber's
|
|
112
|
+
// reference without a remount and announced via `loader/volatile-update`.
|
|
113
|
+
// allGoalsMode is volatile so the panel toggle is live; hardGate and the
|
|
114
|
+
// gate/roles fields are deliberately non-volatile — they change structural
|
|
115
|
+
// wiring (tool stripping listener, per-role routing), so an edit remounts
|
|
116
|
+
// the row, which is the correct behavior for them.
|
|
117
|
+
export const Config = z.object({
|
|
118
|
+
/** Model-facing tool name. */
|
|
119
|
+
toolName: z.string().default('cybernetics').description('模型侧工具名(缺省 cybernetics)。'),
|
|
120
|
+
/** Inject the implicit goal-loop guidance section. */
|
|
121
|
+
implicitTrigger: z.boolean().default(true).description('注入隐式 goal-loop 系统提示段(缺省开启)。'),
|
|
122
|
+
/** Per-role sub-agent model/provider overrides: role -> {provider, model}. */
|
|
123
|
+
roles: z.dict(z.object({
|
|
124
|
+
provider: z.string(),
|
|
125
|
+
model: z.string(),
|
|
126
|
+
})).description('按角色的子 agent provider/model 覆盖:role -> {provider, model}(如 default/verify/triage/frontend)。'),
|
|
127
|
+
/**
|
|
128
|
+
* Flat task-agent model override ("provider/model" or a bare model name).
|
|
129
|
+
* Empty = inherit the session agent. Merged over roles.default at wiring
|
|
130
|
+
* time (parseAgentModelSpec) — the flat fields are the settings-panel face;
|
|
131
|
+
* roles stays the canonical runtime shape the round script consumes.
|
|
132
|
+
* Volatile (v0.7.7): the host settings form (@deepseek-ai/dsh-settings
|
|
133
|
+
* volatileForm, lib/index.js:122-131) serves ONLY `.volatile()` fields, and
|
|
134
|
+
* its write() refuses any non-volatile path ("Config field ... is not
|
|
135
|
+
* volatile") — a plain field is invisible AND unwritable on the plugins
|
|
136
|
+
* page. Volatile commit keeps the running instance live; the value is read
|
|
137
|
+
* per round start (readConfigField unwrap below), so an edit takes effect
|
|
138
|
+
* on the next goal round without a remount.
|
|
139
|
+
*/
|
|
140
|
+
taskAgentModel: z.string().default('').volatile().description('task agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
|
|
141
|
+
/** Flat verify-agent model override; same format, fallback and volatile semantics as taskAgentModel. */
|
|
142
|
+
verifyAgentModel: z.string().default('').volatile().description('verify agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
|
|
143
|
+
/** Hard orchestration gate (default false). */
|
|
144
|
+
hardGate: z.boolean().default(false).description('硬编排门:装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。修改会触发插件重载。'),
|
|
145
|
+
/** Tool names hidden in denylist mode (default DEFAULT_GATED_TOOLS). */
|
|
146
|
+
gatedTools: z.array(z.string()).description('denylist 模式下隐藏的工具名(缺省内置 DEFAULT_GATED_TOOLS)。'),
|
|
147
|
+
/** Gate semantics. */
|
|
148
|
+
gateMode: z.union(['denylist', 'allowlist'] as const).default('denylist').description('门语义:denylist 隐藏 gatedTools;allowlist 只保留 gateAllowlist(+gateAllowlistExtra)与 cybernetics 工具本身。'),
|
|
149
|
+
/** Allowlist-mode survival set (default DEFAULT_GATE_ALLOWLIST). */
|
|
150
|
+
gateAllowlist: z.array(z.string()).description('allowlist 模式生存工具集(缺省内置 DEFAULT_GATE_ALLOWLIST)。'),
|
|
151
|
+
/** Extra tool names unioned onto the allowlist. */
|
|
152
|
+
gateAllowlistExtra: z.array(z.string()).description('并集追加进 allowlist 的额外工具名。'),
|
|
153
|
+
/**
|
|
154
|
+
* All-goals mode (v0.7, default false): every user request must go through
|
|
155
|
+
* a cybernetics goal first. Volatile: the settings-panel toggle commits
|
|
156
|
+
* into the running instance without a remount.
|
|
157
|
+
*/
|
|
158
|
+
allGoalsMode: z.boolean().default(false).volatile()
|
|
159
|
+
.description('全目标模式(v0.7,缺省关):所有用户请求(含只读问题)都必须先经 cybernetics goal。开关在面板上保存后即时生效,无需重载。'),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
/** One resolved config field: a live reference (volatile), or a plain value. */
|
|
163
|
+
type ConfigField<T> = { get(): T } | T
|
|
164
|
+
|
|
165
|
+
/** Read one resolved config field, following the live reference the schema produces for volatile fields. */
|
|
166
|
+
function readConfigField<T>(field: ConfigField<T> | undefined, fallback: T): T {
|
|
167
|
+
if (field === undefined) return fallback
|
|
168
|
+
if (typeof field === 'object' && field !== null && typeof (field as { get?: unknown }).get === 'function') {
|
|
169
|
+
const value = (field as { get(): T }).get()
|
|
170
|
+
return value === undefined ? fallback : value
|
|
171
|
+
}
|
|
172
|
+
return field as T
|
|
173
|
+
}
|
|
64
174
|
|
|
65
175
|
/**
|
|
66
176
|
* Multi-goal loop registry: session -> goalId -> loop. v0.3 lifts the
|
|
@@ -80,9 +190,170 @@ interface RunningRound {
|
|
|
80
190
|
config: ControllerConfig
|
|
81
191
|
run: { result: Promise<any>; cancel(reason?: string): void; dispose(): Promise<void> }
|
|
82
192
|
settled?: { stopReason: string; value?: unknown; error?: unknown }
|
|
193
|
+
/** Set once the run has been disposed (cancel-by-revision and collect can race). */
|
|
194
|
+
disposed?: boolean
|
|
83
195
|
}
|
|
84
196
|
const running = new Map<string, RunningRound>()
|
|
85
197
|
|
|
198
|
+
/** Dispose a run at most once; disposal is best-effort observability cleanup. */
|
|
199
|
+
async function disposeOnce(handle: RunningRound): Promise<void> {
|
|
200
|
+
if (handle.disposed) return
|
|
201
|
+
handle.disposed = true
|
|
202
|
+
try {
|
|
203
|
+
await handle.run.dispose()
|
|
204
|
+
} catch {
|
|
205
|
+
// An already-settled or torn-down run must not fail the calling action.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- v0.6 board persistence / rehydrate -------------------------------------
|
|
210
|
+
// The `boards` map above is process memory, and the `cyberneticsGoals`
|
|
211
|
+
// projection only replays session events written by SOME process. After a host
|
|
212
|
+
// restart the map is empty and — when the previous process never published (or
|
|
213
|
+
// the log was not yet replayed) — so is the tab: every historical goal seems to
|
|
214
|
+
// have vanished and the user can no longer track goals separately. Each
|
|
215
|
+
// session's board therefore snapshots itself next to the round logs at
|
|
216
|
+
// `<workspace>/.cybernetics/board-<sessionId>.json`, and the first tool call of
|
|
217
|
+
// a session with an empty memory board restores it and republishes once.
|
|
218
|
+
//
|
|
219
|
+
// Persistence is durability, never workflow control: both directions swallow
|
|
220
|
+
// their errors (a read-only workspace still runs rounds; a corrupt snapshot
|
|
221
|
+
// degrades to a fresh board instead of failing the call).
|
|
222
|
+
|
|
223
|
+
/** Snapshot format version; a mismatch is treated as "no snapshot". */
|
|
224
|
+
export const BOARD_FILE_VERSION = 1
|
|
225
|
+
|
|
226
|
+
/** On-disk board snapshot: the controller's whole goal board for one session. */
|
|
227
|
+
export interface BoardFile {
|
|
228
|
+
version: number
|
|
229
|
+
sessionId: string
|
|
230
|
+
/** Epoch milliseconds of the snapshot write. */
|
|
231
|
+
savedAt: number
|
|
232
|
+
goals: LoopState[]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const BOARD_STATUSES: ReadonlySet<string> = new Set(['running', 'paused', 'met', 'cancelled', 'superseded'])
|
|
236
|
+
const PAUSE_REASONS: ReadonlySet<string> = new Set([
|
|
237
|
+
'goal-questionable', 'human-input', 'round-cap', 'failed', 'no-goal',
|
|
238
|
+
])
|
|
239
|
+
const UNSAFE_FILENAME = /[^a-zA-Z0-9._-]/g
|
|
240
|
+
|
|
241
|
+
/** Snapshot file name for one session (ids are slugged: never a path escape). */
|
|
242
|
+
export function boardFileName(sessionId: string): string {
|
|
243
|
+
return `board-${sessionId.replace(UNSAFE_FILENAME, '_')}.json`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Absolute snapshot path under the workspace root. */
|
|
247
|
+
export function boardFilePath(root: string, sessionId: string): string {
|
|
248
|
+
return join(root, STATE_DIR, boardFileName(sessionId))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Normalize one persisted goal back into a LoopState. Anything that is not a
|
|
253
|
+
* usable goal record (no goalId, wrong shape) is dropped, so a partially
|
|
254
|
+
* hand-edited or half-written snapshot still yields a usable board.
|
|
255
|
+
*/
|
|
256
|
+
function restoreLoop(raw: unknown): LoopState | undefined {
|
|
257
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
|
258
|
+
const g = raw as Record<string, unknown>
|
|
259
|
+
if (typeof g.goalId !== 'string' || g.goalId.length === 0) return undefined
|
|
260
|
+
const loop: LoopState = {
|
|
261
|
+
goalId: g.goalId,
|
|
262
|
+
round: typeof g.round === 'number' && Number.isFinite(g.round) ? g.round : 0,
|
|
263
|
+
doneSummary: typeof g.doneSummary === 'string' ? g.doneSummary : '',
|
|
264
|
+
subgoal: typeof g.subgoal === 'string' ? g.subgoal : '',
|
|
265
|
+
role: typeof g.role === 'string' && g.role.length > 0 ? g.role : 'default',
|
|
266
|
+
}
|
|
267
|
+
if (typeof g.title === 'string') loop.title = g.title
|
|
268
|
+
if (typeof g.objective === 'string') loop.objective = g.objective
|
|
269
|
+
if (typeof g.brief === 'string') loop.brief = g.brief
|
|
270
|
+
if (g.depth === 'quick' || g.depth === 'full') loop.depth = g.depth
|
|
271
|
+
if (g.executor === 'ephemeral' || g.executor === 'persistent') loop.executor = g.executor
|
|
272
|
+
if (typeof g.memberName === 'string' && g.memberName.length > 0) loop.memberName = g.memberName
|
|
273
|
+
if (typeof g.status === 'string' && BOARD_STATUSES.has(g.status)) loop.status = g.status as LoopState['status']
|
|
274
|
+
if (g.paused !== null && typeof g.paused === 'object' && !Array.isArray(g.paused)) {
|
|
275
|
+
const p = g.paused as Record<string, unknown>
|
|
276
|
+
if (typeof p.reason === 'string' && PAUSE_REASONS.has(p.reason) && typeof p.note === 'string') {
|
|
277
|
+
loop.paused = { reason: p.reason as ResumableReason, note: p.note }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (typeof g.parentGoalId === 'string' && g.parentGoalId.length > 0) loop.parentGoalId = g.parentGoalId
|
|
281
|
+
if (typeof g.supersededBy === 'string' && g.supersededBy.length > 0) loop.supersededBy = g.supersededBy
|
|
282
|
+
if (Array.isArray(g.revisions)) {
|
|
283
|
+
loop.revisions = []
|
|
284
|
+
for (const entry of g.revisions) {
|
|
285
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
286
|
+
const r = entry as Record<string, unknown>
|
|
287
|
+
loop.revisions.push({
|
|
288
|
+
version: typeof r.version === 'number' && Number.isFinite(r.version) ? r.version : 0,
|
|
289
|
+
reason: typeof r.reason === 'string' ? r.reason : '',
|
|
290
|
+
at: typeof r.at === 'number' && Number.isFinite(r.at) ? r.at : 0,
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return loop
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Read one session's board snapshot. Missing file, unreadable file, invalid
|
|
299
|
+
* JSON, a different format version or a foreign session id all mean "no
|
|
300
|
+
* history" — this function never throws.
|
|
301
|
+
*/
|
|
302
|
+
export async function loadBoardFile(root: string, sessionId: string): Promise<LoopState[] | undefined> {
|
|
303
|
+
let text: string
|
|
304
|
+
try {
|
|
305
|
+
text = await readFile(boardFilePath(root, sessionId), 'utf8')
|
|
306
|
+
} catch {
|
|
307
|
+
return undefined // no snapshot: a genuinely fresh session
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const parsed = JSON.parse(text) as Partial<BoardFile> | null
|
|
311
|
+
if (parsed === null || typeof parsed !== 'object') return undefined
|
|
312
|
+
if (parsed.version !== BOARD_FILE_VERSION) return undefined
|
|
313
|
+
if (typeof parsed.sessionId === 'string' && parsed.sessionId !== sessionId) return undefined
|
|
314
|
+
if (!Array.isArray(parsed.goals)) return undefined
|
|
315
|
+
const out: LoopState[] = []
|
|
316
|
+
const seen = new Set<string>()
|
|
317
|
+
for (const raw of parsed.goals) {
|
|
318
|
+
const loop = restoreLoop(raw)
|
|
319
|
+
if (loop === undefined || loop.goalId === undefined || seen.has(loop.goalId)) continue
|
|
320
|
+
seen.add(loop.goalId)
|
|
321
|
+
out.push(loop)
|
|
322
|
+
}
|
|
323
|
+
return out
|
|
324
|
+
} catch {
|
|
325
|
+
return undefined // corrupt snapshot: behave like an empty board
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Atomically snapshot one session's board (temp file + rename, so a concurrent
|
|
331
|
+
* reader never sees a half-written JSON). Returns the snapshot path, or
|
|
332
|
+
* undefined when the write failed — never throws.
|
|
333
|
+
*/
|
|
334
|
+
export async function saveBoardFile(
|
|
335
|
+
root: string,
|
|
336
|
+
sessionId: string,
|
|
337
|
+
loops: Iterable<LoopState>,
|
|
338
|
+
): Promise<string | undefined> {
|
|
339
|
+
const file = boardFilePath(root, sessionId)
|
|
340
|
+
const payload: BoardFile = {
|
|
341
|
+
version: BOARD_FILE_VERSION,
|
|
342
|
+
sessionId,
|
|
343
|
+
savedAt: Date.now(),
|
|
344
|
+
goals: [...loops],
|
|
345
|
+
}
|
|
346
|
+
const tmp = `${file}.${process.pid}-${randomUUID().slice(0, 8)}.tmp`
|
|
347
|
+
try {
|
|
348
|
+
await mkdir(join(root, STATE_DIR), { recursive: true })
|
|
349
|
+
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
350
|
+
await rename(tmp, file)
|
|
351
|
+
return file
|
|
352
|
+
} catch {
|
|
353
|
+
return undefined
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
86
357
|
/** Minimal required-field validation the omitted dsh-tools wrapper would do. */
|
|
87
358
|
function requireFields(args: Record<string, unknown>, names: string[]): void {
|
|
88
359
|
for (const n of names) {
|
|
@@ -92,14 +363,29 @@ function requireFields(args: Record<string, unknown>, names: string[]): void {
|
|
|
92
363
|
|
|
93
364
|
const IMPLICIT_GUIDANCE = `Goal-driven orchestration (cybernetics). You are the SESSION agent: you never execute task work
|
|
94
365
|
yourself — you manage goals and the task agents that execute them. Classify each user input:
|
|
95
|
-
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
366
|
+
- TRIVIAL READ-ONLY QUESTION (a fact your read tools answer directly, no computation) → answer inline, no goal.
|
|
367
|
+
- LIGHTWEIGHT ANALYSIS (a bounded query/lookup needing tool work but no multi-step planning) → create a goal
|
|
368
|
+
with depth 'quick': one task-agent round, feed the result back directly.
|
|
369
|
+
- COMPLEX ANALYSIS (data fetching to disk, datasets, statistics, multi-step query planning) and ANY
|
|
370
|
+
WORLD-CHANGING TASK → create a goal with depth 'full' (default): the work-verify loop must confirm the
|
|
371
|
+
result before you deliver it. Never shortcut a change or a complex analysis through a quick round.
|
|
372
|
+
- For goals: call cybernetics with action 'start' (non-blocking) or 'round' (blocking), a short title, and
|
|
373
|
+
a brief — the COMPLETE work order extracted from the request (background, paths, constraints, acceptance
|
|
374
|
+
criteria). The task agent is an independent subagent with NO conversation history; the brief must be
|
|
375
|
+
detailed enough for it to finish the work unaided. One session supports multiple concurrent goals.
|
|
376
|
+
- EXECUTOR (new): pass executor 'persistent' for goals whose rounds should accumulate context across
|
|
377
|
+
iterations (multi-turn analysis by a durable teammate — the member keeps its identity and findings
|
|
378
|
+
while you keep the dialogue). Default 'ephemeral' runs each round as fresh workflow subagents, which
|
|
379
|
+
is right for one-shot work and changes. Persistent rounds still verify (full depth) through ephemeral
|
|
380
|
+
verifiers, so verification never inherits the task narrative.
|
|
381
|
+
- CORRECTION to an existing task → an APPEND-ONLY goal revision: call action 'revise' with the old goalId, a
|
|
382
|
+
required reason, the new objective when the goal itself changed (omit it to inherit), and optionally a new
|
|
383
|
+
title / depth / executor. 'revise' creates a NEW goal (parentGoalId = old goal) and starts its first round;
|
|
384
|
+
the old goal keeps its final state as 'superseded'. Pass abortPrevious: true only when the session agent
|
|
385
|
+
decides the old goal's in-flight task agent must be interrupted (default: let it finish; the abort reuses
|
|
386
|
+
the 'cancel' path, and 'cancel' on its own still stops a round without revising the goal).
|
|
387
|
+
NEVER edit a goal's objective in place — 'revise' is the only goal-change verb, and only after the user
|
|
388
|
+
has confirmed the change in conversation.
|
|
103
389
|
- FEEDBACK arrives as the tool result ('round') or, for async rounds, as a queued notification followed by
|
|
104
390
|
action 'collect'. Then choose: summarize the outcome to the user; start a verification round; or start a
|
|
105
391
|
corrective round whose brief is built from the verdict gaps. On decision 'continue', launch the next
|
|
@@ -108,16 +394,201 @@ yourself — you manage goals and the task agents that execute them. Classify ea
|
|
|
108
394
|
'round-cap'/'failed' = report status. Never mark a goal complete yourself; completion is the user's act.
|
|
109
395
|
Use action 'list' to see every goal on this session's board with its status, round and subgoal.`
|
|
110
396
|
|
|
397
|
+
/**
|
|
398
|
+
* v0.7 all-goals mode addendum: turns the implicit trigger from "suggest a
|
|
399
|
+
* goal for substantial tasks" into "every request is a goal". Contains the
|
|
400
|
+
* four routing rules the protocol and tests pin down:
|
|
401
|
+
* (1) everything enters a goal; (2) simple read-only → 'quick' single round;
|
|
402
|
+
* (3) complex analysis / write tasks → progressive multi-round ('full');
|
|
403
|
+
* (4) the revise auto-abort decision tree.
|
|
404
|
+
*/
|
|
405
|
+
const ALL_GOALS_GUIDANCE = `
|
|
406
|
+
ALL-GOALS MODE ACTIVE (routing protocol — no exceptions, no in-place analysis):
|
|
407
|
+
- RULE 1 — EVERYTHING ENTERS A GOAL: for ANY user request (question, lookup, analysis, change), first
|
|
408
|
+
create a goal via cybernetics (action 'start' with title + brief), then let the task agent produce the
|
|
409
|
+
answer. NEVER answer a request by doing the task work yourself with read tools — even a one-file lookup
|
|
410
|
+
or a quick calculation goes through the loop. If a change is requested while the hard gate is off, the
|
|
411
|
+
protocol still forbids in-place edits: delegate.
|
|
412
|
+
- RULE 2 — SIMPLE READ-ONLY REQUEST → depth 'quick': one task-agent round, no verify/triage, feed the
|
|
413
|
+
result straight back to the user (bounded lookups, single-file reads, small queries).
|
|
414
|
+
- RULE 3 — COMPLEX ANALYSIS or WRITE TASK → depth 'full', progressive multi-round: launch with action
|
|
415
|
+
'start' (async), then 'collect' each settled round and advance the subgoal round by round (task →
|
|
416
|
+
verify → triage). Analysis: data fetching to disk, datasets, statistics, multi-step query planning.
|
|
417
|
+
Write tasks additionally rely on the hard gate: the session agent must NEVER execute a change itself —
|
|
418
|
+
delegation through the goal loop is the only path, however small the change.
|
|
419
|
+
- RULE 4 — REVISE DECISION TREE (updating an existing goal stays append-only: a new goal with
|
|
420
|
+
parentGoalId, old goal superseded; 'revise' is the only goal-change verb). Decide abortPrevious by the
|
|
421
|
+
old task agent's state (check with action 'list'):
|
|
422
|
+
(a) old round STILL RUNNING and the revision contradicts it (objective/depth rewritten — conflicting
|
|
423
|
+
goals / write-domain overlap) → let 'revise' auto-abort: do not pass abortPrevious; the plugin
|
|
424
|
+
cancels the old round automatically;
|
|
425
|
+
(b) old round STILL RUNNING but the correction keeps the objective (brief/title/role only) and the old
|
|
426
|
+
work stays valuable / parallelizable / reusable → do not pass abortPrevious; the plugin waits for
|
|
427
|
+
the old round to finish naturally;
|
|
428
|
+
(c) old task agent ALREADY FINISHED (goal not running on the board) → just call 'revise'; no abort is
|
|
429
|
+
needed and none is attempted.
|
|
430
|
+
The chosen branch and its reason are recorded in the .cybernetics/revisions-<newGoalId>.jsonl audit
|
|
431
|
+
line (abort.mode = auto|explicit + basis). Passing abortPrevious explicitly keeps manual control.`
|
|
432
|
+
|
|
433
|
+
/** Tool names hidden from top-level session agents when the hard gate is on. */
|
|
434
|
+
const DEFAULT_GATED_TOOLS = ['bash', 'edit', 'write']
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Allowlist-mode survival set for top-level orchestrator agents: pure reads,
|
|
438
|
+
* conversation, presentation and goal/orchestration verbs. Anything that
|
|
439
|
+
* changes the world — files, services, boards, knowledge, schedules, docs,
|
|
440
|
+
* other agents' work — is absent, so the session agent must delegate it to a
|
|
441
|
+
* cybernetics task agent. The cybernetics tool itself is always re-added.
|
|
442
|
+
*/
|
|
443
|
+
const DEFAULT_GATE_ALLOWLIST = [
|
|
444
|
+
// local reads & search
|
|
445
|
+
'read', 'read_image', 'read_pdf', 'glob', 'grep', 'working_directory',
|
|
446
|
+
// web reads
|
|
447
|
+
'web_fetch', 'web_search', 'websearch',
|
|
448
|
+
// conversation & presentation
|
|
449
|
+
'ask_user_question', 'skill', 'todo_write',
|
|
450
|
+
'render_ui', 'validate_dsh_ui', 'visualize', 'ui_present', 'present', 'sidebar_open',
|
|
451
|
+
// goal verbs (orchestrator's own)
|
|
452
|
+
'create_goal', 'update_goal', 'get_goal',
|
|
453
|
+
// knowledge reads
|
|
454
|
+
'knowledge_query', 'knowledge_get', 'knowledge_search', 'knowledge_graph',
|
|
455
|
+
'knowledge_status', 'knowledge_stats', 'knowledge_chart',
|
|
456
|
+
// photostory (read-only plugin)
|
|
457
|
+
'photostory_search', 'photostory_search_text', 'photostory_photo_get',
|
|
458
|
+
'photostory_photo_faces', 'photostory_person_get', 'photostory_person_query',
|
|
459
|
+
'photostory_person_search', 'photostory_tags', 'photostory_stats',
|
|
460
|
+
'photostory_status', 'photostory_zdisk_list', 'photostory_zdisk_file',
|
|
461
|
+
'photostory_download',
|
|
462
|
+
// service/board reads
|
|
463
|
+
'farm_status', 'farm_logs', 'taskboard_list', 'taskboard_get', 'taskboard_comments',
|
|
464
|
+
// team/session reads
|
|
465
|
+
'list_agents', 'wait_agent', 'team_task_list', 'team_task_get',
|
|
466
|
+
'graft_read', 'graft_search', 'graft_sessions', 'dsh_im_group_history', 'dsh_im_group_file',
|
|
467
|
+
// misc reads
|
|
468
|
+
'schedule_list', 'job_list', 'job_output', 'audit_status',
|
|
469
|
+
'config_list_snapshots', 'uno_read', 'uno_status',
|
|
470
|
+
'univer_api', 'univer_inspect', 'univer_status', 'univer_lint', 'univer_resources',
|
|
471
|
+
'browser_tabs', 'browser_snapshot', 'browser_console', 'browser_network',
|
|
472
|
+
'browser_screenshot', 'browser_navigate', 'browser_scroll',
|
|
473
|
+
]
|
|
474
|
+
|
|
475
|
+
const HARD_GATE_GUIDANCE = `
|
|
476
|
+
HARD GATE ACTIVE: your tool list is restricted to reading, searching, conversation, presentation and
|
|
477
|
+
goal/orchestration verbs — every tool that CHANGES the world (edit/write/bash, service lifecycle,
|
|
478
|
+
board/knowledge/schedule writes, spawning other workers, ...) is STRUCTURALLY unavailable to you.
|
|
479
|
+
Answering a read-only question directly is fine and needs no goal; but the moment the user asks for a
|
|
480
|
+
change — however small — that is your signal to delegate: create a cybernetics goal (action 'start'
|
|
481
|
+
with title+brief) and let a task agent execute it. Never tell the user a change is "too small for the
|
|
482
|
+
loop": the loop is the only way changes get done. If you look for a tool to perform a change and do
|
|
483
|
+
not find it, that is the gate working as intended — delegate instead.`
|
|
484
|
+
|
|
111
485
|
export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
112
486
|
const toolName = config.toolName ?? 'cybernetics'
|
|
487
|
+
const hardGate = config.hardGate === true
|
|
488
|
+
// allGoalsMode is a volatile field: under the loader's schema resolution it
|
|
489
|
+
// arrives as a live reference whose value the settings panel commits in
|
|
490
|
+
// place (no remount), so read it at use time instead of capturing it once.
|
|
491
|
+
const allGoalsMode = () => readConfigField(config.allGoalsMode, false) === true
|
|
492
|
+
const disposers: Array<() => void> = []
|
|
493
|
+
|
|
494
|
+
// v0.6: register the multi-goal session projection, so every goal on the
|
|
495
|
+
// session board — not just dsh-goal's single focus slot — reaches the
|
|
496
|
+
// 「目标 / Goal」tab. The registration rides this plugin's fiber: unloading
|
|
497
|
+
// the plugin removes the key and the tab falls back to the `goal` face.
|
|
498
|
+
// Guarded because the offline harness (and any host without the projection
|
|
499
|
+
// seam) builds a ctx without `sessionProjections`.
|
|
500
|
+
const projections = ctx.sessionProjections as
|
|
501
|
+
| { register?: (definition: unknown) => () => void }
|
|
502
|
+
| undefined
|
|
503
|
+
if (projections !== undefined && typeof projections.register === 'function') {
|
|
504
|
+
const projectionDisposer = projections.register(cyberneticsGoalsProjection())
|
|
505
|
+
if (typeof projectionDisposer === 'function') disposers.push(projectionDisposer)
|
|
506
|
+
}
|
|
113
507
|
|
|
114
|
-
|
|
115
|
-
|
|
508
|
+
// The implicit guidance section embeds the volatile allGoalsMode wording,
|
|
509
|
+
// so it must be re-derived when a settings-panel toggle is committed into
|
|
510
|
+
// the running instance (the loader announces loader/volatile-update on this
|
|
511
|
+
// fiber instead of remounting; same re-derive pattern as dsh-ssh's sync()).
|
|
512
|
+
let disposeSection: (() => void) | undefined
|
|
513
|
+
function registerGuidanceSection() {
|
|
514
|
+
if (disposeSection !== undefined) disposeSection()
|
|
515
|
+
disposeSection = undefined
|
|
516
|
+
if (config.implicitTrigger === false) return
|
|
517
|
+
disposeSection = ctx.systemPrompt.section({
|
|
116
518
|
name: `tool:${toolName}`,
|
|
117
519
|
order: ctx.systemPrompt.getSectionOrder('TOOL_WORKFLOW'),
|
|
118
|
-
text: IMPLICIT_GUIDANCE,
|
|
520
|
+
text: IMPLICIT_GUIDANCE + (allGoalsMode() ? ALL_GOALS_GUIDANCE : '') + (hardGate ? HARD_GATE_GUIDANCE : ''),
|
|
119
521
|
})
|
|
120
522
|
}
|
|
523
|
+
registerGuidanceSection()
|
|
524
|
+
disposers.push(() => { if (disposeSection !== undefined) disposeSection() })
|
|
525
|
+
ctx.on?.('loader/volatile-update', () => { registerGuidanceSection() })
|
|
526
|
+
|
|
527
|
+
if (hardGate) {
|
|
528
|
+
// The hard gate: a system-prompt/assemble waterfall listener that strips
|
|
529
|
+
// execution tools from TOP-LEVEL agents' assemblies. Workflow subagents
|
|
530
|
+
// (task/verify/triage) and team teammates are never in agents.roots(),
|
|
531
|
+
// so their assemblies pass through untouched with full tool access.
|
|
532
|
+
const allowlistMode = config.gateMode === 'allowlist'
|
|
533
|
+
// NOTE: schema-resolved config turns an unset z.array field into [] (not
|
|
534
|
+
// undefined), so fall back on length, not on ?? — otherwise the gate
|
|
535
|
+
// would silently keep/gate nothing.
|
|
536
|
+
const keep = new Set([
|
|
537
|
+
...(config.gateAllowlist?.length ? config.gateAllowlist : DEFAULT_GATE_ALLOWLIST),
|
|
538
|
+
...(config.gateAllowlistExtra ?? []),
|
|
539
|
+
toolName,
|
|
540
|
+
])
|
|
541
|
+
const gated = new Set(config.gatedTools?.length ? config.gatedTools : DEFAULT_GATED_TOOLS)
|
|
542
|
+
const listenerDisposer = ctx.on('system-prompt/assemble', (assembly: any, context: any, next: () => unknown) => {
|
|
543
|
+
const scope = context?.scope
|
|
544
|
+
if (!scope) return next()
|
|
545
|
+
let roots: unknown[] = []
|
|
546
|
+
try { roots = ctx.agents?.roots?.() ?? [] } catch { roots = [] }
|
|
547
|
+
const isRoot = roots.some((r: any) =>
|
|
548
|
+
r === scope || r?.id === scope || (r?.id && r.id === scope?.id))
|
|
549
|
+
// Fallback for agents the registry does not list as roots (a resumed
|
|
550
|
+
// session's agent, for example): a session with no parentSession is a
|
|
551
|
+
// top-level session and belongs on the orchestrator side of the gate.
|
|
552
|
+
// Delegated children (workflow subagents, team members) carry
|
|
553
|
+
// `origin: subagent` + `parentSession` and keep their full tool set.
|
|
554
|
+
const parentSession = (scope as any)?.session?.header?.parentSession
|
|
555
|
+
const isSessionRoot = parentSession === undefined
|
|
556
|
+
if (!isRoot && !isSessionRoot) return next()
|
|
557
|
+
if (Array.isArray(assembly.tools)) {
|
|
558
|
+
assembly.tools = allowlistMode
|
|
559
|
+
? assembly.tools.filter((t: any) => keep.has(t?.name))
|
|
560
|
+
: assembly.tools.filter((t: any) => !gated.has(t?.name))
|
|
561
|
+
}
|
|
562
|
+
return next()
|
|
563
|
+
})
|
|
564
|
+
disposers.push(() => listenerDisposer?.())
|
|
565
|
+
|
|
566
|
+
// Execution-layer gate (the assembly filter above only hides names; the
|
|
567
|
+
// host resolves any registered tool by name, so an unadvertised call must
|
|
568
|
+
// be denied here too). Same root/child rule: a session with no
|
|
569
|
+
// parentSession is orchestrated by the gate; delegated children pass.
|
|
570
|
+
const isGatedAgent = (agent: any): boolean => {
|
|
571
|
+
if (agent === undefined || agent === null || typeof agent !== 'object') return false
|
|
572
|
+
let roots: unknown[] = []
|
|
573
|
+
try { roots = ctx.agents?.roots?.() ?? [] } catch { roots = [] }
|
|
574
|
+
if (roots.some((r: any) => r === agent || r?.id === agent || (r?.id && r.id === agent?.id))) return true
|
|
575
|
+
return agent?.session?.header?.parentSession === undefined
|
|
576
|
+
}
|
|
577
|
+
const preDisposer = ctx.on('tools/pre-execute', (exec: any, next: () => Promise<unknown>) => {
|
|
578
|
+
const name = exec?.name
|
|
579
|
+
if (typeof name !== 'string') return next()
|
|
580
|
+
if (!isGatedAgent(exec?.agent)) return next()
|
|
581
|
+
const blocked = allowlistMode ? !keep.has(name) : gated.has(name)
|
|
582
|
+
if (!blocked) return next()
|
|
583
|
+
return Promise.resolve({
|
|
584
|
+
kind: 'deny',
|
|
585
|
+
reason:
|
|
586
|
+
`cybernetics hard gate: tool "${name}" is not available to the session agent. ` +
|
|
587
|
+
'Delegate the change: create a cybernetics goal (action "start" with title+brief) and let a task agent execute it.',
|
|
588
|
+
})
|
|
589
|
+
})
|
|
590
|
+
disposers.push(() => preDisposer?.())
|
|
591
|
+
}
|
|
121
592
|
|
|
122
593
|
const disposer = (ctx.tools as { register: (d: unknown) => () => void }).register({
|
|
123
594
|
name: toolName,
|
|
@@ -130,14 +601,17 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
130
601
|
'(use when a user correction must interrupt the current task agent); list shows every goal on this ' +
|
|
131
602
|
'session board. New task: pass title + brief (a complete, self-contained work order — the task agent ' +
|
|
132
603
|
'has NO conversation history) to create a goal; one session supports multiple concurrent goals. ' +
|
|
604
|
+
'revise is the ONLY goal-change verb and it is append-only: it creates a NEW goal (parentGoalId = old ' +
|
|
605
|
+
'goal, inherited depth/executor/role/objective unless overridden), starts that goal\'s first round, and ' +
|
|
606
|
+
'marks the old goal superseded (supersededBy = new goal) — it never rewrites the objective in place. ' +
|
|
133
607
|
'Existing goal: pass its goalId. The controller never marks a goal complete — that is the user\'s act.',
|
|
134
608
|
parameters: {
|
|
135
609
|
type: 'object',
|
|
136
610
|
properties: {
|
|
137
611
|
action: {
|
|
138
612
|
type: 'string',
|
|
139
|
-
enum: ['round', 'start', 'collect', 'cancel', 'list'],
|
|
140
|
-
description: 'round=同步执行一轮(默认)| start=后台启动一轮并立即返回 | collect=折叠已完成的后台轮 | cancel=中止正在运行的轮(修正场景)| list=查看本会话全部 goal。',
|
|
613
|
+
enum: ['round', 'start', 'collect', 'cancel', 'revise', 'list'],
|
|
614
|
+
description: 'round=同步执行一轮(默认)| start=后台启动一轮并立即返回 | collect=折叠已完成的后台轮 | cancel=中止正在运行的轮(修正场景)| revise=追加式修正:为新目标建新 goal 并启动第 1 轮,旧 goal 置 superseded | list=查看本会话全部 goal。',
|
|
141
615
|
},
|
|
142
616
|
goalId: {
|
|
143
617
|
type: 'string',
|
|
@@ -157,10 +631,37 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
157
631
|
type: 'string',
|
|
158
632
|
description: '本轮子目标;缺省沿用上轮决策(首轮取自 goal 本身)。',
|
|
159
633
|
},
|
|
634
|
+
depth: {
|
|
635
|
+
type: 'string',
|
|
636
|
+
enum: ['quick', 'full'],
|
|
637
|
+
description:
|
|
638
|
+
'本轮深度:quick=轻量分析,只跑 task agent 单 agent 一轮出结果直接反馈(跳过 verify/triage);' +
|
|
639
|
+
'full=完整工作-验证循环(默认)——复杂分析(取数/下载/统计/多步查询规划)与一切变更类任务必须用 full。',
|
|
640
|
+
},
|
|
160
641
|
role: {
|
|
161
642
|
type: 'string',
|
|
162
643
|
description: '本轮 task agent 的角色(对应部署配置 roles 表,如 frontend/backend/writer);缺省 default。',
|
|
163
644
|
},
|
|
645
|
+
reason: {
|
|
646
|
+
type: 'string',
|
|
647
|
+
description:
|
|
648
|
+
'revise 必填:修订原因(写进 .cybernetics/revisions-<新goalId>.jsonl 审计,并向用户解释为什么换目标)。',
|
|
649
|
+
},
|
|
650
|
+
objective: {
|
|
651
|
+
type: 'string',
|
|
652
|
+
description: 'revise:新 goal 的目标文本;缺省继承旧 goal 的 objective(用于「目标本身被校准」的修订)。',
|
|
653
|
+
},
|
|
654
|
+
abortPrevious: {
|
|
655
|
+
type: 'boolean',
|
|
656
|
+
description:
|
|
657
|
+
'revise:是否中止旧 goal 正在运行的 task agent 轮。缺省时按决策树自动判断(查 goal 板:旧轮在跑且目标被改写→自动中止;旧轮在跑但目标不变→等待自然结束;旧轮未跑→直启);' +
|
|
658
|
+
'显式传 true/false 保持人工控制(true 复用 cancel 路径)。',
|
|
659
|
+
},
|
|
660
|
+
executor: {
|
|
661
|
+
type: 'string',
|
|
662
|
+
enum: ['ephemeral', 'persistent'],
|
|
663
|
+
description: 'revise:新 goal 的执行者基质,缺省继承旧 goal(当前均为 ephemeral)。',
|
|
664
|
+
},
|
|
164
665
|
},
|
|
165
666
|
},
|
|
166
667
|
output: {
|
|
@@ -168,6 +669,10 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
168
669
|
type: 'object',
|
|
169
670
|
properties: {
|
|
170
671
|
goalId: { type: 'string' },
|
|
672
|
+
previousGoalId: { type: 'string' },
|
|
673
|
+
revised: { type: 'boolean' },
|
|
674
|
+
abort: { type: 'object' },
|
|
675
|
+
revision: { type: 'object' },
|
|
171
676
|
round: { type: 'number' },
|
|
172
677
|
decision: { type: 'object' },
|
|
173
678
|
result: { type: 'object' },
|
|
@@ -179,6 +684,15 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
179
684
|
`${g.goalId} [${g.status}${g.running ? '/running' : ''}] R${g.round} ${g.title ?? ''} — ${g.subgoal}`)
|
|
180
685
|
return [{ type: 'text', text: lines.length ? lines.join('\n') : '(goal 板为空)' }]
|
|
181
686
|
}
|
|
687
|
+
if (value.revised) {
|
|
688
|
+
return [{
|
|
689
|
+
type: 'text',
|
|
690
|
+
text:
|
|
691
|
+
`goal ${value.previousGoalId} 已置 superseded → 新 goal ${value.goalId}` +
|
|
692
|
+
`(修订自 ${value.previousGoalId}${value.aborted ? ',旧轮已中止' : ''});` +
|
|
693
|
+
`新 goal 第 ${value.round} 轮已后台启动,完成后将通知本会话`,
|
|
694
|
+
}]
|
|
695
|
+
}
|
|
182
696
|
if (value.started) return [{ type: 'text', text: `goal ${value.goalId} 第 ${value.round} 轮已后台启动,完成后将通知本会话` }]
|
|
183
697
|
if (value.pending) return [{ type: 'text', text: `goal ${value.goalId} 第 ${value.round} 轮仍在运行` }]
|
|
184
698
|
if (value.cancelled) return [{ type: 'text', text: `goal ${value.goalId} 第 ${value.round} 轮已请求中止:${value.cancelled}` }]
|
|
@@ -190,12 +704,17 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
190
704
|
},
|
|
191
705
|
},
|
|
192
706
|
async execute(args: {
|
|
193
|
-
action?: 'round' | 'start' | 'collect' | 'cancel' | 'list'
|
|
707
|
+
action?: 'round' | 'start' | 'collect' | 'cancel' | 'revise' | 'list'
|
|
194
708
|
goalId?: string
|
|
195
709
|
title?: string
|
|
196
710
|
brief?: string
|
|
197
711
|
subgoal?: string
|
|
712
|
+
depth?: 'quick' | 'full'
|
|
198
713
|
role?: string
|
|
714
|
+
reason?: string
|
|
715
|
+
objective?: string
|
|
716
|
+
abortPrevious?: boolean
|
|
717
|
+
executor?: 'ephemeral' | 'persistent'
|
|
199
718
|
}, exec: any) {
|
|
200
719
|
requireFields(args as Record<string, unknown>, [])
|
|
201
720
|
const goals = ctx.goals
|
|
@@ -207,15 +726,55 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
207
726
|
const root = workingDirectory.get(parent.session)
|
|
208
727
|
const action = args.action ?? 'round'
|
|
209
728
|
|
|
729
|
+
const sessionId = typeof parent.session?.id === 'string' && parent.session.id.length > 0
|
|
730
|
+
? parent.session.id
|
|
731
|
+
: undefined
|
|
210
732
|
const board = boards.get(parent.session) ?? new Map<string, LoopState>()
|
|
211
733
|
boards.set(parent.session, board)
|
|
212
734
|
|
|
735
|
+
// v0.6 write point: publish the session's WHOLE board as one
|
|
736
|
+
// `cybernetics/goals` event, so the「目标」tab tracks every goal of this
|
|
737
|
+
// session (dsh-goal's single-slot `goal` projection cannot). Idempotent:
|
|
738
|
+
// republishing an unchanged board appends nothing and never throws.
|
|
739
|
+
// Then snapshot the same board to `<workspace>/.cybernetics/board-<id>.json`
|
|
740
|
+
// so a restarted controller can rehydrate it (see loadBoardFile).
|
|
741
|
+
const publishBoard = async (): Promise<void> => {
|
|
742
|
+
// The workspace root is authoritative here (the working-directory
|
|
743
|
+
// service resolved it); hand it to the projection so the one-shot
|
|
744
|
+
// host-copy resolution record lands in `<workspace>/.cybernetics/`
|
|
745
|
+
// even if a session header carries no cwd.
|
|
746
|
+
if (typeof root === 'string' && root.length > 0) setPublishDebugRoot(root)
|
|
747
|
+
publishGoals(parent.session, goalsFromBoard(board.values()))
|
|
748
|
+
if (sessionId !== undefined && typeof root === 'string' && root.length > 0) {
|
|
749
|
+
await saveBoardFile(root, sessionId, board.values())
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// v0.6 read point: first touch of a session after a host restart (empty
|
|
754
|
+
// memory board). Restore the snapshot and republish it once, so the tab
|
|
755
|
+
// shows the recovered history immediately instead of waiting for the next
|
|
756
|
+
// round. A missing/corrupt/foreign snapshot leaves the board empty.
|
|
757
|
+
if (board.size === 0 && sessionId !== undefined && typeof root === 'string' && root.length > 0) {
|
|
758
|
+
const restored = await loadBoardFile(root, sessionId)
|
|
759
|
+
if (restored !== undefined && restored.length > 0) {
|
|
760
|
+
for (const restoredLoop of restored) {
|
|
761
|
+
if (restoredLoop.goalId !== undefined) board.set(restoredLoop.goalId, restoredLoop)
|
|
762
|
+
}
|
|
763
|
+
await publishBoard()
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
213
767
|
// 'list' needs no goal resolution and no workflow engine.
|
|
214
768
|
if (action === 'list') {
|
|
215
769
|
return {
|
|
216
770
|
board: [...board.values()].map((g) => ({
|
|
217
|
-
goalId: g.goalId, title: g.title, status: g.status ?? 'paused',
|
|
771
|
+
goalId: g.goalId, title: g.title, objective: g.objective, status: g.status ?? 'paused',
|
|
218
772
|
running: running.has(g.goalId!), round: g.round, subgoal: g.subgoal,
|
|
773
|
+
depth: g.depth ?? 'full',
|
|
774
|
+
executor: g.executor ?? 'ephemeral',
|
|
775
|
+
...(g.memberName ? { memberName: g.memberName } : {}),
|
|
776
|
+
...(g.parentGoalId ? { parentGoalId: g.parentGoalId } : {}),
|
|
777
|
+
...(g.supersededBy ? { supersededBy: g.supersededBy } : {}),
|
|
219
778
|
})),
|
|
220
779
|
}
|
|
221
780
|
}
|
|
@@ -234,6 +793,8 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
234
793
|
|
|
235
794
|
// --- Resolve the target loop for this call ------------------------------
|
|
236
795
|
// Priority: explicit goalId > new goal (title) > active dsh-goal focus.
|
|
796
|
+
// 'revise' is excluded from the title branch: there `title` names the NEW
|
|
797
|
+
// goal's title while the call targets the OLD goal.
|
|
237
798
|
let loop: LoopState | undefined
|
|
238
799
|
if (args.goalId) {
|
|
239
800
|
loop = board.get(args.goalId)
|
|
@@ -246,16 +807,21 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
246
807
|
board.set(loop.goalId!, loop)
|
|
247
808
|
}
|
|
248
809
|
if (!loop) throw new Error(`cybernetics: goal "${args.goalId}" not on this session's board (try action "list")`)
|
|
249
|
-
} else if (args.title) {
|
|
810
|
+
} else if (args.title && action !== 'revise') {
|
|
250
811
|
// New task: create a registry goal. dsh-goal stays single-slot; when it
|
|
251
812
|
// is free, focus the new goal into it (GUI tab + round-driver pickup).
|
|
252
813
|
const goalId = `cyb-${randomUUID().slice(0, 8)}`
|
|
253
814
|
const objective = args.brief ?? args.title
|
|
254
|
-
loop = {
|
|
255
|
-
goalId,
|
|
256
|
-
|
|
257
|
-
|
|
815
|
+
loop = createGoal(undefined, {
|
|
816
|
+
goalId,
|
|
817
|
+
title: args.title,
|
|
818
|
+
objective,
|
|
819
|
+
...(args.subgoal ? { subgoal: args.subgoal } : {}),
|
|
820
|
+
})
|
|
258
821
|
board.set(goalId, loop)
|
|
822
|
+
// Goal creation is a projection write point: the tab gains the goal
|
|
823
|
+
// immediately, before the first round settles.
|
|
824
|
+
await publishBoard()
|
|
259
825
|
if (!goalView || goalView.phase !== 'active') {
|
|
260
826
|
try { goals.create(parent, { objective: `${args.title}\n\n${objective}` }) } catch { /* focus sync is best-effort */ }
|
|
261
827
|
}
|
|
@@ -283,21 +849,182 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
283
849
|
// Per-call overrides.
|
|
284
850
|
if (args.subgoal) loop.subgoal = args.subgoal
|
|
285
851
|
if (args.brief) loop.brief = args.brief
|
|
852
|
+
if (args.depth) loop.depth = args.depth
|
|
853
|
+
if (args.executor) loop.executor = args.executor
|
|
286
854
|
if (args.role) loop.role = args.role
|
|
287
855
|
|
|
856
|
+
// A goal adopted from the dsh-goal focus slot joins the board here; make
|
|
857
|
+
// it visible in the tab even when this call changes nothing else.
|
|
858
|
+
await publishBoard()
|
|
859
|
+
|
|
860
|
+
// Flat settings-panel model fields fold into the canonical roles shape:
|
|
861
|
+
// an unset field leaves its role untouched = inherit the session agent.
|
|
862
|
+
// Both fields are volatile (v0.7.7), so the loader hands back live
|
|
863
|
+
// references — unwrap through readConfigField ('' = inherit) exactly
|
|
864
|
+
// like the allGoalsMode live ref above.
|
|
865
|
+
const roleOverrides: RolesConfig = { ...(config.roles ?? {}) }
|
|
866
|
+
const taskSpec = parseAgentModelSpec(readConfigField(config.taskAgentModel, ''))
|
|
867
|
+
if (taskSpec) roleOverrides.default = { ...roleOverrides.default, ...taskSpec }
|
|
868
|
+
const verifySpec = parseAgentModelSpec(readConfigField(config.verifyAgentModel, ''))
|
|
869
|
+
if (verifySpec) roleOverrides.verify = { ...roleOverrides.verify, ...verifySpec }
|
|
288
870
|
const controllerConfig: ControllerConfig = {
|
|
289
871
|
roundScriptPath: config.roundScriptPath ?? fileURLToPath(new URL('../workflows/round.js', import.meta.url)),
|
|
290
872
|
roundsPerSubgoal: config.roundsPerSubgoal,
|
|
291
|
-
roles:
|
|
873
|
+
roles: roleOverrides,
|
|
292
874
|
}
|
|
293
875
|
const objective = loop.objective ?? goalView?.objective ?? loop.title ?? ''
|
|
294
876
|
|
|
877
|
+
/**
|
|
878
|
+
* Start one round on the goal's chosen substrate. Ephemeral (default) is
|
|
879
|
+
* the v0.5 workflow run; persistent uses an Agent Teams teammate that
|
|
880
|
+
* survives across rounds. Both return the same run-like handle, so the
|
|
881
|
+
* async/blocking/cancel paths below stay substrate-agnostic.
|
|
882
|
+
*/
|
|
883
|
+
const startAnyRound = async (target: LoopState, signal?: AbortSignal): Promise<any> => {
|
|
884
|
+
if (target.executor === 'persistent') {
|
|
885
|
+
// Soft probe: never read ctx.agentTeams directly here — Cordis throws
|
|
886
|
+
// on an un-injected service property, which would surface as an
|
|
887
|
+
// internal proxy error instead of "unsupported, use ephemeral".
|
|
888
|
+
if (!teamExecutorAvailable(ctx)) throw teamExecutorUnavailableError()
|
|
889
|
+
return startTeamRound(ctx, parent, target, controllerConfig, signal)
|
|
890
|
+
}
|
|
891
|
+
const started = await startRound(engine, parent, root, target, controllerConfig, { objective: target.objective ?? '' }, signal)
|
|
892
|
+
return {
|
|
893
|
+
round: started.round,
|
|
894
|
+
result: started.run.result,
|
|
895
|
+
cancel: (reason?: string) => started.run.cancel(reason),
|
|
896
|
+
dispose: () => started.run.dispose(),
|
|
897
|
+
run: started.run,
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Start one goal's round in the background (action 'start' and `revise`
|
|
903
|
+
* share this): install the handle, then queue the completion notice. The
|
|
904
|
+
* goal's status is already 'running' when this is called.
|
|
905
|
+
*/
|
|
906
|
+
const launchBackground = async (target: LoopState): Promise<{ round: number }> => {
|
|
907
|
+
const controller = new AbortController()
|
|
908
|
+
if (exec.signal) {
|
|
909
|
+
if (exec.signal.aborted) controller.abort()
|
|
910
|
+
else exec.signal.addEventListener('abort', () => controller.abort(), { once: true })
|
|
911
|
+
}
|
|
912
|
+
const started: any = await startAnyRound(target, controller.signal)
|
|
913
|
+
const round = started.round as number
|
|
914
|
+
const handle: RunningRound = { goalId: target.goalId!, round, parent, root, loop: target, config: controllerConfig, run: started }
|
|
915
|
+
running.set(target.goalId!, handle)
|
|
916
|
+
started.result.then(async (settled: any) => {
|
|
917
|
+
handle.settled = settled
|
|
918
|
+
// Superseded goal (revise with abortPrevious: false): the successor
|
|
919
|
+
// carries the work from here — no collect notice, and release the run
|
|
920
|
+
// so a finished-but-replaced round cannot leak the handle.
|
|
921
|
+
if (target.supersededBy !== undefined) {
|
|
922
|
+
if (running.get(target.goalId!) === handle) {
|
|
923
|
+
running.delete(target.goalId!)
|
|
924
|
+
await disposeOnce(handle)
|
|
925
|
+
}
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
notifyRoundSettled(parent, target, round)
|
|
929
|
+
}).catch(() => { /* result never rejects by contract; belt and braces */ })
|
|
930
|
+
return { round }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// --- revise (append-only goal model) ------------------------------------
|
|
934
|
+
// A goal change NEVER rewrites the objective in place: it creates a
|
|
935
|
+
// successor goal (parentGoalId = old), starts that goal's first round, and
|
|
936
|
+
// freezes the old goal as superseded (supersededBy = new). The session
|
|
937
|
+
// agent decides whether the old goal's in-flight task agent is aborted.
|
|
938
|
+
if (action === 'revise') {
|
|
939
|
+
const previous = loop
|
|
940
|
+
const reason = typeof args.reason === 'string' ? args.reason.trim() : ''
|
|
941
|
+
if (reason.length === 0) {
|
|
942
|
+
throw new Error('cybernetics: action "revise" requires a non-empty "reason" — record why the goal changed')
|
|
943
|
+
}
|
|
944
|
+
if (previous.supersededBy !== undefined) {
|
|
945
|
+
throw new Error(
|
|
946
|
+
`cybernetics: goal "${previous.goalId}" was already superseded by "${previous.supersededBy}" — ` +
|
|
947
|
+
'revise the successor goal instead of forking the lineage',
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
// v0.7 abort policy: explicit abortPrevious keeps the v0.6 manual
|
|
951
|
+
// semantics; when the caller omits it, all-goals mode auto-decides via
|
|
952
|
+
// the decision tree (see decideAbort). With allGoalsMode off, the
|
|
953
|
+
// omitted flag means false exactly as before (backward compat) and the
|
|
954
|
+
// audit records that legacy default instead of a fabricated decision.
|
|
955
|
+
const roundInFlight = running.has(previous.goalId!)
|
|
956
|
+
const abortDecision = decideAbort(previous, roundInFlight, args)
|
|
957
|
+
const legacyDefault = args.abortPrevious === undefined && !allGoalsMode()
|
|
958
|
+
const effectiveAbort = legacyDefault
|
|
959
|
+
? { abort: false, mode: 'explicit' as const, basis: 'all-goals 模式未开启:abortPrevious 缺省沿用 v0.6 默认 false(不中止旧轮)' }
|
|
960
|
+
: abortDecision
|
|
961
|
+
const abortPrevious = args.abortPrevious !== undefined
|
|
962
|
+
? args.abortPrevious === true
|
|
963
|
+
: (allGoalsMode() ? abortDecision.abort : false)
|
|
964
|
+
const previousHandle = running.get(previous.goalId!)
|
|
965
|
+
let aborted: string | undefined
|
|
966
|
+
if (abortPrevious && previousHandle) {
|
|
967
|
+
previousHandle.run.cancel(args.brief ?? `goal 被修订取代:${reason}`)
|
|
968
|
+
running.delete(previous.goalId!)
|
|
969
|
+
await disposeOnce(previousHandle)
|
|
970
|
+
aborted = '旧 goal 的在跑轮已中止'
|
|
971
|
+
}
|
|
972
|
+
const newGoalId = `cyb-${randomUUID().slice(0, 8)}`
|
|
973
|
+
const next = createGoal(previous, {
|
|
974
|
+
goalId: newGoalId,
|
|
975
|
+
...(args.title ? { title: args.title } : {}),
|
|
976
|
+
...(args.objective ? { objective: args.objective } : {}),
|
|
977
|
+
...(args.depth ? { depth: args.depth } : {}),
|
|
978
|
+
...(args.executor ? { executor: args.executor } : {}),
|
|
979
|
+
...(args.role ? { role: args.role } : {}),
|
|
980
|
+
subgoal: args.objective ?? previous.objective ?? previous.title ?? newGoalId,
|
|
981
|
+
})
|
|
982
|
+
// A revision inherits the previous work order unless a corrected brief
|
|
983
|
+
// is supplied; the round-1 task agent reads `next.brief` verbatim.
|
|
984
|
+
if (args.brief ?? previous.brief) next.brief = args.brief ?? previous.brief
|
|
985
|
+
board.set(newGoalId, next)
|
|
986
|
+
supersedeGoal(previous, newGoalId)
|
|
987
|
+
await publishBoard()
|
|
988
|
+
const at = Date.now()
|
|
989
|
+
let revisionLog: string | undefined
|
|
990
|
+
try {
|
|
991
|
+
revisionLog = await writeRevisionLog(root, newGoalId, {
|
|
992
|
+
from: previous.goalId!, to: newGoalId, reason, at,
|
|
993
|
+
abort: effectiveAbort,
|
|
994
|
+
})
|
|
995
|
+
} catch {
|
|
996
|
+
// Audit is observability: a read-only workspace must not break the
|
|
997
|
+
// revision (the lineage fields on the board still carry the change).
|
|
998
|
+
}
|
|
999
|
+
const { round } = await launchBackground(next)
|
|
1000
|
+
return {
|
|
1001
|
+
goalId: newGoalId,
|
|
1002
|
+
previousGoalId: previous.goalId,
|
|
1003
|
+
revised: true,
|
|
1004
|
+
round,
|
|
1005
|
+
started: true,
|
|
1006
|
+
...(aborted ? { aborted } : {}),
|
|
1007
|
+
abort: effectiveAbort,
|
|
1008
|
+
revision: { from: previous.goalId, to: newGoalId, reason, at },
|
|
1009
|
+
...(revisionLog ? { revisionLog } : {}),
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// A superseded goal is frozen history: only the successor may be driven.
|
|
1014
|
+
if (loop.supersededBy !== undefined) {
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
`cybernetics: goal "${loop.goalId}" is superseded by "${loop.supersededBy}" — ` +
|
|
1017
|
+
'drive the successor goal (see action "list")',
|
|
1018
|
+
)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
295
1021
|
// --- cancel -------------------------------------------------------------
|
|
296
1022
|
if (action === 'cancel') {
|
|
297
1023
|
const h = running.get(loop.goalId!)
|
|
298
1024
|
if (!h) return { goalId: loop.goalId, cancelled: '该 goal 当前没有运行中的轮' }
|
|
299
1025
|
loop.status = 'cancelled'
|
|
300
1026
|
h.run.cancel(args.brief ?? 'session agent 决定终止(用户修正)')
|
|
1027
|
+
await publishBoard()
|
|
301
1028
|
return { goalId: loop.goalId, round: h.round, cancelled: '已请求中止;完成通知到达后请 collect 折叠结果' }
|
|
302
1029
|
}
|
|
303
1030
|
|
|
@@ -309,7 +1036,9 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
309
1036
|
const { result, decision } = await foldRound(h.root, h.round, h.settled, h.loop, h.config)
|
|
310
1037
|
applyDecision(h.loop, h.round, result, decision)
|
|
311
1038
|
running.delete(loop.goalId!)
|
|
312
|
-
await h
|
|
1039
|
+
await disposeOnce(h)
|
|
1040
|
+
// Round settlement decision applied — republish (round / status / subgoal).
|
|
1041
|
+
await publishBoard()
|
|
313
1042
|
return { goalId: loop.goalId, round: h.round, decision, result }
|
|
314
1043
|
}
|
|
315
1044
|
|
|
@@ -318,35 +1047,33 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
318
1047
|
throw new Error(`cybernetics: goal "${loop.goalId}" already has a round in flight — collect or cancel it first`)
|
|
319
1048
|
}
|
|
320
1049
|
loop.status = 'running'
|
|
1050
|
+
// In-flight visibility: the blocking 'round' path can run for minutes, so
|
|
1051
|
+
// publish before awaiting it (the async path needs no second publish —
|
|
1052
|
+
// the state did not change).
|
|
1053
|
+
await publishBoard()
|
|
321
1054
|
|
|
322
1055
|
if (action === 'start') {
|
|
323
|
-
const
|
|
324
|
-
if (exec.signal) {
|
|
325
|
-
if (exec.signal.aborted) controller.abort()
|
|
326
|
-
else exec.signal.addEventListener('abort', () => controller.abort(), { once: true })
|
|
327
|
-
}
|
|
328
|
-
const { round, run } = await startRound(
|
|
329
|
-
engine, parent, root, loop, controllerConfig, { objective }, controller.signal,
|
|
330
|
-
)
|
|
331
|
-
const handle: RunningRound = { goalId: loop.goalId!, round, parent, root, loop, config: controllerConfig, run }
|
|
332
|
-
running.set(loop.goalId!, handle)
|
|
333
|
-
run.result.then((settled: any) => {
|
|
334
|
-
handle.settled = settled
|
|
335
|
-
notifyRoundSettled(parent, loop, round)
|
|
336
|
-
}).catch(() => { /* result never rejects by contract; belt and braces */ })
|
|
1056
|
+
const { round } = await launchBackground(loop)
|
|
337
1057
|
return { goalId: loop.goalId, round, started: true }
|
|
338
1058
|
}
|
|
339
1059
|
|
|
340
|
-
// Blocking round (default).
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
1060
|
+
// Blocking round (default). Both substrates expose {round, result, dispose}.
|
|
1061
|
+
const handle: any = await startAnyRound(loop, exec.signal)
|
|
1062
|
+
let settled: any
|
|
1063
|
+
try {
|
|
1064
|
+
settled = await handle.result
|
|
1065
|
+
} finally {
|
|
1066
|
+
await handle.dispose()
|
|
1067
|
+
}
|
|
1068
|
+
const { result, decision } = await foldRound(root, handle.round, settled, loop, controllerConfig)
|
|
344
1069
|
applyDecision(loop, result.round, result, decision)
|
|
1070
|
+
// Round settlement decision applied — republish (round / status / subgoal).
|
|
1071
|
+
await publishBoard()
|
|
345
1072
|
return { goalId: loop.goalId, round: result.round, decision, result }
|
|
346
1073
|
},
|
|
347
1074
|
})
|
|
348
1075
|
|
|
349
|
-
return () => disposer()
|
|
1076
|
+
return () => { disposer(); disposers.forEach((d) => d()) }
|
|
350
1077
|
}
|
|
351
1078
|
|
|
352
1079
|
/** Fold a controller decision back into the loop's durable-ish state. */
|