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,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
|
+
});
|
package/lib/nubosloop.cjs
CHANGED
|
@@ -357,7 +357,7 @@ async function preflightCacheLookup(query, opts, cwd) {
|
|
|
357
357
|
const spawnInput = taskId
|
|
358
358
|
? { task_id: taskId, task_query: query }
|
|
359
359
|
: { task_query: query };
|
|
360
|
-
const spawnSpecs = swarm.buildSpawnSpecs(spawnInput, o.k);
|
|
360
|
+
const spawnSpecs = swarm.buildSpawnSpecs(spawnInput, o.k, { cwd });
|
|
361
361
|
const swarmBlock = {
|
|
362
362
|
k: o.k,
|
|
363
363
|
threshold: o.threshold,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const OPEN = '<nubos_output_shaping>';
|
|
4
|
+
const CLOSE = '</nubos_output_shaping>';
|
|
5
|
+
const SHAPING_RE = /\n*<nubos_output_shaping>[\s\S]*?<\/nubos_output_shaping>\s*$/;
|
|
6
|
+
|
|
7
|
+
const PROFILES = Object.freeze({
|
|
8
|
+
balanced: '',
|
|
9
|
+
concise: 'Skip preamble and postamble; lead with the substance. Do not restate code, file '
|
|
10
|
+
+ 'contents, diffs, or tool output that already appear above — reference them by path and line.',
|
|
11
|
+
terse: 'Skip preamble and postamble; lead with the substance. Never restate code, files, '
|
|
12
|
+
+ 'diffs, or tool output already present — reference them by path and line. After a tool '
|
|
13
|
+
+ 'call succeeds, continue without narrating the result.',
|
|
14
|
+
minimal: 'Minimum tokens. Fragments are fine. No preamble, no postamble, no narration. Never '
|
|
15
|
+
+ 'restate existing code, files, diffs, or tool output — reference by path and line. State '
|
|
16
|
+
+ 'only what changed or what the answer is.',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const EFFORT_RANK = Object.freeze({ low: 0, medium: 1, high: 2, xhigh: 3, max: 4 });
|
|
20
|
+
|
|
21
|
+
function steeringDirective(profile) {
|
|
22
|
+
const key = typeof profile === 'string' ? profile.toLowerCase() : 'balanced';
|
|
23
|
+
return Object.prototype.hasOwnProperty.call(PROFILES, key) ? PROFILES[key] : '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function enrichSystemPrompt(prompt, profile) {
|
|
27
|
+
const base = String(prompt == null ? '' : prompt).replace(SHAPING_RE, '');
|
|
28
|
+
const directive = steeringDirective(profile);
|
|
29
|
+
if (!directive) return base;
|
|
30
|
+
return base + '\n\n' + OPEN + '\n' + directive + '\n' + CLOSE;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function classifyTurn(messages) {
|
|
34
|
+
if (!Array.isArray(messages) || messages.length === 0) return 'new_user_ask';
|
|
35
|
+
let lastAssistant = -1;
|
|
36
|
+
for (let k = messages.length - 1; k >= 0; k -= 1) {
|
|
37
|
+
if (messages[k] && messages[k].role === 'assistant') { lastAssistant = k; break; }
|
|
38
|
+
}
|
|
39
|
+
const pending = messages.slice(lastAssistant + 1);
|
|
40
|
+
if (pending.length === 0) return 'new_user_ask';
|
|
41
|
+
if (pending.some((m) => m && m.role === 'user')) return 'new_user_ask';
|
|
42
|
+
const tools = pending.filter((m) => m && m.role === 'tool');
|
|
43
|
+
if (tools.length === 0) return 'new_user_ask';
|
|
44
|
+
return tools.some((m) => _isErrorResult(m.content)) ? 'error_continuation' : 'mechanical_continuation';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _isErrorResult(content) {
|
|
48
|
+
const s = String(content == null ? '' : content);
|
|
49
|
+
return /^Error:/.test(s) || /\b(ERROR|FAILED|FATAL|Exception|Traceback)\b/.test(s);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function routeEffort(currentEffort, turnKind, opts) {
|
|
53
|
+
const o = opts || {};
|
|
54
|
+
if (typeof currentEffort !== 'string' || !(currentEffort in EFFORT_RANK)) return currentEffort;
|
|
55
|
+
if (turnKind !== 'mechanical_continuation') return currentEffort;
|
|
56
|
+
const target = typeof o.mechanicalEffort === 'string' && o.mechanicalEffort in EFFORT_RANK
|
|
57
|
+
? o.mechanicalEffort : 'low';
|
|
58
|
+
return EFFORT_RANK[target] < EFFORT_RANK[currentEffort] ? target : currentEffort;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = {
|
|
62
|
+
PROFILES,
|
|
63
|
+
EFFORT_RANK,
|
|
64
|
+
steeringDirective,
|
|
65
|
+
enrichSystemPrompt,
|
|
66
|
+
classifyTurn,
|
|
67
|
+
routeEffort,
|
|
68
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const steering = require('./output-steering.cjs');
|
|
7
|
+
|
|
8
|
+
test('OS-1: balanced/unknown profile is a no-op (no shaping block)', () => {
|
|
9
|
+
const p = 'You are an agent.';
|
|
10
|
+
assert.equal(steering.enrichSystemPrompt(p, 'balanced'), p);
|
|
11
|
+
assert.equal(steering.enrichSystemPrompt(p, 'nonsense'), p);
|
|
12
|
+
assert.equal(steering.enrichSystemPrompt(p, undefined), p);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('OS-2: a real profile appends one tagged, byte-stable block', () => {
|
|
16
|
+
const out = steering.enrichSystemPrompt('You are an agent.', 'terse');
|
|
17
|
+
assert.match(out, /^You are an agent\./);
|
|
18
|
+
assert.match(out, /<nubos_output_shaping>[\s\S]*<\/nubos_output_shaping>$/);
|
|
19
|
+
assert.equal(out, steering.enrichSystemPrompt('You are an agent.', 'terse'), 'deterministic');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('OS-3: enrichment is idempotent and profile-switchable (always exactly one block)', () => {
|
|
23
|
+
const once = steering.enrichSystemPrompt('base', 'terse');
|
|
24
|
+
const twice = steering.enrichSystemPrompt(once, 'terse');
|
|
25
|
+
assert.equal(twice, once, 're-enriching with same profile converges');
|
|
26
|
+
const switched = steering.enrichSystemPrompt(once, 'minimal');
|
|
27
|
+
assert.equal((switched.match(/<nubos_output_shaping>/g) || []).length, 1, 'never stacks blocks');
|
|
28
|
+
assert.match(switched, /Minimum tokens/);
|
|
29
|
+
assert.equal(steering.enrichSystemPrompt(once, 'balanced'), 'base', 'balanced strips back to bare prompt');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('OS-4: classifyTurn — fresh user ask', () => {
|
|
33
|
+
const msgs = [{ role: 'system', content: 's' }, { role: 'user', content: 'do x' }];
|
|
34
|
+
assert.equal(steering.classifyTurn(msgs), 'new_user_ask');
|
|
35
|
+
assert.equal(steering.classifyTurn([]), 'new_user_ask');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('OS-5: classifyTurn — clean tool results are a mechanical continuation', () => {
|
|
39
|
+
const msgs = [
|
|
40
|
+
{ role: 'user', content: 'do x' },
|
|
41
|
+
{ role: 'assistant', content: '', tool_calls: [] },
|
|
42
|
+
{ role: 'tool', tool_call_id: 'a', content: 'file written ok' },
|
|
43
|
+
{ role: 'tool', tool_call_id: 'b', content: 'ok' },
|
|
44
|
+
];
|
|
45
|
+
assert.equal(steering.classifyTurn(msgs), 'mechanical_continuation');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('OS-6: classifyTurn — an error tool result forces full effort', () => {
|
|
49
|
+
const msgs = [
|
|
50
|
+
{ role: 'user', content: 'do x' },
|
|
51
|
+
{ role: 'assistant', content: '' },
|
|
52
|
+
{ role: 'tool', tool_call_id: 'a', content: 'ok' },
|
|
53
|
+
{ role: 'tool', tool_call_id: 'b', content: 'Error: file not found' },
|
|
54
|
+
];
|
|
55
|
+
assert.equal(steering.classifyTurn(msgs), 'error_continuation');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('OS-8: classifyTurn — a fresh user message after a tool turn is a new ask', () => {
|
|
59
|
+
const msgs = [
|
|
60
|
+
{ role: 'user', content: 'do x' },
|
|
61
|
+
{ role: 'assistant', content: '' },
|
|
62
|
+
{ role: 'user', content: 'actually do y' },
|
|
63
|
+
{ role: 'tool', tool_call_id: 'a', content: 'ok' },
|
|
64
|
+
];
|
|
65
|
+
assert.equal(steering.classifyTurn(msgs), 'new_user_ask');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('OS-7: routeEffort downgrades only on mechanical turns, never injects or upgrades', () => {
|
|
69
|
+
assert.equal(steering.routeEffort(undefined, 'mechanical_continuation', {}), undefined);
|
|
70
|
+
assert.equal(steering.routeEffort('high', 'mechanical_continuation', { mechanicalEffort: 'low' }), 'low');
|
|
71
|
+
assert.equal(steering.routeEffort('high', 'new_user_ask', { mechanicalEffort: 'low' }), 'high');
|
|
72
|
+
assert.equal(steering.routeEffort('high', 'error_continuation', { mechanicalEffort: 'low' }), 'high');
|
|
73
|
+
assert.equal(steering.routeEffort('low', 'mechanical_continuation', { mechanicalEffort: 'high' }), 'low');
|
|
74
|
+
});
|
package/lib/researcher-swarm.cjs
CHANGED
|
@@ -4,6 +4,7 @@ const crypto = require('node:crypto');
|
|
|
4
4
|
|
|
5
5
|
const { DEFAULT_THRESHOLD, DEFAULT_MIN_OCCURRENCE } = require('./knowledge-adapter.cjs');
|
|
6
6
|
const config = require('./config.cjs');
|
|
7
|
+
const elision = require('./elision.cjs');
|
|
7
8
|
const { normalizeText } = require('./core.cjs');
|
|
8
9
|
|
|
9
10
|
const DEFAULT_K = 3;
|
|
@@ -50,19 +51,29 @@ function resolveSwarmOpts(cwd, override) {
|
|
|
50
51
|
return { k, threshold, minOccurrence };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
function
|
|
54
|
+
function _dedupInputRef(input, cwd) {
|
|
55
|
+
if (!cwd) return null;
|
|
56
|
+
const cx = elision.compressionContext(cwd);
|
|
57
|
+
return cx.store ? cx.store(JSON.stringify(input), 'json-array') : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildSpawnSpecs(input, k, opts) {
|
|
54
61
|
if (!input || typeof input !== 'object') {
|
|
55
62
|
throw new TypeError('buildSpawnSpecs: input object is required');
|
|
56
63
|
}
|
|
57
64
|
const safeK = _coerceK(k);
|
|
65
|
+
const o = opts || {};
|
|
66
|
+
const inputRef = _dedupInputRef(input, o.cwd);
|
|
58
67
|
const specs = [];
|
|
59
68
|
for (let i = 0; i < safeK; i += 1) {
|
|
60
|
-
|
|
69
|
+
const spec = {
|
|
61
70
|
index: i,
|
|
62
71
|
seed_delta: i,
|
|
63
72
|
seed_nudge: SEED_DELTAS[i % SEED_DELTAS.length],
|
|
64
73
|
input,
|
|
65
|
-
}
|
|
74
|
+
};
|
|
75
|
+
if (inputRef) spec.input_ref = inputRef;
|
|
76
|
+
specs.push(spec);
|
|
66
77
|
}
|
|
67
78
|
return specs;
|
|
68
79
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
4
|
+
const compress = require('../compress.cjs');
|
|
5
|
+
const elision = require('../elision.cjs');
|
|
6
|
+
const steering = require('../output-steering.cjs');
|
|
7
|
+
const { EXPAND_TOOL_NAME } = require('./tools/index.cjs');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MAX_ITERATIONS = 25;
|
|
10
|
+
|
|
11
|
+
function _compressToolResult(text, cx) {
|
|
12
|
+
if (!cx || !cx.enabled || typeof cx.store !== 'function') return text;
|
|
13
|
+
try {
|
|
14
|
+
const res = compress.compressBlock(text, { minBlockBytes: cx.minBlockBytes, store: cx.store });
|
|
15
|
+
return (res && res.changed) ? res.compressed : text;
|
|
16
|
+
} catch {
|
|
17
|
+
return text;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function runAgentLoop(a) {
|
|
22
|
+
const {
|
|
23
|
+
systemPrompt, task, toolset, provider, cwd,
|
|
24
|
+
maxIterations, chatImpl,
|
|
25
|
+
} = a || {};
|
|
26
|
+
if (!toolset || typeof toolset.execute !== 'function') {
|
|
27
|
+
throw new NubosPilotError('agent-loop-no-toolset', 'runAgentLoop requires a toolset with execute()', {});
|
|
28
|
+
}
|
|
29
|
+
if (!provider || typeof provider.model !== 'string') {
|
|
30
|
+
throw new NubosPilotError('agent-loop-no-provider', 'runAgentLoop requires a provider with a model', {});
|
|
31
|
+
}
|
|
32
|
+
const chat = chatImpl || require('./providers/openai-compat.cjs').chat;
|
|
33
|
+
const max = Math.max(1, maxIterations || DEFAULT_MAX_ITERATIONS);
|
|
34
|
+
const schemas = (toolset.schemas && toolset.schemas.length) ? toolset.schemas : undefined;
|
|
35
|
+
|
|
36
|
+
const cx = elision.compressionContext(cwd);
|
|
37
|
+
const os = cx.outputSteering || { enabled: false, effortRouting: false };
|
|
38
|
+
const compression = { tool_results: 0, blocks_compressed: 0, bytes_before: 0, bytes_after: 0 };
|
|
39
|
+
|
|
40
|
+
const messages = [];
|
|
41
|
+
if (systemPrompt) {
|
|
42
|
+
const sys = os.enabled ? steering.enrichSystemPrompt(String(systemPrompt), os.profile) : String(systemPrompt);
|
|
43
|
+
messages.push({ role: 'system', content: sys });
|
|
44
|
+
}
|
|
45
|
+
messages.push({ role: 'user', content: String(task == null ? '' : task) });
|
|
46
|
+
|
|
47
|
+
const toolLog = [];
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < max; i++) {
|
|
50
|
+
const turnProvider = os.effortRouting
|
|
51
|
+
? { ...provider, effort: steering.routeEffort(provider.effort, steering.classifyTurn(messages), { mechanicalEffort: os.mechanicalEffort }) }
|
|
52
|
+
: provider;
|
|
53
|
+
const resp = await chat({ ...turnProvider, messages, tools: schemas });
|
|
54
|
+
|
|
55
|
+
if (!resp.toolCalls || resp.toolCalls.length === 0) {
|
|
56
|
+
return { content: resp.content || '', iterations: i + 1, stopped: 'final', toolLog, compression };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
messages.push({
|
|
60
|
+
role: 'assistant',
|
|
61
|
+
content: resp.content || '',
|
|
62
|
+
tool_calls: resp.toolCalls.map((tc) => ({
|
|
63
|
+
id: tc.id,
|
|
64
|
+
type: 'function',
|
|
65
|
+
function: {
|
|
66
|
+
name: tc.name,
|
|
67
|
+
arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments || {}),
|
|
68
|
+
},
|
|
69
|
+
})),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
for (const tc of resp.toolCalls) {
|
|
73
|
+
const raw = String(toolset.execute(tc.name, tc.arguments, { cwd: cwd || process.cwd() }));
|
|
74
|
+
toolLog.push({ name: tc.name, ok: !raw.startsWith('Error:') });
|
|
75
|
+
const stored = tc.name === EXPAND_TOOL_NAME ? raw : _compressToolResult(raw, cx);
|
|
76
|
+
compression.tool_results += 1;
|
|
77
|
+
compression.bytes_before += Buffer.byteLength(raw, 'utf-8');
|
|
78
|
+
compression.bytes_after += Buffer.byteLength(stored, 'utf-8');
|
|
79
|
+
if (stored !== raw) compression.blocks_compressed += 1;
|
|
80
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: stored });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const last = messages[messages.length - 1];
|
|
85
|
+
return {
|
|
86
|
+
content: (last && typeof last.content === 'string') ? last.content : '',
|
|
87
|
+
iterations: max,
|
|
88
|
+
stopped: 'max-iterations',
|
|
89
|
+
toolLog,
|
|
90
|
+
compression,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { runAgentLoop, DEFAULT_MAX_ITERATIONS };
|