@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.
- 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/model-selection.mjs +72 -0
- package/src/core/request-retry.mjs +106 -0
- package/src/core/tool-error.mjs +155 -0
- package/src/core/usage-normalization.mjs +32 -0
- package/src/local-service/agent-relay.mjs +279 -7
- package/src/local-service/server.mjs +37 -0
- package/src/orchestration/node-runner.mjs +7 -3
- 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/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
|
@@ -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
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
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
|
|