@bahulam/code 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -152,8 +152,13 @@ export function createToolExecutor({
152
152
  return project.resource.root;
153
153
  }
154
154
 
155
- async function commandCwd(args = {}) {
156
- return await resolvePath(args.cwd || null, args);
155
+ async function commandCwd(args = {}, { readOnly = false } = {}) {
156
+ try {
157
+ return await resolvePath(args.cwd || null, args);
158
+ } catch (err) {
159
+ if (readOnly) return args.cwd ? path.resolve(args.cwd) : process.cwd();
160
+ throw err;
161
+ }
157
162
  }
158
163
 
159
164
  function shellTargetPath(cwd, target) {
@@ -1390,7 +1395,7 @@ export function createToolExecutor({
1390
1395
  args._riskReason = classification.reason || shellCheck.reason;
1391
1396
  }
1392
1397
  args._classification = classification.classification; // 'safe' or 'contained'
1393
- const cwd = await commandCwd(args);
1398
+ const cwd = await commandCwd(args, { readOnly: classification.classification === 'safe' });
1394
1399
 
1395
1400
  // Background execution: start via the BackgroundTasks registry
1396
1401
  // and return immediately. Safety checks above still apply;
@@ -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
+ }