nubos-pilot 1.2.3 → 1.3.0
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 +24 -0
- package/README.md +18 -1
- package/SECURITY.md +3 -4
- package/bin/np-tools/_commands.cjs +1 -0
- package/bin/np-tools/learnings.cjs +5 -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 +4 -1
- package/bin/np-tools/spawn-headless.cjs +135 -2
- package/bin/np-tools/spawn-headless.test.cjs +225 -40
- 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/config-schema.cjs +5 -1
- package/lib/headless-guard.cjs +127 -0
- package/lib/headless-guard.test.cjs +119 -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/runtime/agent-loop.cjs +64 -0
- package/lib/runtime/agent-loop.test.cjs +135 -0
- package/lib/runtime/dispatch.cjs +174 -0
- package/lib/runtime/dispatch.test.cjs +193 -0
- package/lib/runtime/preflight.cjs +68 -0
- package/lib/runtime/preflight.test.cjs +62 -0
- package/lib/runtime/providers/openai-compat.cjs +102 -0
- package/lib/runtime/providers/openai-compat.test.cjs +103 -0
- package/lib/runtime/tools/index.cjs +415 -0
- package/lib/runtime/tools/index.test.cjs +230 -0
- package/lib/security/review.cjs +4 -4
- package/lib/security/review.test.cjs +6 -6
- package/np-tools.cjs +1 -0
- package/package.json +1 -1
- package/templates/claude/payload/hooks/np-learnings-hook.cjs +1 -0
- package/templates/claude/payload/hooks/np-security-hook.cjs +1 -0
- 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,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const { atomicCreateExclusiveSync } = require('./core.cjs');
|
|
9
|
+
|
|
10
|
+
const HEADLESS_ENV = 'NUBOS_PILOT_HEADLESS';
|
|
11
|
+
const DEPTH_ENV = 'NUBOS_PILOT_HOOK_DEPTH';
|
|
12
|
+
const MAX_DEPTH_ENV = 'NUBOS_PILOT_MAX_HOOK_DEPTH';
|
|
13
|
+
const DEFAULT_MAX_DEPTH = 1;
|
|
14
|
+
const DEFAULT_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
function isHeadless(env) {
|
|
17
|
+
const e = env || process.env;
|
|
18
|
+
return e[HEADLESS_ENV] === '1';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function currentDepth(env) {
|
|
22
|
+
const e = env || process.env;
|
|
23
|
+
const n = parseInt(e[DEPTH_ENV], 10);
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function maxDepth(env) {
|
|
28
|
+
const e = env || process.env;
|
|
29
|
+
const n = parseInt(e[MAX_DEPTH_ENV], 10);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_MAX_DEPTH;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function depthExceeded(env) {
|
|
34
|
+
return currentDepth(env) >= maxDepth(env);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function childSpawnEnv(env) {
|
|
38
|
+
const out = Object.create(null);
|
|
39
|
+
out[HEADLESS_ENV] = '1';
|
|
40
|
+
out[DEPTH_ENV] = String(currentDepth(env) + 1);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function _isPidAlive(pid) {
|
|
45
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
46
|
+
try { process.kill(pid, 0); return true; }
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err && err.code === 'ESRCH') return false;
|
|
49
|
+
if (err && err.code === 'EPERM') return true;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _lockPath(root, agent) {
|
|
55
|
+
return path.join(root, '.nubos-pilot', 'run', 'headless-' + agent + '.lock');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _reclaimStaleLock(lockPath) {
|
|
59
|
+
const aside = lockPath + '.stale.' + process.pid + '.' + crypto.randomBytes(4).toString('hex');
|
|
60
|
+
try { fs.renameSync(lockPath, aside); }
|
|
61
|
+
catch { return; }
|
|
62
|
+
try { fs.unlinkSync(aside); } catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tryAcquireSpawnLock(root, agent, opts) {
|
|
66
|
+
const o = opts || {};
|
|
67
|
+
const staleMs = Number.isFinite(o.staleMs) ? o.staleMs : DEFAULT_LOCK_STALE_MS;
|
|
68
|
+
const lockPath = _lockPath(root, agent);
|
|
69
|
+
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch {}
|
|
70
|
+
const payload = JSON.stringify({
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
agent,
|
|
73
|
+
hostname: os.hostname(),
|
|
74
|
+
acquiredAt: new Date().toISOString(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
atomicCreateExclusiveSync(lockPath, payload);
|
|
80
|
+
let released = false;
|
|
81
|
+
return {
|
|
82
|
+
acquired: true,
|
|
83
|
+
lockPath,
|
|
84
|
+
release() {
|
|
85
|
+
if (released) return;
|
|
86
|
+
released = true;
|
|
87
|
+
let meta = null;
|
|
88
|
+
try { meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch {}
|
|
89
|
+
if (meta && meta.pid !== process.pid) return;
|
|
90
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (!err || err.code !== 'EEXIST') {
|
|
95
|
+
return { acquired: false, error: (err && err.code) || 'unknown' };
|
|
96
|
+
}
|
|
97
|
+
let meta = null;
|
|
98
|
+
let stat = null;
|
|
99
|
+
try { meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch {}
|
|
100
|
+
try { stat = fs.statSync(lockPath); } catch {}
|
|
101
|
+
const ageStale = !!stat && (Date.now() - stat.mtimeMs > staleMs);
|
|
102
|
+
const pidDead = !!meta && _isPidAlive(meta.pid) === false;
|
|
103
|
+
if (ageStale || pidDead) {
|
|
104
|
+
_reclaimStaleLock(lockPath);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
return { acquired: false, holder: meta };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { acquired: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
HEADLESS_ENV,
|
|
115
|
+
DEPTH_ENV,
|
|
116
|
+
MAX_DEPTH_ENV,
|
|
117
|
+
DEFAULT_MAX_DEPTH,
|
|
118
|
+
DEFAULT_LOCK_STALE_MS,
|
|
119
|
+
isHeadless,
|
|
120
|
+
currentDepth,
|
|
121
|
+
maxDepth,
|
|
122
|
+
depthExceeded,
|
|
123
|
+
childSpawnEnv,
|
|
124
|
+
tryAcquireSpawnLock,
|
|
125
|
+
_isPidAlive,
|
|
126
|
+
_lockPath,
|
|
127
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { test, afterEach } = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
|
|
9
|
+
const guard = require('./headless-guard.cjs');
|
|
10
|
+
|
|
11
|
+
const _sandboxes = [];
|
|
12
|
+
|
|
13
|
+
function _mkRoot() {
|
|
14
|
+
const r = fs.mkdtempSync(path.join(os.tmpdir(), 'np-headless-guard-'));
|
|
15
|
+
fs.mkdirSync(path.join(r, '.nubos-pilot'), { recursive: true });
|
|
16
|
+
_sandboxes.push(r);
|
|
17
|
+
return r;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
while (_sandboxes.length) {
|
|
22
|
+
const r = _sandboxes.pop();
|
|
23
|
+
try { fs.rmSync(r, { recursive: true, force: true }); } catch {}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('HG-1: isHeadless is true only when NUBOS_PILOT_HEADLESS=1', () => {
|
|
28
|
+
assert.equal(guard.isHeadless({}), false);
|
|
29
|
+
assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: '0' }), false);
|
|
30
|
+
assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: 'yes' }), false);
|
|
31
|
+
assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: '1' }), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('HG-2: currentDepth parses NUBOS_PILOT_HOOK_DEPTH, defaults to 0', () => {
|
|
35
|
+
assert.equal(guard.currentDepth({}), 0);
|
|
36
|
+
assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: 'x' }), 0);
|
|
37
|
+
assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: '0' }), 0);
|
|
38
|
+
assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: '2' }), 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('HG-3: depthExceeded honours default cap of 1 and the env override', () => {
|
|
42
|
+
assert.equal(guard.depthExceeded({}), false);
|
|
43
|
+
assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '1' }), true);
|
|
44
|
+
assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '1', NUBOS_PILOT_MAX_HOOK_DEPTH: '2' }), false);
|
|
45
|
+
assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '2', NUBOS_PILOT_MAX_HOOK_DEPTH: '2' }), true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('HG-4: childSpawnEnv marks headless and increments depth', () => {
|
|
49
|
+
assert.deepEqual({ ...guard.childSpawnEnv({}) }, { NUBOS_PILOT_HEADLESS: '1', NUBOS_PILOT_HOOK_DEPTH: '1' });
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
{ ...guard.childSpawnEnv({ NUBOS_PILOT_HOOK_DEPTH: '1' }) },
|
|
52
|
+
{ NUBOS_PILOT_HEADLESS: '1', NUBOS_PILOT_HOOK_DEPTH: '2' },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('HG-5: tryAcquireSpawnLock acquires, then refuses a live concurrent holder', () => {
|
|
57
|
+
const r = _mkRoot();
|
|
58
|
+
const first = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
59
|
+
assert.equal(first.acquired, true);
|
|
60
|
+
assert.ok(fs.existsSync(first.lockPath));
|
|
61
|
+
|
|
62
|
+
const second = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
63
|
+
assert.equal(second.acquired, false, 'second concurrent acquire must be refused');
|
|
64
|
+
assert.ok(second.holder && second.holder.pid === process.pid);
|
|
65
|
+
|
|
66
|
+
first.release();
|
|
67
|
+
assert.equal(fs.existsSync(first.lockPath), false, 'release removes the lock');
|
|
68
|
+
|
|
69
|
+
const third = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
70
|
+
assert.equal(third.acquired, true, 'lock is re-acquirable after release');
|
|
71
|
+
third.release();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('HG-6: different agents get independent locks', () => {
|
|
75
|
+
const r = _mkRoot();
|
|
76
|
+
const a = guard.tryAcquireSpawnLock(r, 'np-security-reviewer');
|
|
77
|
+
const b = guard.tryAcquireSpawnLock(r, 'np-learnings-extractor');
|
|
78
|
+
assert.equal(a.acquired, true);
|
|
79
|
+
assert.equal(b.acquired, true, 'a second agent must not be blocked by the first');
|
|
80
|
+
a.release();
|
|
81
|
+
b.release();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('HG-7: a stale lock (old mtime) is reclaimed', () => {
|
|
85
|
+
const r = _mkRoot();
|
|
86
|
+
const held = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
87
|
+
assert.equal(held.acquired, true);
|
|
88
|
+
const past = new Date(Date.now() - 60 * 60 * 1000);
|
|
89
|
+
fs.utimesSync(held.lockPath, past, past);
|
|
90
|
+
|
|
91
|
+
const next = guard.tryAcquireSpawnLock(r, 'np-test-critic', { staleMs: 1000 });
|
|
92
|
+
assert.equal(next.acquired, true, 'a lock older than staleMs must be reclaimed');
|
|
93
|
+
next.release();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('HG-8: a dead-pid lock is reclaimed even when fresh', () => {
|
|
97
|
+
const r = _mkRoot();
|
|
98
|
+
const lockPath = guard._lockPath(r, 'np-test-critic');
|
|
99
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
100
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: 2147483646, hostname: os.hostname(), acquiredAt: new Date().toISOString() }), 'utf-8');
|
|
101
|
+
assert.equal(guard._isPidAlive(2147483646), false);
|
|
102
|
+
|
|
103
|
+
const next = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
104
|
+
assert.equal(next.acquired, true, 'a lock owned by a dead pid must be reclaimed');
|
|
105
|
+
next.release();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('HG-9: stale reclaim leaves no .stale residue behind', () => {
|
|
109
|
+
const r = _mkRoot();
|
|
110
|
+
const held = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
111
|
+
const past = new Date(Date.now() - 60 * 60 * 1000);
|
|
112
|
+
fs.utimesSync(held.lockPath, past, past);
|
|
113
|
+
const next = guard.tryAcquireSpawnLock(r, 'np-test-critic', { staleMs: 1000 });
|
|
114
|
+
assert.equal(next.acquired, true);
|
|
115
|
+
next.release();
|
|
116
|
+
const runDir = path.join(r, '.nubos-pilot', 'run');
|
|
117
|
+
const residue = fs.readdirSync(runDir).filter((n) => n.includes('.stale.'));
|
|
118
|
+
assert.deepEqual(residue, [], 'rename-aside reclaim must clean up its temp file');
|
|
119
|
+
});
|
|
@@ -124,7 +124,7 @@ function parseExtractorOutput(raw) {
|
|
|
124
124
|
return { candidates, parse_ok: true };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
function _defaultSpawn(promptText, opts) {
|
|
127
|
+
async function _defaultSpawn(promptText, opts) {
|
|
128
128
|
const spawnHeadless = require('../../bin/np-tools/spawn-headless.cjs');
|
|
129
129
|
const tmp = os.tmpdir();
|
|
130
130
|
const tag = process.pid + '-' + crypto.randomBytes(4).toString('hex');
|
|
@@ -132,7 +132,7 @@ function _defaultSpawn(promptText, opts) {
|
|
|
132
132
|
const outputPath = path.join(tmp, 'np-learn-out-' + tag + '.json');
|
|
133
133
|
fs.writeFileSync(promptPath, promptText, 'utf-8');
|
|
134
134
|
try {
|
|
135
|
-
spawnHeadless.run(
|
|
135
|
+
await spawnHeadless.run(
|
|
136
136
|
['--agent', EXTRACTOR_AGENT, '--prompt-path', promptPath, '--output-path', outputPath,
|
|
137
137
|
'--timeout-ms', String(opts.timeoutMs)],
|
|
138
138
|
{ cwd: opts.cwd, stdout: { write: () => {} } },
|
|
@@ -144,7 +144,7 @@ function _defaultSpawn(promptText, opts) {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
function runExtract(opts) {
|
|
147
|
+
async function runExtract(opts) {
|
|
148
148
|
const o = opts || {};
|
|
149
149
|
const cwd = o.cwd || process.cwd();
|
|
150
150
|
const config = o.config || {};
|
|
@@ -164,7 +164,7 @@ function runExtract(opts) {
|
|
|
164
164
|
const promptText = buildExtractorPrompt(diff);
|
|
165
165
|
let raw = '';
|
|
166
166
|
try {
|
|
167
|
-
raw = spawn(promptText, { cwd, timeoutMs: config.timeout_ms || 120000 });
|
|
167
|
+
raw = await spawn(promptText, { cwd, timeoutMs: config.timeout_ms || 120000 });
|
|
168
168
|
} catch {
|
|
169
169
|
return { ran: true, logged: 0, reason: 'spawn-failed' };
|
|
170
170
|
}
|
|
@@ -67,31 +67,31 @@ test('EX-6: non-JSON output → parse_ok false', () => {
|
|
|
67
67
|
assert.strictEqual(extract.parseExtractorOutput('').parse_ok, false);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
test('EX-7: runExtract on a non-repo returns not-a-repo, logs nothing', () => {
|
|
70
|
+
test('EX-7: runExtract on a non-repo returns not-a-repo, logs nothing', async () => {
|
|
71
71
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-norepo-'));
|
|
72
72
|
try {
|
|
73
73
|
const logged = [];
|
|
74
|
-
const r = extract.runExtract({ cwd: dir, spawnImpl: () => '{}', logImpl: (c) => logged.push(c) });
|
|
74
|
+
const r = await extract.runExtract({ cwd: dir, spawnImpl: () => '{}', logImpl: (c) => logged.push(c) });
|
|
75
75
|
assert.strictEqual(r.ran, false);
|
|
76
76
|
assert.strictEqual(r.reason, 'not-a-repo');
|
|
77
77
|
assert.strictEqual(logged.length, 0);
|
|
78
78
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
test('EX-8: runExtract on empty repo (no commit, no changes) → empty-diff', () => {
|
|
81
|
+
test('EX-8: runExtract on empty repo (no commit, no changes) → empty-diff', async () => {
|
|
82
82
|
const dir = _gitRepo(false);
|
|
83
83
|
try {
|
|
84
|
-
const r = extract.runExtract({ cwd: dir, spawnImpl: () => '{}', logImpl: () => {} });
|
|
84
|
+
const r = await extract.runExtract({ cwd: dir, spawnImpl: () => '{}', logImpl: () => {} });
|
|
85
85
|
assert.strictEqual(r.ran, true);
|
|
86
86
|
assert.strictEqual(r.reason, 'empty-diff');
|
|
87
87
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
test('EX-9: runExtract over a commit logs parsed candidates', () => {
|
|
90
|
+
test('EX-9: runExtract over a commit logs parsed candidates', async () => {
|
|
91
91
|
const dir = _gitRepo(true);
|
|
92
92
|
try {
|
|
93
93
|
const logged = [];
|
|
94
|
-
const r = extract.runExtract({
|
|
94
|
+
const r = await extract.runExtract({
|
|
95
95
|
cwd: dir,
|
|
96
96
|
spawnImpl: () => JSON.stringify({ result: JSON.stringify({ learnings: [
|
|
97
97
|
{ pattern: 'keep add() pure and total', outcome: 'verified' },
|
|
@@ -104,11 +104,11 @@ test('EX-9: runExtract over a commit logs parsed candidates', () => {
|
|
|
104
104
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
test('EX-10: runExtract with unparseable spawn output → parse-failed, no log', () => {
|
|
107
|
+
test('EX-10: runExtract with unparseable spawn output → parse-failed, no log', async () => {
|
|
108
108
|
const dir = _gitRepo(true);
|
|
109
109
|
try {
|
|
110
110
|
const logged = [];
|
|
111
|
-
const r = extract.runExtract({ cwd: dir, spawnImpl: () => 'garbage', logImpl: (c) => logged.push(c) });
|
|
111
|
+
const r = await extract.runExtract({ cwd: dir, spawnImpl: () => 'garbage', logImpl: (c) => logged.push(c) });
|
|
112
112
|
assert.strictEqual(r.reason, 'parse-failed');
|
|
113
113
|
assert.strictEqual(logged.length, 0);
|
|
114
114
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('./core.cjs');
|
|
4
|
+
|
|
5
|
+
const VALID_PROVIDER_KINDS = Object.freeze(['native', 'openai-compat']);
|
|
6
|
+
const DEFAULT_PROVIDER = 'claude';
|
|
7
|
+
|
|
8
|
+
function matchRouting(agentName, routing) {
|
|
9
|
+
if (!agentName || !routing || typeof routing !== 'object') return null;
|
|
10
|
+
if (Object.prototype.hasOwnProperty.call(routing, agentName)) {
|
|
11
|
+
return { key: agentName, entry: routing[agentName], match: 'exact' };
|
|
12
|
+
}
|
|
13
|
+
let best = null;
|
|
14
|
+
for (const key of Object.keys(routing)) {
|
|
15
|
+
if (key.length > 1 && key.endsWith('*')) {
|
|
16
|
+
const prefix = key.slice(0, -1);
|
|
17
|
+
if (agentName.startsWith(prefix) && (!best || prefix.length > best.prefixLen)) {
|
|
18
|
+
best = { key, entry: routing[key], match: 'glob', prefixLen: prefix.length };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return best ? { key: best.key, entry: best.entry, match: 'glob' } : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveProvider({ agentName, tier, config }) {
|
|
26
|
+
const cfg = config || {};
|
|
27
|
+
const providers = cfg.model_providers;
|
|
28
|
+
const routing = cfg.agent_routing;
|
|
29
|
+
|
|
30
|
+
const matched = matchRouting(agentName || null, routing);
|
|
31
|
+
|
|
32
|
+
let providerName;
|
|
33
|
+
let pinnedModel = null;
|
|
34
|
+
let source;
|
|
35
|
+
if (matched) {
|
|
36
|
+
const entry = matched.entry;
|
|
37
|
+
if (!entry || typeof entry !== 'object') {
|
|
38
|
+
throw new NubosPilotError(
|
|
39
|
+
'agent-routing-invalid-entry',
|
|
40
|
+
'agent_routing["' + matched.key + '"] must be an object with a "provider" field',
|
|
41
|
+
{ key: matched.key },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
providerName = entry.provider;
|
|
45
|
+
if (typeof providerName !== 'string' || !providerName) {
|
|
46
|
+
throw new NubosPilotError(
|
|
47
|
+
'agent-routing-missing-provider',
|
|
48
|
+
'agent_routing["' + matched.key + '"] has no "provider" field',
|
|
49
|
+
{ key: matched.key },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (typeof entry.model === 'string' && entry.model) pinnedModel = entry.model;
|
|
53
|
+
source = 'agent_routing["' + matched.key + '"]';
|
|
54
|
+
} else if (providers && typeof providers.default === 'string' && providers.default) {
|
|
55
|
+
providerName = providers.default;
|
|
56
|
+
source = 'model_providers.default';
|
|
57
|
+
} else {
|
|
58
|
+
providerName = DEFAULT_PROVIDER;
|
|
59
|
+
source = 'default';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let def;
|
|
63
|
+
if (providerName === DEFAULT_PROVIDER && (!providers || !providers[DEFAULT_PROVIDER])) {
|
|
64
|
+
def = { kind: 'native' };
|
|
65
|
+
} else if (providers && typeof providers === 'object' && providers[providerName]
|
|
66
|
+
&& typeof providers[providerName] === 'object') {
|
|
67
|
+
def = providers[providerName];
|
|
68
|
+
} else {
|
|
69
|
+
throw new NubosPilotError(
|
|
70
|
+
'provider-undefined',
|
|
71
|
+
source + ' references provider "' + providerName
|
|
72
|
+
+ '", but model_providers.' + providerName + ' is not defined',
|
|
73
|
+
{ provider: providerName, source },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const kind = def.kind || 'native';
|
|
78
|
+
if (!VALID_PROVIDER_KINDS.includes(kind)) {
|
|
79
|
+
throw new NubosPilotError(
|
|
80
|
+
'provider-invalid-kind',
|
|
81
|
+
'model_providers.' + providerName + '.kind must be one of ' + VALID_PROVIDER_KINDS.join('/'),
|
|
82
|
+
{ provider: providerName, got: kind, allowed: VALID_PROVIDER_KINDS.slice() },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let model = null;
|
|
87
|
+
if (kind === 'native') {
|
|
88
|
+
model = pinnedModel || null;
|
|
89
|
+
} else if (pinnedModel) {
|
|
90
|
+
model = pinnedModel;
|
|
91
|
+
} else if (def.models && typeof def.models === 'object' && typeof def.models[tier] === 'string' && def.models[tier]) {
|
|
92
|
+
model = def.models[tier];
|
|
93
|
+
} else {
|
|
94
|
+
throw new NubosPilotError(
|
|
95
|
+
'provider-model-unresolved',
|
|
96
|
+
'cannot resolve a model for provider "' + providerName + '" at tier "' + tier
|
|
97
|
+
+ '": no pinned model in agent_routing and no model_providers.' + providerName + '.models.' + tier,
|
|
98
|
+
{ provider: providerName, tier },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
provider: providerName,
|
|
104
|
+
kind,
|
|
105
|
+
model,
|
|
106
|
+
baseUrl: (typeof def.base_url === 'string' && def.base_url) ? def.base_url : null,
|
|
107
|
+
apiKeyEnv: (typeof def.api_key_env === 'string' && def.api_key_env) ? def.api_key_env : null,
|
|
108
|
+
routed: !!matched,
|
|
109
|
+
source,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
matchRouting,
|
|
115
|
+
resolveProvider,
|
|
116
|
+
VALID_PROVIDER_KINDS,
|
|
117
|
+
DEFAULT_PROVIDER,
|
|
118
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const { matchRouting, resolveProvider, VALID_PROVIDER_KINDS, DEFAULT_PROVIDER } = require('./model-providers.cjs');
|
|
5
|
+
|
|
6
|
+
test('MPV-1: exact routing key beats glob', () => {
|
|
7
|
+
const r = { 'np-critic': { provider: 'a' }, 'np-critic*': { provider: 'b' } };
|
|
8
|
+
assert.equal(matchRouting('np-critic', r).match, 'exact');
|
|
9
|
+
assert.equal(matchRouting('np-critic', r).entry.provider, 'a');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('MPV-2: trailing-* glob matches by prefix, longest prefix wins', () => {
|
|
13
|
+
const r = { 'np-*': { provider: 'wide' }, 'np-critic*': { provider: 'narrow' } };
|
|
14
|
+
assert.equal(matchRouting('np-critic-style', r).entry.provider, 'narrow');
|
|
15
|
+
assert.equal(matchRouting('np-planner', r).entry.provider, 'wide');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('MPV-3: no match returns null; empty agentName returns null', () => {
|
|
19
|
+
assert.equal(matchRouting('np-x', { 'np-y*': {} }), null);
|
|
20
|
+
assert.equal(matchRouting(null, { 'np-y*': {} }), null);
|
|
21
|
+
assert.equal(matchRouting('np-x', null), null);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('MPV-4: absent config resolves to implicit claude-native default', () => {
|
|
25
|
+
const out = resolveProvider({ agentName: 'np-planner', tier: 'opus', config: {} });
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
{ provider: out.provider, kind: out.kind, model: out.model, routed: out.routed },
|
|
28
|
+
{ provider: DEFAULT_PROVIDER, kind: 'native', model: null, routed: false },
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('MPV-5: openai-compat resolves models[tier] when unpinned', () => {
|
|
33
|
+
const config = {
|
|
34
|
+
model_providers: { ollama: { kind: 'openai-compat', base_url: 'http://x/v1', models: { sonnet: 'm-s', opus: 'm-o' } } },
|
|
35
|
+
agent_routing: { 'np-executor': { provider: 'ollama' } },
|
|
36
|
+
};
|
|
37
|
+
assert.equal(resolveProvider({ agentName: 'np-executor', tier: 'sonnet', config }).model, 'm-s');
|
|
38
|
+
assert.equal(resolveProvider({ agentName: 'np-executor', tier: 'opus', config }).model, 'm-o');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('MPV-6: undefined provider reference throws provider-undefined', () => {
|
|
42
|
+
let thrown = null;
|
|
43
|
+
try {
|
|
44
|
+
resolveProvider({
|
|
45
|
+
agentName: 'np-executor', tier: 'opus',
|
|
46
|
+
config: { model_providers: { claude: { kind: 'native' } }, agent_routing: { 'np-executor': { provider: 'ghost' } } },
|
|
47
|
+
});
|
|
48
|
+
} catch (e) { thrown = e; }
|
|
49
|
+
assert.equal(thrown && thrown.code, 'provider-undefined');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('MPV-7: invalid kind throws provider-invalid-kind', () => {
|
|
53
|
+
let thrown = null;
|
|
54
|
+
try {
|
|
55
|
+
resolveProvider({
|
|
56
|
+
agentName: 'np-executor', tier: 'opus',
|
|
57
|
+
config: { model_providers: { weird: { kind: 'grpc' } }, agent_routing: { 'np-executor': { provider: 'weird' } } },
|
|
58
|
+
});
|
|
59
|
+
} catch (e) { thrown = e; }
|
|
60
|
+
assert.equal(thrown && thrown.code, 'provider-invalid-kind');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('MPV-8: routing entry without provider throws agent-routing-missing-provider', () => {
|
|
64
|
+
let thrown = null;
|
|
65
|
+
try {
|
|
66
|
+
resolveProvider({ agentName: 'np-executor', tier: 'opus', config: { agent_routing: { 'np-executor': { model: 'x' } } } });
|
|
67
|
+
} catch (e) { thrown = e; }
|
|
68
|
+
assert.equal(thrown && thrown.code, 'agent-routing-missing-provider');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('MPV-9: baseUrl + apiKeyEnv surfaced for openai-compat', () => {
|
|
72
|
+
const out = resolveProvider({
|
|
73
|
+
agentName: 'np-executor', tier: 'opus',
|
|
74
|
+
config: {
|
|
75
|
+
model_providers: { openai: { kind: 'openai-compat', base_url: 'https://api.openai.com/v1', api_key_env: 'OPENAI_API_KEY', models: { opus: 'gpt-4.1' } } },
|
|
76
|
+
agent_routing: { 'np-executor': { provider: 'openai' } },
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
assert.equal(out.baseUrl, 'https://api.openai.com/v1');
|
|
80
|
+
assert.equal(out.apiKeyEnv, 'OPENAI_API_KEY');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('MPV-10: VALID_PROVIDER_KINDS is the closed set [native, openai-compat]', () => {
|
|
84
|
+
assert.deepEqual(VALID_PROVIDER_KINDS, ['native', 'openai-compat']);
|
|
85
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MAX_ITERATIONS = 25;
|
|
6
|
+
|
|
7
|
+
async function runAgentLoop(a) {
|
|
8
|
+
const {
|
|
9
|
+
systemPrompt, task, toolset, provider, cwd,
|
|
10
|
+
maxIterations, chatImpl,
|
|
11
|
+
} = a || {};
|
|
12
|
+
if (!toolset || typeof toolset.execute !== 'function') {
|
|
13
|
+
throw new NubosPilotError('agent-loop-no-toolset', 'runAgentLoop requires a toolset with execute()', {});
|
|
14
|
+
}
|
|
15
|
+
if (!provider || typeof provider.model !== 'string') {
|
|
16
|
+
throw new NubosPilotError('agent-loop-no-provider', 'runAgentLoop requires a provider with a model', {});
|
|
17
|
+
}
|
|
18
|
+
const chat = chatImpl || require('./providers/openai-compat.cjs').chat;
|
|
19
|
+
const max = Math.max(1, maxIterations || DEFAULT_MAX_ITERATIONS);
|
|
20
|
+
const schemas = (toolset.schemas && toolset.schemas.length) ? toolset.schemas : undefined;
|
|
21
|
+
|
|
22
|
+
const messages = [];
|
|
23
|
+
if (systemPrompt) messages.push({ role: 'system', content: String(systemPrompt) });
|
|
24
|
+
messages.push({ role: 'user', content: String(task == null ? '' : task) });
|
|
25
|
+
|
|
26
|
+
const toolLog = [];
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < max; i++) {
|
|
29
|
+
const resp = await chat({ ...provider, messages, tools: schemas });
|
|
30
|
+
|
|
31
|
+
if (!resp.toolCalls || resp.toolCalls.length === 0) {
|
|
32
|
+
return { content: resp.content || '', iterations: i + 1, stopped: 'final', toolLog };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
messages.push({
|
|
36
|
+
role: 'assistant',
|
|
37
|
+
content: resp.content || '',
|
|
38
|
+
tool_calls: resp.toolCalls.map((tc) => ({
|
|
39
|
+
id: tc.id,
|
|
40
|
+
type: 'function',
|
|
41
|
+
function: {
|
|
42
|
+
name: tc.name,
|
|
43
|
+
arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments || {}),
|
|
44
|
+
},
|
|
45
|
+
})),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
for (const tc of resp.toolCalls) {
|
|
49
|
+
const result = toolset.execute(tc.name, tc.arguments, { cwd: cwd || process.cwd() });
|
|
50
|
+
toolLog.push({ name: tc.name, ok: !String(result).startsWith('Error:') });
|
|
51
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: String(result) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const last = messages[messages.length - 1];
|
|
56
|
+
return {
|
|
57
|
+
content: (last && typeof last.content === 'string') ? last.content : '',
|
|
58
|
+
iterations: max,
|
|
59
|
+
stopped: 'max-iterations',
|
|
60
|
+
toolLog,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { runAgentLoop, DEFAULT_MAX_ITERATIONS };
|