@bahulam/code 0.1.23 → 0.1.25
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/package.json +1 -1
- package/src/commands/install.mjs +88 -6
- package/src/commands/plugin-manage.mjs +146 -5
- package/src/config/cli-args.mjs +23 -3
- package/src/config/model-catalog.mjs +9 -0
- package/src/config/model-defaults.mjs +16 -0
- package/src/core/approval.mjs +8 -1
- package/src/core/attachments.mjs +4 -0
- package/src/core/backend-url.mjs +16 -0
- package/src/core/context-reduction.mjs +207 -0
- package/src/core/headless.mjs +85 -13
- package/src/core/local-agent.mjs +352 -44
- package/src/core/mode-selector.mjs +5 -4
- package/src/core/model-selection.mjs +72 -0
- package/src/core/request-retry.mjs +106 -0
- package/src/core/tool-error.mjs +155 -0
- package/src/core/tool-executor.mjs +8 -3
- package/src/core/usage-normalization.mjs +32 -0
- package/src/local-service/agent-relay.mjs +286 -7
- package/src/local-service/server.mjs +37 -0
- package/src/orchestration/node-runner.mjs +7 -3
- package/src/permissions/command-classifier.mjs +35 -0
- package/src/plugins/executor.mjs +4 -4
- package/src/plugins/lifecycle.mjs +337 -0
- package/src/plugins/manifest.mjs +37 -0
- package/src/plugins/pi-compat/requirements.mjs +30 -17
- package/src/plugins/preflight.mjs +37 -0
- package/src/terminal/main.mjs +10 -1
- package/src/terminal/paste-input.mjs +52 -0
- package/src/terminal/repl.mjs +169 -36
- package/src/tools/bash.mjs +15 -2
- package/src/tools/registry.mjs +1 -16
- package/src/core/agent-loop.mjs +0 -503
- package/src/core/context-manager.mjs +0 -198
- package/src/tools/agent.mjs +0 -142
package/src/core/local-agent.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local Agent —
|
|
3
|
-
*
|
|
2
|
+
* Local Agent — CLI-side orchestration for --local mode.
|
|
3
|
+
* Model calls use the shared Bahulam Gateway; tools and the ReAct loop stay local.
|
|
4
4
|
* Yields events matching the same format as BahulamStreamClient.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -14,9 +14,70 @@ import {
|
|
|
14
14
|
withMessageBreakpoint,
|
|
15
15
|
needsExplicitCacheControl,
|
|
16
16
|
} from './cache-control.mjs';
|
|
17
|
+
import { DEFAULT_REASONING_MODEL } from '../config/model-defaults.mjs';
|
|
18
|
+
import {
|
|
19
|
+
SUMMARY_MARKER,
|
|
20
|
+
DISTILLATION_MARKER,
|
|
21
|
+
contextReductionConfig,
|
|
22
|
+
collapseMessages,
|
|
23
|
+
estimateMessagesTokens,
|
|
24
|
+
resolveContextBudget,
|
|
25
|
+
} from './context-reduction.mjs';
|
|
26
|
+
import { normalizeUsage } from './usage-normalization.mjs';
|
|
27
|
+
import { fetchWithRetry, RequestError, requestErrorData } from './request-retry.mjs';
|
|
17
28
|
|
|
18
29
|
const MAX_ITERATIONS = 50;
|
|
19
30
|
|
|
31
|
+
function toOpenAITool(tool) {
|
|
32
|
+
return {
|
|
33
|
+
type: 'function',
|
|
34
|
+
function: {
|
|
35
|
+
name: tool.name,
|
|
36
|
+
description: tool.description || '',
|
|
37
|
+
parameters: tool.input_schema || { type: 'object', properties: {} },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toOpenAIMessage(message) {
|
|
43
|
+
if (message.role === 'assistant' && Array.isArray(message.content)) {
|
|
44
|
+
const text = message.content
|
|
45
|
+
.filter(block => block.type === 'text')
|
|
46
|
+
.map(block => block.text || '')
|
|
47
|
+
.join('');
|
|
48
|
+
const toolCalls = message.content
|
|
49
|
+
.filter(block => block.type === 'tool_use')
|
|
50
|
+
.map(block => ({
|
|
51
|
+
id: block.id,
|
|
52
|
+
type: 'function',
|
|
53
|
+
function: {
|
|
54
|
+
name: block.name,
|
|
55
|
+
arguments: JSON.stringify(block.input || {}),
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
return {
|
|
59
|
+
role: 'assistant',
|
|
60
|
+
content: text || null,
|
|
61
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (message.role === 'user' && Array.isArray(message.content)) {
|
|
66
|
+
const results = message.content.filter(block => block.type === 'tool_result');
|
|
67
|
+
if (results.length > 0) {
|
|
68
|
+
// OpenAI-compatible APIs require one role=tool message per result.
|
|
69
|
+
// The caller expands this marker in _callGateway below.
|
|
70
|
+
return results.map(block => ({
|
|
71
|
+
role: 'tool',
|
|
72
|
+
tool_call_id: block.tool_use_id,
|
|
73
|
+
content: String(block.content || ''),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { role: message.role, content: message.content };
|
|
79
|
+
}
|
|
80
|
+
|
|
20
81
|
/** Tool schemas for the LLM — proper parameter definitions. */
|
|
21
82
|
const TOOL_SCHEMAS = [
|
|
22
83
|
{
|
|
@@ -201,6 +262,12 @@ export class LocalAgent {
|
|
|
201
262
|
toolExecutor,
|
|
202
263
|
verbose = false,
|
|
203
264
|
openRouterKey = null,
|
|
265
|
+
gatewayUrl = null,
|
|
266
|
+
gatewayToken = null,
|
|
267
|
+
product = 'bahulam',
|
|
268
|
+
sessionId = null,
|
|
269
|
+
executionId = null,
|
|
270
|
+
approvalManager = null,
|
|
204
271
|
cwd = null,
|
|
205
272
|
systemPromptOverride = null,
|
|
206
273
|
maxTurns = null,
|
|
@@ -211,10 +278,17 @@ export class LocalAgent {
|
|
|
211
278
|
// the (scoped) toolExecutor; this only makes the schemas visible
|
|
212
279
|
// to the model.
|
|
213
280
|
extraToolSchemas = [],
|
|
281
|
+
summarizerModel = null,
|
|
214
282
|
}) {
|
|
215
283
|
this.apiKey = apiKey;
|
|
216
284
|
this.openRouterKey = openRouterKey;
|
|
217
|
-
this.
|
|
285
|
+
this.gatewayUrl = (gatewayUrl || '').replace(/\/+$/, '');
|
|
286
|
+
this.gatewayToken = gatewayToken;
|
|
287
|
+
this.product = product;
|
|
288
|
+
this.sessionId = sessionId;
|
|
289
|
+
this.executionId = executionId;
|
|
290
|
+
this.approvalManager = approvalManager;
|
|
291
|
+
this.model = model || DEFAULT_REASONING_MODEL;
|
|
218
292
|
this.toolExecutor = toolExecutor;
|
|
219
293
|
this.verbose = verbose;
|
|
220
294
|
this.cwd = cwd || process.cwd();
|
|
@@ -224,11 +298,14 @@ export class LocalAgent {
|
|
|
224
298
|
this.stagnationDetection = stagnationDetection;
|
|
225
299
|
this.stagnationThreshold = stagnationThreshold;
|
|
226
300
|
this.extraToolSchemas = Array.isArray(extraToolSchemas) ? extraToolSchemas : [];
|
|
301
|
+
this.summarizerModel = summarizerModel || process.env.BAHULAM_SUMMARIZE_MODEL || process.env.BAHULAM_CHAT_SUMMARIZER_MODEL || this.model;
|
|
227
302
|
this._cancelled = false;
|
|
303
|
+
this._requestSequence = 0;
|
|
304
|
+
this._pendingInterventions = [];
|
|
228
305
|
this.promptCache = new PromptCache();
|
|
229
306
|
}
|
|
230
307
|
|
|
231
|
-
async *execute(instruction, context = {}) {
|
|
308
|
+
async *execute(instruction, context = {}, priorHistory = []) {
|
|
232
309
|
this._cancelled = false;
|
|
233
310
|
const startTime = Date.now();
|
|
234
311
|
let toolCount = 0;
|
|
@@ -249,7 +326,39 @@ export class LocalAgent {
|
|
|
249
326
|
|
|
250
327
|
const tools = this._buildToolDefs();
|
|
251
328
|
const systemPrompt = this._buildSystemPrompt(context, retrievedContext);
|
|
252
|
-
|
|
329
|
+
// Keep the same turn contract as BahulamStreamClient. The REPL owns
|
|
330
|
+
// the canonical agent history and passes it here when the npm-owned
|
|
331
|
+
// local/direct transports are selected.
|
|
332
|
+
const messages = Array.isArray(priorHistory)
|
|
333
|
+
? priorHistory.map(message => ({ ...message }))
|
|
334
|
+
: [];
|
|
335
|
+
const lastMessage = messages[messages.length - 1];
|
|
336
|
+
if (lastMessage?.role !== 'user' || lastMessage.content !== instruction) {
|
|
337
|
+
messages.push({ role: 'user', content: instruction });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const applyReduction = async () => {
|
|
341
|
+
const reduction = await this._reduceContext(messages, { systemPrompt, tools });
|
|
342
|
+
if (!reduction) return null;
|
|
343
|
+
messages.splice(0, messages.length, ...reduction.messages);
|
|
344
|
+
if (Array.isArray(priorHistory)) {
|
|
345
|
+
priorHistory.splice(0, priorHistory.length, ...reduction.messages);
|
|
346
|
+
}
|
|
347
|
+
if (reduction.usage) {
|
|
348
|
+
this.promptCache.updateStats(reduction.usage);
|
|
349
|
+
usageTotals.input_tokens += reduction.usage.input_tokens || 0;
|
|
350
|
+
usageTotals.output_tokens += reduction.usage.output_tokens || 0;
|
|
351
|
+
usageTotals.cache_read_tokens += reduction.usage.cache_read_input_tokens || 0;
|
|
352
|
+
usageTotals.cache_creation_tokens += reduction.usage.cache_creation_input_tokens || 0;
|
|
353
|
+
}
|
|
354
|
+
return reduction;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Check before the first call and again after every completed
|
|
358
|
+
// tool-call/result cycle. The latter prevents a long ReAct run from
|
|
359
|
+
// growing past the model budget before its next inference.
|
|
360
|
+
let reduction = await applyReduction();
|
|
361
|
+
if (reduction) yield { type: 'summarize', data: reduction.event };
|
|
253
362
|
|
|
254
363
|
const stagnation = createStagnationTracker({
|
|
255
364
|
enabled: this.stagnationDetection,
|
|
@@ -262,11 +371,45 @@ export class LocalAgent {
|
|
|
262
371
|
return;
|
|
263
372
|
}
|
|
264
373
|
|
|
374
|
+
// Match the remote live-steering contract. The REPL may submit a
|
|
375
|
+
// follow-up while a local model/tool call is in flight; apply it
|
|
376
|
+
// at the next safe model boundary and retain it in canonical
|
|
377
|
+
// agent history for the following turn.
|
|
378
|
+
const interventions = this._pendingInterventions.splice(0);
|
|
379
|
+
for (const intervention of interventions) {
|
|
380
|
+
const message = { role: 'user', content: intervention.instruction };
|
|
381
|
+
messages.push(message);
|
|
382
|
+
if (Array.isArray(priorHistory)) priorHistory.push({ ...message });
|
|
383
|
+
yield {
|
|
384
|
+
type: 'user_intervention_delivered',
|
|
385
|
+
data: {
|
|
386
|
+
intervention_id: intervention.interventionId,
|
|
387
|
+
delivered_at_tool: null,
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (i > 0) {
|
|
393
|
+
reduction = await applyReduction();
|
|
394
|
+
if (reduction) yield { type: 'summarize', data: reduction.event };
|
|
395
|
+
}
|
|
396
|
+
|
|
265
397
|
let response;
|
|
266
398
|
try {
|
|
267
399
|
response = await this._callLLM(systemPrompt, messages, tools);
|
|
268
400
|
} catch (err) {
|
|
269
|
-
|
|
401
|
+
const errorData = requestErrorData(err, {
|
|
402
|
+
phase: this.gatewayUrl ? 'gateway' : 'provider',
|
|
403
|
+
provider: this.gatewayUrl ? 'bahulam-gateway' : 'direct-provider',
|
|
404
|
+
});
|
|
405
|
+
yield {
|
|
406
|
+
type: 'error',
|
|
407
|
+
data: {
|
|
408
|
+
...errorData,
|
|
409
|
+
message: `LLM API error: ${errorData.message}`,
|
|
410
|
+
fatal: true,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
270
413
|
return;
|
|
271
414
|
}
|
|
272
415
|
|
|
@@ -283,6 +426,7 @@ export class LocalAgent {
|
|
|
283
426
|
// Process content blocks
|
|
284
427
|
let hasToolUse = false;
|
|
285
428
|
const assistantContent = [];
|
|
429
|
+
const toolResults = [];
|
|
286
430
|
|
|
287
431
|
for (const block of content) {
|
|
288
432
|
if (block.type === 'text') {
|
|
@@ -298,11 +442,7 @@ export class LocalAgent {
|
|
|
298
442
|
const message = stagnationMessage(name, stagnationResult.count);
|
|
299
443
|
yield { type: 'stagnation', data: { tool: name, count: stagnationResult.count, message } };
|
|
300
444
|
assistantContent.push(block);
|
|
301
|
-
|
|
302
|
-
messages.push({
|
|
303
|
-
role: 'user',
|
|
304
|
-
content: [{ type: 'tool_result', tool_use_id: id, content: message }],
|
|
305
|
-
});
|
|
445
|
+
toolResults.push({ tool_use_id: id, content: message });
|
|
306
446
|
continue;
|
|
307
447
|
}
|
|
308
448
|
|
|
@@ -312,7 +452,12 @@ export class LocalAgent {
|
|
|
312
452
|
let result;
|
|
313
453
|
const toolStart = Date.now();
|
|
314
454
|
try {
|
|
315
|
-
|
|
455
|
+
const approval = this.approvalManager
|
|
456
|
+
? await this.approvalManager.check(name, input || {}, false, { source: 'local-agent' })
|
|
457
|
+
: { approved: true };
|
|
458
|
+
result = approval?.approved
|
|
459
|
+
? await this.toolExecutor.execute(name, input || {})
|
|
460
|
+
: { success: false, output: `Tool call not approved: ${approval?.reason || name}` };
|
|
316
461
|
} catch (err) {
|
|
317
462
|
result = { success: false, output: `Error: ${err.message}` };
|
|
318
463
|
}
|
|
@@ -350,15 +495,27 @@ export class LocalAgent {
|
|
|
350
495
|
};
|
|
351
496
|
|
|
352
497
|
assistantContent.push(block);
|
|
353
|
-
|
|
498
|
+
toolResults.push({
|
|
499
|
+
tool_use_id: id,
|
|
500
|
+
content: result.output || JSON.stringify(result),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Keep one provider-shaped assistant turn for the complete tool batch.
|
|
506
|
+
// Appending the cumulative assistant content once per tool duplicates
|
|
507
|
+
// earlier tool calls and makes the next request grow quadratically.
|
|
508
|
+
if (hasToolUse) {
|
|
509
|
+
messages.push({ role: 'assistant', content: assistantContent.slice() });
|
|
510
|
+
for (const result of toolResults) {
|
|
354
511
|
messages.push({
|
|
355
512
|
role: 'user',
|
|
356
|
-
content: [{ type: 'tool_result',
|
|
513
|
+
content: [{ type: 'tool_result', ...result }],
|
|
357
514
|
});
|
|
358
515
|
}
|
|
359
516
|
}
|
|
360
517
|
|
|
361
|
-
if (!hasToolUse || stopReason === 'end_turn') {
|
|
518
|
+
if ((!hasToolUse || stopReason === 'end_turn') && this._pendingInterventions.length === 0) {
|
|
362
519
|
const duration = (Date.now() - startTime) / 1000;
|
|
363
520
|
yield {
|
|
364
521
|
type: 'complete',
|
|
@@ -385,27 +542,93 @@ export class LocalAgent {
|
|
|
385
542
|
};
|
|
386
543
|
}
|
|
387
544
|
|
|
388
|
-
async _callLLM(systemPrompt, messages, tools) {
|
|
545
|
+
async _callLLM(systemPrompt, messages, tools, modelOverride = this.model) {
|
|
546
|
+
// Local orchestration still uses the shared Bahulam Gateway for model
|
|
547
|
+
// access. The gateway resolves identity, credits/BYOK, and provider
|
|
548
|
+
// routing; this process owns the ReAct/tool loop.
|
|
549
|
+
if (this.gatewayUrl && this.gatewayToken) {
|
|
550
|
+
return this._callGateway(systemPrompt, messages, tools, modelOverride);
|
|
551
|
+
}
|
|
552
|
+
|
|
389
553
|
const isClaude = this.model.startsWith('claude') || this.model.startsWith('anthropic/claude');
|
|
390
554
|
|
|
391
555
|
// Use Anthropic direct API only for Claude models when we have an Anthropic key
|
|
392
556
|
if (isClaude && this.apiKey && this.apiKey.startsWith('sk-ant-')) {
|
|
393
|
-
return this._callClaude(systemPrompt, messages, tools);
|
|
557
|
+
return this._callClaude(systemPrompt, messages, tools, modelOverride);
|
|
394
558
|
}
|
|
395
559
|
|
|
396
560
|
// Everything else goes through OpenRouter (DeepSeek, GPT, Gemini, or Claude via OR)
|
|
397
561
|
if (this.openRouterKey) {
|
|
398
|
-
return this._callOpenRouter(systemPrompt, messages, tools);
|
|
562
|
+
return this._callOpenRouter(systemPrompt, messages, tools, modelOverride);
|
|
399
563
|
}
|
|
400
564
|
|
|
401
565
|
if (this.apiKey) {
|
|
402
|
-
return this._callClaude(systemPrompt, messages, tools);
|
|
566
|
+
return this._callClaude(systemPrompt, messages, tools, modelOverride);
|
|
403
567
|
}
|
|
404
568
|
|
|
405
569
|
throw new Error('No API key configured. Set ANTHROPIC_API_KEY or configure OpenRouter key.');
|
|
406
570
|
}
|
|
407
571
|
|
|
408
|
-
async
|
|
572
|
+
async _callGateway(systemPrompt, messages, tools, modelOverride = this.model) {
|
|
573
|
+
const base = this.gatewayUrl.endsWith('/v1')
|
|
574
|
+
? this.gatewayUrl
|
|
575
|
+
: `${this.gatewayUrl}/v1`;
|
|
576
|
+
const headers = {
|
|
577
|
+
Authorization: `Bearer ${this.gatewayToken}`,
|
|
578
|
+
'Content-Type': 'application/json',
|
|
579
|
+
Accept: 'application/json',
|
|
580
|
+
'X-Product': this.product,
|
|
581
|
+
'X-Bahulam-Request-ID': this._nextRequestId(),
|
|
582
|
+
};
|
|
583
|
+
if (this.sessionId) headers['X-Bahulam-Session-ID'] = this.sessionId;
|
|
584
|
+
if (this.executionId) headers['X-Bahulam-Execution-ID'] = this.executionId;
|
|
585
|
+
|
|
586
|
+
const body = {
|
|
587
|
+
model: modelOverride,
|
|
588
|
+
messages: [
|
|
589
|
+
{ role: 'system', content: systemPrompt },
|
|
590
|
+
...messages.flatMap(toOpenAIMessage),
|
|
591
|
+
],
|
|
592
|
+
tools: tools.length > 0 ? tools.map(toOpenAITool) : undefined,
|
|
593
|
+
stream: false,
|
|
594
|
+
};
|
|
595
|
+
const resp = await fetchWithRetry(`${base}/chat/completions`, {
|
|
596
|
+
method: 'POST',
|
|
597
|
+
headers,
|
|
598
|
+
body: JSON.stringify(body),
|
|
599
|
+
});
|
|
600
|
+
if (!resp.ok) {
|
|
601
|
+
const text = await resp.text().catch(() => '');
|
|
602
|
+
throw new RequestError(`Bahulam Gateway ${resp.status}: ${text.slice(0, 300)}`, {
|
|
603
|
+
status: resp.status,
|
|
604
|
+
code: resp.status === 401 || resp.status === 403 ? 'gateway_authentication_error' : `gateway_http_${resp.status}`,
|
|
605
|
+
retryable: false,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const data = await resp.json();
|
|
610
|
+
const message = data.choices?.[0]?.message || {};
|
|
611
|
+
const content = [];
|
|
612
|
+
if (message.content) content.push({ type: 'text', text: message.content });
|
|
613
|
+
for (const call of message.tool_calls || []) {
|
|
614
|
+
const fn = call.function || {};
|
|
615
|
+
let input = {};
|
|
616
|
+
try { input = fn.arguments ? JSON.parse(fn.arguments) : {}; } catch {}
|
|
617
|
+
content.push({
|
|
618
|
+
type: 'tool_use',
|
|
619
|
+
id: call.id,
|
|
620
|
+
name: fn.name,
|
|
621
|
+
input,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
content,
|
|
626
|
+
stopReason: data.choices?.[0]?.finish_reason || null,
|
|
627
|
+
usage: normalizeUsage(data.usage),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async _callClaude(systemPrompt, messages, tools, modelOverride = this.model) {
|
|
409
632
|
// PRD-071 Phase 2 — cache_control breakpoints for Anthropic direct.
|
|
410
633
|
// Extended 1-hour TTL beta on the persistent prefix (system + tools).
|
|
411
634
|
// Message history breakpoint stays at default 5-min TTL.
|
|
@@ -413,7 +636,7 @@ export class LocalAgent {
|
|
|
413
636
|
const cachedTools = cacheableTools(tools);
|
|
414
637
|
const cachedMessages = withMessageBreakpoint(messages);
|
|
415
638
|
|
|
416
|
-
const resp = await
|
|
639
|
+
const resp = await fetchWithRetry('https://api.anthropic.com/v1/messages', {
|
|
417
640
|
method: 'POST',
|
|
418
641
|
headers: {
|
|
419
642
|
'x-api-key': this.apiKey,
|
|
@@ -422,7 +645,7 @@ export class LocalAgent {
|
|
|
422
645
|
'content-type': 'application/json',
|
|
423
646
|
},
|
|
424
647
|
body: JSON.stringify({
|
|
425
|
-
model:
|
|
648
|
+
model: modelOverride,
|
|
426
649
|
system: cachedSystem,
|
|
427
650
|
messages: cachedMessages,
|
|
428
651
|
tools: cachedTools.length > 0 ? cachedTools : undefined,
|
|
@@ -431,15 +654,19 @@ export class LocalAgent {
|
|
|
431
654
|
});
|
|
432
655
|
if (!resp.ok) {
|
|
433
656
|
const text = await resp.text().catch(() => '');
|
|
434
|
-
throw new
|
|
657
|
+
throw new RequestError(`Claude API ${resp.status}: ${text.slice(0, 200)}`, {
|
|
658
|
+
status: resp.status,
|
|
659
|
+
code: `anthropic_http_${resp.status}`,
|
|
660
|
+
retryable: false,
|
|
661
|
+
});
|
|
435
662
|
}
|
|
436
663
|
const data = await resp.json();
|
|
437
664
|
return { content: data.content || [], stopReason: data.stop_reason, usage: data.usage || null };
|
|
438
665
|
}
|
|
439
666
|
|
|
440
|
-
async _callOpenRouter(systemPrompt, messages, tools) {
|
|
667
|
+
async _callOpenRouter(systemPrompt, messages, tools, modelOverride = this.model) {
|
|
441
668
|
// OpenRouter requires provider prefix (e.g. anthropic/claude-sonnet-4-20250514)
|
|
442
|
-
let model =
|
|
669
|
+
let model = modelOverride;
|
|
443
670
|
if (model.startsWith('claude') && !model.includes('/')) {
|
|
444
671
|
model = `anthropic/${model}`;
|
|
445
672
|
}
|
|
@@ -467,11 +694,12 @@ export class LocalAgent {
|
|
|
467
694
|
const headers = {
|
|
468
695
|
'Authorization': `Bearer ${this.openRouterKey}`,
|
|
469
696
|
'Content-Type': 'application/json',
|
|
697
|
+
'X-Request-ID': this._nextRequestId(),
|
|
470
698
|
};
|
|
471
699
|
// OpenRouter forwards `anthropic-beta` to Anthropic upstreams.
|
|
472
700
|
if (isAnthropic) headers['anthropic-beta'] = ANTHROPIC_BETA_HEADER;
|
|
473
701
|
|
|
474
|
-
const resp = await
|
|
702
|
+
const resp = await fetchWithRetry('https://openrouter.ai/api/v1/chat/completions', {
|
|
475
703
|
method: 'POST',
|
|
476
704
|
headers,
|
|
477
705
|
body: JSON.stringify({
|
|
@@ -485,7 +713,11 @@ export class LocalAgent {
|
|
|
485
713
|
});
|
|
486
714
|
if (!resp.ok) {
|
|
487
715
|
const text = await resp.text().catch(() => '');
|
|
488
|
-
throw new
|
|
716
|
+
throw new RequestError(`OpenRouter API ${resp.status}: ${text.slice(0, 200)}`, {
|
|
717
|
+
status: resp.status,
|
|
718
|
+
code: `openrouter_http_${resp.status}`,
|
|
719
|
+
retryable: false,
|
|
720
|
+
});
|
|
489
721
|
}
|
|
490
722
|
const data = await resp.json();
|
|
491
723
|
const choice = data.choices?.[0];
|
|
@@ -499,7 +731,87 @@ export class LocalAgent {
|
|
|
499
731
|
return {
|
|
500
732
|
content,
|
|
501
733
|
stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : 'tool_use',
|
|
502
|
-
usage:
|
|
734
|
+
usage: normalizeUsage(data.usage),
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
_nextRequestId() {
|
|
739
|
+
this._requestSequence += 1;
|
|
740
|
+
return `npm-${this.sessionId || 'local'}-${Date.now()}-${this._requestSequence}`.slice(0, 256);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async _reduceContext(messages, { systemPrompt = '', tools = [] } = {}) {
|
|
744
|
+
const config = contextReductionConfig(process.env, this.product);
|
|
745
|
+
const fixedPromptTokens = estimateMessagesTokens([
|
|
746
|
+
{ role: 'system', content: systemPrompt },
|
|
747
|
+
{ role: 'system', content: tools },
|
|
748
|
+
]);
|
|
749
|
+
const budget = resolveContextBudget({
|
|
750
|
+
product: this.product,
|
|
751
|
+
model: this.model,
|
|
752
|
+
fixedPromptTokens,
|
|
753
|
+
explicitThreshold: config.threshold,
|
|
754
|
+
});
|
|
755
|
+
const preserve = config.preserve || budget.preserve;
|
|
756
|
+
const estimated = estimateMessagesTokens(messages);
|
|
757
|
+
if (!config.enabled || estimated <= budget.threshold || messages.length <= preserve + 2) {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const source = messages.slice(0, -preserve);
|
|
762
|
+
let summary = null;
|
|
763
|
+
let summaryUsage = null;
|
|
764
|
+
let appliedStrategy = config.strategy;
|
|
765
|
+
const prompt = config.strategy === 'distillation'
|
|
766
|
+
? 'You are the Bahulam coding-context distiller. Summarize the middle of the earlier conversation while preserving exact active ingredients: user intent, decisions, file paths, edits, commands, test results, errors, constraints, and unfinished work. Keep it structured and concise. Do not invent facts.'
|
|
767
|
+
: 'You are the Bahulam context summarizer. Summarize the earlier conversation for another coding-agent turn. Preserve user intent, decisions, files changed, commands/results, errors, constraints, and unfinished work. Do not invent facts. Return concise plain text only.';
|
|
768
|
+
const transcript = source.map(message => ({
|
|
769
|
+
role: message.role,
|
|
770
|
+
content: typeof message.content === 'string'
|
|
771
|
+
? message.content
|
|
772
|
+
: JSON.stringify(message.content || ''),
|
|
773
|
+
}));
|
|
774
|
+
try {
|
|
775
|
+
const response = await this._callLLM(
|
|
776
|
+
prompt,
|
|
777
|
+
[{ role: 'user', content: JSON.stringify(transcript) }],
|
|
778
|
+
[],
|
|
779
|
+
this.summarizerModel,
|
|
780
|
+
);
|
|
781
|
+
summaryUsage = response.usage || null;
|
|
782
|
+
summary = response.content
|
|
783
|
+
?.filter(block => block.type === 'text')
|
|
784
|
+
.map(block => block.text || '')
|
|
785
|
+
.join('\n')
|
|
786
|
+
.trim() || null;
|
|
787
|
+
} catch (error) {
|
|
788
|
+
// Match the backend's fail-open behavior: a failed reduction
|
|
789
|
+
// must never prevent the actual user turn from running.
|
|
790
|
+
appliedStrategy = 'summarization_failed';
|
|
791
|
+
if (this.verbose) process.stderr.write(`[context] summarization skipped: ${error.message}\n`);
|
|
792
|
+
}
|
|
793
|
+
if (!summary) return null;
|
|
794
|
+
if (!summary.startsWith(SUMMARY_MARKER) && !summary.startsWith(DISTILLATION_MARKER)) {
|
|
795
|
+
summary = `${config.strategy === 'distillation' ? DISTILLATION_MARKER : SUMMARY_MARKER}\n${summary}`;
|
|
796
|
+
}
|
|
797
|
+
const reduced = collapseMessages(messages, summary, preserve);
|
|
798
|
+
return {
|
|
799
|
+
messages: reduced,
|
|
800
|
+
event: {
|
|
801
|
+
phase: 'pre_turn',
|
|
802
|
+
strategy: appliedStrategy,
|
|
803
|
+
collapsed_messages: source.length,
|
|
804
|
+
kept_recent: preserve,
|
|
805
|
+
before_tokens: estimated,
|
|
806
|
+
threshold: budget.threshold,
|
|
807
|
+
budget_source: budget.source,
|
|
808
|
+
context_length: budget.contextLength || null,
|
|
809
|
+
target_tokens: budget.targetTokens || null,
|
|
810
|
+
source: this.gatewayUrl ? 'gateway' : 'provider',
|
|
811
|
+
summary_preview: summary.replace(/^\[[^\]]+\]\s*/, '').split('\n', 1)[0].slice(0, 120),
|
|
812
|
+
summary_usage: summaryUsage,
|
|
813
|
+
},
|
|
814
|
+
usage: summaryUsage,
|
|
503
815
|
};
|
|
504
816
|
}
|
|
505
817
|
|
|
@@ -541,6 +853,18 @@ export class LocalAgent {
|
|
|
541
853
|
}
|
|
542
854
|
|
|
543
855
|
cancel() { this._cancelled = true; }
|
|
856
|
+
|
|
857
|
+
/** Queue a live follow-up for the next local model boundary. */
|
|
858
|
+
sendIntervention(instruction, { idempotencyKey = null } = {}) {
|
|
859
|
+
const text = String(instruction || '').trim();
|
|
860
|
+
if (!text) return Promise.resolve({ status: 'error', error: 'instruction is empty' });
|
|
861
|
+
const interventionId = idempotencyKey || this._nextRequestId();
|
|
862
|
+
if (this._pendingInterventions.some(item => item.interventionId === interventionId)) {
|
|
863
|
+
return Promise.resolve({ status: 'duplicate', interventionId });
|
|
864
|
+
}
|
|
865
|
+
this._pendingInterventions.push({ instruction: text, interventionId });
|
|
866
|
+
return Promise.resolve({ status: 'accepted', interventionId });
|
|
867
|
+
}
|
|
544
868
|
}
|
|
545
869
|
|
|
546
870
|
// Shape the accumulated per-turn totals into the same envelope the remote
|
|
@@ -561,19 +885,3 @@ function _buildLocalUsageEnvelope(model, totals) {
|
|
|
561
885
|
}],
|
|
562
886
|
};
|
|
563
887
|
}
|
|
564
|
-
|
|
565
|
-
// Normalize OpenRouter usage into Anthropic's field names so downstream
|
|
566
|
-
// consumers (PromptCache, pricing.calculateCost) don't branch on shape.
|
|
567
|
-
// OpenRouter returns OpenAI-style: prompt_tokens, completion_tokens,
|
|
568
|
-
// prompt_tokens_details.cached_tokens. When the underlying model is
|
|
569
|
-
// Anthropic, OpenRouter also relays cache_read_input_tokens verbatim.
|
|
570
|
-
function _normalizeOpenRouterUsage(usage) {
|
|
571
|
-
if (!usage) return null;
|
|
572
|
-
const cachedFromOpenAI = usage.prompt_tokens_details?.cached_tokens || 0;
|
|
573
|
-
return {
|
|
574
|
-
input_tokens: usage.prompt_tokens || 0,
|
|
575
|
-
output_tokens: usage.completion_tokens || 0,
|
|
576
|
-
cache_read_input_tokens: usage.cache_read_input_tokens || cachedFromOpenAI || 0,
|
|
577
|
-
cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
|
|
578
|
-
};
|
|
579
|
-
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mode Selector
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* local (default): CLI-side orchestration via Bahulam Gateway.
|
|
5
|
+
* The npm process runs the ReAct loop and tool execution; model
|
|
6
|
+
* calls route through the gateway. Requires `bahulam login`.
|
|
7
|
+
*
|
|
8
|
+
* remote: All requests go to Bahulam backend.
|
|
5
9
|
* Backend handles orchestration, model selection, tool routing.
|
|
6
10
|
* User's provider and models configured via web Settings page.
|
|
7
|
-
*
|
|
8
|
-
* local: For local LLMs (Ollama, LM Studio, etc.)
|
|
9
|
-
* Direct API call, no backend. Only when user explicitly opts in.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
let _probeCache = { available: null, timestamp: 0 };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared model-selection contract used by every runtime transport.
|
|
3
|
+
*
|
|
4
|
+
* Remote/bundled send the context fields to /api/execute. Local/direct use
|
|
5
|
+
* `model` for their provider request, while retaining the same override and
|
|
6
|
+
* mode metadata for observability and delegation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CHAT_MODE_DEFAULTS } from '../config/model-defaults.mjs';
|
|
10
|
+
|
|
11
|
+
const MODE_ALIASES = Object.freeze({
|
|
12
|
+
fast: 'fast',
|
|
13
|
+
thinking: 'thinking',
|
|
14
|
+
extra: 'extra_thinking',
|
|
15
|
+
extra_thinking: 'extra_thinking',
|
|
16
|
+
max: 'max_thinking',
|
|
17
|
+
max_thinking: 'max_thinking',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function clean(value) {
|
|
21
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cleanOverrides(value) {
|
|
25
|
+
if (!value || typeof value !== 'object') return {};
|
|
26
|
+
return Object.fromEntries(Object.entries(value)
|
|
27
|
+
.map(([role, model]) => [role, clean(model)])
|
|
28
|
+
.filter(([, model]) => model));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveModelSelection({
|
|
32
|
+
explicitModel = null,
|
|
33
|
+
modelOverrides = {},
|
|
34
|
+
modelMode = null,
|
|
35
|
+
modelRoute = null,
|
|
36
|
+
modeModels = {},
|
|
37
|
+
profileModels = {},
|
|
38
|
+
fallbackModel = null,
|
|
39
|
+
} = {}) {
|
|
40
|
+
const overrides = cleanOverrides(modelOverrides);
|
|
41
|
+
const rawExplicit = clean(explicitModel);
|
|
42
|
+
const explicitMode = rawExplicit ? MODE_ALIASES[rawExplicit.toLowerCase()] : null;
|
|
43
|
+
const explicit = explicitMode ? null : rawExplicit;
|
|
44
|
+
const reasoningOverride = clean(overrides.reasoning);
|
|
45
|
+
const requestedMode = MODE_ALIASES[clean(modelMode)?.toLowerCase()] || explicitMode || null;
|
|
46
|
+
const profileReasoning = clean(profileModels.reasoning);
|
|
47
|
+
const profileLocal = clean(profileModels.local);
|
|
48
|
+
const modeModel = requestedMode
|
|
49
|
+
? clean(modeModels[requestedMode])
|
|
50
|
+
|| (requestedMode === 'fast' ? clean(profileModels.fast) : profileReasoning)
|
|
51
|
+
|| CHAT_MODE_DEFAULTS[requestedMode]
|
|
52
|
+
: null;
|
|
53
|
+
const model = explicit || reasoningOverride || modeModel || profileReasoning || profileLocal || clean(fallbackModel);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
model,
|
|
57
|
+
modelOverride: explicit || reasoningOverride || null,
|
|
58
|
+
modelOverrides: overrides,
|
|
59
|
+
modelMode: requestedMode,
|
|
60
|
+
modelRoute: clean(modelRoute),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Add the shared selection fields to an /api/execute-style context. */
|
|
65
|
+
export function applyModelSelection(context, selection) {
|
|
66
|
+
const next = { ...context };
|
|
67
|
+
if (selection.modelOverride) next.model_override = selection.modelOverride;
|
|
68
|
+
if (Object.keys(selection.modelOverrides || {}).length) next.model_overrides = selection.modelOverrides;
|
|
69
|
+
if (selection.modelMode) next.model_mode = selection.modelMode;
|
|
70
|
+
if (selection.modelRoute) next.model_route = selection.modelRoute;
|
|
71
|
+
return next;
|
|
72
|
+
}
|