@kasenri/dsh-orbit 0.5.1
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/LICENSE +21 -0
- package/README.md +220 -0
- package/cordis.patch.yml +9 -0
- package/lib/activation.js +110 -0
- package/lib/client.js +625 -0
- package/lib/decisions.js +167 -0
- package/lib/dsh-host.js +364 -0
- package/lib/evidence.js +149 -0
- package/lib/guard.js +157 -0
- package/lib/host.js +1 -0
- package/lib/index.js +138 -0
- package/lib/kernel.js +355 -0
- package/lib/pipeline-guard.js +75 -0
- package/lib/routes.js +52 -0
- package/lib/sanitize.js +39 -0
- package/lib/service.js +104 -0
- package/lib/settlement.js +33 -0
- package/lib/state-store.js +128 -0
- package/lib/supervisor.js +792 -0
- package/lib/tool.js +82 -0
- package/lib/types.js +19 -0
- package/package.json +74 -0
package/lib/decisions.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { ORBIT_CAPABILITIES } from "./kernel.js";
|
|
2
|
+
import {} from "./types.js";
|
|
3
|
+
// ── DSH-native structured decision schemas ───────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The model submits its decision through the DSH structured-output protocol and
|
|
6
|
+
// the provider validates it against one of these schemas. They are written in
|
|
7
|
+
// the enforced JSON Schema subset (dsh-tools `assertObjectJsonSchema`), keep only
|
|
8
|
+
// format-level rules (types, required fields, legal enums), and leave Orbit's
|
|
9
|
+
// domain rules (plan size, correction depth, budget) to the checks below.
|
|
10
|
+
const CAPABILITY_ENUM = [...ORBIT_CAPABILITIES];
|
|
11
|
+
/** Plan steps are object-rooted; capabilities stay optional per step. */
|
|
12
|
+
export const COMMANDER_PLAN_SCHEMA = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
required: ['summary', 'steps'],
|
|
16
|
+
properties: {
|
|
17
|
+
summary: { type: 'string', description: 'One-sentence plan summary.' },
|
|
18
|
+
steps: {
|
|
19
|
+
type: 'array',
|
|
20
|
+
description: '2-5 logical engineering steps.',
|
|
21
|
+
items: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
required: ['goal'],
|
|
25
|
+
properties: {
|
|
26
|
+
id: { type: 'string', description: 'Stable id such as P0.' },
|
|
27
|
+
goal: { type: 'string' },
|
|
28
|
+
capabilities: { type: 'array', items: { type: 'string', enum: CAPABILITY_ENUM } },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export const COMMANDER_STEP_EVALUATE_SCHEMA = {
|
|
35
|
+
type: 'object',
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
required: ['decision'],
|
|
38
|
+
properties: {
|
|
39
|
+
decision: { type: 'string', enum: ['PASS_CURRENT_STEP', 'CORRECT_CURRENT_STEP', 'NEEDS_USER'] },
|
|
40
|
+
reason: { type: 'string' },
|
|
41
|
+
next_step_goal: { type: 'string' },
|
|
42
|
+
next_step_capabilities: { type: 'array', items: { type: 'string', enum: CAPABILITY_ENUM } },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
export const COMMANDER_FINAL_EVALUATE_SCHEMA = {
|
|
46
|
+
type: 'object',
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
required: ['decision'],
|
|
49
|
+
properties: {
|
|
50
|
+
decision: { type: 'string', enum: ['SUCCESS', 'APPEND', 'NEEDS_USER'] },
|
|
51
|
+
summary: { type: 'string' },
|
|
52
|
+
next_step_goal: { type: 'string' },
|
|
53
|
+
next_step_capabilities: { type: 'array', items: { type: 'string', enum: CAPABILITY_ENUM } },
|
|
54
|
+
next_steps: {
|
|
55
|
+
type: 'array',
|
|
56
|
+
items: {
|
|
57
|
+
oneOf: [
|
|
58
|
+
{ type: 'string' },
|
|
59
|
+
{
|
|
60
|
+
type: 'object',
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
required: ['goal'],
|
|
63
|
+
properties: {
|
|
64
|
+
goal: { type: 'string' },
|
|
65
|
+
capabilities: { type: 'array', items: { type: 'string', enum: CAPABILITY_ENUM } },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
export const COMMANDER_STRATEGY_SCHEMA = {
|
|
74
|
+
type: 'object',
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
required: ['decision'],
|
|
77
|
+
properties: {
|
|
78
|
+
decision: { type: 'string', enum: ['KEEP_APPROACH', 'REPLACE_CURRENT_STEP', 'NEEDS_USER'] },
|
|
79
|
+
reason: { type: 'string' },
|
|
80
|
+
replacement_goal: { type: 'string' },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
export const WATCHDOG_RUNTIME_SCHEMA = {
|
|
84
|
+
type: 'object',
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
required: ['decision'],
|
|
87
|
+
properties: {
|
|
88
|
+
decision: { type: 'string', enum: ['RESUME_CHILD', 'RESTART_STEP', 'NEEDS_USER', 'RUNTIME_BUG'] },
|
|
89
|
+
reason: { type: 'string' },
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
export const WATCHDOG_STRATEGY_SCHEMA = {
|
|
93
|
+
type: 'object',
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
required: ['question'],
|
|
96
|
+
properties: {
|
|
97
|
+
question: { type: 'string', description: 'The single strategy question to put to the Commander.' },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
export const WATCHDOG_GUARD_SCHEMA = {
|
|
101
|
+
type: 'object',
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
required: ['decision'],
|
|
104
|
+
properties: {
|
|
105
|
+
decision: { type: 'string', enum: ['RETRY_DIFFERENTLY', 'NEEDS_USER'] },
|
|
106
|
+
instruction: { type: 'string' },
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
export const WATCHDOG_TIMEOUT_SCHEMA = {
|
|
110
|
+
type: 'object',
|
|
111
|
+
additionalProperties: false,
|
|
112
|
+
required: ['decision'],
|
|
113
|
+
properties: {
|
|
114
|
+
decision: { type: 'string', enum: ['EXTEND', 'INTERRUPT', 'NEEDS_USER'] },
|
|
115
|
+
reason: { type: 'string' },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
export const ORBIT_DECISION_SCHEMAS = {
|
|
119
|
+
COMMANDER_PLAN_SCHEMA,
|
|
120
|
+
COMMANDER_STEP_EVALUATE_SCHEMA,
|
|
121
|
+
COMMANDER_FINAL_EVALUATE_SCHEMA,
|
|
122
|
+
COMMANDER_STRATEGY_SCHEMA,
|
|
123
|
+
WATCHDOG_RUNTIME_SCHEMA,
|
|
124
|
+
WATCHDOG_STRATEGY_SCHEMA,
|
|
125
|
+
WATCHDOG_GUARD_SCHEMA,
|
|
126
|
+
WATCHDOG_TIMEOUT_SCHEMA,
|
|
127
|
+
};
|
|
128
|
+
const STEP_DECISIONS = ['PASS_CURRENT_STEP', 'CORRECT_CURRENT_STEP', 'NEEDS_USER'];
|
|
129
|
+
const FINAL_DECISIONS = ['SUCCESS', 'APPEND', 'NEEDS_USER'];
|
|
130
|
+
export function assertCommanderDecision(decision, mode) {
|
|
131
|
+
const allowed = mode === 'FINAL_EVALUATE' ? FINAL_DECISIONS : STEP_DECISIONS;
|
|
132
|
+
if (!allowed.includes(decision.decision)) {
|
|
133
|
+
throw new Error(`COMMANDER_EVALUATION_DECISION_INVALID_FOR_MODE: ${mode} cannot return ${decision.decision}`);
|
|
134
|
+
}
|
|
135
|
+
return decision;
|
|
136
|
+
}
|
|
137
|
+
export function assertStrategyDecision(decision) {
|
|
138
|
+
const allowed = ['KEEP_APPROACH', 'REPLACE_CURRENT_STEP', 'NEEDS_USER'];
|
|
139
|
+
if (!allowed.includes(decision.decision)) {
|
|
140
|
+
throw new Error(`COMMANDER_STRATEGY_DECISION_INVALID: ${decision.decision}`);
|
|
141
|
+
}
|
|
142
|
+
if (decision.decision === 'REPLACE_CURRENT_STEP' && !decision.replacement_goal?.trim()) {
|
|
143
|
+
throw new Error('COMMANDER_STRATEGY_OUTPUT_INVALID: replacement_goal is required');
|
|
144
|
+
}
|
|
145
|
+
return decision;
|
|
146
|
+
}
|
|
147
|
+
export function assertTimeoutDecision(decision) {
|
|
148
|
+
const allowed = ['EXTEND', 'INTERRUPT', 'NEEDS_USER'];
|
|
149
|
+
if (!allowed.includes(decision.decision)) {
|
|
150
|
+
throw new Error(`COMMANDER_TIMEOUT_WATCHDOG_DECISION_INVALID: ${decision.decision}`);
|
|
151
|
+
}
|
|
152
|
+
return decision;
|
|
153
|
+
}
|
|
154
|
+
export function assertWatchdogDecision(decision) {
|
|
155
|
+
const allowed = ['RESUME_CHILD', 'RESTART_STEP', 'NEEDS_USER', 'RUNTIME_BUG'];
|
|
156
|
+
if (!allowed.includes(decision.decision)) {
|
|
157
|
+
throw new Error(`SMART_WATCHDOG_RUNTIME_DECISION_INVALID: ${decision.decision}`);
|
|
158
|
+
}
|
|
159
|
+
return decision;
|
|
160
|
+
}
|
|
161
|
+
export function assertGuardWatchdogDecision(decision) {
|
|
162
|
+
const allowed = ['RETRY_DIFFERENTLY', 'NEEDS_USER'];
|
|
163
|
+
if (!allowed.includes(decision.decision)) {
|
|
164
|
+
throw new Error(`SMART_WATCHDOG_GUARD_DECISION_INVALID: ${decision.decision}`);
|
|
165
|
+
}
|
|
166
|
+
return decision;
|
|
167
|
+
}
|
package/lib/dsh-host.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { collectTurnToolFacts } from "./evidence.js";
|
|
3
|
+
import { redactText, truncateSafe } from "./sanitize.js";
|
|
4
|
+
import { classifyTurnSettlement } from "./settlement.js";
|
|
5
|
+
const EXECUTOR_TURN_START_TIMEOUT_MS = 5_000;
|
|
6
|
+
function contentToText(blocks) {
|
|
7
|
+
if (!blocks)
|
|
8
|
+
return '';
|
|
9
|
+
return blocks
|
|
10
|
+
.map((block) => (block.type === 'text' ? block.text : `[${block.type}]`))
|
|
11
|
+
.join('\n');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Wire the Orbit supervisor to DeepSeek Harness native Agent/Subagent services.
|
|
15
|
+
*
|
|
16
|
+
* Roles keep the Pi-validated split:
|
|
17
|
+
* - Commander/Watchdog: one-shot children, cancelled through the launch signal
|
|
18
|
+
* plus `run.dispose()` (the real one-shot cancellation seam).
|
|
19
|
+
* - Executor: continuable child so a runtime restart can interrupt it and a
|
|
20
|
+
* resume can reuse the same child.
|
|
21
|
+
*/
|
|
22
|
+
export class DshOrbitHost {
|
|
23
|
+
ctx;
|
|
24
|
+
ownedChildren = new Set();
|
|
25
|
+
interruptedChildren = new Set();
|
|
26
|
+
childParents = new Map();
|
|
27
|
+
nowFn;
|
|
28
|
+
sleepFn;
|
|
29
|
+
constructor(ctx, options = {}) {
|
|
30
|
+
this.ctx = ctx;
|
|
31
|
+
this.nowFn = options.now ?? (() => Date.now());
|
|
32
|
+
this.sleepFn = options.sleep ?? defaultSleep;
|
|
33
|
+
}
|
|
34
|
+
now() {
|
|
35
|
+
return this.nowFn();
|
|
36
|
+
}
|
|
37
|
+
sleep(ms, signal) {
|
|
38
|
+
return this.sleepFn(ms, signal);
|
|
39
|
+
}
|
|
40
|
+
async startRole(request) {
|
|
41
|
+
const parent = this.parent();
|
|
42
|
+
const prompt = [{ type: 'text', text: request.prompt }];
|
|
43
|
+
const agentOptions = {
|
|
44
|
+
provider: request.route.provider,
|
|
45
|
+
model: request.route.model,
|
|
46
|
+
...(request.route.reasoningEffort ? { reasoningEffort: request.route.reasoningEffort } : {}),
|
|
47
|
+
...(request.route.maxTokens ? { maxTokens: request.route.maxTokens } : {}),
|
|
48
|
+
};
|
|
49
|
+
if (request.role === 'executor') {
|
|
50
|
+
return this.startExecutor(parent, request, prompt, agentOptions);
|
|
51
|
+
}
|
|
52
|
+
return this.startOneShot(parent, request, prompt, agentOptions);
|
|
53
|
+
}
|
|
54
|
+
parent() {
|
|
55
|
+
const initiator = this.ctx.agents.currentInitiator();
|
|
56
|
+
if (initiator)
|
|
57
|
+
return initiator;
|
|
58
|
+
return this.ctx.agents.requireInitiator();
|
|
59
|
+
}
|
|
60
|
+
async startOneShot(parent, request, prompt, agentOptions) {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const onAbort = () => controller.abort();
|
|
63
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
64
|
+
const run = (await this.ctx.subagents.start('spawn', {
|
|
65
|
+
label: request.label,
|
|
66
|
+
prompt,
|
|
67
|
+
parent,
|
|
68
|
+
signal: controller.signal,
|
|
69
|
+
agentOptions,
|
|
70
|
+
...(request.toolFilter ? { toolFilter: request.toolFilter } : {}),
|
|
71
|
+
...(request.outputSchema ? { outputSchema: request.outputSchema } : {}),
|
|
72
|
+
}));
|
|
73
|
+
this.registerChild(run.id, parent);
|
|
74
|
+
const result = run.result
|
|
75
|
+
.then((value) => ({
|
|
76
|
+
childId: run.id,
|
|
77
|
+
output: contentToText(value.output),
|
|
78
|
+
interrupted: value.stopReason !== 'completed',
|
|
79
|
+
...(value.stopReason !== 'completed' ? { reason: value.stopReason } : {}),
|
|
80
|
+
...(value.diagnostic ? { testSummary: [value.diagnostic] } : {}),
|
|
81
|
+
...(value.structured !== undefined ? { structured: value.structured } : {}),
|
|
82
|
+
}))
|
|
83
|
+
.catch((error) => ({
|
|
84
|
+
childId: run.id,
|
|
85
|
+
output: '',
|
|
86
|
+
interrupted: true,
|
|
87
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
88
|
+
}));
|
|
89
|
+
return {
|
|
90
|
+
childId: run.id,
|
|
91
|
+
result,
|
|
92
|
+
cancel: async (reason) => {
|
|
93
|
+
this.interruptedChildren.add(run.id);
|
|
94
|
+
controller.abort(new Error(reason));
|
|
95
|
+
await run.dispose();
|
|
96
|
+
this.forgetChild(run.id);
|
|
97
|
+
},
|
|
98
|
+
dispose: async () => {
|
|
99
|
+
await run.dispose();
|
|
100
|
+
this.forgetChild(run.id);
|
|
101
|
+
},
|
|
102
|
+
runtimeSnapshot: () => this.snapshotAgent(run.localAgent),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async startExecutor(parent, request, prompt, agentOptions) {
|
|
106
|
+
if (request.resumeOf) {
|
|
107
|
+
const existingId = request.resumeOf;
|
|
108
|
+
const existing = this.ctx.agents.get(existingId);
|
|
109
|
+
if (existing) {
|
|
110
|
+
const agent = existing;
|
|
111
|
+
this.interruptedChildren.delete(existingId);
|
|
112
|
+
const done = this.waitForExecutorSettlement(agent, existingId);
|
|
113
|
+
await this.ctx.subagents.sendMessage(parent, existingId, prompt, {
|
|
114
|
+
signal: new AbortController().signal,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
childId: existingId,
|
|
118
|
+
result: done,
|
|
119
|
+
cancel: async (reason) => this.interruptExecutor(existingId, reason),
|
|
120
|
+
dispose: async () => this.drainExecutor(parent, existingId),
|
|
121
|
+
runtimeSnapshot: () => this.snapshotAgent(agent),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
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);
|
|
140
|
+
const agent = this.ctx.agents.get(started.childId);
|
|
141
|
+
const result = agent
|
|
142
|
+
? this.waitForExecutorSettlement(agent, childId)
|
|
143
|
+
: Promise.resolve({ childId, output: '', interrupted: true, reason: 'EXECUTOR_CHILD_MISSING' });
|
|
144
|
+
return {
|
|
145
|
+
childId,
|
|
146
|
+
result,
|
|
147
|
+
cancel: async (reason) => this.interruptExecutor(childId, reason),
|
|
148
|
+
dispose: async () => this.drainExecutor(parent, childId),
|
|
149
|
+
runtimeSnapshot: agent ? () => this.snapshotAgent(agent) : undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
registerChild(childId, parent) {
|
|
153
|
+
this.ownedChildren.add(childId);
|
|
154
|
+
this.childParents.set(childId, parent);
|
|
155
|
+
}
|
|
156
|
+
forgetChild(childId) {
|
|
157
|
+
this.ownedChildren.delete(childId);
|
|
158
|
+
this.interruptedChildren.delete(childId);
|
|
159
|
+
this.childParents.delete(childId);
|
|
160
|
+
}
|
|
161
|
+
async interruptExecutor(childId, reason) {
|
|
162
|
+
this.interruptedChildren.add(childId);
|
|
163
|
+
const parent = this.childParents.get(childId);
|
|
164
|
+
this.ctx.subagents.interrupt(childId, parent ? { kind: 'ancestor', agent: parent } : { kind: 'user', parentSessionId: childId });
|
|
165
|
+
void reason;
|
|
166
|
+
}
|
|
167
|
+
async drainExecutor(parent, childId) {
|
|
168
|
+
try {
|
|
169
|
+
await this.ctx.subagents.drainContinuableChildren(parent, [childId]);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// already released
|
|
173
|
+
}
|
|
174
|
+
this.forgetChild(childId);
|
|
175
|
+
}
|
|
176
|
+
async waitForExecutorSettlement(agent, childId) {
|
|
177
|
+
await this.waitForTurnOrIdle(agent);
|
|
178
|
+
const events = agent.session?.snapshotEvents?.() ?? agent.session?.ownEvents?.() ?? [];
|
|
179
|
+
const classified = classifyTurnSettlement(events);
|
|
180
|
+
const output = this.readFinalOutput(agent);
|
|
181
|
+
const telemetry = await this.snapshotAgent(agent);
|
|
182
|
+
// Evidence is read from the settled turn's own events; a resumed executor
|
|
183
|
+
// therefore reports only the turn that just finished, never an earlier one.
|
|
184
|
+
const toolEvidence = collectTurnToolFacts(events);
|
|
185
|
+
const evidence = {
|
|
186
|
+
settlement: classified.settlement,
|
|
187
|
+
...(toolEvidence.length > 0 ? { toolEvidence } : {}),
|
|
188
|
+
};
|
|
189
|
+
if (this.interruptedChildren.has(childId)) {
|
|
190
|
+
return { childId, output, interrupted: true, reason: 'EXECUTOR_INTERRUPTED', telemetry, ...evidence };
|
|
191
|
+
}
|
|
192
|
+
switch (classified.settlement) {
|
|
193
|
+
case 'completed':
|
|
194
|
+
return { childId, output, interrupted: false, telemetry, ...evidence };
|
|
195
|
+
case 'aborted':
|
|
196
|
+
return {
|
|
197
|
+
childId,
|
|
198
|
+
output,
|
|
199
|
+
interrupted: true,
|
|
200
|
+
reason: `EXECUTOR_ABORTED${classified.cancelCause ? `:${classified.cancelCause}` : ''}`,
|
|
201
|
+
telemetry,
|
|
202
|
+
...evidence,
|
|
203
|
+
};
|
|
204
|
+
case 'error':
|
|
205
|
+
return {
|
|
206
|
+
childId,
|
|
207
|
+
output,
|
|
208
|
+
interrupted: true,
|
|
209
|
+
reason: `EXECUTOR_ERROR: ${redactText(classified.errorMessage ?? 'unknown failure')}`,
|
|
210
|
+
telemetry,
|
|
211
|
+
...evidence,
|
|
212
|
+
};
|
|
213
|
+
case 'blocked':
|
|
214
|
+
return { childId, output, interrupted: true, reason: 'EXECUTOR_BLOCKED', telemetry, ...evidence };
|
|
215
|
+
case 'max-tokens':
|
|
216
|
+
return { childId, output, interrupted: true, reason: 'EXECUTOR_MAX_TOKENS', telemetry, ...evidence };
|
|
217
|
+
default:
|
|
218
|
+
return { childId, output, interrupted: true, reason: 'EXECUTOR_NO_TURN', telemetry, ...evidence };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async waitForTurnOrIdle(agent) {
|
|
222
|
+
const deadline = this.nowFn() + EXECUTOR_TURN_START_TIMEOUT_MS;
|
|
223
|
+
while (this.nowFn() < deadline && !this.hasTurnStarted(agent)) {
|
|
224
|
+
await this.sleepFn(20);
|
|
225
|
+
}
|
|
226
|
+
await agent.whenIdle?.();
|
|
227
|
+
}
|
|
228
|
+
hasTurnStarted(agent) {
|
|
229
|
+
const events = agent.session?.snapshotEvents?.() ?? [];
|
|
230
|
+
return events.some((event) => event.type === 'turn/start');
|
|
231
|
+
}
|
|
232
|
+
readFinalOutput(agent) {
|
|
233
|
+
const events = agent.session?.snapshotEvents?.() ?? agent.session?.ownEvents?.() ?? [];
|
|
234
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
235
|
+
const event = events[index];
|
|
236
|
+
if (!event || event.type !== 'assistant/message')
|
|
237
|
+
continue;
|
|
238
|
+
const data = event.data;
|
|
239
|
+
const text = contentToText(data?.message?.content ?? data?.content);
|
|
240
|
+
if (text.trim())
|
|
241
|
+
return text;
|
|
242
|
+
}
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
/** Bounded telemetry for the exact agent behind a handle. */
|
|
246
|
+
async snapshotAgent(agent) {
|
|
247
|
+
if (!agent)
|
|
248
|
+
return { status: 'unknown' };
|
|
249
|
+
const events = agent.session?.snapshotEvents?.() ?? [];
|
|
250
|
+
const openCalls = new Map();
|
|
251
|
+
let turnCount = 0;
|
|
252
|
+
let toolCount = 0;
|
|
253
|
+
let lastAssistant = '';
|
|
254
|
+
for (const event of events) {
|
|
255
|
+
if (event.type === 'turn/end')
|
|
256
|
+
turnCount += 1;
|
|
257
|
+
if (event.type === 'tool/call') {
|
|
258
|
+
toolCount += 1;
|
|
259
|
+
const data = event.data;
|
|
260
|
+
if (data?.callId)
|
|
261
|
+
openCalls.set(data.callId, data.name ?? 'unknown');
|
|
262
|
+
}
|
|
263
|
+
if (event.type === 'tool/result') {
|
|
264
|
+
const data = event.data;
|
|
265
|
+
if (data?.callId)
|
|
266
|
+
openCalls.delete(data.callId);
|
|
267
|
+
}
|
|
268
|
+
if (event.type === 'assistant/message') {
|
|
269
|
+
const data = event.data;
|
|
270
|
+
const text = contentToText(data?.message?.content ?? data?.content);
|
|
271
|
+
if (text.trim())
|
|
272
|
+
lastAssistant = text;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const currentTool = [...openCalls.values()].pop();
|
|
276
|
+
return {
|
|
277
|
+
status: agent.status ?? 'unknown',
|
|
278
|
+
activity_state: agent.status ?? 'unknown',
|
|
279
|
+
turn_count: turnCount,
|
|
280
|
+
tool_count: toolCount,
|
|
281
|
+
...(currentTool ? { current_tool: currentTool } : {}),
|
|
282
|
+
...(lastAssistant ? { recent_output: truncateSafe(lastAssistant, 1500) } : {}),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
async interruptRole(handle, reason) {
|
|
286
|
+
if (handle.cancel) {
|
|
287
|
+
await handle.cancel(reason);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (handle.childId)
|
|
291
|
+
await this.interruptExecutor(handle.childId, reason);
|
|
292
|
+
}
|
|
293
|
+
async releaseRole(handle) {
|
|
294
|
+
if (handle.dispose) {
|
|
295
|
+
await handle.dispose();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (handle.childId) {
|
|
299
|
+
const parent = this.childParents.get(handle.childId);
|
|
300
|
+
if (parent)
|
|
301
|
+
await this.drainExecutor(parent, handle.childId);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
hasTool(name) {
|
|
305
|
+
// Standard profiles mount their tool composition on the agent plane
|
|
306
|
+
// (agent presets), so the visible set must resolve against the initiating
|
|
307
|
+
// agent's scope. Without an initiator this falls back to the global view.
|
|
308
|
+
const agent = this.ctx.agents.currentInitiator();
|
|
309
|
+
return this.ctx.tools.get(name, agent) !== undefined;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Mutation ownership is about top-level autonomous drivers, not about every
|
|
313
|
+
* running agent. The calling parent, Orbit's own children, and ordinary
|
|
314
|
+
* conversational/read-only agents are never competitors. Goal is the one
|
|
315
|
+
* DSH-native driver with a readable active state; ralph/workflow are blocked
|
|
316
|
+
* at tool start by the Orbit mutation guard.
|
|
317
|
+
*/
|
|
318
|
+
async otherMutationDrivers(cwd) {
|
|
319
|
+
void cwd;
|
|
320
|
+
const drivers = [];
|
|
321
|
+
const initiator = this.ctx.agents.currentInitiator();
|
|
322
|
+
// `ctx.reflect.get` is the official service lookup that does not require an
|
|
323
|
+
// inject declaration, so Orbit stays loadable in profiles without dsh-goal.
|
|
324
|
+
const reflect = this.ctx.reflect;
|
|
325
|
+
const goals = reflect?.get('goals');
|
|
326
|
+
if (goals && initiator) {
|
|
327
|
+
try {
|
|
328
|
+
const goal = goals.get(initiator);
|
|
329
|
+
if (goal?.phase === 'active')
|
|
330
|
+
drivers.push('goal');
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// goal service is present but not readable for this initiator
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return drivers;
|
|
337
|
+
}
|
|
338
|
+
changedFiles(cwd) {
|
|
339
|
+
try {
|
|
340
|
+
const output = execFileSync('git', ['-C', cwd, 'status', '--porcelain'], {
|
|
341
|
+
encoding: 'utf8',
|
|
342
|
+
timeout: 5000,
|
|
343
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
344
|
+
});
|
|
345
|
+
return output
|
|
346
|
+
.split('\n')
|
|
347
|
+
.map((line) => line.slice(3).trim())
|
|
348
|
+
.filter((line) => line.length > 0 && !line.startsWith('.cx/'));
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function defaultSleep(ms, signal) {
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
const timer = setTimeout(resolve, ms);
|
|
358
|
+
const onAbort = () => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
reject(new Error('ORBIT_ABORTED'));
|
|
361
|
+
};
|
|
362
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
363
|
+
});
|
|
364
|
+
}
|
package/lib/evidence.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded execution evidence assembled from real DSH session events.
|
|
3
|
+
*
|
|
4
|
+
* Everything in a bundle comes from data Orbit already has when a child
|
|
5
|
+
* settles: current-turn `tool/call` + `tool/result` events, the durable turn
|
|
6
|
+
* settlement, the executor's final text, real workspace changes, and agent
|
|
7
|
+
* telemetry. Nothing is inferred, nothing is re-run, and no extra model call
|
|
8
|
+
* is made. Bundles are sanitized and bounded before they reach a prompt, and
|
|
9
|
+
* they are never persisted into `.cx/state.json` as a whole.
|
|
10
|
+
*/
|
|
11
|
+
import { redactText, redactValue } from "./sanitize.js";
|
|
12
|
+
export const EVIDENCE_LIMITS = {
|
|
13
|
+
changedFiles: 50,
|
|
14
|
+
toolEvidence: 20,
|
|
15
|
+
testEvidence: 10,
|
|
16
|
+
entry: 400,
|
|
17
|
+
executorSummary: 2000,
|
|
18
|
+
total: 8000,
|
|
19
|
+
};
|
|
20
|
+
const TEST_COMMAND_PATTERN = /(?:\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|\bnode\s+--test\b|\bvitest\b|\bjest\b|\bpytest\b|\bcargo\s+test\b|\bgo\s+test\b|\bmake\s+test\b)/u;
|
|
21
|
+
/** True when a real command string is recognizably a test invocation. */
|
|
22
|
+
export function isTestCommand(command) {
|
|
23
|
+
return TEST_COMMAND_PATTERN.test(command);
|
|
24
|
+
}
|
|
25
|
+
/** Strictly bound and redact one string without exceeding `max` characters. */
|
|
26
|
+
function bounded(value, max) {
|
|
27
|
+
const redacted = redactText(value);
|
|
28
|
+
if (redacted.length <= max)
|
|
29
|
+
return redacted;
|
|
30
|
+
const marker = '...[truncated]';
|
|
31
|
+
return `${redacted.slice(0, Math.max(0, max - marker.length))}${marker}`;
|
|
32
|
+
}
|
|
33
|
+
function eventTurn(event) {
|
|
34
|
+
const turn = event.data?.turn;
|
|
35
|
+
return typeof turn === 'number' ? turn : undefined;
|
|
36
|
+
}
|
|
37
|
+
/** The most recent turn boundary in the log (closed or open), if any. */
|
|
38
|
+
function currentTurnOf(events) {
|
|
39
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
40
|
+
const event = events[index];
|
|
41
|
+
if (event?.type !== 'turn/end' && event?.type !== 'turn/start')
|
|
42
|
+
continue;
|
|
43
|
+
const turn = eventTurn(event);
|
|
44
|
+
if (turn !== undefined)
|
|
45
|
+
return turn;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
function commandOf(argumentsJson) {
|
|
50
|
+
if (!argumentsJson)
|
|
51
|
+
return undefined;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(argumentsJson);
|
|
54
|
+
return typeof parsed.command === 'string' && parsed.command.length > 0 ? parsed.command : undefined;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Extract tool facts for the most recent turn only, so a resumed executor
|
|
62
|
+
* never mixes a previous turn's activity into the current settlement.
|
|
63
|
+
*/
|
|
64
|
+
export function collectTurnToolFacts(events) {
|
|
65
|
+
const turn = currentTurnOf(events);
|
|
66
|
+
if (turn === undefined)
|
|
67
|
+
return [];
|
|
68
|
+
const results = new Map();
|
|
69
|
+
for (const event of events) {
|
|
70
|
+
if (event.type !== 'tool/result' || eventTurn(event) !== turn)
|
|
71
|
+
continue;
|
|
72
|
+
const data = event.data;
|
|
73
|
+
const block = data?.message?.content?.[0];
|
|
74
|
+
if (!data || !block?.toolCallId)
|
|
75
|
+
continue;
|
|
76
|
+
const isError = data.error !== undefined || block.isError === true;
|
|
77
|
+
const identity = data.error ? [data.error.name, data.error.code].filter(Boolean).join(':') : '';
|
|
78
|
+
results.set(block.toolCallId, {
|
|
79
|
+
status: isError ? 'error' : 'ok',
|
|
80
|
+
...(identity ? { detail: identity } : {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const facts = [];
|
|
84
|
+
for (const event of events) {
|
|
85
|
+
if (event.type !== 'tool/call' || eventTurn(event) !== turn)
|
|
86
|
+
continue;
|
|
87
|
+
const data = event.data;
|
|
88
|
+
if (!data?.name)
|
|
89
|
+
continue;
|
|
90
|
+
const result = data.callId ? results.get(data.callId) : undefined;
|
|
91
|
+
const command = commandOf(data.arguments);
|
|
92
|
+
const detail = result?.detail ?? command;
|
|
93
|
+
facts.push({
|
|
94
|
+
name: data.name,
|
|
95
|
+
status: result?.status ?? 'unknown',
|
|
96
|
+
...(command ? { command } : {}),
|
|
97
|
+
...(detail ? { detail } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return facts;
|
|
101
|
+
}
|
|
102
|
+
function boundTelemetry(telemetry) {
|
|
103
|
+
const redacted = redactValue(telemetry);
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [key, value] of Object.entries(redacted)) {
|
|
106
|
+
out[key] = typeof value === 'string' ? bounded(value, EVIDENCE_LIMITS.entry) : value;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** Build one bounded, sanitized bundle from already-available execution facts. */
|
|
111
|
+
export function buildEvidenceBundle(input) {
|
|
112
|
+
const changedFiles = [];
|
|
113
|
+
for (const path of input.changedFiles ?? []) {
|
|
114
|
+
const entry = bounded(path.trim(), EVIDENCE_LIMITS.entry);
|
|
115
|
+
if (entry && !changedFiles.includes(entry))
|
|
116
|
+
changedFiles.push(entry);
|
|
117
|
+
if (changedFiles.length >= EVIDENCE_LIMITS.changedFiles)
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
const tools = [];
|
|
121
|
+
for (const tool of (input.tools ?? []).slice(0, EVIDENCE_LIMITS.toolEvidence)) {
|
|
122
|
+
tools.push({
|
|
123
|
+
name: bounded(tool.name, EVIDENCE_LIMITS.entry),
|
|
124
|
+
status: tool.status,
|
|
125
|
+
...(tool.detail ? { detail: bounded(tool.detail, EVIDENCE_LIMITS.entry) } : {}),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const tests = [];
|
|
129
|
+
for (const tool of input.tools ?? []) {
|
|
130
|
+
if (tool.command === undefined || !isTestCommand(tool.command))
|
|
131
|
+
continue;
|
|
132
|
+
tests.push({ command: bounded(tool.command, EVIDENCE_LIMITS.entry), status: tool.status });
|
|
133
|
+
if (tests.length >= EVIDENCE_LIMITS.testEvidence)
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const summary = input.executorOutput ? bounded(input.executorOutput, EVIDENCE_LIMITS.executorSummary) : '';
|
|
137
|
+
return {
|
|
138
|
+
...(input.settlement ? { settlement: input.settlement } : {}),
|
|
139
|
+
...(summary ? { executor_summary: summary } : {}),
|
|
140
|
+
changed_files: changedFiles,
|
|
141
|
+
tools,
|
|
142
|
+
tests,
|
|
143
|
+
...(input.telemetry ? { telemetry: boundTelemetry(input.telemetry) } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Render a bundle for a prompt, hard-capped at the total evidence budget. */
|
|
147
|
+
export function formatEvidenceBundle(bundle) {
|
|
148
|
+
return bounded(JSON.stringify(bundle), EVIDENCE_LIMITS.total);
|
|
149
|
+
}
|