@mars.liu/dsh-cybernetics 0.7.8 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -73,6 +73,8 @@ export interface Config extends ControllerConfig {
73
73
  taskAgentModel?: string
74
74
  /** Flat verify-agent model override (same format); empty = inherit session agent. */
75
75
  verifyAgentModel?: string
76
+ /** Flat triage-agent model override (same format); empty = inherit session agent. */
77
+ triageAgentModel?: string
76
78
  /**
77
79
  * Hard orchestration gate (default false): hide execution tools from
78
80
  * TOP-LEVEL session agents at prompt-assembly time, so the session agent
@@ -101,6 +103,12 @@ export interface Config extends ControllerConfig {
101
103
  * Off (default) keeps the v0.6 implicit-trigger behavior exactly unchanged.
102
104
  */
103
105
  allGoalsMode?: boolean
106
+ /**
107
+ * Max concurrent rounds across ALL sessions (default 0 = unlimited): a new
108
+ * round (blocking, 'start' or 'revise') is refused while this many rounds
109
+ * are in flight, protecting the host from parallel-agent storms.
110
+ */
111
+ maxConcurrentRounds?: number
104
112
  }
105
113
 
106
114
  // Under the host's 0.1.7 settings model, an exported schemastery `Config`
@@ -110,20 +118,35 @@ export interface Config extends ControllerConfig {
110
118
  // pattern). At least one field must be `.volatile()` for the page to be
111
119
  // served at all; a volatile edit is committed into the running fiber's
112
120
  // 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.
121
+ // The user-facing knobs (taskAgentModel / verifyAgentModel / triageAgentModel /
122
+ // roundsPerSubgoal / maxConcurrentRounds / allGoalsMode) are all
123
+ // volatile so they are visible AND writable in the settings panel and take
124
+ // effect on the next goal round. roles stays in the schema but is NOT
125
+ // volatile (v0.8.4): the panel serves only volatile fields, so it no longer
126
+ // shows in the GUI — advanced users (agent-guided) configure it directly in
127
+ // the profile config yaml; a host reload applies it. hardGate and the gate fields stay non-volatile: the panel
128
+ // cannot write them (they change structural wiring — tool stripping listener —
129
+ // so they belong in the profile config document instead).
117
130
  export const Config = z.object({
118
131
  /** Model-facing tool name. */
119
132
  toolName: z.string().default('cybernetics').description('模型侧工具名(缺省 cybernetics)。'),
120
133
  /** Inject the implicit goal-loop guidance section. */
121
134
  implicitTrigger: z.boolean().default(true).description('注入隐式 goal-loop 系统提示段(缺省开启)。'),
122
- /** Per-role sub-agent model/provider overrides: role -> {provider, model}. */
135
+ /**
136
+ * Per-role sub-agent model/provider overrides: role -> {provider, model}.
137
+ * v0.8.4: NOT volatile — the settings card no longer exposes the roles JSON
138
+ * textarea (hand-editing JSON in the GUI is unfriendly), and the host panel
139
+ * serves only volatile fields, so a non-volatile roles simply disappears
140
+ * from the GUI while remaining configurable in the profile config yaml
141
+ * (save triggers a plugin-row remount / host reload, which is fine for an
142
+ * advanced setting an agent guides the user through). Runtime semantics are
143
+ * unchanged: readConfigField accepts a plain object (non-volatile) exactly
144
+ * like a live reference (volatile), and named task roles keep working.
145
+ */
123
146
  roles: z.dict(z.object({
124
147
  provider: z.string(),
125
148
  model: z.string(),
126
- })).description('按角色的子 agent provider/model 覆盖:role -> {provider, model}(如 default/verify/triage/frontend)。'),
149
+ })).description('按角色的子 agent provider/model 覆盖:role -> {provider, model}(如 default/verify/triage/frontend)。高级配置:直接编辑 profile 配置 yaml 后需宿主重载生效(GUI 不再提供此字段的编辑控件)。'),
127
150
  /**
128
151
  * Flat task-agent model override ("provider/model" or a bare model name).
129
152
  * Empty = inherit the session agent. Merged over roles.default at wiring
@@ -140,6 +163,25 @@ export const Config = z.object({
140
163
  taskAgentModel: z.string().default('').volatile().description('task agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
141
164
  /** Flat verify-agent model override; same format, fallback and volatile semantics as taskAgentModel. */
142
165
  verifyAgentModel: z.string().default('').volatile().description('verify agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
166
+ /**
167
+ * Flat triage-agent model override (v0.8.2); identical shape, fallback and
168
+ * volatile semantics as taskAgentModel/verifyAgentModel. The triage role
169
+ * reads the task/verify results and produces the round's convergence
170
+ * decision (met / continue / human-input / ...); quick-depth rounds skip
171
+ * verify+triage entirely, so this override only ever fires on full rounds.
172
+ */
173
+ triageAgentModel: z.string().default('').volatile().description('triage agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。triage 角色读 task/verify 结果输出本轮收敛决策(met/continue/human-input 等);quick 轮不启用。保存后下一轮 goal 即生效,无需重载。'),
174
+ /**
175
+ * Max consecutive rounds per subgoal before the loop pauses for the user
176
+ * (controller round-cap, default 3). Volatile: read per round start, so the
177
+ * panel edit governs the very next goal round without a remount.
178
+ */
179
+ roundsPerSubgoal: z.number().default(3).volatile().description('同一子目标的最大连续轮数(递归深度上限,缺省 3):超过即暂停并升级给用户。保存后下一轮 goal 即生效,无需重载。'),
180
+ /**
181
+ * Max concurrent rounds across all sessions (default 0 = unlimited).
182
+ * Volatile: enforced at every launch, live on the next tool call.
183
+ */
184
+ maxConcurrentRounds: z.number().default(0).volatile().description('全局并行轮数上限(缺省 0 = 不限):在跑轮数达到上限时拒绝启动新一轮,防止并行风暴。保存后即时生效,无需重载。'),
143
185
  /** Hard orchestration gate (default false). */
144
186
  hardGate: z.boolean().default(false).description('硬编排门:装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。修改会触发插件重载。'),
145
187
  /** Tool names hidden in denylist mode (default DEFAULT_GATED_TOOLS). */
@@ -859,19 +901,39 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
859
901
 
860
902
  // Flat settings-panel model fields fold into the canonical roles shape:
861
903
  // 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 ?? {}) }
904
+ // All three fields are volatile (task/verify v0.7.7, triage v0.8.2), so
905
+ // the loader hands back live references — unwrap through readConfigField
906
+ // ('' = inherit) exactly like the allGoalsMode live ref above.
907
+ // roles may be volatile (a live reference, pre-0.8.3 configs) or a plain
908
+ // object (non-volatile since v0.8.4, configured via profile yaml) —
909
+ // readConfigField handles both shapes: a live ref is unwrapped, a plain
910
+ // object is returned as-is (hardGate and friends take the same path).
911
+ const roleOverrides: RolesConfig = { ...readConfigField(config.roles, {} as RolesConfig) }
866
912
  const taskSpec = parseAgentModelSpec(readConfigField(config.taskAgentModel, ''))
867
913
  if (taskSpec) roleOverrides.default = { ...roleOverrides.default, ...taskSpec }
868
914
  const verifySpec = parseAgentModelSpec(readConfigField(config.verifyAgentModel, ''))
869
915
  if (verifySpec) roleOverrides.verify = { ...roleOverrides.verify, ...verifySpec }
916
+ const triageSpec = parseAgentModelSpec(readConfigField(config.triageAgentModel, ''))
917
+ if (triageSpec) roleOverrides.triage = { ...roleOverrides.triage, ...triageSpec }
870
918
  const controllerConfig: ControllerConfig = {
871
919
  roundScriptPath: config.roundScriptPath ?? fileURLToPath(new URL('../workflows/round.js', import.meta.url)),
872
- roundsPerSubgoal: config.roundsPerSubgoal,
920
+ // Volatile schema fields surface as live references — unwrap per round
921
+ // start so a settings-panel edit governs the next round (0/undefined
922
+ // falls back to the controller's built-in default).
923
+ roundsPerSubgoal: Math.max(1, Math.floor(readConfigField(config.roundsPerSubgoal, 3))),
873
924
  roles: roleOverrides,
874
925
  }
926
+ // Concurrent-round cap (0 = unlimited), read live at every launch.
927
+ const maxConcurrentRounds = () => {
928
+ const raw = Math.floor(readConfigField(config.maxConcurrentRounds, 0))
929
+ return Number.isFinite(raw) && raw > 0 ? raw : 0
930
+ }
931
+ const concurrentCapError = () => {
932
+ const cap = maxConcurrentRounds()
933
+ return cap > 0 && running.size >= cap
934
+ ? `cybernetics: 已有 ${running.size} 个运行中的轮,达到全局并行上限 ${cap}(可在设置面板调高 maxConcurrentRounds 或等待完成)`
935
+ : undefined
936
+ }
875
937
  const objective = loop.objective ?? goalView?.objective ?? loop.title ?? ''
876
938
 
877
939
  /**
@@ -904,6 +966,8 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
904
966
  * goal's status is already 'running' when this is called.
905
967
  */
906
968
  const launchBackground = async (target: LoopState): Promise<{ round: number }> => {
969
+ const blocked = concurrentCapError()
970
+ if (blocked) throw new Error(blocked)
907
971
  const controller = new AbortController()
908
972
  if (exec.signal) {
909
973
  if (exec.signal.aborted) controller.abort()
@@ -1046,6 +1110,8 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1046
1110
  if (running.has(loop.goalId!)) {
1047
1111
  throw new Error(`cybernetics: goal "${loop.goalId}" already has a round in flight — collect or cancel it first`)
1048
1112
  }
1113
+ const blocked = concurrentCapError()
1114
+ if (blocked) throw new Error(blocked)
1049
1115
  loop.status = 'running'
1050
1116
  // In-flight visibility: the blocking 'round' path can run for minutes, so
1051
1117
  // publish before awaiting it (the async path needs no second publish —
@@ -36,13 +36,90 @@ const specFor = (key) => {
36
36
  : {}
37
37
  }
38
38
 
39
+ // --- v0.8 调用级模型回退(call-level model fallback) --------------------------
40
+ // 判定信号来自 workflow runtime(@deepseek-ai/dsh-workflow-ptc)的 agent() hook:
41
+ // - 抛出 WorkflowError:code AGENT_START(消息 `agent() could not start a child: …`,
42
+ // provider 解析 / 子 agent spawn / 首调失败——恒为可用性失败)或 AGENT_RESULT
43
+ // (`child agent run failed: …`,消息内嵌失败原因,需命中可用性模式才回退);
44
+ // 其余抛出(INVALID_ARGUMENT / UNSUPPORTED_* / AGENT_CAP 等脚本或守卫错误)
45
+ // 原样上抛,绝不换模型。离线 harness 的 mock 错误没有 code 时按消息模式判定。
46
+ // - 返回 null:runtime 把子 agent 的失败压成 null 且不透传原因(子 run
47
+ // stopReason ≠ 'completed' —— LLM 调用中途失败(限额/网络/provider 报错经
48
+ // dsh-subagent 的 agent/error → stopReason 'error')或无结构化产出)。仅当该
49
+ // 角色配置了模型覆盖时,把 null 视为「覆盖模型疑似不可调用」,撤销覆盖以
50
+ // session agent 模型重试一次(无覆盖时 null 维持原失败语义——本来就跑在
51
+ // session 模型上,无处可退)。
52
+ // 逻辑失败是返回值层面的判定(task agent 正常交付 done=false 的报告、verify
53
+ // 给出未达成 verdict),从不触发回退。每次回退记录进 fallbacks(轮结果携带,
54
+ // 随审计日志落盘),并经 log() 出现在 workflow/log 事件流。
55
+ const fallbacks = []
56
+
57
+ const errorTextOf = (err) => {
58
+ try { return String((err && (err.stack || err.message)) || err) } catch { return '' }
59
+ }
60
+
61
+ // 模型可用性失败的消息模式:限流/quota/鉴权/服务端不可用/网络/provider 解析。
62
+ const AVAILABILITY_PATTERNS = [
63
+ /could not start a child/i, // AGENT_START 包装
64
+ /no subagent provider registered/i, // 覆盖的 provider 未注册
65
+ // v0.8.3:配置的模型在模型设置里删除订阅(provider 仍在但该 model 不可路由)时
66
+ // 的典型报错形态(AGENT_RESULT 内嵌或无 code 的直抛),与既有模式互补。
67
+ /model not found|no such model|unknown model|invalid model|not subscribed|model .*not (?:available|enabled|found)/i,
68
+ /rate.?limit|\b429\b|too many requests/i, // 限流
69
+ /quota|insufficient|额度|余额|配额/i, // 限额 / quota 耗尽
70
+ /\b401\b|\b403\b|unauthorized|forbidden|invalid api key|authentication/i, // 鉴权
71
+ /overloaded|service unavailable|temporarily unavailable|\b502\b|\b503\b|\b529\b/i, // 服务端不可用
72
+ /ECONNREFUSED|ECONNRESET|ECONNABORTED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|socket hang up|fetch failed|network/i, // 网络不可达
73
+ ]
74
+
75
+ const isAvailabilityFailure = (err) => {
76
+ const code = err && typeof err === 'object' ? err.code : undefined
77
+ if (code === 'AGENT_START') return true // 子 agent 启动即失败恒为可用性失败
78
+ const text = errorTextOf(err)
79
+ return AVAILABILITY_PATTERNS.some((re) => re.test(text))
80
+ }
81
+
82
+ const specLabel = (spec) => [spec.provider, spec.model].filter(Boolean).join('/')
83
+
84
+ /**
85
+ * 以角色模型覆盖调用 agent();覆盖模型不可用(可用性错误或 null 无产出)时
86
+ * 回退到 session agent 的模型配置重试一次(不传 provider/model = 继承调用方)。
87
+ * 无覆盖 = 直接调用(无回退必要);非可用性错误原样上抛;重试再失败降级为
88
+ * null(沿用既有「本轮无结果」路径,回退记录仍可见)。
89
+ */
90
+ const callRoleAgent = async (roleKey, prompt, opts) => {
91
+ const spec = specFor(roleKey)
92
+ const overridden = !!(spec.provider || spec.model)
93
+ try {
94
+ const result = await agent(prompt, { ...opts, ...spec })
95
+ if (result === null && overridden) {
96
+ const reason = 'agent() 返回 null(runtime 未透传原因:子 agent 未完成或无结构化产出),疑似覆盖模型不可调用'
97
+ fallbacks.push({ role: roleKey, from: spec, trigger: 'null-result', reason })
98
+ log(`[cybernetics fallback] ${roleKey} 角色覆盖模型 ${specLabel(spec)} 执行无产出,回退到 session agent 模型重试`)
99
+ try { return await agent(prompt, opts) } catch { return null }
100
+ }
101
+ return result
102
+ } catch (err) {
103
+ if (overridden && isAvailabilityFailure(err)) {
104
+ const reason = errorTextOf(err).split('\n')[0].slice(0, 300)
105
+ fallbacks.push({ role: roleKey, from: spec, trigger: 'availability-error', reason })
106
+ log(`[cybernetics fallback] ${roleKey} 角色覆盖模型 ${specLabel(spec)} 调用失败(${reason}),回退到 session agent 模型重试`)
107
+ try { return await agent(prompt, opts) } catch { return null }
108
+ }
109
+ throw err
110
+ }
111
+ }
112
+
113
+ /** 轮结果统一携带回退记录(空数组 = 未发生回退)。 */
114
+ const withFallbacks = (out) => ({ ...out, fallbacks })
115
+
39
116
  // v0.6 TeamExecutor: the persistent teammate already did the task work; the
40
117
  // controller hands its report in as `taskReport`. Skip the task agent entirely
41
118
  // and go straight to verify/triage (quick depth returns the derived verdict).
42
119
  if (taskReport && typeof taskReport === 'object') {
43
120
  const report = taskReport
44
121
  if (depth === 'quick') {
45
- return {
122
+ return withFallbacks({
46
123
  round: state.round,
47
124
  report,
48
125
  verdict: {
@@ -53,10 +130,10 @@ if (taskReport && typeof taskReport === 'object') {
53
130
  goalItselfQuestionable: false,
54
131
  },
55
132
  triage: { auto: [], defer: [], reject: [], human: [] },
56
- }
133
+ })
57
134
  }
58
135
  await phase('verify')
59
- const verdict = await agent(
136
+ const verdict = await callRoleAgent('verify',
60
137
  [
61
138
  `你是独立的 verify agent:你没有、也不应获得 task agent 的叙述过程。`,
62
139
  `下面是目标、工作委托书和 task agent 的报告,请基于证据判定本轮是否达成目标。`,
@@ -72,7 +149,7 @@ if (taskReport && typeof taskReport === 'object') {
72
149
  ``,
73
150
  `判定标准:证据是否支撑报告的每一项结论;是否覆盖了本轮子目标;是否存在未验证的断言。`,
74
151
  ].join('\n'),
75
- { label: 'verify', ...specFor('verify'), schema: {
152
+ { label: 'verify', schema: {
76
153
  type: 'object', additionalProperties: false,
77
154
  required: ['met', 'criteria', 'gaps', 'goalItselfQuestionable'],
78
155
  properties: {
@@ -84,7 +161,7 @@ if (taskReport && typeof taskReport === 'object') {
84
161
  } },
85
162
  )
86
163
  await phase('triage')
87
- const triage = await agent(
164
+ const triage = await callRoleAgent('triage',
88
165
  [
89
166
  `你是 triage agent:把 verify 的缺口拆成四类处置建议。`,
90
167
  ``,
@@ -94,7 +171,7 @@ if (taskReport && typeof taskReport === 'object') {
94
171
  `【本轮子目标】`,
95
172
  subgoal || '',
96
173
  ].join('\n'),
97
- { label: 'triage', ...specFor('triage'), schema: {
174
+ { label: 'triage', schema: {
98
175
  type: 'object', additionalProperties: false,
99
176
  required: ['auto', 'defer', 'reject', 'human'],
100
177
  properties: {
@@ -106,9 +183,9 @@ if (taskReport && typeof taskReport === 'object') {
106
183
  } },
107
184
  )
108
185
  if (verdict === null || triage === null) {
109
- return { round: state.round, report, failed: 'verify/triage agent 失败,本轮无结论' }
186
+ return withFallbacks({ round: state.round, report, failed: 'verify/triage agent 失败,本轮无结论' })
110
187
  }
111
- return { round: state.round, report, verdict, triage }
188
+ return withFallbacks({ round: state.round, report, verdict, triage })
112
189
  }
113
190
 
114
191
  await phase('task')
@@ -119,7 +196,7 @@ await phase('task')
119
196
  // enough to fully drive execution) falling back to the durable objective.
120
197
  const workOrder = (brief && brief.trim()) || goal.objective
121
198
 
122
- const report = await agent(
199
+ const report = await callRoleAgent(role ?? 'default',
123
200
  [
124
201
  `你是一个独立的 task agent:不继承任何对话历史,从零开始工作。`,
125
202
  `下面这份工作委托书是你可以获得的全部任务信息,它被要求详细到足以让你独立完成当前工作;`,
@@ -142,7 +219,7 @@ const report = await agent(
142
219
  `完成后交付:summary、done、artifacts、evidence、nextStepThoughts。`,
143
220
  `nextStepThoughts 只记录"值得做但超出本轮子目标"的后续工作线索,不要执行它们。`,
144
221
  ].join('\n'),
145
- { label: `task:${role ?? 'default'}`, ...specFor(role ?? 'default'), schema: {
222
+ { label: `task:${role ?? 'default'}`, schema: {
146
223
  type: 'object', additionalProperties: false,
147
224
  required: ['summary', 'done', 'artifacts', 'evidence', 'nextStepThoughts'],
148
225
  properties: {
@@ -155,14 +232,14 @@ const report = await agent(
155
232
  )
156
233
 
157
234
  if (report === null) {
158
- return { round: state.round, failed: 'task agent 失败,本轮无结果' }
235
+ return withFallbacks({ round: state.round, failed: 'task agent 失败,本轮无结果' })
159
236
  }
160
237
 
161
238
  // quick depth: lightweight analysis — one agent, one round, direct feedback.
162
239
  // Skip verify/triage entirely; derive a verdict-shaped outcome from the
163
240
  // task report so the controller's decision logic works unchanged.
164
241
  if (depth === 'quick') {
165
- return {
242
+ return withFallbacks({
166
243
  round: state.round,
167
244
  report,
168
245
  verdict: {
@@ -173,12 +250,12 @@ if (depth === 'quick') {
173
250
  goalItselfQuestionable: false,
174
251
  },
175
252
  triage: { auto: [], defer: [], reject: [], human: [] },
176
- }
253
+ })
177
254
  }
178
255
 
179
256
  await phase('verify')
180
257
 
181
- const verdict = await agent(
258
+ const verdict = await callRoleAgent('verify',
182
259
  [
183
260
  `你是 verify agent,独立验收 task agent 的交付。你只对照目标判定达成度,把目标拆成可判定的方面逐条列出并判定,每个判断必须给出证据;`,
184
261
  `不重做工作,不接受"大概完成了"。`,
@@ -192,7 +269,7 @@ const verdict = await agent(
192
269
  ``,
193
270
  `若你认为是目标本身有问题(而非执行缺口),置 goalItselfQuestionable=true。`,
194
271
  ].join('\n'),
195
- { label: 'verify', ...specFor('verify'), schema: {
272
+ { label: 'verify', schema: {
196
273
  type: 'object', additionalProperties: false,
197
274
  required: ['met', 'criteria', 'gaps', 'goalItselfQuestionable'],
198
275
  properties: {
@@ -209,12 +286,12 @@ const verdict = await agent(
209
286
  )
210
287
 
211
288
  if (verdict === null) {
212
- return { round: state.round, failed: 'verify agent 失败,本轮无结果', report }
289
+ return withFallbacks({ round: state.round, failed: 'verify agent 失败,本轮无结果', report })
213
290
  }
214
291
 
215
292
  await phase('triage')
216
293
 
217
- const triage = await agent(
294
+ const triage = await callRoleAgent('triage',
218
295
  [
219
296
  `你是 goal agent。对 task agent 提出的后续工作线索做三分拣:`,
220
297
  `- auto:目标内、低风险、可自动执行的下一步工作`,
@@ -229,7 +306,7 @@ const triage = await agent(
229
306
  `待分拣线索:`,
230
307
  ...report.nextStepThoughts.map((s, i) => `${i + 1}. ${s}`),
231
308
  ].join('\n'),
232
- { label: 'triage', ...specFor('triage'), schema: {
309
+ { label: 'triage', schema: {
233
310
  type: 'object', additionalProperties: false,
234
311
  required: ['auto', 'defer', 'reject', 'human'],
235
312
  properties: {
@@ -242,4 +319,4 @@ const triage = await agent(
242
319
  } },
243
320
  )
244
321
 
245
- return { round: state.round, report, verdict, triage: triage ?? { auto: [], defer: [], reject: [], human: [] } }
322
+ return withFallbacks({ round: state.round, report, verdict, triage: triage ?? { auto: [], defer: [], reject: [], human: [] } })