car-runtime 0.55.0 → 0.56.1-nightly.20261007

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/README.md CHANGED
@@ -112,6 +112,7 @@ const result = await executeProposal(rt, proposal, async (callJson) => {
112
112
  // Dispatch to your tool implementation.
113
113
  return JSON.stringify({ stdout: 'ok' });
114
114
  });
115
+ await rt.close(); // disconnect is an alias; a later call reconnects lazily
115
116
  ```
116
117
 
117
118
  ## Packaged agent loop
@@ -135,7 +136,10 @@ main({
135
136
  });
136
137
  ```
137
138
 
138
- `runAgent(config, goal)` is also exported for embedding and tests. Its
139
+ `runAgent(config, goal)` is also exported for embedding and tests. Pass one
140
+ long-lived runtime as `config.runtime` to reuse its daemon connection across
141
+ runs; otherwise the loop creates and closes a runtime for each call. `serveChat`
142
+ uses the same option and returns the runtime it is serving on. Its
139
143
  `AgentOutcome.run_id` is the durable key accepted by `runsGetTrace`; it is
140
144
  `null` when an older daemon has no `runs.*` surface or did not acknowledge the
141
145
  start. `runChatTurn` keeps returning bare text by default; pass
@@ -143,7 +147,11 @@ start. `runChatTurn` keeps returning bare text by default; pass
143
147
  `{ text, run_id }`. `main(config)` provides `--task`, `--json`, and `--serve`.
144
148
  The loop adds `finish`, statically verifies every proposal before execution,
145
149
  preserves tool-call/result IDs, honors per-schema `timeoutMs`, traces task and
146
- chat runs, and uses request-shaped inference with prompt-cache breakpoints. It
150
+ chat runs, and uses request-shaped inference with prompt-cache breakpoints.
151
+ Each inference is recorded before its requested actions with assistant text,
152
+ normalized tool-call IDs, model id, token usage, and duration; `runsGetTrace`
153
+ returns those model turns interleaved with action turns. Recording is
154
+ best-effort, so an older daemon without the method still runs the agent. It
147
155
  loads the native binding only when a loop runs, so
148
156
  `require('car-runtime/agent-loop')` is safe for package discovery. Type
149
157
  declarations ship as `agent-loop.d.ts` and shared config/outcome types in
package/agent-loop.d.ts CHANGED
@@ -39,7 +39,14 @@ export function runChatTurn(
39
39
  options?: AgentChatTurnOptions & { includeRunId?: false },
40
40
  ): Promise<string>;
41
41
 
42
+ export function serveChat(config: AgentLoopConfig): Promise<CarRuntime>;
43
+
42
44
  export function main(config: AgentLoopConfig): Promise<void>;
43
45
 
44
- declare const agentLoop: { main: typeof main; runAgent: typeof runAgent; runChatTurn: typeof runChatTurn };
46
+ declare const agentLoop: {
47
+ main: typeof main;
48
+ runAgent: typeof runAgent;
49
+ runChatTurn: typeof runChatTurn;
50
+ serveChat: typeof serveChat;
51
+ };
45
52
  export default agentLoop;
package/agent-loop.js CHANGED
@@ -138,6 +138,40 @@ function makeToolCallback(tools, toolTimeouts = {}, onFinish = null) {
138
138
  // single full re-write of the prefix, and every surviving turn then reads at
139
139
  // ~0.1x.
140
140
  //
141
+ // Registration belongs to the daemon connection, not to an individual run.
142
+ // Remember what this package installed so a caller-owned runtime can serve
143
+ // hundreds of sequential runs without appending the same policy hundreds of
144
+ // times. Weak keys ensure this bookkeeping never keeps a runtime/socket alive.
145
+ const RUNTIME_REGISTRATION = new WeakMap();
146
+
147
+ async function prepareRuntime(rt, toolSchemas, policies) {
148
+ let state = RUNTIME_REGISTRATION.get(rt);
149
+ if (!state) {
150
+ state = { basicsAttempted: false, tools: new Set(), policies: new Set() };
151
+ RUNTIME_REGISTRATION.set(rt, state);
152
+ }
153
+ if (!state.basicsAttempted) {
154
+ state.basicsAttempted = true;
155
+ try { await rt.registerAgentBasics(); } catch { /* unsupported on this daemon — fine */ }
156
+ }
157
+ for (const schema of toolSchemas) {
158
+ if (state.tools.has(schema.name)) continue;
159
+ await rt.registerTool(schema.name);
160
+ state.tools.add(schema.name);
161
+ }
162
+ for (const policy of policies || []) {
163
+ const fingerprint = JSON.stringify(policy);
164
+ if (state.policies.has(fingerprint)) continue;
165
+ await rt.registerPolicy(...policy);
166
+ state.policies.add(fingerprint);
167
+ }
168
+ }
169
+
170
+ async function closeRuntime(rt) {
171
+ if (typeof rt.close === 'function') await rt.close();
172
+ else if (typeof rt.disconnect === 'function') await rt.disconnect();
173
+ }
174
+
141
175
  /** One request-shaped, cache-aware inference path for task and chat loops. */
142
176
  async function inferTurn(rt, { model, maxTokens, toolSchemas, messages, toolChoice = 'auto' }) {
143
177
  return JSON.parse(await rt.inferTrackedWithRequest(JSON.stringify({
@@ -155,6 +189,28 @@ async function inferTurn(rt, { model, maxTokens, toolSchemas, messages, toolChoi
155
189
  })));
156
190
  }
157
191
 
192
+ /** Record one completed inference before any requested action executes. */
193
+ async function recordModelTurn(rt, runId, turnIndex, tracked, fallbackModel) {
194
+ if (runId === null || typeof rt.runsRecordModelTurn !== 'function') return;
195
+ try {
196
+ await rt.runsRecordModelTurn(JSON.stringify({
197
+ run_id: runId,
198
+ turn_index: turnIndex,
199
+ assistant_text: tracked.text || '',
200
+ requested_tool_calls: (tracked.tool_calls || []).map((call) => ({
201
+ id: call.id || '',
202
+ name: call.name || '',
203
+ arguments: call.arguments ?? {},
204
+ })),
205
+ model_id: tracked.model_used || fallbackModel || 'unknown',
206
+ token_usage: tracked.usage ?? null,
207
+ duration_ms: Number.isSafeInteger(tracked.latency_ms) && tracked.latency_ms >= 0
208
+ ? tracked.latency_ms
209
+ : 0,
210
+ }));
211
+ } catch { /* older daemon or transient trace failure — inference still counts */ }
212
+ }
213
+
158
214
  /**
159
215
  * Run the agent once toward `goal`. Returns an AgentOutcome-shaped object:
160
216
  * { status, summary, evidence[], metrics{}, tools_called[], run_id }
@@ -168,8 +224,19 @@ async function inferTurn(rt, { model, maxTokens, toolSchemas, messages, toolChoi
168
224
  * `rt.runsComplete`. Both are best-effort and never print, so the daemon traces
169
225
  * the run for CarHost while older daemons report `run_id: null` and still run.
170
226
  */
171
- async function runAgent(config, goal, { maxTurns } = {}) {
172
- const { CarRuntime, executeProposal } = runtimeApi();
227
+ async function runAgent(config, goal, options = {}) {
228
+ const { CarRuntime } = runtimeApi();
229
+ const ownsRuntime = config.runtime == null;
230
+ const rt = config.runtime ?? new CarRuntime();
231
+ try {
232
+ return await runAgentWithRuntime(rt, config, goal, options);
233
+ } finally {
234
+ if (ownsRuntime) await closeRuntime(rt);
235
+ }
236
+ }
237
+
238
+ async function runAgentWithRuntime(rt, config, goal, { maxTurns } = {}) {
239
+ const { executeProposal } = runtimeApi();
173
240
  const turnsCap = maxTurns ?? config.maxTurns ?? 8;
174
241
  const toolSchemas = [...(config.toolSchemas || []), FINISH_SCHEMA];
175
242
  const tools = { finish: ({ answer }) => ({ answer }), ...(config.tools || {}) };
@@ -180,15 +247,9 @@ async function runAgent(config, goal, { maxTurns } = {}) {
180
247
  toolSchemas.filter((s) => s && s.timeoutMs != null).map((s) => [s.name, s.timeoutMs]),
181
248
  );
182
249
 
183
- const rt = new CarRuntime();
184
- // Best-effort: some daemon versions don't expose agents.register_basics. It
185
- // only adds CAR's built-in utility tools, which a tool-declaring agent doesn't
186
- // depend on, so a missing method must not abort the run.
187
- try { await rt.registerAgentBasics(); } catch { /* unsupported on this daemon — fine */ }
188
- for (const s of toolSchemas) await rt.registerTool(s.name);
189
- // Guardrails are declarative policies, enforced in Rust BEFORE the tool fires
190
- // — not prompt rules. Each entry is the argument list for registerPolicy.
191
- for (const p of (config.policies || [])) await rt.registerPolicy(...p);
250
+ // Tools and declarative policies live on the daemon connection. Register
251
+ // each exact entry once when the caller reuses a runtime across runs.
252
+ await prepareRuntime(rt, toolSchemas, config.policies);
192
253
 
193
254
  // Run-trace bracket (open). Tell the daemon a run is starting so CarHost can
194
255
  // trace it: prompt -> CLI outcome -> verifier verdict -> AgentOutcome. The
@@ -258,16 +319,16 @@ async function runAgent(config, goal, { maxTurns } = {}) {
258
319
  }
259
320
 
260
321
  const calls = tracked.tool_calls || [];
322
+ // Normalize ids before tracing so the recorded decision carries the same
323
+ // ids proposal execution and subsequent tool_results use.
324
+ calls.forEach((c, i) => { c.id = c.id || `a${i}`; });
325
+ await recordModelTurn(rt, runId, turns, tracked, config.defaultModel);
261
326
  if (calls.length === 0) {
262
327
  // Model answered in prose with no tool call — treat as a neutral Done.
263
328
  outcome = mkOutcome('done', tracked.text || 'no further actions',
264
329
  [{ kind: 'self_assessment', description: tracked.text || '', data: null }], metrics, toolsCalled);
265
330
  break;
266
331
  }
267
-
268
- // Normalize ids so assistant tool_calls and tool_results correlate across
269
- // turns (local models often omit ids).
270
- calls.forEach((c, i) => { c.id = c.id || `a${i}`; });
271
332
  messages.push({ role: 'assistant', content: tracked.text || '', tool_calls: calls });
272
333
  const idToTool = Object.fromEntries(calls.map((c) => [c.id, c.name]));
273
334
 
@@ -432,13 +493,16 @@ async function runChatTurn(
432
493
  messages,
433
494
  });
434
495
  const calls = tracked.tool_calls || [];
496
+ calls.forEach((c, i) => { c.id = c.id || `a${i}`; });
497
+ await recordModelTurn(
498
+ rt, runId, turns + 1, tracked, selectedModel ?? config.defaultModel,
499
+ );
435
500
  if (calls.length === 0) {
436
501
  finalText = tracked.text || '';
437
502
  messages.push({ role: 'assistant', content: finalText });
438
503
  terminal = 'done';
439
504
  break;
440
505
  }
441
- calls.forEach((c, i) => { c.id = c.id || `a${i}`; });
442
506
  messages.push({ role: 'assistant', content: tracked.text || '', tool_calls: calls });
443
507
  const idToTool = Object.fromEntries(calls.map((c) => [c.id, c.name]));
444
508
  // Surface non-finish tool calls as progress so the host UI can show them.
@@ -495,15 +559,19 @@ async function runChatTurn(
495
559
  /** Set up the long-lived chat runtime: register tools/policies + the agent.chat handler. */
496
560
  async function serveChat(config) {
497
561
  const { CarRuntime, registerChatHandler } = runtimeApi();
498
- const rt = new CarRuntime();
562
+ const ownsRuntime = config.runtime == null;
563
+ const rt = config.runtime ?? new CarRuntime();
499
564
  const toolSchemas = [...(config.toolSchemas || []), FINISH_SCHEMA];
500
565
  const tools = { finish: ({ answer }) => ({ answer }), ...(config.tools || {}) };
501
566
  const toolTimeouts = Object.fromEntries(
502
567
  toolSchemas.filter((s) => s && s.timeoutMs != null).map((s) => [s.name, s.timeoutMs]),
503
568
  );
504
- try { await rt.registerAgentBasics(); } catch { /* unsupported — fine */ }
505
- for (const s of toolSchemas) await rt.registerTool(s.name);
506
- for (const p of (config.policies || [])) await rt.registerPolicy(...p);
569
+ try {
570
+ await prepareRuntime(rt, toolSchemas, config.policies);
571
+ } catch (error) {
572
+ if (ownsRuntime) await closeRuntime(rt);
573
+ throw error;
574
+ }
507
575
 
508
576
  if (typeof registerChatHandler !== 'function') {
509
577
  console.error(`[${config.agentName}] car-runtime has no agent.chat support — chat disabled (update car-runtime).`);
@@ -536,6 +604,21 @@ async function serveChat(config) {
536
604
  return rt;
537
605
  }
538
606
 
607
+ function shutdownLatch() {
608
+ let resolve;
609
+ const promise = new Promise((r) => { resolve = r; });
610
+ const onSignal = () => resolve();
611
+ process.once('SIGINT', onSignal);
612
+ process.once('SIGTERM', onSignal);
613
+ return {
614
+ promise,
615
+ cleanup() {
616
+ process.removeListener('SIGINT', onSignal);
617
+ process.removeListener('SIGTERM', onSignal);
618
+ },
619
+ };
620
+ }
621
+
539
622
  async function main(config) {
540
623
  const args = parseArgs(process.argv.slice(2));
541
624
 
@@ -544,24 +627,39 @@ async function main(config) {
544
627
  // "running". Always serve chat (agent.chat); if the agent has a standing
545
628
  // goal + interval, ALSO run it on a loop.
546
629
  // car_register.py can set these via CAR_STANDING_GOAL / CAR_INTERVAL_SECS.
547
- try { await serveChat(config); } catch (e) { console.error(`[${config.agentName}] chat setup failed:`, e); }
548
- const goal = config.standingGoal ?? process.env.CAR_STANDING_GOAL ?? null;
549
- const everyMs = (config.intervalSecs ?? Number(process.env.CAR_INTERVAL_SECS || 0)) * 1000;
550
- console.error(`[${config.agentName}] serving${goal ? ` — "${goal}" every ${everyMs / 1000}s` : ' (idle; start via dashboard or --task)'}`);
551
- if (goal && everyMs > 0) {
552
- for (;;) {
553
- try {
554
- // Each iteration is its own run: runAgent opens and closes one
555
- // runs.start/runs.complete bracket on its own fresh CarRuntime.
556
- const o = await runAgent(config, goal);
557
- console.error(`[${config.agentName}] ${o.status} — ${o.summary}`);
558
- } catch (e) { console.error(`[${config.agentName}] error:`, e); }
559
- await new Promise((r) => setTimeout(r, everyMs));
630
+ let chatRuntime = null;
631
+ const ownsRuntime = config.runtime == null;
632
+ const shutdown = shutdownLatch();
633
+ try {
634
+ try { chatRuntime = await serveChat(config); } catch (e) { console.error(`[${config.agentName}] chat setup failed:`, e); }
635
+ const goal = config.standingGoal ?? process.env.CAR_STANDING_GOAL ?? null;
636
+ const everyMs = (config.intervalSecs ?? Number(process.env.CAR_INTERVAL_SECS || 0)) * 1000;
637
+ console.error(`[${config.agentName}] serving${goal ? ` — "${goal}" every ${everyMs / 1000}s` : ' (idle; start via dashboard or --task)'}`);
638
+ if (goal && everyMs > 0) {
639
+ for (;;) {
640
+ try {
641
+ // Each iteration is its own run-trace bracket on the same long-lived
642
+ // chat runtime, so standing service does not grow daemon sockets.
643
+ const runConfig = chatRuntime ? { ...config, runtime: chatRuntime } : config;
644
+ const o = await runAgent(runConfig, goal);
645
+ console.error(`[${config.agentName}] ${o.status} — ${o.summary}`);
646
+ } catch (e) { console.error(`[${config.agentName}] error:`, e); }
647
+ const delay = new Promise((resolve) => {
648
+ const timer = setTimeout(() => resolve(false), everyMs);
649
+ timer.unref?.();
650
+ });
651
+ const stopped = await Promise.race([
652
+ shutdown.promise.then(() => true),
653
+ delay,
654
+ ]);
655
+ if (stopped) return;
656
+ }
657
+ } else {
658
+ await shutdown.promise;
560
659
  }
561
- } else {
562
- // Keep the event loop alive. A pending promise alone does NOT keep Node
563
- // running — it exits when the loop is empty — so use a no-op heartbeat.
564
- setInterval(() => {}, 1 << 30);
660
+ } finally {
661
+ shutdown.cleanup();
662
+ if (ownsRuntime && chatRuntime) await closeRuntime(chatRuntime);
565
663
  }
566
664
  return;
567
665
  }
@@ -589,4 +687,4 @@ async function main(config) {
589
687
  process.exit(outcome.status === 'failure' ? 1 : 0);
590
688
  }
591
689
 
592
- module.exports = { main, runAgent, runChatTurn };
690
+ module.exports = { main, runAgent, runChatTurn, serveChat };
package/agent-loop.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  import loop from './agent-loop.js';
2
2
 
3
- export const { main, runAgent, runChatTurn } = loop;
3
+ export const { main, runAgent, runChatTurn, serveChat } = loop;
4
4
  export default loop;