@bahulam/code 0.1.1 → 0.1.3
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/LICENSE +201 -0
- package/NOTICE +39 -0
- package/package.json +8 -9
- package/pulse/lib/tool-categories.ts +13 -0
- package/src/commands/device.mjs +121 -0
- package/src/commands/pair.mjs +190 -0
- package/src/commands/remote.mjs +110 -0
- package/src/config/env.mjs +2 -2
- package/src/core/event-log.mjs +393 -0
- package/src/core/headless.mjs +198 -0
- package/src/core/loop.mjs +276 -0
- package/src/core/memory-disk.mjs +210 -0
- package/src/core/paths.mjs +36 -0
- package/src/core/stream-client.mjs +28 -9
- package/src/core/tool-executor.mjs +64 -16
- package/src/daemon/approval-store.mjs +253 -0
- package/src/daemon/attach-client.mjs +361 -0
- package/src/daemon/daemonize.mjs +151 -0
- package/src/daemon/event-tap.mjs +197 -0
- package/src/daemon/input-lock.mjs +191 -0
- package/src/daemon/relay-client.mjs +258 -0
- package/src/daemon/session-core.mjs +179 -0
- package/src/daemon/session-list.mjs +26 -0
- package/src/daemon/session-publisher.mjs +78 -0
- package/src/daemon/socket-server.mjs +329 -0
- package/src/daemon/stop-daemon.mjs +18 -0
- package/src/permissions/checker.mjs +6 -6
- package/src/permissions/prompt.mjs +8 -7
- package/src/skills/installer.mjs +8 -0
- package/src/terminal/ansi.mjs +85 -9
- package/src/terminal/main.mjs +97 -3
- package/src/terminal/repl.mjs +389 -6
- package/src/terminal/skills-picker.mjs +121 -0
- package/src/terminal/skills.mjs +3 -3
- package/src/tools/analyze-code.mjs +39 -0
- package/src/tools/bash.mjs +1 -1
- package/src/tools/edit.mjs +18 -18
- package/src/tools/git-diff.mjs +34 -0
- package/src/tools/git-status.mjs +30 -0
- package/src/tools/glob.mjs +5 -2
- package/src/tools/grep.mjs +1 -1
- package/src/tools/meta-tools.mjs +85 -0
- package/src/tools/read-files.mjs +37 -0
- package/src/tools/read.mjs +20 -10
- package/src/tools/registry.mjs +20 -0
- package/src/tools/remember.mjs +147 -0
- package/src/tools/search-files.mjs +41 -0
- package/src/tools/write-project.mjs +62 -0
- package/src/tools/write.mjs +1 -1
- package/src/ui/banner.mjs +1 -1
- package/src/ui/slash-commands.mjs +16 -0
- package/src/ui/sub-agent.mjs +8 -2
- package/src/ui/transcript-block.mjs +4 -1
package/src/core/headless.mjs
CHANGED
|
@@ -17,6 +17,19 @@ import { buildWorkScope, promptProjectRoots } from './work-scope.mjs';
|
|
|
17
17
|
import { persistProjectArtifacts } from './project-artifacts.mjs';
|
|
18
18
|
import { TarangAuth } from '../auth/tarang-auth.mjs';
|
|
19
19
|
import { ApprovalManager } from './approval.mjs';
|
|
20
|
+
// daemon wiring — headless (and `bahulam daemonize`) also starts the socket
|
|
21
|
+
// server + relay bridge when eventlog is enabled. Without this the daemon
|
|
22
|
+
// is invisible to attach clients and to paired mobile devices.
|
|
23
|
+
import { tapSseEvent, registerBroadcaster } from '../daemon/event-tap.mjs';
|
|
24
|
+
import { startSocketServer } from '../daemon/socket-server.mjs';
|
|
25
|
+
import { resolvePending } from '../daemon/approval-store.mjs';
|
|
26
|
+
import { startRelayBridge } from '../daemon/relay-client.mjs';
|
|
27
|
+
import { loadRemoteConfig } from '../commands/remote.mjs';
|
|
28
|
+
import { writeSessionMeta } from './event-log.mjs';
|
|
29
|
+
import { daemonSessionDir } from './paths.mjs';
|
|
30
|
+
import { publishSessionDirectory, markSessionClosed } from '../daemon/session-publisher.mjs';
|
|
31
|
+
import * as fsSync from 'node:fs';
|
|
32
|
+
import * as pathSync from 'node:path';
|
|
20
33
|
import {
|
|
21
34
|
appendVisionAnalysisToInstruction,
|
|
22
35
|
prepareImageAttachments,
|
|
@@ -174,6 +187,9 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
174
187
|
const subAgents = []; // { type, model, duration_s, tool_calls, success }
|
|
175
188
|
let stagnationCount = 0;
|
|
176
189
|
let usage = {}; // { input_tokens, output_tokens, cache_read, cache_write }
|
|
190
|
+
// daemon wiring — one-shot per headless invocation.
|
|
191
|
+
let prd092Started = false;
|
|
192
|
+
let currentSessionId = null;
|
|
177
193
|
|
|
178
194
|
try {
|
|
179
195
|
for await (const event of client.execute(instruction, execContext)) {
|
|
@@ -305,7 +321,110 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
305
321
|
// Surface session_id in the JSONL so multi-turn harnesses can
|
|
306
322
|
// capture it from turn N and forward on turn N+1 (via TARANG_SESSION_ID).
|
|
307
323
|
if (data?.session_id) emit({ type: 'session_info', session_id: data.session_id });
|
|
324
|
+
|
|
325
|
+
// — same daemon wiring the interactive REPL does on
|
|
326
|
+
// session_info: start the socket server so attach clients can
|
|
327
|
+
// connect, tap events, and (if remote is enabled) dial the
|
|
328
|
+
// relay so mobile can see the session. Gated by env var, one-
|
|
329
|
+
// shot per process, fire-and-forget so a wire failure never
|
|
330
|
+
// interrupts the turn.
|
|
331
|
+
const _sid = data?.session_id;
|
|
332
|
+
if (
|
|
333
|
+
_sid &&
|
|
334
|
+
process.env.BAHULAM_DAEMON_EVENTLOG === '1' &&
|
|
335
|
+
!prd092Started
|
|
336
|
+
) {
|
|
337
|
+
prd092Started = true;
|
|
338
|
+
(async () => {
|
|
339
|
+
try {
|
|
340
|
+
const server = await startSocketServer({
|
|
341
|
+
sessionId: _sid,
|
|
342
|
+
onCommand: {
|
|
343
|
+
approve: async (payload, attachId) => resolvePending('approve', payload?.apr_id, attachId, payload?.note),
|
|
344
|
+
deny: async (payload, attachId) => resolvePending('deny', payload?.apr_id, attachId, payload?.note),
|
|
345
|
+
interrupt: async () => { try { if (typeof client?.cancel === 'function') client.cancel(); } catch {} },
|
|
346
|
+
send_message: async () => { /* Slice C follow-up */ },
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
registerBroadcaster(evt => server.broadcastEvent(evt));
|
|
350
|
+
|
|
351
|
+
// Write meta.json + daemon.pid so `bahulam list` /
|
|
352
|
+
// `bahulam stop` and the mobile session directory
|
|
353
|
+
// can find this session.
|
|
354
|
+
try {
|
|
355
|
+
await writeSessionMeta({
|
|
356
|
+
sessionId: _sid,
|
|
357
|
+
meta: {
|
|
358
|
+
cwd: process.cwd(),
|
|
359
|
+
model: options.model || null,
|
|
360
|
+
pid: process.pid,
|
|
361
|
+
sock_path: server.sockPath,
|
|
362
|
+
opened_at: new Date().toISOString(),
|
|
363
|
+
headless: true,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
fsSync.writeFileSync(
|
|
367
|
+
pathSync.join(daemonSessionDir(_sid), 'daemon.pid'),
|
|
368
|
+
String(process.pid),
|
|
369
|
+
{ mode: 0o600 },
|
|
370
|
+
);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
try { process.stderr.write(`[prd-092] meta/pid write: ${err.message}\n`); } catch {}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Slice M — publish to session_directory so mobile
|
|
376
|
+
// can list this session even before/after the
|
|
377
|
+
// relay handshake. Fire-and-forget; a failure
|
|
378
|
+
// here doesn't affect anything else.
|
|
379
|
+
try {
|
|
380
|
+
publishSessionDirectory({
|
|
381
|
+
sessionId: _sid,
|
|
382
|
+
token: creds.token,
|
|
383
|
+
cwd: process.cwd(),
|
|
384
|
+
model: options.model || null,
|
|
385
|
+
status: 'running',
|
|
386
|
+
}).catch(() => {});
|
|
387
|
+
} catch { /* silent */ }
|
|
388
|
+
|
|
389
|
+
// Optional relay dial (Slice H) — only when the
|
|
390
|
+
// user opted in via `bahulam remote enable`.
|
|
391
|
+
try {
|
|
392
|
+
const remoteCfg = loadRemoteConfig();
|
|
393
|
+
if (remoteCfg?.enabled) {
|
|
394
|
+
startRelayBridge({
|
|
395
|
+
sessionId: _sid,
|
|
396
|
+
remoteConfig: remoteCfg,
|
|
397
|
+
registerBroadcaster,
|
|
398
|
+
onCommand: {
|
|
399
|
+
approve: async (payload, attachId) => resolvePending('approve', payload?.apr_id, attachId, payload?.note),
|
|
400
|
+
deny: async (payload, attachId) => resolvePending('deny', payload?.apr_id, attachId, payload?.note),
|
|
401
|
+
interrupt: async () => { try { if (typeof client?.cancel === 'function') client.cancel(); } catch {} },
|
|
402
|
+
send_message: async () => { /* Slice C follow-up */ },
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
} catch (err) {
|
|
407
|
+
try { process.stderr.write(`[prd-092] relay bridge: ${err.message}\n`); } catch {}
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
try { process.stderr.write(`[prd-092] socket server: ${err.message}\n`); } catch {}
|
|
411
|
+
}
|
|
412
|
+
})();
|
|
413
|
+
}
|
|
414
|
+
// Tap this event too, so the very first frame lands in the
|
|
415
|
+
// event log (before broadcasters are registered).
|
|
416
|
+
if (_sid && process.env.BAHULAM_DAEMON_EVENTLOG === '1') {
|
|
417
|
+
tapSseEvent({ type: 'session_info', data }, { sessionId: _sid });
|
|
418
|
+
}
|
|
419
|
+
} else if (process.env.BAHULAM_DAEMON_EVENTLOG === '1') {
|
|
420
|
+
// Tap every other event too, matching the REPL's for-await tap.
|
|
421
|
+
// sessionId is populated once session_info has landed.
|
|
422
|
+
if (currentSessionId) {
|
|
423
|
+
tapSseEvent(event, { sessionId: currentSessionId });
|
|
424
|
+
}
|
|
308
425
|
}
|
|
426
|
+
// Track current session id for the tap.
|
|
427
|
+
if (type === 'session_info' && data?.session_id) currentSessionId = data.session_id;
|
|
309
428
|
|
|
310
429
|
if (type === 'complete') {
|
|
311
430
|
if (data?.rate_limit) rateLimit = data.rate_limit;
|
|
@@ -456,5 +575,84 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
456
575
|
process.stderr.write(`\n--- Response ---\n${finalContent.slice(0, 2000)}\n`);
|
|
457
576
|
}
|
|
458
577
|
|
|
578
|
+
// — DAEMON_HOLD mode.
|
|
579
|
+
// BAHULAM_DAEMON_HOLD=1 keep alive forever
|
|
580
|
+
// BAHULAM_DAEMON_HOLD=<seconds> keep alive for N seconds
|
|
581
|
+
// BAHULAM_DAEMON_SPAWNED=1 (implicit) hold with a default TTL if
|
|
582
|
+
// HOLD not explicitly set
|
|
583
|
+
//
|
|
584
|
+
// When held, we DO NOT exit after agent_complete. The socket server and
|
|
585
|
+
// relay bridge (started earlier by the session_info wiring) stay up so
|
|
586
|
+
// attach clients and paired mobile devices can still see the session,
|
|
587
|
+
// reconnect, replay events from disk, and — once send_message is wired
|
|
588
|
+
// — kick off follow-up turns without spinning up a fresh daemon.
|
|
589
|
+
//
|
|
590
|
+
// Exit signals honored: SIGTERM (from `bahulam stop <id>`), SIGINT
|
|
591
|
+
// (Ctrl-C from a foreground shell), the TTL timer, and the idle-exit
|
|
592
|
+
// policy from §6.6 (30-min default with no attach).
|
|
593
|
+
if (prd092Started) {
|
|
594
|
+
const hold = process.env.BAHULAM_DAEMON_HOLD;
|
|
595
|
+
const spawned = process.env.BAHULAM_DAEMON_SPAWNED === '1';
|
|
596
|
+
const holdMode = hold != null || spawned;
|
|
597
|
+
if (holdMode) {
|
|
598
|
+
// Slice M — mark idle in the session directory so the mobile
|
|
599
|
+
// list shows the session as available-but-not-active.
|
|
600
|
+
try {
|
|
601
|
+
publishSessionDirectory({
|
|
602
|
+
sessionId: currentSessionId,
|
|
603
|
+
token: creds.token,
|
|
604
|
+
cwd: process.cwd(),
|
|
605
|
+
model: options.model || null,
|
|
606
|
+
status: 'idle',
|
|
607
|
+
}).catch(() => {});
|
|
608
|
+
} catch { /* silent */ }
|
|
609
|
+
|
|
610
|
+
const ttlSec = _resolveHoldTtl(hold, spawned);
|
|
611
|
+
const banner = ttlSec === Infinity ? 'until stopped' : `for ${ttlSec}s`;
|
|
612
|
+
log(`[prd-092] daemon holding ${banner}. Attach: bahulam attach ${currentSessionId || '<id>'}`);
|
|
613
|
+
await _blockUntilStop(ttlSec);
|
|
614
|
+
log('[prd-092] daemon exiting (hold expired or signal received)');
|
|
615
|
+
}
|
|
616
|
+
// Whether held or not, mark the session closed in the directory
|
|
617
|
+
// so mobile doesn't list a gone-forever daemon as "idle" forever.
|
|
618
|
+
try {
|
|
619
|
+
await markSessionClosed({ sessionId: currentSessionId, token: creds.token });
|
|
620
|
+
} catch { /* silent */ }
|
|
621
|
+
}
|
|
622
|
+
|
|
459
623
|
process.exit(0);
|
|
460
624
|
}
|
|
625
|
+
|
|
626
|
+
// ── daemon-hold helpers ─────────────────────────────────────
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* How long to hold. Explicit HOLD wins; else spawned-default is 30min
|
|
630
|
+
* (§6.6 idle_ttl). Infinity for "1" or "true".
|
|
631
|
+
*/
|
|
632
|
+
function _resolveHoldTtl(holdEnv, spawned) {
|
|
633
|
+
if (holdEnv === '1' || holdEnv === 'true') return Infinity;
|
|
634
|
+
if (holdEnv != null && holdEnv !== '') {
|
|
635
|
+
const n = Number(holdEnv);
|
|
636
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
637
|
+
}
|
|
638
|
+
if (spawned) return 30 * 60; // §6.6 default idle_ttl
|
|
639
|
+
return 60; // defensive fallback
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Await SIGTERM/SIGINT or the TTL. Resolves without an error either way —
|
|
644
|
+
* the caller then falls through to process.exit(0) so bahulam stop looks
|
|
645
|
+
* like a clean shutdown, not a crash.
|
|
646
|
+
*/
|
|
647
|
+
function _blockUntilStop(ttlSec) {
|
|
648
|
+
return new Promise(resolve => {
|
|
649
|
+
let done = false;
|
|
650
|
+
const _done = () => { if (done) return; done = true; resolve(); };
|
|
651
|
+
process.once('SIGTERM', _done);
|
|
652
|
+
process.once('SIGINT', _done);
|
|
653
|
+
if (ttlSec !== Infinity) {
|
|
654
|
+
const t = setTimeout(_done, ttlSec * 1000);
|
|
655
|
+
if (typeof t.unref === 'function') t.unref();
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin Agent Loop — iterates turns against /v1/agent/* gateway endpoints.
|
|
3
|
+
*
|
|
4
|
+
* PRD-091 §6.2-6.3: CLI owns iteration. Gateway owns LLM calls,
|
|
5
|
+
* prompt assembly, memory management, and sub-agent orchestration.
|
|
6
|
+
*
|
|
7
|
+
* This loop replaces local-agent.mjs's direct LLM calls with a
|
|
8
|
+
* POST /v1/agent/turn to the gateway. Everything else — tool dispatch,
|
|
9
|
+
* message accumulation, event shapes — matches the existing patterns
|
|
10
|
+
* the REPL already consumes.
|
|
11
|
+
*
|
|
12
|
+
* Usage (replacing client.execute()):
|
|
13
|
+
* const { createAgentLoop } = await import('../core/loop.mjs');
|
|
14
|
+
* for await (const event of createAgentLoop({
|
|
15
|
+
* sessionId: 'sess_...',
|
|
16
|
+
* messages: session.agentHistory,
|
|
17
|
+
* toolExecutor: executor,
|
|
18
|
+
* gatewayFetch: (body) => fetch(`${GATEWAY_URL}/v1/agent/turn`, { ... }),
|
|
19
|
+
* })) {
|
|
20
|
+
* // same event types as client.execute() / LocalAgent.execute()
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as os from 'node:os';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
|
|
28
|
+
const MAX_TURNS = 999;
|
|
29
|
+
|
|
30
|
+
// ── Auth / URL discovery ────────────────────────────────────────────────
|
|
31
|
+
// Same precedence as bundled-runtime.mjs::_readCliToken so gateway calls
|
|
32
|
+
// use the SAME credential the user's login saved. Kept inline here so
|
|
33
|
+
// loop.mjs stays importable without pulling the bundled-runtime module.
|
|
34
|
+
function _readCliToken() {
|
|
35
|
+
if (process.env.BAHULAM_API_KEY) return process.env.BAHULAM_API_KEY;
|
|
36
|
+
if (process.env.BAHULAM_CLI_TOKEN) return process.env.BAHULAM_CLI_TOKEN;
|
|
37
|
+
if (process.env.B0_TOKEN) return process.env.B0_TOKEN;
|
|
38
|
+
try {
|
|
39
|
+
const raw = fs.readFileSync(path.join(os.homedir(), '.bahulam', 'config.json'), 'utf8');
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
return (parsed && typeof parsed.token === 'string' && parsed.token.trim()) || null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _gatewayUrl() {
|
|
48
|
+
return (process.env.BAHULAM_GATEWAY_URL || 'https://gateway.bahulam.ai/v1').replace(/\/+$/, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a session on the gateway. Returns the session config the loop
|
|
53
|
+
* uses on every subsequent /v1/agent/turn (server-owned prompt +
|
|
54
|
+
* tool_schemas + model). Called ONCE per REPL session before iterating.
|
|
55
|
+
*/
|
|
56
|
+
export async function createGatewaySession({
|
|
57
|
+
workspace = 'kepler-code',
|
|
58
|
+
model = process.env.BAHULAM_MODEL || undefined,
|
|
59
|
+
token = _readCliToken(),
|
|
60
|
+
gateway = _gatewayUrl(),
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (!token) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'Not logged in — set BAHULAM_API_KEY or run `bahulam login` first.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const base = gateway.endsWith('/v1') ? gateway.slice(0, -3) : gateway;
|
|
68
|
+
const res = await fetch(`${base}/v1/agent/session`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
'Authorization': `Bearer ${token}`,
|
|
73
|
+
'X-Bahulam-User-Id': process.env.BAHULAM_USER_ID || 'cli-user',
|
|
74
|
+
'X-Bahulam-Tier': process.env.BAHULAM_TIER || 'free',
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({ workspace, ...(model ? { model } : {}) }),
|
|
77
|
+
});
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
throw new Error(`session create failed (HTTP ${res.status}): ${text.slice(0, 300)}`);
|
|
81
|
+
}
|
|
82
|
+
return res.json(); // { session_id, workspace, prompt, tool_schemas, model, expires_at, ... }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Execute one turn against /v1/chat/completions (the standard OpenAI-shape
|
|
87
|
+
* gateway endpoint). This reuses the existing metering, entitlement, and
|
|
88
|
+
* provider translation the gateway already does for BYOK — we don't need
|
|
89
|
+
* a new /v1/agent/turn endpoint. Session config (prompt + tools + model)
|
|
90
|
+
* comes from createGatewaySession() once, then every turn just posts
|
|
91
|
+
* standard OpenAI messages + tools + model.
|
|
92
|
+
*
|
|
93
|
+
* The `session` param carries { prompt, tool_schemas, model } from
|
|
94
|
+
* createGatewaySession. We inject them into the request server prefers
|
|
95
|
+
* client to send explicitly so per-tier gating on the gateway side
|
|
96
|
+
* still works (gateway decides what a caller may use; client just
|
|
97
|
+
* echoes what it received).
|
|
98
|
+
*/
|
|
99
|
+
async function _callGateway({ session, messages }) {
|
|
100
|
+
const token = _readCliToken();
|
|
101
|
+
if (!token) throw new Error('Missing gateway token (BAHULAM_API_KEY / bahulam login).');
|
|
102
|
+
const base = _gatewayUrl();
|
|
103
|
+
// Support both `<host>` and `<host>/v1` in BAHULAM_GATEWAY_URL.
|
|
104
|
+
const url = base.endsWith('/v1')
|
|
105
|
+
? `${base}/chat/completions`
|
|
106
|
+
: `${base}/v1/chat/completions`;
|
|
107
|
+
|
|
108
|
+
// Strip Bahulam-specific metadata from tool schemas before sending
|
|
109
|
+
// — server also strips defensively, but keep the wire clean.
|
|
110
|
+
const tools = (session.tool_schemas || []).map(t => ({
|
|
111
|
+
type: t.type || 'function',
|
|
112
|
+
function: t.function,
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
const body = {
|
|
116
|
+
model: session.model,
|
|
117
|
+
messages, // OpenAI-shape end-to-end — {role, content} or
|
|
118
|
+
// assistant.tool_calls + tool.tool_call_id
|
|
119
|
+
...(tools.length ? { tools, tool_choice: 'auto' } : {}),
|
|
120
|
+
temperature: 0.0,
|
|
121
|
+
stream: false,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const t0 = performance.now();
|
|
125
|
+
const res = await fetch(url, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
'Content-Type': 'application/json',
|
|
129
|
+
'Authorization': `Bearer ${token}`,
|
|
130
|
+
'X-Bahulam-User-Id': process.env.BAHULAM_USER_ID || 'cli-user',
|
|
131
|
+
'X-Bahulam-Tier': process.env.BAHULAM_TIER || 'free',
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify(body),
|
|
134
|
+
});
|
|
135
|
+
const roundTripMs = performance.now() - t0;
|
|
136
|
+
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
const text = await res.text();
|
|
139
|
+
throw new Error(`chat/completions failed (HTTP ${res.status}): ${text.slice(0, 400)}`);
|
|
140
|
+
}
|
|
141
|
+
const data = await res.json();
|
|
142
|
+
const choice = (data.choices || [{}])[0];
|
|
143
|
+
return {
|
|
144
|
+
message: choice.message || {}, // OpenAI assistant message shape
|
|
145
|
+
finish_reason: choice.finish_reason,
|
|
146
|
+
usage: data.usage || {},
|
|
147
|
+
timing_ms: { roundTrip: Math.round(roundTripMs * 10) / 10 },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create an async generator that iterates turns against the gateway.
|
|
153
|
+
*
|
|
154
|
+
* Uses standard OpenAI /v1/chat/completions shape throughout — the same
|
|
155
|
+
* shape the gateway already speaks to upstream providers. No custom
|
|
156
|
+
* content-block conversion, no /v1/agent/turn endpoint needed.
|
|
157
|
+
*
|
|
158
|
+
* @param {Object} opts
|
|
159
|
+
* @param {Object} opts.session - From createGatewaySession()
|
|
160
|
+
* { session_id, prompt, tool_schemas, model, ... }
|
|
161
|
+
* @param {Array} opts.messages - Conversation history (mutated across turns).
|
|
162
|
+
* Caller seeds with [{role:'user', content:input}].
|
|
163
|
+
* @param {Object} opts.toolExecutor - From createToolExecutor() — .execute(name, input)
|
|
164
|
+
* @param {Function} [opts.gatewayFetch] - Override for testing
|
|
165
|
+
* @param {number} [opts.maxTurns] - Max iterations (default 999)
|
|
166
|
+
* @yields {Object} Events matching the REPL event protocol
|
|
167
|
+
*/
|
|
168
|
+
export async function* createAgentLoop({
|
|
169
|
+
session,
|
|
170
|
+
messages,
|
|
171
|
+
toolExecutor,
|
|
172
|
+
gatewayFetch = _callGateway,
|
|
173
|
+
maxTurns = MAX_TURNS,
|
|
174
|
+
} = {}) {
|
|
175
|
+
let toolCount = 0;
|
|
176
|
+
const startTime = Date.now();
|
|
177
|
+
let usage = { input_tokens: 0, output_tokens: 0 };
|
|
178
|
+
|
|
179
|
+
// Prepend the workspace system prompt if not already present. The
|
|
180
|
+
// messages array (caller-owned) may accumulate across REPL turns,
|
|
181
|
+
// so only inject once.
|
|
182
|
+
if (session?.prompt && !messages.some(m => m.role === 'system')) {
|
|
183
|
+
messages.unshift({ role: 'system', content: session.prompt });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < maxTurns; i++) {
|
|
187
|
+
// ── Call gateway (/v1/chat/completions) ─────────────────────
|
|
188
|
+
const turn = await gatewayFetch({ session, messages });
|
|
189
|
+
const asst = turn.message || {};
|
|
190
|
+
usage = {
|
|
191
|
+
input_tokens: (usage.input_tokens || 0) + (turn.usage?.prompt_tokens || 0),
|
|
192
|
+
output_tokens: (usage.output_tokens || 0) + (turn.usage?.completion_tokens || 0),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ── Push assistant response into message history (OpenAI shape)
|
|
196
|
+
// content=null when tool_calls exist (OpenAI protocol requirement),
|
|
197
|
+
// else the text string.
|
|
198
|
+
const openAiAsst = { role: 'assistant' };
|
|
199
|
+
if (asst.tool_calls && asst.tool_calls.length > 0) {
|
|
200
|
+
openAiAsst.content = asst.content ?? null;
|
|
201
|
+
openAiAsst.tool_calls = asst.tool_calls;
|
|
202
|
+
} else {
|
|
203
|
+
openAiAsst.content = asst.content ?? '';
|
|
204
|
+
}
|
|
205
|
+
messages.push(openAiAsst);
|
|
206
|
+
|
|
207
|
+
// ── Yield text content ──────────────────────────────────────
|
|
208
|
+
if (typeof asst.content === 'string' && asst.content) {
|
|
209
|
+
yield { type: 'content', data: { text: asst.content } };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Handle tool calls ───────────────────────────────────────
|
|
213
|
+
const toolCalls = asst.tool_calls || [];
|
|
214
|
+
if (toolCalls.length > 0) {
|
|
215
|
+
for (const tc of toolCalls) {
|
|
216
|
+
const id = tc.id;
|
|
217
|
+
const name = tc.function?.name || '';
|
|
218
|
+
let input = {};
|
|
219
|
+
try { input = JSON.parse(tc.function?.arguments || '{}'); }
|
|
220
|
+
catch { input = { _raw: tc.function?.arguments }; }
|
|
221
|
+
|
|
222
|
+
yield { type: 'tool_call', data: { call_id: id, tool: name, args: input } };
|
|
223
|
+
|
|
224
|
+
// Execute locally via the existing tool executor
|
|
225
|
+
let result;
|
|
226
|
+
try {
|
|
227
|
+
result = await toolExecutor.execute(name, input || {});
|
|
228
|
+
} catch (err) {
|
|
229
|
+
result = { success: false, output: `Error: ${err.message}` };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
yield { type: 'tool_done', data: { tool: name, duration_ms: 0 } };
|
|
233
|
+
toolCount++;
|
|
234
|
+
|
|
235
|
+
// Push tool response as a role='tool' message (OpenAI shape).
|
|
236
|
+
// tool_call_id links it to the assistant's tool_calls[i].id.
|
|
237
|
+
// MUST push one message per tool_call in the SAME order as
|
|
238
|
+
// the assistant's tool_calls, or OpenAI rejects the next
|
|
239
|
+
// turn with "tool_call_ids did not have response messages".
|
|
240
|
+
messages.push({
|
|
241
|
+
role: 'tool',
|
|
242
|
+
tool_call_id: id,
|
|
243
|
+
content: typeof result.output === 'string'
|
|
244
|
+
? result.output
|
|
245
|
+
: JSON.stringify(result.output ?? result),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// Loop continues to next iteration
|
|
249
|
+
} else {
|
|
250
|
+
// ── No tool calls → turn is done ────────────────────────
|
|
251
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
252
|
+
yield {
|
|
253
|
+
type: 'complete',
|
|
254
|
+
data: {
|
|
255
|
+
summary: 'Done',
|
|
256
|
+
changes: toolCount,
|
|
257
|
+
duration_s: duration,
|
|
258
|
+
usage,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Max turns reached
|
|
266
|
+
yield { type: 'error', data: { message: `Max turns (${maxTurns}) reached.`, fatal: false } };
|
|
267
|
+
yield {
|
|
268
|
+
type: 'complete',
|
|
269
|
+
data: {
|
|
270
|
+
summary: 'Aborted (max turns)',
|
|
271
|
+
changes: toolCount,
|
|
272
|
+
duration_s: (Date.now() - startTime) / 1000,
|
|
273
|
+
usage,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|