nubos-pilot 1.2.4 → 1.3.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/CHANGELOG.md +17 -1
- package/README.md +2 -1
- package/SECURITY.md +3 -4
- package/bin/np-tools/_commands.cjs +3 -0
- package/bin/np-tools/_elision-proxy-entry.cjs +13 -0
- package/bin/np-tools/elision-bench.cjs +67 -0
- package/bin/np-tools/elision-get.cjs +48 -0
- package/bin/np-tools/elision-get.test.cjs +66 -0
- package/bin/np-tools/learnings.cjs +1 -1
- package/bin/np-tools/loop-run-round.cjs +25 -11
- package/bin/np-tools/plan-milestone.cjs +1 -0
- package/bin/np-tools/research-phase.cjs +1 -1
- package/bin/np-tools/resolve-model.cjs +55 -1
- package/bin/np-tools/resolve-model.test.cjs +139 -0
- package/bin/np-tools/security.cjs +1 -1
- package/bin/np-tools/spawn-headless.cjs +155 -3
- package/bin/np-tools/spawn-headless.test.cjs +108 -58
- package/bin/np-tools/spawn-offhost.cjs +93 -0
- package/bin/np-tools/spawn-offhost.test.cjs +38 -0
- package/lib/agents.cjs +16 -2
- package/lib/cache-align.cjs +78 -0
- package/lib/cache-align.test.cjs +69 -0
- package/lib/compress.cjs +495 -0
- package/lib/compress.test.cjs +267 -0
- package/lib/config-defaults.cjs +39 -0
- package/lib/config-schema.cjs +45 -5
- package/lib/elision-bench.cjs +409 -0
- package/lib/elision-bench.test.cjs +89 -0
- package/lib/elision-proxy.cjs +158 -0
- package/lib/elision-proxy.test.cjs +243 -0
- package/lib/elision.cjs +163 -0
- package/lib/elision.test.cjs +143 -0
- package/lib/learnings/extract.cjs +4 -4
- package/lib/learnings/extract.test.cjs +8 -8
- package/lib/model-providers.cjs +118 -0
- package/lib/model-providers.test.cjs +85 -0
- package/lib/nubosloop.cjs +1 -1
- package/lib/output-steering.cjs +68 -0
- package/lib/output-steering.test.cjs +74 -0
- package/lib/researcher-swarm.cjs +14 -3
- package/lib/runtime/agent-loop.cjs +94 -0
- package/lib/runtime/agent-loop.test.cjs +240 -0
- package/lib/runtime/dispatch.cjs +174 -0
- package/lib/runtime/dispatch.test.cjs +207 -0
- package/lib/runtime/preflight.cjs +68 -0
- package/lib/runtime/preflight.test.cjs +62 -0
- package/lib/runtime/providers/openai-compat.cjs +103 -0
- package/lib/runtime/providers/openai-compat.test.cjs +112 -0
- package/lib/runtime/tools/index.cjs +447 -0
- package/lib/runtime/tools/index.test.cjs +254 -0
- package/lib/schemas/data/elision-entry.v1.json +16 -0
- package/lib/security/review.cjs +4 -4
- package/lib/security/review.test.cjs +6 -6
- package/lib/token-cost.cjs +46 -0
- package/lib/token-cost.test.cjs +42 -0
- package/np-tools.cjs +3 -0
- package/package.json +1 -1
- package/workflows/add-tests.md +41 -0
- package/workflows/architect-phase.md +19 -0
- package/workflows/discuss-phase.md +29 -10
- package/workflows/execute-phase.md +93 -4
- package/workflows/plan-phase.md +57 -16
- package/workflows/research-phase.md +45 -0
- package/workflows/scan-codebase.md +21 -3
- package/workflows/validate-phase.md +30 -13
- package/workflows/verify-work.md +17 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { test, afterEach } = require('node:test');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
|
|
7
|
+
const { runAgentLoop, DEFAULT_MAX_ITERATIONS } = require('./agent-loop.cjs');
|
|
8
|
+
const { toolsetFor } = require('./tools/index.cjs');
|
|
9
|
+
const elision = require('../elision.cjs');
|
|
10
|
+
|
|
11
|
+
function _bigLog() {
|
|
12
|
+
const lines = [];
|
|
13
|
+
for (let i = 0; i < 300; i++) {
|
|
14
|
+
if (i % 73 === 0) lines.push('ERROR: boom at module_' + i);
|
|
15
|
+
else lines.push('[info] step ' + i + ' ok processed record ' + (i * 7) + ' ' + 'x'.repeat(30));
|
|
16
|
+
}
|
|
17
|
+
return lines.join('\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const _dirs = [];
|
|
21
|
+
function _ws(files) {
|
|
22
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'np-loop-')));
|
|
23
|
+
for (const [rel, content] of Object.entries(files || {})) {
|
|
24
|
+
const abs = path.join(root, rel);
|
|
25
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
26
|
+
fs.writeFileSync(abs, content, 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
_dirs.push(root);
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
afterEach(() => { while (_dirs.length) { try { fs.rmSync(_dirs.pop(), { recursive: true, force: true }); } catch {} } });
|
|
32
|
+
|
|
33
|
+
function _scriptedChat(turns) {
|
|
34
|
+
let i = 0;
|
|
35
|
+
const seen = [];
|
|
36
|
+
const fn = async ({ messages }) => {
|
|
37
|
+
seen.push(JSON.parse(JSON.stringify(messages)));
|
|
38
|
+
const t = turns[Math.min(i, turns.length - 1)];
|
|
39
|
+
i++;
|
|
40
|
+
if (t.toolCalls) {
|
|
41
|
+
return { content: t.content || '', toolCalls: t.toolCalls, finishReason: 'tool_calls', raw: { role: 'assistant', content: t.content || '', tool_calls: t.toolCalls.map((c) => ({ id: c.id, function: { name: c.name, arguments: c.arguments } })) } };
|
|
42
|
+
}
|
|
43
|
+
return { content: t.content, toolCalls: [], finishReason: 'stop', raw: { role: 'assistant', content: t.content } };
|
|
44
|
+
};
|
|
45
|
+
fn.seen = seen;
|
|
46
|
+
return fn;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('AL-1: a final-answer turn returns immediately, stopped=final', async () => {
|
|
50
|
+
const chatImpl = _scriptedChat([{ content: 'done' }]);
|
|
51
|
+
const out = await runAgentLoop({
|
|
52
|
+
systemPrompt: 'you are x', task: 'do it',
|
|
53
|
+
toolset: toolsetFor(['Read']), provider: { baseUrl: 'http://x/v1', model: 'm' }, chatImpl,
|
|
54
|
+
});
|
|
55
|
+
assert.equal(out.content, 'done');
|
|
56
|
+
assert.equal(out.stopped, 'final');
|
|
57
|
+
assert.equal(out.iterations, 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('AL-2: a tool call is executed in the workspace and fed back, then a final answer', async () => {
|
|
61
|
+
const cwd = _ws({ 'data.txt': 'hello' });
|
|
62
|
+
const chatImpl = _scriptedChat([
|
|
63
|
+
{ toolCalls: [{ id: 't1', name: 'Read', arguments: '{"path":"data.txt"}' }] },
|
|
64
|
+
{ content: 'the file says hello' },
|
|
65
|
+
]);
|
|
66
|
+
const out = await runAgentLoop({
|
|
67
|
+
systemPrompt: 's', task: 'read data.txt', cwd,
|
|
68
|
+
toolset: toolsetFor(['Read']), provider: { baseUrl: 'http://x/v1', model: 'm' }, chatImpl,
|
|
69
|
+
});
|
|
70
|
+
assert.equal(out.stopped, 'final');
|
|
71
|
+
assert.equal(out.iterations, 2);
|
|
72
|
+
assert.deepEqual(out.toolLog, [{ name: 'Read', ok: true }]);
|
|
73
|
+
const lastTurnMsgs = chatImpl.seen[1];
|
|
74
|
+
const toolMsg = lastTurnMsgs.find((m) => m.role === 'tool');
|
|
75
|
+
assert.equal(toolMsg.tool_call_id, 't1');
|
|
76
|
+
assert.equal(toolMsg.content, '1\thello');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('AL-3: a failing tool call returns an error string, ok=false, loop continues', async () => {
|
|
80
|
+
const cwd = _ws({});
|
|
81
|
+
const chatImpl = _scriptedChat([
|
|
82
|
+
{ toolCalls: [{ id: 't1', name: 'Read', arguments: '{"path":"missing.txt"}' }] },
|
|
83
|
+
{ content: 'could not read' },
|
|
84
|
+
]);
|
|
85
|
+
const out = await runAgentLoop({
|
|
86
|
+
systemPrompt: 's', task: 't', cwd,
|
|
87
|
+
toolset: toolsetFor(['Read']), provider: { baseUrl: 'http://x/v1', model: 'm' }, chatImpl,
|
|
88
|
+
});
|
|
89
|
+
assert.equal(out.toolLog[0].ok, false);
|
|
90
|
+
assert.equal(out.stopped, 'final');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('AL-4: a model that never stops hits the iteration cap', async () => {
|
|
94
|
+
const cwd = _ws({ 'a.txt': 'x' });
|
|
95
|
+
const chatImpl = _scriptedChat([{ toolCalls: [{ id: 't', name: 'Read', arguments: '{"path":"a.txt"}' }] }]);
|
|
96
|
+
const out = await runAgentLoop({
|
|
97
|
+
systemPrompt: 's', task: 't', cwd, maxIterations: 3,
|
|
98
|
+
toolset: toolsetFor(['Read']), provider: { baseUrl: 'http://x/v1', model: 'm' }, chatImpl,
|
|
99
|
+
});
|
|
100
|
+
assert.equal(out.stopped, 'max-iterations');
|
|
101
|
+
assert.equal(out.iterations, 3);
|
|
102
|
+
assert.equal(out.toolLog.length, 3);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('AL-5: missing toolset / provider throw loud', async () => {
|
|
106
|
+
let a = null; try { await runAgentLoop({ provider: { model: 'm' } }); } catch (e) { a = e; }
|
|
107
|
+
assert.equal(a.code, 'agent-loop-no-toolset');
|
|
108
|
+
let b = null; try { await runAgentLoop({ toolset: toolsetFor(['Read']) }); } catch (e) { b = e; }
|
|
109
|
+
assert.equal(b.code, 'agent-loop-no-provider');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('AL-6: DEFAULT_MAX_ITERATIONS is a sane positive cap', () => {
|
|
113
|
+
assert.ok(DEFAULT_MAX_ITERATIONS >= 1 && DEFAULT_MAX_ITERATIONS <= 100);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('AL-8: compression default OFF — tool result enters history verbatim, no blocks compressed', async () => {
|
|
117
|
+
const cwd = _ws({ 'log.txt': _bigLog() });
|
|
118
|
+
const chatImpl = _scriptedChat([
|
|
119
|
+
{ toolCalls: [{ id: 't1', name: 'Read', arguments: '{"path":"log.txt"}' }] },
|
|
120
|
+
{ content: 'done' },
|
|
121
|
+
]);
|
|
122
|
+
const out = await runAgentLoop({
|
|
123
|
+
systemPrompt: 's', task: 't', cwd,
|
|
124
|
+
toolset: toolsetFor(['Read']), provider: { baseUrl: 'http://x/v1', model: 'm' }, chatImpl,
|
|
125
|
+
});
|
|
126
|
+
const toolMsg = chatImpl.seen[1].find((m) => m.role === 'tool');
|
|
127
|
+
assert.ok(!toolMsg.content.includes('⟦elided:'), 'no marker when compression off');
|
|
128
|
+
assert.equal(out.compression.blocks_compressed, 0);
|
|
129
|
+
assert.equal(out.compression.bytes_after, out.compression.bytes_before);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('AL-9: compression ON — large tool result is crushed in history, original retrievable from Elision store', async () => {
|
|
133
|
+
const cwd = _ws({
|
|
134
|
+
'log.txt': _bigLog(),
|
|
135
|
+
'.nubos-pilot/config.json': JSON.stringify({ compression: { enabled: true } }),
|
|
136
|
+
});
|
|
137
|
+
const chatImpl = _scriptedChat([
|
|
138
|
+
{ toolCalls: [{ id: 't1', name: 'Read', arguments: '{"path":"log.txt"}' }] },
|
|
139
|
+
{ content: 'done' },
|
|
140
|
+
]);
|
|
141
|
+
const out = await runAgentLoop({
|
|
142
|
+
systemPrompt: 's', task: 't', cwd,
|
|
143
|
+
toolset: toolsetFor(['Read']), provider: { baseUrl: 'http://x/v1', model: 'm' }, chatImpl,
|
|
144
|
+
});
|
|
145
|
+
const toolMsg = chatImpl.seen[1].find((m) => m.role === 'tool');
|
|
146
|
+
assert.equal(out.compression.blocks_compressed, 1);
|
|
147
|
+
assert.ok(out.compression.bytes_after < out.compression.bytes_before, 'history shrank');
|
|
148
|
+
const m = toolMsg.content.match(/⟦elided:([a-f0-9]{12})/);
|
|
149
|
+
assert.ok(m, 'marker with hash present in history');
|
|
150
|
+
const back = elision.retrieve(m[1], cwd);
|
|
151
|
+
assert.equal(back.status, 'ok');
|
|
152
|
+
assert.ok(back.original.includes('ERROR: boom at module_0'), 'original recoverable byte-for-byte');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('AL-10: end-to-end — model retrieves an elided original mid-loop via context-expand', async () => {
|
|
156
|
+
const cwd = _ws({
|
|
157
|
+
'log.txt': _bigLog(),
|
|
158
|
+
'.nubos-pilot/config.json': JSON.stringify({ compression: { enabled: true } }),
|
|
159
|
+
});
|
|
160
|
+
let expanded = null;
|
|
161
|
+
const chat = async ({ messages }) => {
|
|
162
|
+
chat.n = (chat.n || 0) + 1;
|
|
163
|
+
if (chat.n === 1) {
|
|
164
|
+
return { content: '', finishReason: 'tool_calls', toolCalls: [{ id: 'r1', name: 'Read', arguments: '{"path":"log.txt"}' }] };
|
|
165
|
+
}
|
|
166
|
+
if (chat.n === 2) {
|
|
167
|
+
const toolMsg = messages.filter((m) => m.role === 'tool').pop();
|
|
168
|
+
const hash = toolMsg.content.match(/⟦elided:([a-f0-9]{12})/)[1];
|
|
169
|
+
return { content: '', finishReason: 'tool_calls', toolCalls: [{ id: 'r2', name: 'context-expand', arguments: JSON.stringify({ hash }) }] };
|
|
170
|
+
}
|
|
171
|
+
expanded = messages.filter((m) => m.role === 'tool').pop().content;
|
|
172
|
+
return { content: 'done', finishReason: 'stop', toolCalls: [] };
|
|
173
|
+
};
|
|
174
|
+
const out = await runAgentLoop({
|
|
175
|
+
systemPrompt: 's', task: 't', cwd,
|
|
176
|
+
toolset: toolsetFor(['Read'], { withExpand: true }), provider: { baseUrl: 'http://x/v1', model: 'm' }, chatImpl: chat,
|
|
177
|
+
});
|
|
178
|
+
assert.equal(out.stopped, 'final');
|
|
179
|
+
assert.ok(expanded.includes('ERROR: boom at module_0'), 'model recovered the full original byte-for-byte');
|
|
180
|
+
assert.ok(!expanded.includes('⟦elided:'), 'the expanded original carries no marker');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('AL-7: assistant echo is rebuilt in OpenAI wire shape; ids round-trip even if provider omits them', async () => {
|
|
184
|
+
const cwd = _ws({ 'a.txt': 'A', 'b.txt': 'B' });
|
|
185
|
+
const chatImpl = async ({ messages }) => {
|
|
186
|
+
chatImpl.seen = (chatImpl.seen || []).concat([JSON.parse(JSON.stringify(messages))]);
|
|
187
|
+
if (!chatImpl.called) {
|
|
188
|
+
chatImpl.called = true;
|
|
189
|
+
return {
|
|
190
|
+
content: '', finishReason: 'tool_calls',
|
|
191
|
+
toolCalls: [
|
|
192
|
+
{ id: 'call_0', name: 'Read', arguments: '{"path":"a.txt"}' },
|
|
193
|
+
{ id: 'call_1', name: 'Read', arguments: '{"path":"b.txt"}' },
|
|
194
|
+
],
|
|
195
|
+
raw: { role: 'assistant', content: '', tool_calls: [{ function: { name: 'Read' } }] },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { content: 'done', toolCalls: [], finishReason: 'stop', raw: { role: 'assistant', content: 'done' } };
|
|
199
|
+
};
|
|
200
|
+
const out = await runAgentLoop({
|
|
201
|
+
systemPrompt: 's', task: 't', cwd,
|
|
202
|
+
toolset: toolsetFor(['Read']), provider: { baseUrl: 'http://x/v1', model: 'm' }, chatImpl,
|
|
203
|
+
});
|
|
204
|
+
assert.equal(out.stopped, 'final');
|
|
205
|
+
const secondTurn = chatImpl.seen[1];
|
|
206
|
+
const assistant = secondTurn.find((m) => m.role === 'assistant' && m.tool_calls);
|
|
207
|
+
assert.equal(assistant.tool_calls[0].type, 'function');
|
|
208
|
+
assert.equal(assistant.tool_calls[0].function.name, 'Read');
|
|
209
|
+
assert.deepEqual(assistant.tool_calls.map((c) => c.id), ['call_0', 'call_1']);
|
|
210
|
+
const toolMsgs = secondTurn.filter((m) => m.role === 'tool');
|
|
211
|
+
assert.deepEqual(toolMsgs.map((m) => m.tool_call_id), ['call_0', 'call_1']);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('AL-12: output_steering ON — system prompt is enriched and mechanical turns downgrade effort', async () => {
|
|
215
|
+
const cwd = _ws({
|
|
216
|
+
'a.txt': 'A',
|
|
217
|
+
'.nubos-pilot/config.json': JSON.stringify({
|
|
218
|
+
compression: {
|
|
219
|
+
enabled: true,
|
|
220
|
+
output_steering: { enabled: true, verbosity_profile: 'terse', effort_routing: { enabled: true, base_effort: 'high', mechanical_effort: 'low' } },
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
const seen = [];
|
|
225
|
+
const chatImpl = async (args) => {
|
|
226
|
+
seen.push({ effort: args.effort, system: (args.messages.find((m) => m.role === 'system') || {}).content });
|
|
227
|
+
if (seen.length === 1) {
|
|
228
|
+
return { content: '', finishReason: 'tool_calls', toolCalls: [{ id: 'c0', name: 'Read', arguments: '{"path":"a.txt"}' }], raw: { role: 'assistant', content: '' } };
|
|
229
|
+
}
|
|
230
|
+
return { content: 'done', toolCalls: [], finishReason: 'stop', raw: { role: 'assistant', content: 'done' } };
|
|
231
|
+
};
|
|
232
|
+
const out = await runAgentLoop({
|
|
233
|
+
systemPrompt: 'you are x', task: 't', cwd,
|
|
234
|
+
toolset: toolsetFor(['Read']), provider: { baseUrl: 'http://x/v1', model: 'm', effort: 'high' }, chatImpl,
|
|
235
|
+
});
|
|
236
|
+
assert.equal(out.stopped, 'final');
|
|
237
|
+
assert.match(seen[0].system, /<nubos_output_shaping>[\s\S]*<\/nubos_output_shaping>$/, 'system prompt carries the shaping block');
|
|
238
|
+
assert.equal(seen[0].effort, 'high', 'first turn (new user ask) keeps full effort');
|
|
239
|
+
assert.equal(seen[1].effort, 'low', 'second turn (clean tool result) downgrades to low');
|
|
240
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
5
|
+
const { loadAgentSource } = require('../agents.cjs');
|
|
6
|
+
const { resolveFromConfig } = require('../../bin/np-tools/resolve-model.cjs');
|
|
7
|
+
const { assertPreflight } = require('./preflight.cjs');
|
|
8
|
+
const { runAgentLoop } = require('./agent-loop.cjs');
|
|
9
|
+
const { toolsetFor } = require('./tools/index.cjs');
|
|
10
|
+
const elision = require('../elision.cjs');
|
|
11
|
+
const { AUDITED_AGENTS, auditToolUse } = require('../nubosloop-audit.cjs');
|
|
12
|
+
const { TASK_ID_RE } = require('../ids.cjs');
|
|
13
|
+
const metrics = require('../metrics.cjs');
|
|
14
|
+
|
|
15
|
+
function _lintOutput(content, schemaName) {
|
|
16
|
+
if (!schemaName) return null;
|
|
17
|
+
try {
|
|
18
|
+
const { getSchema } = require('../schemas/index.cjs');
|
|
19
|
+
const { lintContent } = require('../output-lint.cjs');
|
|
20
|
+
const res = lintContent(String(content == null ? '' : content), getSchema(schemaName));
|
|
21
|
+
return { ok: !!res.ok, schema: schemaName, violations: res.violations || [] };
|
|
22
|
+
} catch (err) {
|
|
23
|
+
return { ok: false, schema: schemaName, error: (err && err.code) || 'output-lint-failed' };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function _defaultInWorktree(cwd) {
|
|
28
|
+
try {
|
|
29
|
+
const { listSliceWorktrees } = require('../worktree.cjs');
|
|
30
|
+
return listSliceWorktrees(cwd).some((w) => cwd === w.path || cwd.startsWith(w.path + path.sep));
|
|
31
|
+
} catch { return false; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _parseTools(toolsField) {
|
|
35
|
+
if (Array.isArray(toolsField)) return toolsField.map((s) => String(s).trim()).filter(Boolean);
|
|
36
|
+
if (typeof toolsField === 'string') return toolsField.split(',').map((s) => s.trim()).filter(Boolean);
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function dispatchOffHost(o) {
|
|
41
|
+
const opts = o || {};
|
|
42
|
+
const cwd = opts.cwd || process.cwd();
|
|
43
|
+
const deps = opts.deps || {};
|
|
44
|
+
const resolve = deps.resolve || resolveFromConfig;
|
|
45
|
+
const preflight = deps.preflight || assertPreflight;
|
|
46
|
+
const loadSource = deps.loadSource || loadAgentSource;
|
|
47
|
+
const runLoop = deps.runLoop || runAgentLoop;
|
|
48
|
+
const isInWorktree = deps.isInWorktree || _defaultInWorktree;
|
|
49
|
+
const now = deps.now || (() => new Date().toISOString());
|
|
50
|
+
|
|
51
|
+
if (typeof opts.agent !== 'string' || !opts.agent) {
|
|
52
|
+
throw new NubosPilotError('dispatch-no-agent', 'dispatchOffHost requires an agent name', {});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const res = resolve({ agentOrTier: opts.agent, cwd });
|
|
56
|
+
if (res.kind !== 'openai-compat') {
|
|
57
|
+
throw new NubosPilotError(
|
|
58
|
+
'dispatch-not-offhost',
|
|
59
|
+
'agent "' + opts.agent + '" resolves to provider "' + res.provider + '" (kind ' + res.kind
|
|
60
|
+
+ ') — dispatchOffHost only runs openai-compat providers',
|
|
61
|
+
{ provider: res.provider, kind: res.kind },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const audited = AUDITED_AGENTS.includes(opts.agent);
|
|
65
|
+
const hasTaskCtx = typeof opts.taskId === 'string' && TASK_ID_RE.test(opts.taskId);
|
|
66
|
+
if (audited && !hasTaskCtx) {
|
|
67
|
+
throw new NubosPilotError(
|
|
68
|
+
'offhost-audited-agent-unsupported',
|
|
69
|
+
'agent "' + opts.agent + '" is Rule-9-audited and needs a task context off-host — pass --task-id '
|
|
70
|
+
+ 'M<NNN>-S<NNN>-T<NNNN> so the search-evidence ledger + audit apply. (Wired into execute-phase in ADR-0021 Slice 4b.)',
|
|
71
|
+
{ agent: opts.agent, audited: AUDITED_AGENTS.slice() },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (opts.allowBash && !isInWorktree(cwd)) {
|
|
76
|
+
throw new NubosPilotError(
|
|
77
|
+
'offhost-bash-requires-sandbox',
|
|
78
|
+
'off-host Bash needs worktree isolation — run inside a slice worktree (workflow.worktree_isolation) so model-driven shell is confined. Refused outside one.',
|
|
79
|
+
{ cwd: path.basename(cwd) },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const src = loadSource(opts.agent, cwd);
|
|
84
|
+
const declared = _parseTools(src.frontmatter && src.frontmatter.tools);
|
|
85
|
+
const cx = elision.compressionContext(cwd);
|
|
86
|
+
const toolset = toolsetFor(declared, {
|
|
87
|
+
readOnly: !!opts.readOnly,
|
|
88
|
+
allowBash: !!opts.allowBash,
|
|
89
|
+
withSearch: audited,
|
|
90
|
+
withExpand: cx.enabled,
|
|
91
|
+
ctx: { taskId: hasTaskCtx ? opts.taskId : null, customRulesPath: opts.customRulesPath },
|
|
92
|
+
});
|
|
93
|
+
const provider = { baseUrl: res.baseUrl, apiKeyEnv: res.apiKeyEnv, model: res.model };
|
|
94
|
+
const _os = cx.outputSteering;
|
|
95
|
+
if (_os && _os.effortRouting && _os.baseEffort) provider.effort = _os.baseEffort;
|
|
96
|
+
|
|
97
|
+
await preflight(provider);
|
|
98
|
+
|
|
99
|
+
const started = now();
|
|
100
|
+
let result = null;
|
|
101
|
+
let status = 'ok';
|
|
102
|
+
let errObj = null;
|
|
103
|
+
try {
|
|
104
|
+
result = await runLoop({
|
|
105
|
+
systemPrompt: src.body,
|
|
106
|
+
task: opts.task,
|
|
107
|
+
toolset,
|
|
108
|
+
provider,
|
|
109
|
+
cwd,
|
|
110
|
+
maxIterations: opts.maxIterations,
|
|
111
|
+
});
|
|
112
|
+
} catch (err) {
|
|
113
|
+
status = 'error';
|
|
114
|
+
errObj = { code: (err && err.code) || 'dispatch-loop-failed', message: (err && err.message) || 'loop failed' };
|
|
115
|
+
}
|
|
116
|
+
const ended = now();
|
|
117
|
+
|
|
118
|
+
let metricsRecorded = false;
|
|
119
|
+
try {
|
|
120
|
+
const record = metrics.buildRecord({
|
|
121
|
+
agent: opts.agent,
|
|
122
|
+
tier: res.tier,
|
|
123
|
+
resolved_model: res.model,
|
|
124
|
+
phase: opts.phase || '',
|
|
125
|
+
plan: opts.plan || 'offhost',
|
|
126
|
+
task: opts.taskId || 'adhoc',
|
|
127
|
+
started_at: started,
|
|
128
|
+
ended_at: ended,
|
|
129
|
+
status,
|
|
130
|
+
runtime: res.provider,
|
|
131
|
+
error: errObj,
|
|
132
|
+
});
|
|
133
|
+
metrics.appendRecord(record, { cwd });
|
|
134
|
+
metricsRecorded = true;
|
|
135
|
+
} catch {}
|
|
136
|
+
|
|
137
|
+
if (status === 'error') {
|
|
138
|
+
throw new NubosPilotError(errObj.code, errObj.message, { agent: opts.agent, provider: res.provider });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let rule9 = null;
|
|
142
|
+
if (audited && hasTaskCtx && !opts.skipAudit) {
|
|
143
|
+
try {
|
|
144
|
+
rule9 = auditToolUse(opts.taskId, opts.agent, (result.toolLog || []).map((t) => t.name), cwd);
|
|
145
|
+
} catch (err) { rule9 = { ok: false, error: (err && err.code) || 'audit-failed' }; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const toolsAdvertised = (toolset.schemas || []).length;
|
|
149
|
+
const toolCalls = (result.toolLog || []).length;
|
|
150
|
+
const capability = {
|
|
151
|
+
toolsAdvertised,
|
|
152
|
+
toolCalls,
|
|
153
|
+
mutating: toolset.names.some((n) => n === 'Write' || n === 'Edit' || n === 'Bash'),
|
|
154
|
+
ok: !(toolsAdvertised > 0 && toolCalls === 0),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
agent: opts.agent,
|
|
159
|
+
provider: res.provider,
|
|
160
|
+
model: res.model,
|
|
161
|
+
content: result.content,
|
|
162
|
+
stopped: result.stopped,
|
|
163
|
+
iterations: result.iterations,
|
|
164
|
+
toolLog: result.toolLog,
|
|
165
|
+
tools: toolset.names,
|
|
166
|
+
rule9,
|
|
167
|
+
capability,
|
|
168
|
+
output_lint: _lintOutput(result.content, opts.outputSchema),
|
|
169
|
+
metrics_recorded: metricsRecorded,
|
|
170
|
+
compression: result.compression || null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { dispatchOffHost, _parseTools };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { test, afterEach } = require('node:test');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
|
|
7
|
+
const { dispatchOffHost, _parseTools } = require('./dispatch.cjs');
|
|
8
|
+
|
|
9
|
+
const _dirs = [];
|
|
10
|
+
function _root() {
|
|
11
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'np-dispatch-')));
|
|
12
|
+
fs.mkdirSync(path.join(root, '.nubos-pilot'), { recursive: true });
|
|
13
|
+
_dirs.push(root);
|
|
14
|
+
return root;
|
|
15
|
+
}
|
|
16
|
+
afterEach(() => { while (_dirs.length) { try { fs.rmSync(_dirs.pop(), { recursive: true, force: true }); } catch {} } });
|
|
17
|
+
|
|
18
|
+
const NOW = () => '2026-06-16T00:00:00.000Z';
|
|
19
|
+
|
|
20
|
+
function _deps(over) {
|
|
21
|
+
return Object.assign({
|
|
22
|
+
resolve: () => ({ kind: 'openai-compat', provider: 'ollama', model: 'qwen2.5-coder:32b', baseUrl: 'http://localhost:11434/v1', apiKeyEnv: null, tier: 'sonnet' }),
|
|
23
|
+
preflight: async () => ({ ok: true }),
|
|
24
|
+
loadSource: () => ({ frontmatter: { name: 'np-executor', tier: 'sonnet', tools: 'Read, Write, Bash, Grep' }, body: 'You are the executor.' }),
|
|
25
|
+
runLoop: async () => ({ content: 'done', stopped: 'final', iterations: 2, toolLog: [{ name: 'Read', ok: true }] }),
|
|
26
|
+
now: NOW,
|
|
27
|
+
}, over || {});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('DSP-1: happy path returns the envelope and records a metrics row', async () => {
|
|
31
|
+
const cwd = _root();
|
|
32
|
+
const out = await dispatchOffHost({ agent: 'np-architect', task: 'do it', cwd, deps: _deps() });
|
|
33
|
+
assert.equal(out.provider, 'ollama');
|
|
34
|
+
assert.equal(out.model, 'qwen2.5-coder:32b');
|
|
35
|
+
assert.equal(out.content, 'done');
|
|
36
|
+
assert.equal(out.stopped, 'final');
|
|
37
|
+
assert.equal(out.metrics_recorded, true);
|
|
38
|
+
const meta = fs.readFileSync(path.join(cwd, '.nubos-pilot', 'metrics', 'meta.jsonl'), 'utf-8');
|
|
39
|
+
const rec = JSON.parse(meta.trim().split('\n').pop());
|
|
40
|
+
assert.equal(rec.runtime, 'ollama');
|
|
41
|
+
assert.equal(rec.resolved_model, 'qwen2.5-coder:32b');
|
|
42
|
+
assert.equal(rec.status, 'ok');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('DSP-1c: provider.effort is seeded from base_effort only when effort routing is opted in', async () => {
|
|
46
|
+
const bare = _root();
|
|
47
|
+
let seenBare;
|
|
48
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd: bare,
|
|
49
|
+
deps: _deps({ runLoop: async ({ provider }) => { seenBare = provider; return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; } }) });
|
|
50
|
+
assert.equal(seenBare.effort, undefined, 'no effort field absent opt-in (providers without support unaffected)');
|
|
51
|
+
|
|
52
|
+
const on = _root();
|
|
53
|
+
fs.writeFileSync(path.join(on, '.nubos-pilot', 'config.json'), JSON.stringify({
|
|
54
|
+
compression: { enabled: true, output_steering: { enabled: true, effort_routing: { enabled: true, base_effort: 'high', mechanical_effort: 'low' } } },
|
|
55
|
+
}));
|
|
56
|
+
let seenOn;
|
|
57
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd: on,
|
|
58
|
+
deps: _deps({ runLoop: async ({ provider }) => { seenOn = provider; return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; } }) });
|
|
59
|
+
assert.equal(seenOn.effort, 'high', 'base_effort seeds the provider effort');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('DSP-2: a native-kind agent is refused (dispatch-not-offhost)', async () => {
|
|
63
|
+
const cwd = _root();
|
|
64
|
+
const deps = _deps({ resolve: () => ({ kind: 'native', provider: 'claude', model: null, tier: 'opus' }) });
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
dispatchOffHost({ agent: 'np-planner', task: 't', cwd, deps }),
|
|
67
|
+
(e) => e.code === 'dispatch-not-offhost',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('DSP-3: Bash is excluded by default; opt-in (inside a worktree) includes it', async () => {
|
|
72
|
+
const cwd = _root();
|
|
73
|
+
let seen = null;
|
|
74
|
+
const deps = _deps({
|
|
75
|
+
isInWorktree: () => true,
|
|
76
|
+
runLoop: async ({ toolset }) => { seen = toolset.names.slice(); return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; },
|
|
77
|
+
});
|
|
78
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps });
|
|
79
|
+
assert.deepEqual(seen, ['Read', 'Write', 'Grep']);
|
|
80
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps, allowBash: true });
|
|
81
|
+
assert.deepEqual(seen, ['Read', 'Write', 'Bash', 'Grep']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('DSP-3b: readOnly restricts the toolset to read tools', async () => {
|
|
85
|
+
const cwd = _root();
|
|
86
|
+
let seen = null;
|
|
87
|
+
const deps = _deps({ runLoop: async ({ toolset }) => { seen = toolset.names.slice(); return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; } });
|
|
88
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps, readOnly: true });
|
|
89
|
+
assert.deepEqual(seen, ['Read', 'Grep']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('DSP-4: a loop error records an error metrics row and rethrows the loop code', async () => {
|
|
93
|
+
const cwd = _root();
|
|
94
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
95
|
+
const deps = _deps({ runLoop: async () => { throw new NubosPilotError('provider-http-error', 'HTTP 500', {}); } });
|
|
96
|
+
await assert.rejects(dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps }), (e) => e.code === 'provider-http-error');
|
|
97
|
+
const rec = JSON.parse(fs.readFileSync(path.join(cwd, '.nubos-pilot', 'metrics', 'meta.jsonl'), 'utf-8').trim().split('\n').pop());
|
|
98
|
+
assert.equal(rec.status, 'error');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('DSP-5: preflight runs before the loop and a failure aborts before any tool call', async () => {
|
|
102
|
+
const cwd = _root();
|
|
103
|
+
let looped = false;
|
|
104
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
105
|
+
const deps = _deps({
|
|
106
|
+
preflight: async () => { throw new NubosPilotError('preflight-failed', 'unreachable', {}); },
|
|
107
|
+
runLoop: async () => { looped = true; return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; },
|
|
108
|
+
});
|
|
109
|
+
await assert.rejects(dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps }), (e) => e.code === 'preflight-failed');
|
|
110
|
+
assert.equal(looped, false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('DSP-6: missing agent throws dispatch-no-agent', async () => {
|
|
114
|
+
await assert.rejects(dispatchOffHost({ task: 't', deps: _deps() }), (e) => e.code === 'dispatch-no-agent');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('DSP-8: a Rule-9-audited agent without a task context is refused off-host', async () => {
|
|
118
|
+
const cwd = _root();
|
|
119
|
+
await assert.rejects(
|
|
120
|
+
dispatchOffHost({ agent: 'np-executor', task: 't', cwd, deps: _deps() }),
|
|
121
|
+
(e) => e.code === 'offhost-audited-agent-unsupported',
|
|
122
|
+
);
|
|
123
|
+
await assert.rejects(
|
|
124
|
+
dispatchOffHost({ agent: 'np-researcher', task: 't', cwd, deps: _deps() }),
|
|
125
|
+
(e) => e.code === 'offhost-audited-agent-unsupported',
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('DSP-9: an audited agent WITH a valid --task-id is allowed; Rule-9 audit rides the envelope', async () => {
|
|
130
|
+
const cwd = _root();
|
|
131
|
+
let seen = null;
|
|
132
|
+
const deps = _deps({ runLoop: async ({ toolset }) => { seen = toolset.names.slice(); return { content: 'x', stopped: 'final', iterations: 1, toolLog: [{ name: 'knowledge-search', ok: true }] }; } });
|
|
133
|
+
const out = await dispatchOffHost({ agent: 'np-executor', task: 't', cwd, deps, taskId: 'M001-S001-T0001' });
|
|
134
|
+
assert.ok(seen.includes('knowledge-search'), 'knowledge-search must be injected for an audited agent');
|
|
135
|
+
assert.ok(out.rule9 && typeof out.rule9 === 'object', 'audit result must ride the envelope');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('DSP-9b: with recorded search evidence the Rule-9 audit passes', async () => {
|
|
139
|
+
const cwd = _root();
|
|
140
|
+
const taskId = 'M001-S001-T0002';
|
|
141
|
+
require('../nubosloop-audit.cjs').recordSearchEvidence(taskId, 'auth', cwd);
|
|
142
|
+
const deps = _deps({ runLoop: async () => ({ content: 'x', stopped: 'final', iterations: 1, toolLog: [{ name: 'knowledge-search', ok: true }] }) });
|
|
143
|
+
const out = await dispatchOffHost({ agent: 'np-executor', task: 't', cwd, deps, taskId });
|
|
144
|
+
assert.equal(out.rule9.ok, true);
|
|
145
|
+
assert.equal(out.rule9.violation, null);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('DSP-12: skipAudit defers Rule-9 to the orchestrator (rule9 not run by dispatch)', async () => {
|
|
149
|
+
const cwd = _root();
|
|
150
|
+
const deps = _deps({ runLoop: async () => ({ content: 'x', stopped: 'final', iterations: 1, toolLog: [{ name: 'knowledge-search', ok: true }] }) });
|
|
151
|
+
const out = await dispatchOffHost({ agent: 'np-executor', task: 't', cwd, deps, taskId: 'M001-S001-T0003', skipAudit: true });
|
|
152
|
+
assert.equal(out.rule9, null, 'dispatch must not audit when skipAudit is set');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('DSP-10: --allow-bash outside a worktree is refused (offhost-bash-requires-sandbox)', async () => {
|
|
156
|
+
const cwd = _root();
|
|
157
|
+
const deps = _deps({ isInWorktree: () => false });
|
|
158
|
+
await assert.rejects(
|
|
159
|
+
dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps, allowBash: true }),
|
|
160
|
+
(e) => e.code === 'offhost-bash-requires-sandbox',
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('DSP-11: --allow-bash inside a worktree includes Bash in the toolset', async () => {
|
|
165
|
+
const cwd = _root();
|
|
166
|
+
let seen = null;
|
|
167
|
+
const deps = _deps({
|
|
168
|
+
isInWorktree: () => true,
|
|
169
|
+
runLoop: async ({ toolset }) => { seen = toolset.names.slice(); return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; },
|
|
170
|
+
});
|
|
171
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps, allowBash: true });
|
|
172
|
+
assert.ok(seen.includes('Bash'), 'Bash must be available inside a worktree');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('DSP-13: outputSchema lints the result and rides the envelope (null when unset)', async () => {
|
|
176
|
+
const cwd = _root();
|
|
177
|
+
const out1 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: _deps() });
|
|
178
|
+
assert.equal(out1.output_lint, null, 'no schema ⇒ no lint');
|
|
179
|
+
const out2 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: _deps(), outputSchema: 'researcher-output' });
|
|
180
|
+
assert.ok(out2.output_lint && out2.output_lint.schema === 'researcher-output', 'lint result rides the envelope');
|
|
181
|
+
assert.equal(typeof out2.output_lint.ok, 'boolean');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('DSP-14: capability flags zero tool-calls despite an advertised toolset (tool-calling unsupported signal)', async () => {
|
|
185
|
+
const cwd = _root();
|
|
186
|
+
const noTools = _deps({ runLoop: async () => ({ content: 'just text', stopped: 'final', iterations: 1, toolLog: [] }) });
|
|
187
|
+
const out1 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: noTools });
|
|
188
|
+
assert.equal(out1.capability.ok, false);
|
|
189
|
+
assert.equal(out1.capability.toolCalls, 0);
|
|
190
|
+
assert.ok(out1.capability.toolsAdvertised > 0);
|
|
191
|
+
assert.equal(out1.capability.mutating, true);
|
|
192
|
+
|
|
193
|
+
const usedTool = _deps({ runLoop: async () => ({ content: 'x', stopped: 'final', iterations: 2, toolLog: [{ name: 'Read', ok: true }] }) });
|
|
194
|
+
const out2 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: usedTool });
|
|
195
|
+
assert.equal(out2.capability.ok, true);
|
|
196
|
+
|
|
197
|
+
const ro = _deps({ runLoop: async () => ({ content: 'x', stopped: 'final', iterations: 1, toolLog: [] }) });
|
|
198
|
+
const out3 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: ro, readOnly: true });
|
|
199
|
+
assert.equal(out3.capability.ok, false);
|
|
200
|
+
assert.equal(out3.capability.mutating, false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('DSP-7: _parseTools accepts a comma string or an array', () => {
|
|
204
|
+
assert.deepEqual(_parseTools('Read, Write , Bash'), ['Read', 'Write', 'Bash']);
|
|
205
|
+
assert.deepEqual(_parseTools(['Read', 'Grep']), ['Read', 'Grep']);
|
|
206
|
+
assert.deepEqual(_parseTools(undefined), []);
|
|
207
|
+
});
|