@bahulam/code 0.1.24 → 0.1.26

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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared retry policy for one-shot model HTTP requests.
3
+ *
4
+ * Streaming /api/execute has its own event-id resume protocol in
5
+ * stream-client.mjs. This helper is for npm-owned local/direct model calls
6
+ * where the request must finish before the agent loop can continue.
7
+ */
8
+
9
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 529]);
10
+ const NETWORK_CODES = new Set([
11
+ 'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ETIMEDOUT',
12
+ 'EAI_AGAIN', 'ENETUNREACH', 'ENETDOWN', 'EHOSTUNREACH', 'UND_ERR_CONNECT_TIMEOUT',
13
+ ]);
14
+
15
+ function numberEnv(name, fallback) {
16
+ const value = Number.parseInt(process.env[name] || '', 10);
17
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
18
+ }
19
+
20
+ export function isNetworkError(error) {
21
+ const code = error?.cause?.code || error?.code;
22
+ return error?.name === 'AbortError'
23
+ || NETWORK_CODES.has(String(code || '').toUpperCase())
24
+ || /fetch failed|network|socket|timed out|timeout|connection reset|connection refused/i.test(String(error?.message || ''));
25
+ }
26
+
27
+ export function isRetryableStatus(status) {
28
+ return RETRYABLE_STATUS.has(Number(status));
29
+ }
30
+
31
+ function retryDelay(attempt, base, max) {
32
+ const exponential = Math.min(max, base * (2 ** Math.max(0, attempt - 1)));
33
+ return Math.min(max, Math.round(exponential * (0.8 + Math.random() * 0.4)));
34
+ }
35
+
36
+ function wait(ms) {
37
+ return new Promise(resolve => setTimeout(resolve, ms));
38
+ }
39
+
40
+ export class RequestError extends Error {
41
+ constructor(message, { status = null, code = 'request_error', retryable = false, attempts = 1, cause = null } = {}) {
42
+ super(message, { cause: cause || undefined });
43
+ this.name = 'RequestError';
44
+ this.status = status;
45
+ this.code = code;
46
+ this.retryable = retryable;
47
+ this.attempts = attempts;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Fetch with bounded retry for transient transport/server failures.
53
+ * 401/403 and all other non-retryable 4xx responses return immediately.
54
+ */
55
+ export async function fetchWithRetry(url, options = {}, {
56
+ fetchImpl = globalThis.fetch,
57
+ maxRetries = numberEnv('BAHULAM_REQUEST_MAX_RETRIES', 2),
58
+ baseDelayMs = numberEnv('BAHULAM_REQUEST_RETRY_BASE_MS', 500),
59
+ maxDelayMs = numberEnv('BAHULAM_REQUEST_RETRY_MAX_MS', 8000),
60
+ onRetry = null,
61
+ } = {}) {
62
+ const retries = Math.max(0, Number(maxRetries) || 0);
63
+ let attempt = 0;
64
+
65
+ while (true) {
66
+ attempt++;
67
+ let response;
68
+ try {
69
+ response = await fetchImpl(url, options);
70
+ } catch (error) {
71
+ if (!isNetworkError(error) || attempt > retries + 1) {
72
+ throw new RequestError(
73
+ `Network request failed after ${attempt} attempt${attempt === 1 ? '' : 's'}: ${error?.message || error}`,
74
+ { code: 'network_error', retryable: true, attempts: attempt, cause: error },
75
+ );
76
+ }
77
+ const delayMs = retryDelay(attempt, baseDelayMs, maxDelayMs);
78
+ onRetry?.({ attempt, delayMs, reason: 'network', error });
79
+ await wait(delayMs);
80
+ continue;
81
+ }
82
+
83
+ if (!isRetryableStatus(response.status) || attempt > retries + 1) {
84
+ return response;
85
+ }
86
+
87
+ // Release the failed response before retrying. The final response remains
88
+ // available to the caller for its normal provider-specific error body.
89
+ try { response.body?.cancel?.(); } catch {}
90
+ const delayMs = retryDelay(attempt, baseDelayMs, maxDelayMs);
91
+ onRetry?.({ attempt, delayMs, reason: `http_${response.status}`, status: response.status });
92
+ await wait(delayMs);
93
+ }
94
+ }
95
+
96
+ export function requestErrorData(error, { phase = 'model', provider = null } = {}) {
97
+ return {
98
+ message: error?.message || String(error),
99
+ code: error?.code || (error?.status ? `http_${error.status}` : 'request_error'),
100
+ phase,
101
+ provider,
102
+ status: error?.status ?? null,
103
+ retryable: error?.retryable === true || isRetryableStatus(error?.status),
104
+ attempts: error?.attempts || 1,
105
+ };
106
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Structured tool errors.
3
+ *
4
+ * Plugin tool handlers used to return { success: false, output: "<msg>" }
5
+ * and thrown errors got wrapped the same way — the message became a
6
+ * blob the agent had to re-read to figure out what went wrong.
7
+ *
8
+ * This module standardizes the envelope so the trace UI can filter and
9
+ * the agent can self-correct on the next turn without re-parsing prose.
10
+ *
11
+ * Handler patterns
12
+ * ----------------
13
+ *
14
+ * Return a structured error explicitly:
15
+ *
16
+ * import { toolError } from '../../src/core/tool-error.mjs';
17
+ * if (!scene) return toolError('MISSING_SCENE', `Scene "${slug}" not found.`,
18
+ * 'Call create_scene(name="<slug>") first.');
19
+ *
20
+ * OR throw a ToolError:
21
+ *
22
+ * throw new ToolError('INVALID_ARGS', 'position must be [x,y,z]', 'Pass a length-3 array.');
23
+ *
24
+ * OR throw a plain Error — normalizeToolResult wraps it as { code: 'UNKNOWN' }
25
+ * so nothing crashes the executor.
26
+ *
27
+ * Error codes (extend freely; keep short, SCREAMING_SNAKE_CASE)
28
+ * ---
29
+ * INVALID_ARGS — schema violation or missing required field
30
+ * MISSING_RESOURCE — referenced id (node, ref, section, ...) not found
31
+ * IO — filesystem or network operation failed
32
+ * PROVIDER_UNAVAILABLE — external provider missing key or offline
33
+ * PROVIDER_ERROR — external provider returned an error response
34
+ * STATE_ERROR — plugin state DB read/write failure
35
+ * RATE_LIMITED — provider or platform rate limit hit
36
+ * TIMEOUT — operation exceeded its deadline
37
+ * UNKNOWN — thrown Error that wasn't a ToolError (fallback)
38
+ */
39
+
40
+ export class ToolError extends Error {
41
+ constructor(code, message, hint) {
42
+ super(message);
43
+ this.name = 'ToolError';
44
+ this.code = String(code || 'UNKNOWN');
45
+ this.hint = hint ? String(hint) : null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Build a structured error envelope suitable for `return`-ing from a
51
+ * plugin tool handler. Never throws.
52
+ */
53
+ export function toolError(code, message, hint) {
54
+ return {
55
+ success: false,
56
+ output: String(message || ''),
57
+ error: {
58
+ code: String(code || 'UNKNOWN'),
59
+ message: String(message || ''),
60
+ ...(hint ? { hint: String(hint) } : {}),
61
+ },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Normalize an arbitrary handler return value or thrown error into the
67
+ * canonical shape:
68
+ *
69
+ * Success: { success: true, output, _tool, _plugin }
70
+ * Failure: { success: false, output, error: { code, message, hint?, stack? },
71
+ * _tool, _plugin }
72
+ *
73
+ * `stack` is only attached in DEBUG mode so the trace export can show
74
+ * it without leaking noise into normal responses.
75
+ *
76
+ * `traceId` is optional; when provided by the caller it's forwarded so
77
+ * export / logs can join back to the trace row.
78
+ */
79
+ export function normalizeToolResult({ tool, plugin, traceId }, result, thrown) {
80
+ const meta = {
81
+ _tool: tool,
82
+ ...(plugin ? { _plugin: plugin } : {}),
83
+ ...(traceId ? { _trace_id: traceId } : {}),
84
+ };
85
+
86
+ if (thrown) {
87
+ const err = thrown instanceof ToolError
88
+ ? { code: thrown.code, message: thrown.message, ...(thrown.hint ? { hint: thrown.hint } : {}) }
89
+ : { code: 'UNKNOWN', message: String(thrown?.message || thrown) };
90
+ if (process.env.DEBUG && thrown?.stack) err.stack = String(thrown.stack).split('\n').slice(0, 6).join('\n');
91
+ const outputText = [`[${err.code}] ${err.message}`, err.hint ? `(hint: ${err.hint})` : ''].filter(Boolean).join(' ');
92
+ return { success: false, output: outputText, error: err, ...meta };
93
+ }
94
+
95
+ if (!result || typeof result !== 'object') {
96
+ return { success: true, output: result, ...meta };
97
+ }
98
+
99
+ if (result.success !== false) {
100
+ return { success: true, output: result.output ?? result, ...meta };
101
+ }
102
+
103
+ // success === false — extract structured error if present, else synthesize one
104
+ const err = result.error && typeof result.error === 'object'
105
+ ? {
106
+ code: String(result.error.code || 'UNKNOWN'),
107
+ message: String(result.error.message || result.output || 'Tool call failed.'),
108
+ ...(result.error.hint ? { hint: String(result.error.hint) } : {}),
109
+ }
110
+ : {
111
+ code: 'UNKNOWN',
112
+ message: typeof result.output === 'string' ? result.output : JSON.stringify(result.output ?? 'Tool call failed.'),
113
+ };
114
+
115
+ // The agent reads `output` on the next turn — make it self-contained so
116
+ // the LLM doesn't need to re-parse. Format: [CODE] message (hint: ...)
117
+ const rawOutput = typeof result.output === 'string' && result.output.trim()
118
+ ? result.output
119
+ : err.message;
120
+ const alreadyHasCode = rawOutput.startsWith(`[${err.code}]`);
121
+ const alreadyHasHint = err.hint && rawOutput.toLowerCase().includes(String(err.hint).toLowerCase());
122
+ const outputText = [
123
+ alreadyHasCode ? rawOutput : `[${err.code}] ${rawOutput}`,
124
+ err.hint && !alreadyHasHint ? `(hint: ${err.hint})` : '',
125
+ ].filter(Boolean).join(' ');
126
+ return { success: false, output: outputText, error: err, ...meta };
127
+ }
128
+
129
+ /**
130
+ * Format an error envelope as a short one-line summary for logs and
131
+ * compact trace rows.
132
+ */
133
+ export function formatErrorSummary(err) {
134
+ if (!err) return '';
135
+ const code = err.code || 'UNKNOWN';
136
+ const msg = String(err.message || '').replace(/\s+/g, ' ').slice(0, 240);
137
+ return `[${code}] ${msg}${err.hint ? ` — hint: ${String(err.hint).slice(0, 140)}` : ''}`;
138
+ }
139
+
140
+ /**
141
+ * Build a compact hint the next agent turn can consume so it can
142
+ * self-correct without re-reading a raw stack trace.
143
+ *
144
+ * Example:
145
+ * "Previous tool call `create_node` failed. [MISSING_RESOURCE] Node
146
+ * 'chair_99' not found. Hint: call get_scene(slug='cafe') to see
147
+ * current node ids."
148
+ */
149
+ export function buildNextTurnHint({ tool, error }) {
150
+ if (!error) return '';
151
+ const parts = [`Previous tool call \`${tool || 'unknown'}\` failed.`];
152
+ parts.push(`[${error.code || 'UNKNOWN'}] ${error.message || ''}`.trim());
153
+ if (error.hint) parts.push(`Hint: ${error.hint}`);
154
+ return parts.join(' ');
155
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Normalize provider/gateway usage into the npm agent's canonical shape.
3
+ *
4
+ * Providers use prompt_tokens/completion_tokens, Anthropic-style callers use
5
+ * input_tokens/output_tokens, and remote complete events use total_* fields.
6
+ * Keeping this conversion here prevents each runtime mode from drifting.
7
+ */
8
+ export function normalizeUsage(usage) {
9
+ if (!usage) return null;
10
+
11
+ const input = usage.input_tokens
12
+ ?? usage.total_input_tokens
13
+ ?? usage.prompt_tokens
14
+ ?? 0;
15
+ const output = usage.output_tokens
16
+ ?? usage.total_output_tokens
17
+ ?? usage.completion_tokens
18
+ ?? 0;
19
+ const promptDetails = usage.prompt_tokens_details || {};
20
+
21
+ return {
22
+ input_tokens: input,
23
+ output_tokens: output,
24
+ cache_read_input_tokens: usage.cache_read_input_tokens
25
+ ?? usage.cache_read_tokens
26
+ ?? promptDetails.cached_tokens
27
+ ?? 0,
28
+ cache_creation_input_tokens: usage.cache_creation_input_tokens
29
+ ?? usage.cache_creation_tokens
30
+ ?? 0,
31
+ };
32
+ }
@@ -110,6 +110,13 @@ export class LocalAgentRelay {
110
110
  this.flushingFollowups = false;
111
111
  this.cancellationRequested = false;
112
112
  this.cancellationEventEmitted = false;
113
+ // Background-job nudge bookkeeping. See _onBackgroundJobFinished.
114
+ this._bgUnsubscribe = null;
115
+ this._bgNotifiedJobIds = new Set();
116
+ // Trace: per-call_id start-time cache for computing duration on tool_result.
117
+ // Keyed by call_id | request_id. Cleaned up on tool_result.
118
+ this._pendingToolCalls = new Map();
119
+ this._traceSeq = 0;
113
120
  }
114
121
 
115
122
  async listHistorySessions() {
@@ -126,6 +133,24 @@ export class LocalAgentRelay {
126
133
  return this._historySnapshot();
127
134
  }
128
135
 
136
+ /**
137
+ * Return full trace entries (unelided args/output/errors) for
138
+ * /api/trace/export. One entry per tool call. Pass includeTurns=true
139
+ * to also include user/assistant turns so exports can be correlated.
140
+ */
141
+ fullTrace({ includeTurns = false } = {}) {
142
+ const trace = fullTraceEntries(this.displayHistory);
143
+ if (!includeTurns) return trace;
144
+ const turns = this.displayHistory
145
+ .filter(e => e?.role === 'user' || e?.role === 'assistant')
146
+ .map(e => ({
147
+ role: e.role,
148
+ timestamp: e.timestamp || null,
149
+ content: typeof e.content === 'string' ? e.content : JSON.stringify(e.content ?? ''),
150
+ }));
151
+ return { turns, trace };
152
+ }
153
+
129
154
  async startNewHistory() {
130
155
  if (this.running) {
131
156
  const err = new Error('A local agent turn is already running for this workspace');
@@ -137,6 +162,8 @@ export class LocalAgentRelay {
137
162
  this.turnCount = 0;
138
163
  this.displayHistory = [];
139
164
  this.agentHistory = [];
165
+ this._pendingToolCalls.clear();
166
+ this._traceSeq = 0;
140
167
  this.jsonlWriter = null;
141
168
  if (this.client) {
142
169
  this.client.sessionId = null;
@@ -288,6 +315,7 @@ export class LocalAgentRelay {
288
315
  const data = event.data || {};
289
316
  turnHistory.addToolUse(data);
290
317
  writer.accumulateToolCall(data.call_id || data.request_id, data.tool || data.name, data.args || data.input);
318
+ this._traceRecordCall(data);
291
319
  }
292
320
 
293
321
  if (event.type === 'tool_done' || event.type === 'tool_result') {
@@ -299,6 +327,7 @@ export class LocalAgentRelay {
299
327
  data.success === false || data.is_error,
300
328
  data,
301
329
  );
330
+ this._traceRecordResult(data);
302
331
  }
303
332
 
304
333
  if (event.type === 'complete') {
@@ -471,10 +500,41 @@ export class LocalAgentRelay {
471
500
  err.code = 'BAD_REQUEST';
472
501
  throw err;
473
502
  }
503
+ // Promote-on-idle: if no turn is currently running, treat the
504
+ // follow-up as a fresh instruction and start a new turn. Fixes the
505
+ // "cancelled + typed continue → task ended, task wont resume" gap
506
+ // where the last turn was cancelled (or the last tool call was a
507
+ // fire-and-forget background job) and the client still routes input
508
+ // through the follow-up channel.
474
509
  if (!this.running || !this.client) {
475
- const err = new Error('No running agent turn to follow up');
476
- err.code = 'CONFLICT';
477
- throw err;
510
+ const promotedId = `promoted-followup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
511
+ // Best-effort acknowledgement so panels can show "picked up where
512
+ // you left off" instead of a silent restart.
513
+ try {
514
+ this.emit('agent_followup_promoted', {
515
+ intervention_id: promotedId,
516
+ instruction: text.slice(0, 500),
517
+ reason: this.running ? 'client-not-initialized' : 'no-running-turn',
518
+ });
519
+ } catch { /* SSE failure must never block promotion */ }
520
+ // Fire and await runTurn; caller's HTTP handler expects a JSON
521
+ // reply, and we want to signal the promoted status regardless of
522
+ // how long the new turn takes to spin up.
523
+ const started = this.runTurn({ prompt: text }).catch((err) => {
524
+ try {
525
+ this.emit('agent_error', { turn_id: null, message: `promoted follow-up failed: ${err.message || String(err)}` });
526
+ } catch { /* ok */ }
527
+ });
528
+ // Do not block: return acknowledgement synchronously so the
529
+ // client can render immediately; the new turn's events stream
530
+ // over SSE as they normally do.
531
+ void started;
532
+ return {
533
+ ok: true,
534
+ status: 'promoted_to_new_turn',
535
+ intervention_id: promotedId,
536
+ task_id: null,
537
+ };
478
538
  }
479
539
 
480
540
  const item = {
@@ -532,6 +592,83 @@ export class LocalAgentRelay {
532
592
  }
533
593
  }
534
594
 
595
+ /**
596
+ * Background-task nudge — called by backgroundTasks.onExit when any
597
+ * shell-spawned job finishes. Filters:
598
+ *
599
+ * - Skips jobs that declared an explicit `on_complete` target
600
+ * (the caller opted into a specific trigger; nudging would be a
601
+ * double dispatch).
602
+ * - Skips user-killed jobs (killing signals "I'm done with it").
603
+ * - Skips jobs whose cwd is outside this workspace (defensive —
604
+ * lets the singleton be shared across processes without cross-talk).
605
+ * - Idempotent: each job id nudges at most once.
606
+ *
607
+ * Routing:
608
+ * - If a turn is running: append to pendingFollowups so the current
609
+ * agent turn picks it up naturally at the next event tick.
610
+ * - If idle: promote to a fresh runTurn — same path as the
611
+ * followup-promote branch, so client behavior is uniform.
612
+ */
613
+ _onBackgroundJobFinished(jobDesc) {
614
+ if (!jobDesc || !jobDesc.id) return;
615
+ if (this._bgNotifiedJobIds.has(jobDesc.id)) return;
616
+ if (jobDesc.on_complete) return;
617
+ if (jobDesc.status === 'killed') return;
618
+ const ownRoot = this.session?.root_path || '';
619
+ const jobCwd = jobDesc.cwd || '';
620
+ if (ownRoot && jobCwd && !jobCwd.startsWith(ownRoot)) return;
621
+ this._bgNotifiedJobIds.add(jobDesc.id);
622
+
623
+ const instruction = this._buildBackgroundJobNudge(jobDesc);
624
+
625
+ try {
626
+ this.emit('agent_background_job_finished', {
627
+ job_id: jobDesc.id,
628
+ status: jobDesc.status,
629
+ exit_code: jobDesc.exit_code,
630
+ duration_s: jobDesc.duration_s,
631
+ command: (jobDesc.command || '').slice(0, 240),
632
+ will_nudge: true,
633
+ });
634
+ } catch { /* SSE failure must never block the nudge */ }
635
+
636
+ const idempotencyKey = `bg-nudge-${jobDesc.id}`;
637
+ if (this.running && this.client) {
638
+ this.pendingFollowups.push({
639
+ instruction,
640
+ role: 'user',
641
+ messageType: 'background_job_nudge',
642
+ priority: 'normal',
643
+ idempotencyKey,
644
+ });
645
+ // If the runtime is already draining events, flush now; else the
646
+ // next tool_result cycle will do it.
647
+ this._flushQueuedFollowups().catch(() => { /* best-effort */ });
648
+ return;
649
+ }
650
+ // Idle — promote a fresh turn.
651
+ this.runTurn({ prompt: instruction }).catch((err) => {
652
+ try { this.emit('agent_error', { turn_id: null, message: `bg nudge turn failed: ${err.message || String(err)}` }); }
653
+ catch { /* ok */ }
654
+ });
655
+ }
656
+
657
+ _buildBackgroundJobNudge(job) {
658
+ const tailLines = String(job.tail || '').split('\n').slice(-20).join('\n');
659
+ const parts = [
660
+ `Background job \`${job.id}\` finished (status=${job.status}, exit=${job.exit_code}, duration ${job.duration_s}s).`,
661
+ `Command: ${job.command}`,
662
+ ];
663
+ if (tailLines) {
664
+ parts.push('Recent output:\n```\n' + tailLines + '\n```');
665
+ } else {
666
+ parts.push('(no output captured)');
667
+ }
668
+ parts.push('Continue from here.');
669
+ return parts.join('\n\n');
670
+ }
671
+
535
672
  async _sendFollowupNow(item) {
536
673
  const result = await this.client.sendIntervention(item.instruction, {
537
674
  idempotencyKey: item.idempotencyKey,
@@ -600,6 +737,21 @@ export class LocalAgentRelay {
600
737
  process.chdir(this.session.root_path);
601
738
  }
602
739
 
740
+ // Background-task nudge: subscribe once so a `shell {run_in_background:true}`
741
+ // that finishes AFTER the user cancelled the turn (or after the turn
742
+ // that started it completed) doesn't die in silence. See
743
+ // _onBackgroundJobFinished for the routing rules.
744
+ if (!this._bgUnsubscribe) {
745
+ const { backgroundTasks } = await import('../core/background-tasks.mjs');
746
+ this._bgUnsubscribe = backgroundTasks.onExit((jobDesc) => {
747
+ try { this._onBackgroundJobFinished(jobDesc); }
748
+ catch (err) {
749
+ try { this.emit('agent_error', { turn_id: null, message: `bg nudge handler failed: ${err.message || String(err)}` }); }
750
+ catch { /* ok */ }
751
+ }
752
+ });
753
+ }
754
+
603
755
  const { PluginRegistry } = await import('../plugins/registry.mjs');
604
756
  const activePlugins = activePluginNamesFromSession(this.session);
605
757
  const pluginDirs = pluginScanDirsFromSession(this.session);
@@ -826,6 +978,68 @@ export class LocalAgentRelay {
826
978
  return lines.join('\n');
827
979
  }
828
980
 
981
+ // ── Trace ────────────────────────────────────────────────────────
982
+ // Two entry points fire for every tool: _traceRecordCall on
983
+ // tool_call / tool_request, _traceRecordResult on tool_result /
984
+ // tool_done. Together they populate displayHistory with role:'tool'
985
+ // rows carrying { id, ts, tool, plugin, args, status, output, error?,
986
+ // duration_ms, sub_agent?, call_id, parent_id?, kind }. The compact
987
+ // view is derived from these; the full data is written verbatim to
988
+ // the transcript writer for /api/trace/export.
989
+
990
+ _traceRecordCall(data) {
991
+ const callId = data.call_id || data.request_id || data.tool_id || `call_${++this._traceSeq}`;
992
+ const startTs = Date.now();
993
+ this._pendingToolCalls.set(callId, {
994
+ startTs,
995
+ tool: data.tool || data.name || '',
996
+ plugin: data.plugin || data._plugin || null,
997
+ args: data.args || data.input || {},
998
+ sub_agent: data.sub_agent || null,
999
+ parent_id: data.parent_id || data.parent_call_id || null,
1000
+ });
1001
+ }
1002
+
1003
+ _traceRecordResult(data) {
1004
+ const callId = data.call_id || data._callId || data.request_id || data.id || data.tool_use_id;
1005
+ const pending = callId ? this._pendingToolCalls.get(callId) : null;
1006
+ if (callId) this._pendingToolCalls.delete(callId);
1007
+
1008
+ const startTs = pending?.startTs || Date.now();
1009
+ const endTs = Date.now();
1010
+ const durationMs = data.duration_ms || (endTs - startTs);
1011
+ const isError = data.success === false || data.is_error === true || Boolean(data.error);
1012
+
1013
+ // Prefer the structured error envelope from normalizeToolResult if present.
1014
+ const errEnvelope = data.error && typeof data.error === 'object' && data.error.code
1015
+ ? {
1016
+ code: String(data.error.code || 'UNKNOWN'),
1017
+ message: String(data.error.message || data.output || ''),
1018
+ ...(data.error.hint ? { hint: String(data.error.hint) } : {}),
1019
+ ...(process.env.DEBUG && data.error.stack ? { stack: String(data.error.stack) } : {}),
1020
+ }
1021
+ : (isError
1022
+ ? { code: 'UNKNOWN', message: typeof data.output === 'string' ? data.output : (data.message || 'Tool call failed.') }
1023
+ : null);
1024
+
1025
+ this.displayHistory.push({
1026
+ role: 'tool',
1027
+ kind: isError ? 'error' : 'result',
1028
+ tool: pending?.tool || data.tool || data.name || '',
1029
+ plugin: pending?.plugin || data.plugin || data._plugin || null,
1030
+ call_id: callId || null,
1031
+ parent_id: pending?.parent_id || null,
1032
+ sub_agent: pending?.sub_agent || data.sub_agent || null,
1033
+ args: pending?.args || data.args || {},
1034
+ output: data.output ?? data.result ?? data.message ?? '',
1035
+ error: errEnvelope,
1036
+ status: isError ? 'error' : 'ok',
1037
+ duration_ms: durationMs,
1038
+ timestamp: new Date(endTs).toISOString(),
1039
+ order: this.displayHistory.length,
1040
+ });
1041
+ }
1042
+
829
1043
  _historySnapshot() {
830
1044
  return {
831
1045
  ok: true,
@@ -1040,15 +1254,73 @@ function browserMessages(history = []) {
1040
1254
  }));
1041
1255
  }
1042
1256
 
1043
- function browserTraceItems(history = []) {
1257
+ // Compact trace summary for the panel — full data lives on the
1258
+ // history entry itself (available via /api/trace/export).
1259
+ //
1260
+ // Handles two entry shapes:
1261
+ // 1. Live entries pushed by _traceRecordResult (single row per round-trip,
1262
+ // with kind: 'result'|'error', args, output, error, duration_ms).
1263
+ // 2. Resume entries built by local-store.buildResumeHistory (separate
1264
+ // rows per tool_call and tool_result, with kind: 'call'|'result',
1265
+ // only `content` populated).
1266
+ //
1267
+ // The `type` field preserves the historical `history_tool_<kind>` shape
1268
+ // so consumers can distinguish call vs. result vs. error rows without
1269
+ // knowing which pipeline produced them.
1270
+ export function browserTraceItems(history = []) {
1271
+ return history
1272
+ .filter((entry) => entry?.role === 'tool')
1273
+ .map((entry) => {
1274
+ const kind = entry.kind || (entry.status === 'error' ? 'error' : 'result');
1275
+ const isLive = entry.call_id != null || entry.args !== undefined || entry.error !== undefined;
1276
+ const base = {
1277
+ type: `history_tool_${kind}`,
1278
+ timestamp: entry.timestamp || null,
1279
+ tool: entry.tool || null,
1280
+ kind,
1281
+ };
1282
+ if (!isLive) {
1283
+ // Resume entry — surface content as-is for backward compat.
1284
+ return { ...base, content: typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content || '') };
1285
+ }
1286
+ return {
1287
+ ...base,
1288
+ id: entry.call_id || null,
1289
+ parent_id: entry.parent_id || null,
1290
+ plugin: entry.plugin || null,
1291
+ status: entry.status || (kind === 'error' ? 'error' : 'ok'),
1292
+ duration_ms: entry.duration_ms ?? null,
1293
+ args_summary: elide(typeof entry.args === 'string' ? entry.args : JSON.stringify(entry.args ?? {}), 320),
1294
+ output_summary: elide(typeof entry.output === 'string' ? entry.output : JSON.stringify(entry.output ?? ''), 320),
1295
+ error: entry.error
1296
+ ? { code: entry.error.code, message: elide(entry.error.message, 220), hint: entry.error.hint || null }
1297
+ : null,
1298
+ sub_agent: entry.sub_agent || null,
1299
+ };
1300
+ });
1301
+ }
1302
+
1303
+ function elide(text, max = 320) {
1304
+ const s = typeof text === 'string' ? text : JSON.stringify(text ?? '');
1305
+ return s.length > max ? `${s.slice(0, max)}…` : s;
1306
+ }
1307
+
1308
+ // Full trace entries (unelided) for /api/trace/export.
1309
+ export function fullTraceEntries(history = []) {
1044
1310
  return history
1045
1311
  .filter((entry) => entry?.role === 'tool')
1046
1312
  .map((entry) => ({
1047
- type: `history_tool_${entry.kind || 'event'}`,
1313
+ id: entry.call_id || null,
1314
+ parent_id: entry.parent_id || null,
1048
1315
  timestamp: entry.timestamp || null,
1049
1316
  tool: entry.tool || null,
1050
- kind: entry.kind || null,
1051
- content: typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content || ''),
1317
+ plugin: entry.plugin || null,
1318
+ status: entry.status || (entry.kind === 'error' ? 'error' : 'ok'),
1319
+ duration_ms: entry.duration_ms ?? null,
1320
+ args: entry.args ?? null,
1321
+ output: entry.output ?? null,
1322
+ error: entry.error || null,
1323
+ sub_agent: entry.sub_agent || null,
1052
1324
  }));
1053
1325
  }
1054
1326