@kasenri/dsh-orbit 0.5.6 → 0.5.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/lib/dsh-host.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { resolve } from 'node:path';
4
+ import { SessionId } from '@deepseek-ai/dsh-session';
2
5
  import { collectTurnToolFacts } from "./evidence.js";
3
6
  import { redactText, truncateSafe } from "./sanitize.js";
4
7
  import { classifyTurnSettlement } from "./settlement.js";
@@ -24,6 +27,7 @@ export class DshOrbitHost {
24
27
  ownedChildren = new Set();
25
28
  interruptedChildren = new Set();
26
29
  childParents = new Map();
30
+ childGrants = new Map();
27
31
  nowFn;
28
32
  sleepFn;
29
33
  constructor(ctx, options = {}) {
@@ -39,12 +43,14 @@ export class DshOrbitHost {
39
43
  }
40
44
  async startRole(request) {
41
45
  const parent = this.parent();
46
+ if (request.workspace !== undefined && resolve(parent.session.header.cwd ?? process.cwd()) !== resolve(request.workspace)) {
47
+ throw new Error('ORBIT_WORKSPACE_MISMATCH: 子代理工作目录必须与 Orbit 持有的 workspace 一致。');
48
+ }
42
49
  const prompt = [{ type: 'text', text: request.prompt }];
43
50
  const agentOptions = {
44
51
  provider: request.route.provider,
45
52
  model: request.route.model,
46
- ...(request.route.reasoningEffort ? { reasoningEffort: request.route.reasoningEffort } : {}),
47
- ...(request.route.maxTokens ? { maxTokens: request.route.maxTokens } : {}),
53
+ reasoningEffort: request.route.reasoningEffort,
48
54
  };
49
55
  if (request.role === 'executor') {
50
56
  return this.startExecutor(parent, request, prompt, agentOptions);
@@ -70,7 +76,7 @@ export class DshOrbitHost {
70
76
  ...(request.toolFilter ? { toolFilter: request.toolFilter } : {}),
71
77
  ...(request.outputSchema ? { outputSchema: request.outputSchema } : {}),
72
78
  }));
73
- this.registerChild(run.id, parent);
79
+ this.registerChild(run.id, parent, request);
74
80
  const result = run.result
75
81
  .then((value) => ({
76
82
  childId: run.id,
@@ -106,13 +112,18 @@ export class DshOrbitHost {
106
112
  if (request.resumeOf) {
107
113
  const existingId = request.resumeOf;
108
114
  const existing = this.ctx.agents.get(existingId);
109
- if (existing) {
115
+ const grant = this.childGrants.get(existingId);
116
+ const requestedTools = new Set(request.toolFilter?.allow ?? []);
117
+ const sameGrant = grant?.role === 'executor' && grant.workspace === (request.workspace ?? parent.session.header.cwd ?? process.cwd())
118
+ && grant.tools.size === requestedTools.size && [...grant.tools].every((tool) => requestedTools.has(tool));
119
+ if (existing && sameGrant && this.childParents.get(existingId) === parent && this.ctx.agents.isOwnedBy(SessionId(existingId), parent)) {
110
120
  const agent = existing;
111
121
  this.interruptedChildren.delete(existingId);
112
- const done = this.waitForExecutorSettlement(agent, existingId);
122
+ const previousTurns = (agent.session?.snapshotEvents?.() ?? []).filter((event) => event.type === 'turn/start').length;
113
123
  await this.ctx.subagents.sendMessage(parent, existingId, prompt, {
114
- signal: new AbortController().signal,
124
+ signal: request.signal ?? new AbortController().signal,
115
125
  });
126
+ const done = this.waitForExecutorSettlement(agent, existingId, previousTurns);
116
127
  return {
117
128
  childId: existingId,
118
129
  result: done,
@@ -124,19 +135,28 @@ export class DshOrbitHost {
124
135
  }
125
136
  const controller = new AbortController();
126
137
  request.signal?.addEventListener('abort', () => controller.abort(), { once: true });
127
- const started = await this.ctx.subagents.startContinuable({
128
- provider: 'spawn',
129
- label: request.label,
130
- request: {
131
- prompt,
132
- parent,
133
- agentOptions,
134
- ...(request.toolFilter ? { toolFilter: request.toolFilter } : {}),
135
- },
136
- signal: controller.signal,
137
- });
138
- const childId = String(started.childId);
139
- this.registerChild(childId, parent);
138
+ const reservedId = SessionId(randomUUID());
139
+ const childId = String(reservedId);
140
+ this.registerChild(childId, parent, request);
141
+ let started;
142
+ try {
143
+ started = await this.ctx.subagents.startContinuable({
144
+ provider: 'spawn',
145
+ label: request.label,
146
+ childId: reservedId,
147
+ request: {
148
+ prompt,
149
+ parent,
150
+ agentOptions,
151
+ ...(request.toolFilter ? { toolFilter: request.toolFilter } : {}),
152
+ },
153
+ signal: controller.signal,
154
+ });
155
+ }
156
+ catch (error) {
157
+ this.forgetChild(childId);
158
+ throw error;
159
+ }
140
160
  const agent = this.ctx.agents.get(started.childId);
141
161
  const result = agent
142
162
  ? this.waitForExecutorSettlement(agent, childId)
@@ -149,19 +169,26 @@ export class DshOrbitHost {
149
169
  runtimeSnapshot: agent ? () => this.snapshotAgent(agent) : undefined,
150
170
  };
151
171
  }
152
- registerChild(childId, parent) {
172
+ registerChild(childId, parent, request) {
153
173
  this.ownedChildren.add(childId);
154
174
  this.childParents.set(childId, parent);
175
+ this.childGrants.set(childId, {
176
+ role: request.role,
177
+ workspace: resolve(request.workspace ?? parent.session.header.cwd ?? process.cwd()),
178
+ tools: new Set(request.toolFilter?.allow ?? []),
179
+ });
155
180
  }
156
181
  forgetChild(childId) {
157
182
  this.ownedChildren.delete(childId);
158
183
  this.interruptedChildren.delete(childId);
159
184
  this.childParents.delete(childId);
185
+ this.childGrants.delete(childId);
160
186
  }
161
187
  async interruptExecutor(childId, reason) {
162
188
  this.interruptedChildren.add(childId);
163
189
  const parent = this.childParents.get(childId);
164
190
  this.ctx.subagents.interrupt(childId, parent ? { kind: 'ancestor', agent: parent } : { kind: 'user', parentSessionId: childId });
191
+ await this.ctx.agents.get(SessionId(childId))?.whenIdle();
165
192
  void reason;
166
193
  }
167
194
  async drainExecutor(parent, childId) {
@@ -173,8 +200,8 @@ export class DshOrbitHost {
173
200
  }
174
201
  this.forgetChild(childId);
175
202
  }
176
- async waitForExecutorSettlement(agent, childId) {
177
- await this.waitForTurnOrIdle(agent);
203
+ async waitForExecutorSettlement(agent, childId, previousTurns = 0) {
204
+ await this.waitForTurnOrIdle(agent, previousTurns);
178
205
  const events = agent.session?.snapshotEvents?.() ?? agent.session?.ownEvents?.() ?? [];
179
206
  const classified = classifyTurnSettlement(events);
180
207
  const output = this.readFinalOutput(agent);
@@ -214,20 +241,22 @@ export class DshOrbitHost {
214
241
  return { childId, output, interrupted: true, reason: 'EXECUTOR_BLOCKED', telemetry, ...evidence };
215
242
  case 'max-tokens':
216
243
  return { childId, output, interrupted: true, reason: 'EXECUTOR_MAX_TOKENS', telemetry, ...evidence };
244
+ case 'interrupted':
245
+ return { childId, output, interrupted: true, reason: 'EXECUTOR_INTERRUPTED', telemetry, ...evidence };
217
246
  default:
218
247
  return { childId, output, interrupted: true, reason: 'EXECUTOR_NO_TURN', telemetry, ...evidence };
219
248
  }
220
249
  }
221
- async waitForTurnOrIdle(agent) {
250
+ async waitForTurnOrIdle(agent, previousTurns = 0) {
222
251
  const deadline = this.nowFn() + EXECUTOR_TURN_START_TIMEOUT_MS;
223
- while (this.nowFn() < deadline && !this.hasTurnStarted(agent)) {
252
+ while (this.nowFn() < deadline && !this.hasTurnStarted(agent, previousTurns)) {
224
253
  await this.sleepFn(20);
225
254
  }
226
255
  await agent.whenIdle?.();
227
256
  }
228
- hasTurnStarted(agent) {
257
+ hasTurnStarted(agent, previousTurns) {
229
258
  const events = agent.session?.snapshotEvents?.() ?? [];
230
- return events.some((event) => event.type === 'turn/start');
259
+ return events.filter((event) => event.type === 'turn/start').length > previousTurns;
231
260
  }
232
261
  readFinalOutput(agent) {
233
262
  const events = agent.session?.snapshotEvents?.() ?? agent.session?.ownEvents?.() ?? [];
@@ -308,6 +337,53 @@ export class DshOrbitHost {
308
337
  const agent = this.ctx.agents.currentInitiator();
309
338
  return this.ctx.tools.get(name, agent) !== undefined;
310
339
  }
340
+ async validateRoutes(routes, signal) {
341
+ const issues = [];
342
+ const labels = { commander: '指挥官', executor: '执行员', watchdog: '监控模型' };
343
+ for (const role of ['commander', 'executor', 'watchdog']) {
344
+ const route = routes[role];
345
+ try {
346
+ const llm = this.ctx.reflect.get('llm');
347
+ if (!llm)
348
+ throw new Error('DSH LLM registry 不可用');
349
+ const deadline = AbortSignal.timeout(10_000);
350
+ const activeSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
351
+ const info = await llm.resolveModelInfo(route.provider, route.model, activeSignal);
352
+ const catalog = await llm.listModels(route.provider);
353
+ if (catalog.length > 0 && !catalog.some((entry) => entry.id === route.model)) {
354
+ issues.push(`${labels[role]}:ORBIT_MODEL_UNAVAILABLE (${route.provider}/${route.model}),当前模型目录中不存在,请重新选择。`);
355
+ continue;
356
+ }
357
+ if (route.reasoningEffort !== undefined) {
358
+ const efforts = info.reasoning?.efforts ?? [];
359
+ if (!efforts.some((effort) => effort.id === route.reasoningEffort)) {
360
+ issues.push(`${labels[role]}:ORBIT_REASONING_EFFORT_UNAVAILABLE (${route.provider}/${route.model}/${route.reasoningEffort}),当前模型未声明此推理等级。`);
361
+ }
362
+ }
363
+ }
364
+ catch (error) {
365
+ issues.push(`${labels[role]}:ORBIT_MODEL_UNAVAILABLE (${route.provider}/${route.model}),当前不可用,请重新选择:${truncateSafe(error instanceof Error ? error.message : String(error), 200)}`);
366
+ }
367
+ }
368
+ return issues;
369
+ }
370
+ isMutationAuthorized(agent, cwd, tool) {
371
+ const id = String(agent?.id ?? '');
372
+ const grant = this.childGrants.get(id);
373
+ const parent = this.childParents.get(id);
374
+ return grant?.role === 'executor' && grant.workspace === resolve(cwd) && grant.tools.has(tool)
375
+ && !this.interruptedChildren.has(id) && this.ctx.agents.get(SessionId(id)) === agent
376
+ && parent !== undefined && this.ctx.agents.isOwnedBy(SessionId(id), parent);
377
+ }
378
+ async revokeWorkspace(cwd) {
379
+ const ids = [...this.childGrants].filter(([, grant]) => grant.workspace === resolve(cwd) && grant.role === 'executor').map(([id]) => id);
380
+ for (const id of ids) {
381
+ const parent = this.childParents.get(id);
382
+ await this.interruptExecutor(id, 'ORBIT_WORKSPACE_RELEASED');
383
+ if (parent)
384
+ await this.drainExecutor(parent, id);
385
+ }
386
+ }
311
387
  /**
312
388
  * Mutation ownership is about top-level autonomous drivers, not about every
313
389
  * running agent. The calling parent, Orbit's own children, and ordinary
@@ -316,17 +392,19 @@ export class DshOrbitHost {
316
392
  * at tool start by the Orbit mutation guard.
317
393
  */
318
394
  async otherMutationDrivers(cwd) {
319
- void cwd;
320
395
  const drivers = [];
321
396
  const initiator = this.ctx.agents.currentInitiator();
322
397
  // `ctx.reflect.get` is the official service lookup that does not require an
323
398
  // inject declaration, so Orbit stays loadable in profiles without dsh-goal.
324
399
  const reflect = this.ctx.reflect;
325
400
  const goals = reflect?.get('goals');
326
- if (goals && initiator) {
401
+ const agents = this.ctx.agents.list?.() ?? (initiator ? [initiator] : []);
402
+ for (const candidate of agents) {
403
+ if (!goals || resolve(candidate.session.header.cwd ?? process.cwd()) !== resolve(cwd) || this.ownedChildren.has(String(candidate.id)))
404
+ continue;
327
405
  try {
328
- const goal = goals.get(initiator);
329
- if (goal?.phase === 'active')
406
+ const goal = goals.get(candidate);
407
+ if (goal?.phase === 'active' && !drivers.includes('goal'))
330
408
  drivers.push('goal');
331
409
  }
332
410
  catch {
package/lib/evidence.js CHANGED
@@ -147,3 +147,27 @@ export function buildEvidenceBundle(input) {
147
147
  export function formatEvidenceBundle(bundle) {
148
148
  return bounded(JSON.stringify(bundle), EVIDENCE_LIMITS.total);
149
149
  }
150
+ /** Persist only compact result facts, never a child transcript. */
151
+ export function buildStepResult(stepId, attempt, bundle, tests = []) {
152
+ return {
153
+ step_id: bounded(stepId, 80),
154
+ attempt,
155
+ summary: bounded(bundle.executor_summary ?? '', EVIDENCE_LIMITS.executorSummary),
156
+ changed_files: bundle.changed_files.map((entry) => bounded(entry, EVIDENCE_LIMITS.entry)).slice(0, EVIDENCE_LIMITS.changedFiles),
157
+ test_summary: [...tests, ...bundle.tests.map((entry) => `${entry.command}: ${entry.status}`)]
158
+ .slice(0, EVIDENCE_LIMITS.testEvidence).map((entry) => bounded(entry, EVIDENCE_LIMITS.entry)),
159
+ evidence: formatEvidenceBundle(bundle),
160
+ };
161
+ }
162
+ /** Fair per-step quotas keep every step visible in a bounded final review. */
163
+ export function formatStepResults(state) {
164
+ const steps = state.plan.steps;
165
+ const quota = Math.max(1, Math.floor(EVIDENCE_LIMITS.total / Math.max(1, steps.length)) - 100);
166
+ return steps.map((step) => {
167
+ const result = state.step_results?.find((entry) => entry.step_id === step.id);
168
+ const content = result === undefined ? '未记录持久化结果' : JSON.stringify({
169
+ summary: result.summary, test_summary: result.test_summary, changed_files: result.changed_files, evidence: result.evidence,
170
+ });
171
+ return `${bounded(step.id, 80)}[${step.status}] ${bounded(content, quota)}`;
172
+ }).join('\n');
173
+ }
package/lib/guard.js CHANGED
@@ -10,23 +10,23 @@ const GITHUB_REMOTE_PATTERNS = [
10
10
  /\bgit\s+remote\s+(?:set-url|add|remove)\b/i,
11
11
  ];
12
12
  const DANGEROUS_PATTERNS = [
13
- ['destructive_operation', /\brm\s+-[A-Za-z]*r[A-Za-z]*f|\brm\s+-rf\b/i, 'recursive force deletion'],
14
- ['destructive_operation', /\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[A-Za-z]*f/i, 'irreversible git reset/clean'],
15
- ['production_operation', /\bdrop\s+database\b|\b(?:drop|truncate)\s+(?:table|schema)\b/i, 'irreversible database operation'],
13
+ ['destructive_operation', /\brm\s+-[A-Za-z]*r[A-Za-z]*f|\brm\s+-rf\b/i, '递归强制删除'],
14
+ ['destructive_operation', /\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[A-Za-z]*f/i, '不可逆的 git reset/clean'],
15
+ ['production_operation', /\bdrop\s+database\b|\b(?:drop|truncate)\s+(?:table|schema)\b/i, '不可逆的数据库操作'],
16
16
  [
17
17
  'production_operation',
18
18
  /\b(?:production|prod)\b.*\b(?:deploy|migrate|database|restart)\b|\b(?:deploy|migrate)\b.*\b(?:production|prod)\b/i,
19
- 'production operation',
19
+ '生产环境操作',
20
20
  ],
21
21
  [
22
22
  'production_operation',
23
23
  /\b(?:kubectl|helm)\s+(?:apply|delete|upgrade|rollback)\b|\bterraform\s+apply\b|\bdocker\s+push\b/i,
24
- 'deployment or remote registry write',
24
+ '部署或远程 registry 写入',
25
25
  ],
26
- ['production_operation', /\b(?:alembic|prisma|sequelize|rails)\b.*\b(?:upgrade|migrate|db:migrate)\b/i, 'database migration'],
27
- ['production_operation', /\b(?:ufw|iptables)\b.*\b(?:allow|insert|append)\b|\bdocker\s+run\b.*\s-p\s/i, 'public network exposure'],
28
- ['secret_operation', /\b(?:cat|less|more|head|tail)\b[^\n]*(?:\.env|secret|credential|auth\.json|cookie)/i, 'credential or secret output'],
29
- ['secret_operation', /\b(?:curl|wget)\b[^\n]*(?:Authorization|Bearer|api[_-]?key|token)=?/i, 'credential-bearing network request'],
26
+ ['production_operation', /\b(?:alembic|prisma|sequelize|rails)\b.*\b(?:upgrade|migrate|db:migrate)\b/i, '数据库迁移'],
27
+ ['production_operation', /\b(?:ufw|iptables)\b.*\b(?:allow|insert|append)\b|\bdocker\s+run\b.*\s-p\s/i, '暴露公共网络端口'],
28
+ ['secret_operation', /\b(?:cat|less|more|head|tail)\b[^\n]*(?:\.env|secret|credential|auth\.json|cookie)/i, '输出凭证或敏感信息'],
29
+ ['secret_operation', /\b(?:curl|wget)\b[^\n]*(?:Authorization|Bearer|api[_-]?key|token)=?/i, '携带凭证的网络请求'],
30
30
  ];
31
31
  const SENSITIVE_ENV_NAME = /(?:^|_)(?:KEY|TOKEN|PASSWORD|PASSWD|SECRET|COOKIE|AUTH|CREDENTIAL|PRIVATE_KEY|ACCESS_TOKEN|REFRESH_TOKEN)(?:_|$)/i;
32
32
  const DURABLE_STATE_PATH = /(?:^|[\s/'"])(?:\.\/)?\.cx(?:[/'"\s]|$)/i;
@@ -82,25 +82,25 @@ export function guardBashCommand(command, intent = {}) {
82
82
  if (intent.github_allowed !== true) {
83
83
  for (const pattern of GITHUB_REMOTE_PATTERNS) {
84
84
  if (pattern.test(trimmed))
85
- return block('github_remote_write', `GitHub remote write is not allowed: ${pattern.source}`);
85
+ return block('github_remote_write', `未授权 GitHub 远程写入:${pattern.source}`);
86
86
  }
87
87
  }
88
88
  if (isDurableStateWrite(trimmed)) {
89
- return block('durable_state_write', 'Only the Orbit controller may write .cx durable state.');
89
+ return block('durable_state_write', '只有 Orbit controller 可以写入 .cx 持久状态。');
90
90
  }
91
91
  if (looksLikeEnvSecret(trimmed)) {
92
- return block('secret_operation', 'Reading or exporting secrets is not allowed.');
92
+ return block('secret_operation', '不允许读取或导出敏感凭证。');
93
93
  }
94
94
  if (/^\s*export\s+[A-Za-z_][A-Za-z0-9_]*\s*=/i.test(trimmed)) {
95
95
  const assignment = /^\s*export\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/i.exec(trimmed);
96
96
  if (assignment && SENSITIVE_ENV_NAME.test(assignment[1] ?? '')) {
97
- return block('secret_operation', 'Exporting a secret environment variable is not allowed.');
97
+ return block('secret_operation', '不允许导出敏感环境变量。');
98
98
  }
99
99
  }
100
100
  if (/^\s*set\s+[A-Za-z_][A-Za-z0-9_]*=/i.test(trimmed)) {
101
101
  const assignment = /^\s*set\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/i.exec(trimmed);
102
102
  if (assignment && SENSITIVE_ENV_NAME.test(assignment[1] ?? '')) {
103
- return block('secret_operation', 'Setting a secret environment variable is not allowed.');
103
+ return block('secret_operation', '不允许设置敏感环境变量。');
104
104
  }
105
105
  }
106
106
  for (const [code, pattern, reason] of DANGEROUS_PATTERNS) {
@@ -118,11 +118,11 @@ export function guardToolPath(toolName, targetPath, deps) {
118
118
  const cwd = deps.cwd;
119
119
  const absolute = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
120
120
  if (isInsideDurableState(absolute, cwd)) {
121
- return block('durable_state_write', 'Only the Orbit controller may write .cx durable state.');
121
+ return block('durable_state_write', '只有 Orbit controller 可以写入 .cx 持久状态。');
122
122
  }
123
123
  const realpath = deps.realpath ?? safeRealpath;
124
124
  if (isInsideDurableState(realpathWithin(absolute, realpath), cwd)) {
125
- return block('durable_state_write', 'Only the Orbit controller may write .cx durable state (symlink resolved).');
125
+ return block('durable_state_write', '只有 Orbit controller 可以写入 .cx 持久状态(已解析符号链接)。');
126
126
  }
127
127
  return { allowed: true };
128
128
  }
@@ -153,5 +153,5 @@ function safeRealpath(path) {
153
153
  }
154
154
  }
155
155
  export function guardReason(decision) {
156
- return `Blocked by Orbit safety guard (${decision.code}): ${decision.reason}`;
156
+ return `Orbit 安全护栏阻断当前调用 (${decision.code}):${decision.reason}`;
157
157
  }
package/lib/index.js CHANGED
@@ -1,19 +1,16 @@
1
1
  import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
2
2
  import z from '@deepseek-ai/schemastery';
3
3
  import { installOrbitGestureBoundary, registerOrbitCommand, registerOrbitToggleCommand } from "./activation.js";
4
- import { estimateLoopCount } from "./kernel.js";
5
4
  import { createOrbitPreExecuteHandler } from "./pipeline-guard.js";
6
- import { resolveEffectiveRoutes, sessionSelectionOf } from "./routes.js";
5
+ import { agentDefaultSelectionOf, resolveEffectiveRoutes, sessionModelSelectionOf, sessionModelStateOf } from "./routes.js";
7
6
  import { installOrbitSessionProjection, orbitEnabledOf } from "./session-state.js";
8
7
  import { OrbitService } from "./service.js";
9
8
  import { createOrbitTool } from "./tool.js";
10
9
  /**
11
- * Minimum loop budget for a hard-activated run. The Commander is asked for a
12
- * 2-5 step plan and every executed step consumes one loop slot, so the host
13
- * must grant a budget that can finish a full plan; the goal complexity
14
- * estimate raises it further when the goal asks for more.
10
+ * Fixed hard-activation budget. Explicit tool calls may still supply a bounded
11
+ * user-owned budget; ordinary activation never guesses complexity from words.
15
12
  */
16
- const HARD_ACTIVATION_MIN_LOOPS = 5;
13
+ const HARD_ACTIVATION_LOOP_BUDGET = 5;
17
14
  export const name = 'dsh-orbit';
18
15
  export const inject = ['tools', 'agents', 'subagents'];
19
16
  const Route = z.object({
@@ -39,61 +36,60 @@ export const Config = z.object({
39
36
  executor: Route,
40
37
  watchdog: Route,
41
38
  })
42
- .default({
43
- commander: { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'high' },
44
- executor: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' },
45
- watchdog: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'low' },
46
- }),
39
+ .default({}),
47
40
  browserTools: z.array(z.string()).default(['agent_browser']),
48
41
  commanderReadOnlyTools: z.array(z.string()).default(['read', 'read_image', 'glob', 'grep', 'web_search', 'web_fetch']),
49
42
  watchdogTools: z.array(z.string()).default(['read', 'read_image', 'glob', 'grep']),
50
43
  executorTools: z
51
44
  .array(z.string())
52
- .default(['read', 'read_image', 'glob', 'grep', 'bash', 'write', 'edit', 'str_replace_editor', 'web_search', 'web_fetch']),
45
+ .default(['read', 'read_image', 'glob', 'grep']),
53
46
  executorTimeoutMs: z.natural().default(480_000),
54
47
  registerTool: z.boolean().default(true),
55
48
  registerGuards: z.boolean().default(true),
56
49
  slashCommand: z.boolean().default(true),
57
50
  });
58
51
  export function apply(ctx, config) {
59
- // Orbit settings bridge: register the `orbit` namespace with the composition
60
- // routes as its base/default, so a user who never opens the model UI keeps
61
- // today's behavior exactly. Lazily injected: minimal/headless compositions
62
- // without a settings provider keep running from `config.routes`.
63
- let routeSettings;
52
+ // Orbit settings bridge. The package ships no model defaults; only explicit
53
+ // profile routes can act as a headless compatibility fallback.
54
+ let readRouteSettings = () => undefined;
64
55
  ctx.inject(['settings'], (settingsCtx) => {
65
- const baseRoute = (route) => ({
66
- provider: route.provider,
67
- model: route.model,
68
- reasoningEffort: route.reasoningEffort ?? '',
69
- });
70
56
  const scope = settingsCtx.settings.register('orbit', OrbitRouteSettingsSchema, {
71
57
  base: {
72
- commander: baseRoute(config.routes.commander),
73
- watchdog: baseRoute(config.routes.watchdog),
58
+ commander: { provider: '', model: '', reasoningEffort: '' },
59
+ watchdog: { provider: '', model: '', reasoningEffort: '' },
74
60
  },
75
61
  });
76
- const sync = () => {
77
- routeSettings = scope.get();
78
- };
79
- sync();
80
- scope.watch(() => {
81
- sync();
82
- });
62
+ readRouteSettings = () => scope.get();
63
+ settingsCtx.effect(() => () => { readRouteSettings = () => undefined; });
83
64
  });
84
65
  // A NEW run resolves its three routes exactly once: Commander/Watchdog from
85
66
  // the Orbit settings (base = config), Executor from the initiating Session's
86
- // current model selection (public request-header seam) with the config route
87
- // as the fallback for surfaces without one. Existing runs resume from their
88
- // frozen `state.routes`.
89
- const resolveRoutes = () => resolveEffectiveRoutes({
90
- configRoutes: config.routes,
91
- settings: routeSettings,
92
- sessionSelection: sessionSelectionOf(ctx.agents.currentInitiator()),
93
- });
67
+ // current model with DSH `selectionFor(agent)` semantics (pending choice →
68
+ // logged header → deployment default). Explicit config routes are only for
69
+ // sessions without a DSH model source. Existing runs resume from frozen routes.
70
+ const resolveRoutes = () => {
71
+ const agent = ctx.agents.currentInitiator();
72
+ return resolveEffectiveRoutes({
73
+ configRoutes: config.routes,
74
+ settings: readRouteSettings(),
75
+ hasSession: agent !== undefined,
76
+ sessionSelection: sessionModelSelectionOf({
77
+ sessionModel: sessionModelStateOf(ctx, agent?.session),
78
+ requestHeader: agent?.session?.requestHeader?.(),
79
+ agentDefault: agent === undefined ? undefined : agentDefaultSelectionOf(ctx),
80
+ }),
81
+ });
82
+ };
83
+ // The DSH Session driving the current operation: a new run records it, and
84
+ // only that Session may answer the run's NEEDS_USER question.
85
+ const resolveOwnerSessionId = () => {
86
+ const agent = ctx.agents.currentInitiator();
87
+ return agent === undefined ? undefined : String(agent.session.id);
88
+ };
94
89
  const serviceConfig = {
95
90
  routes: config.routes,
96
91
  resolveRoutes,
92
+ resolveOwnerSessionId,
97
93
  browserTools: config.browserTools,
98
94
  commanderReadOnlyTools: config.commanderReadOnlyTools,
99
95
  watchdogTools: config.watchdogTools,
@@ -132,7 +128,7 @@ export function apply(ctx, config) {
132
128
  // the parent model is never asked to decide or to run the task.
133
129
  result = await ctx.agents.withInitiator(agent, () => service.run({
134
130
  goal,
135
- approved_loop_count: Math.max(estimateLoopCount(goal), HARD_ACTIVATION_MIN_LOOPS),
131
+ approved_loop_count: HARD_ACTIVATION_LOOP_BUDGET,
136
132
  }, cwd, signal));
137
133
  }
138
134
  catch (error) {
@@ -150,8 +146,9 @@ export function apply(ctx, config) {
150
146
  ctx.inject(['sessionProjections'], (projectionCtx) => {
151
147
  installOrbitSessionProjection(projectionCtx);
152
148
  });
153
- if (config.registerGuards) {
149
+ {
154
150
  const handler = createOrbitPreExecuteHandler(service, {
151
+ contentGuards: config.registerGuards,
155
152
  competingDriver: (agent) => {
156
153
  const reflect = ctx.reflect;
157
154
  const goals = reflect?.get('goals');
@@ -193,6 +190,11 @@ function activationNotice(result) {
193
190
  return undefined;
194
191
  const lastError = result.data?.['last_error'];
195
192
  const reason = result.message ?? (typeof lastError === 'string' && lastError !== '' ? lastError : undefined);
193
+ if (result.message?.startsWith('ORBIT_NEEDS_USER_OTHER_SESSION') === true || result.message?.startsWith('ORBIT_NEEDS_USER_OWNER_UNKNOWN') === true) {
194
+ // Another Session owns the waiting run: report instead of consuming the
195
+ // message as its reply.
196
+ return result.message;
197
+ }
196
198
  if (result.phase === 'NEEDS_USER') {
197
199
  return reason !== undefined && !reason.startsWith('COMMANDER_')
198
200
  ? `Orbit 需要你的回复:${reason}`
package/lib/kernel.js CHANGED
@@ -5,9 +5,9 @@
5
5
  * read a clock, or use randomness. Runtime values (routes, timestamps, run ids)
6
6
  * are supplied by the supervisor, which owns orchestration and persistence.
7
7
  */
8
- import { MAX_CORRECTION_DEPTH, MAX_PLAN_STEPS, MAX_WATCHDOG_CALLS_PER_STEP, MIN_PLAN_STEPS, ORBIT_SCHEMA_VERSION, } from "./types.js";
8
+ import { MAX_CORRECTION_DEPTH, DEFAULT_LOOP_BUDGET, MAX_PLAN_STEPS, MAX_WATCHDOG_CALLS_PER_STEP, MIN_PLAN_STEPS, ORBIT_SCHEMA_VERSION, } from "./types.js";
9
9
  /** Capabilities a plan step may request. */
10
- export const ORBIT_CAPABILITIES = ['browser', 'web-api-recon'];
10
+ export const ORBIT_CAPABILITIES = ['filesystem', 'shell', 'web', 'browser'];
11
11
  const STEP_CAPABILITIES = new Set(ORBIT_CAPABILITIES);
12
12
  const PLAN_STEP_ID = /^P\d+$/u;
13
13
  export function normalizeCapabilities(value) {
@@ -15,6 +15,12 @@ export function normalizeCapabilities(value) {
15
15
  return undefined;
16
16
  const result = [];
17
17
  for (const item of value) {
18
+ if (item === 'web-api-recon') {
19
+ for (const legacy of ['web', 'browser'])
20
+ if (!result.includes(legacy))
21
+ result.push(legacy);
22
+ continue;
23
+ }
18
24
  if (typeof item !== 'string' || !STEP_CAPABILITIES.has(item))
19
25
  continue;
20
26
  if (!result.includes(item))
@@ -22,8 +28,6 @@ export function normalizeCapabilities(value) {
22
28
  }
23
29
  if (result.length === 0)
24
30
  return undefined;
25
- if (result.includes('web-api-recon') && !result.includes('browser'))
26
- result.unshift('browser');
27
31
  return result;
28
32
  }
29
33
  export function normalizePlan(plan) {
@@ -46,6 +50,9 @@ export function normalizePlan(plan) {
46
50
  if (steps.some((step) => !step.goal)) {
47
51
  throw new Error('COMMANDER_PLAN_OUTPUT_INVALID: every step needs a goal');
48
52
  }
53
+ if (new Set(steps.map((step) => step.id)).size !== steps.length) {
54
+ throw new Error('COMMANDER_PLAN_OUTPUT_INVALID: 步骤 id 不得重复');
55
+ }
49
56
  return { summary: String(plan.summary ?? '').slice(0, 1000), steps };
50
57
  }
51
58
  export function correctionDepthOf(stepId) {
@@ -68,18 +75,6 @@ export function explicitLoopBudget(input) {
68
75
  throw new Error('ORBIT_LOOP_BUDGET_INVALID: approved_loop_count above 10 requires an explicit execution request');
69
76
  return raw;
70
77
  }
71
- export function estimateLoopCount(goal) {
72
- const text = goal.toLowerCase();
73
- if (/critical|migrate|migration|production|架构|重构/.test(text))
74
- return 6;
75
- if (/integration|联调|ui|high/.test(text))
76
- return 4;
77
- if (/feature|多文件|multi-file/.test(goal))
78
- return 3;
79
- if (/bug|fix|test/.test(text))
80
- return 2;
81
- return 1;
82
- }
83
78
  export function updateLoopBudget(state, budget) {
84
79
  if (budget < state.loop.used)
85
80
  throw new Error(`ORBIT_LOOP_BUDGET_BELOW_USED: requested ${budget}, already used ${state.loop.used}`);
@@ -129,7 +124,7 @@ export function correctionBlockCode(state, step) {
129
124
  }
130
125
  export function createInitialState(input) {
131
126
  const max = explicitLoopBudget({ approved_loop_count: input.approvedLoopCount, max_loops: input.maxLoops }) ??
132
- estimateLoopCount(input.goal);
127
+ DEFAULT_LOOP_BUDGET;
133
128
  return {
134
129
  schema_version: ORBIT_SCHEMA_VERSION,
135
130
  active_run_id: input.runId,
@@ -142,16 +137,18 @@ export function createInitialState(input) {
142
137
  goal: input.goal,
143
138
  goal_hash: hashGoal(input.goal),
144
139
  preset: input.preset ?? 'orbit-lite',
145
- routes: input.routes,
140
+ routes: structuredClone(input.routes),
146
141
  loop: { used: 0, max },
147
142
  approved_loop_count: max,
148
143
  remaining_budget: max,
149
144
  loop_count: 0,
150
145
  plan: { summary: '', steps: [] },
146
+ step_results: [],
151
147
  changed_files: [],
152
148
  test_summary: [],
153
149
  last_error: null,
154
150
  pending_user_reply: null,
151
+ ...(input.ownerSessionId === undefined ? {} : { owner_session_id: input.ownerSessionId }),
155
152
  user_hard_constraints: input.userHardConstraints ? [...input.userHardConstraints] : [],
156
153
  github_allowed: input.githubAllowed === true,
157
154
  interruption_retries: 0,
@@ -193,7 +190,7 @@ export function applyExecutorCapabilityUnavailable(state, stepId) {
193
190
  state.last_error = 'BROWSER_CAPABILITY_UNAVAILABLE';
194
191
  state.commander = {
195
192
  last_decision: state.commander?.last_decision,
196
- summary: `Executor could not run step ${stepId}: BROWSER_CAPABILITY_UNAVAILABLE (agent_browser tool is not registered).`,
193
+ summary: `Executor 无法执行步骤 ${stepId}:BROWSER_CAPABILITY_UNAVAILABLE(配置的 Browser 工具不可用)。`,
197
194
  };
198
195
  state.phase = 'EVALUATE';
199
196
  }
@@ -215,6 +212,17 @@ export function clearExecutorChild(state) {
215
212
  state.child = undefined;
216
213
  state.interruption_retries = 0;
217
214
  }
215
+ export const MAX_STEP_RESULTS = 10;
216
+ /** Upsert one bounded durable result without allowing evidence to drive transitions. */
217
+ export function upsertStepResult(state, result) {
218
+ const results = state.step_results ?? [];
219
+ const index = results.findIndex((entry) => entry.step_id === result.step_id);
220
+ if (index === -1)
221
+ results.push(result);
222
+ else
223
+ results[index] = result;
224
+ state.step_results = results.slice(-MAX_STEP_RESULTS);
225
+ }
218
226
  /** Apply a real Executor success: consume one loop slot, then enter EVALUATE. */
219
227
  export function applyExecutorSuccess(state, input) {
220
228
  state.child = { ...(input.childId ? { id: input.childId } : {}), status: 'completed' };
@@ -262,7 +270,9 @@ export function applyFinalAppend(state, decision) {
262
270
  return 'budget_exhausted';
263
271
  }
264
272
  for (const item of appended.slice(0, remaining)) {
265
- const index = state.plan.steps.filter((candidate) => isBaseStepId(candidate.id)).length;
273
+ let index = state.plan.steps.filter((candidate) => isBaseStepId(candidate.id)).length;
274
+ while (state.plan.steps.some((candidate) => candidate.id === `P${index}`))
275
+ index += 1;
266
276
  state.plan.steps.push({
267
277
  id: `P${index}`,
268
278
  goal: item.goal,
@@ -281,7 +291,9 @@ export function applyCorrectionStep(state, step, input) {
281
291
  step.status = 'needs_correction';
282
292
  const number = correctionDepthOf(step.id) + 2;
283
293
  const insertAt = state.plan.steps.indexOf(step) + 1;
284
- const correctionCapabilities = normalizeCapabilities(input.capabilities) ?? step.capabilities;
294
+ const correctionCapabilities = input.capabilities === undefined
295
+ ? step.capabilities
296
+ : normalizeCapabilities(input.capabilities);
285
297
  state.plan.steps.splice(insertAt, 0, {
286
298
  id: `${base}-${number}`,
287
299
  goal: input.nextGoal,