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.
Files changed (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +18 -1
  3. package/SECURITY.md +3 -4
  4. package/bin/np-tools/_commands.cjs +1 -0
  5. package/bin/np-tools/learnings.cjs +5 -1
  6. package/bin/np-tools/resolve-model.cjs +55 -1
  7. package/bin/np-tools/resolve-model.test.cjs +139 -0
  8. package/bin/np-tools/security.cjs +4 -1
  9. package/bin/np-tools/spawn-headless.cjs +135 -2
  10. package/bin/np-tools/spawn-headless.test.cjs +225 -40
  11. package/bin/np-tools/spawn-offhost.cjs +93 -0
  12. package/bin/np-tools/spawn-offhost.test.cjs +38 -0
  13. package/lib/agents.cjs +16 -2
  14. package/lib/config-schema.cjs +5 -1
  15. package/lib/headless-guard.cjs +127 -0
  16. package/lib/headless-guard.test.cjs +119 -0
  17. package/lib/learnings/extract.cjs +4 -4
  18. package/lib/learnings/extract.test.cjs +8 -8
  19. package/lib/model-providers.cjs +118 -0
  20. package/lib/model-providers.test.cjs +85 -0
  21. package/lib/runtime/agent-loop.cjs +64 -0
  22. package/lib/runtime/agent-loop.test.cjs +135 -0
  23. package/lib/runtime/dispatch.cjs +174 -0
  24. package/lib/runtime/dispatch.test.cjs +193 -0
  25. package/lib/runtime/preflight.cjs +68 -0
  26. package/lib/runtime/preflight.test.cjs +62 -0
  27. package/lib/runtime/providers/openai-compat.cjs +102 -0
  28. package/lib/runtime/providers/openai-compat.test.cjs +103 -0
  29. package/lib/runtime/tools/index.cjs +415 -0
  30. package/lib/runtime/tools/index.test.cjs +230 -0
  31. package/lib/security/review.cjs +4 -4
  32. package/lib/security/review.test.cjs +6 -6
  33. package/np-tools.cjs +1 -0
  34. package/package.json +1 -1
  35. package/templates/claude/payload/hooks/np-learnings-hook.cjs +1 -0
  36. package/templates/claude/payload/hooks/np-security-hook.cjs +1 -0
  37. package/workflows/add-tests.md +41 -0
  38. package/workflows/architect-phase.md +19 -0
  39. package/workflows/discuss-phase.md +29 -10
  40. package/workflows/execute-phase.md +93 -4
  41. package/workflows/plan-phase.md +57 -16
  42. package/workflows/research-phase.md +45 -0
  43. package/workflows/scan-codebase.md +21 -3
  44. package/workflows/validate-phase.md +30 -13
  45. 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 };