@inetafrica/open-claudia 2.14.8 → 3.0.0

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.
Files changed (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +105 -7
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +127 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
@@ -0,0 +1,654 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { AsyncLocalStorage } = require("node:async_hooks");
7
+ const { spawn } = require("child_process");
8
+ const { MAX_PROCESS_TIMEOUT } = require("./config");
9
+ const { killProcessTree } = require("./process-tree");
10
+ const { redactSensitive } = require("./redact");
11
+ const { validateInvocation } = require("./providers/contract");
12
+ const { selectTerminalText } = require("./providers/events");
13
+ const { createUtilityPolicy, normalizePurpose } = require("./providers/utility-policy");
14
+
15
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
16
+ const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
17
+ const DEFAULT_MAX_STDERR_BYTES = 64 * 1024;
18
+ const DEFAULT_MAX_STDOUT_BYTES = 4 * 1024 * 1024;
19
+ const DEFAULT_KILL_GRACE_MS = 500;
20
+ const DEFAULT_GLOBAL_CONCURRENCY = 6;
21
+ const DEFAULT_PURPOSE_CONCURRENCY = 3;
22
+ const DEFAULT_RECURSION_DEPTH = 2;
23
+ const DEFAULT_SYSTEM_PROMPT = [
24
+ "You are a bounded Open Claudia utility agent.",
25
+ "Follow the supplied task and return only the requested result.",
26
+ "Do not message people, change Open Claudia state, or perform product side effects.",
27
+ ].join("\n");
28
+
29
+ class UtilityAgentError extends Error {
30
+ constructor(code, message, result = null, details = {}) {
31
+ super(redactSensitive(message));
32
+ this.name = "UtilityAgentError";
33
+ this.code = code;
34
+ this.result = result;
35
+ this.details = details;
36
+ }
37
+ }
38
+
39
+ function positiveInteger(value, fallback, label) {
40
+ if (value === undefined || value === null || value === "") return fallback;
41
+ const parsed = Number(value);
42
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new TypeError(`${label} must be a positive integer`);
43
+ return parsed;
44
+ }
45
+
46
+ function boundedTimeout(value) {
47
+ const parsed = Number(value || DEFAULT_TIMEOUT_MS);
48
+ if (!Number.isFinite(parsed) || parsed <= 0) throw new TypeError("timeoutMs must be a positive number");
49
+ return Math.min(parsed, MAX_PROCESS_TIMEOUT || DEFAULT_TIMEOUT_MS);
50
+ }
51
+
52
+ function clipUtf8(value, maxBytes) {
53
+ const source = Buffer.from(String(value || ""), "utf8");
54
+ if (source.length <= maxBytes) return { text: source.toString("utf8"), truncated: false };
55
+ let text = source.subarray(0, maxBytes).toString("utf8");
56
+ if (text.endsWith("\ufffd")) text = text.slice(0, -1);
57
+ return { text, truncated: true };
58
+ }
59
+
60
+ function appendBounded(current, addition, maxBytes) {
61
+ const joined = `${current || ""}${addition || ""}`;
62
+ return clipUtf8(joined, maxBytes);
63
+ }
64
+
65
+ function stableError(code, message) {
66
+ return { code, message: redactSensitive(String(message || "Utility agent failed")) };
67
+ }
68
+
69
+ function extractJson(text) {
70
+ const raw = String(text || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "");
71
+ try { return JSON.parse(raw); } catch (_) { /* try a bounded object/array below */ }
72
+ const objectStart = raw.indexOf("{");
73
+ const objectEnd = raw.lastIndexOf("}");
74
+ const arrayStart = raw.indexOf("[");
75
+ const arrayEnd = raw.lastIndexOf("]");
76
+ const candidates = [];
77
+ if (objectStart >= 0 && objectEnd > objectStart) candidates.push(raw.slice(objectStart, objectEnd + 1));
78
+ if (arrayStart >= 0 && arrayEnd > arrayStart) candidates.push(raw.slice(arrayStart, arrayEnd + 1));
79
+ for (const candidate of candidates) {
80
+ try { return JSON.parse(candidate); } catch (_) { /* continue */ }
81
+ }
82
+ throw new Error("utility output was not valid JSON");
83
+ }
84
+
85
+ function schemaTypeMatches(value, type) {
86
+ if (type === "object") return !!value && typeof value === "object" && !Array.isArray(value);
87
+ if (type === "array") return Array.isArray(value);
88
+ if (type === "string") return typeof value === "string";
89
+ if (type === "number") return typeof value === "number" && Number.isFinite(value);
90
+ if (type === "integer") return Number.isInteger(value);
91
+ if (type === "boolean") return typeof value === "boolean";
92
+ if (type === "null") return value === null;
93
+ return true;
94
+ }
95
+
96
+ function validateSchema(value, schema, location = "$", depth = 0) {
97
+ if (!schema || typeof schema !== "object" || depth > 20) return null;
98
+ if (schema.type && !schemaTypeMatches(value, schema.type)) return `${location} must be ${schema.type}`;
99
+ if (Array.isArray(schema.enum) && !schema.enum.some((entry) => Object.is(entry, value))) return `${location} is not an allowed value`;
100
+ if (schema.type === "object" && value && typeof value === "object" && !Array.isArray(value)) {
101
+ for (const key of schema.required || []) {
102
+ if (!Object.prototype.hasOwnProperty.call(value, key)) return `${location}.${key} is required`;
103
+ }
104
+ for (const [key, childSchema] of Object.entries(schema.properties || {})) {
105
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
106
+ const error = validateSchema(value[key], childSchema, `${location}.${key}`, depth + 1);
107
+ if (error) return error;
108
+ }
109
+ if (schema.additionalProperties === false) {
110
+ const known = new Set(Object.keys(schema.properties || {}));
111
+ const extra = Object.keys(value).find((key) => !known.has(key));
112
+ if (extra) return `${location}.${extra} is not allowed`;
113
+ }
114
+ }
115
+ if (schema.type === "array" && Array.isArray(value) && schema.items) {
116
+ for (let index = 0; index < value.length; index += 1) {
117
+ const error = validateSchema(value[index], schema.items, `${location}[${index}]`, depth + 1);
118
+ if (error) return error;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function prepareSchemaFile(schema) {
125
+ if (!schema) return null;
126
+ let serialized;
127
+ try { serialized = JSON.stringify(schema); }
128
+ catch (_) { throw new UtilityAgentError("INVALID_UTILITY_SCHEMA", "outputSchema must be JSON-serializable"); }
129
+ if (!serialized) throw new UtilityAgentError("INVALID_UTILITY_SCHEMA", "outputSchema must be JSON-serializable");
130
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-utility-schema-"));
131
+ const file = path.join(directory, "schema.json");
132
+ fs.writeFileSync(file, `${serialized}\n`, { mode: 0o600 });
133
+ return {
134
+ file,
135
+ cleanup() { fs.rmSync(directory, { recursive: true, force: true }); },
136
+ };
137
+ }
138
+
139
+ function readOnlyMetadata(provider, requested) {
140
+ const capability = provider.capabilities?.readOnlyMode || { support: "unsupported", reason: "read-only capability is undeclared" };
141
+ return {
142
+ requested: !!requested,
143
+ support: requested ? capability.support : "not_requested",
144
+ reason: requested ? capability.reason : null,
145
+ };
146
+ }
147
+
148
+ function noToolsMetadata(options) {
149
+ const requested = Array.isArray(options.allowedTools) && options.allowedTools.length === 0;
150
+ return {
151
+ requested,
152
+ verified: false,
153
+ reason: requested
154
+ ? "No provider capability probe has verified complete hosted-tool suppression"
155
+ : "Complete tool suppression was not requested",
156
+ };
157
+ }
158
+
159
+ function providerEffort(provider, requested) {
160
+ const effort = String(requested || "").trim();
161
+ if (!effort) return null;
162
+ const values = provider.capabilities?.effort?.values;
163
+ if (!Array.isArray(values) || values.includes(effort)) return effort;
164
+ // `max` is the provider-neutral dream setting. Providers name their top
165
+ // reasoning level differently (Claude: max, Codex: xhigh), so map only this
166
+ // generic ceiling and leave every other invalid value to the adapter's
167
+ // normal typed validation.
168
+ if (effort === "max" && values.length) return values[values.length - 1];
169
+ return effort;
170
+ }
171
+
172
+ function executeUtilityInvocation(options) {
173
+ const {
174
+ provider,
175
+ invocation,
176
+ cwd,
177
+ spawnProcess,
178
+ killTree,
179
+ killGraceMs,
180
+ timeoutMs,
181
+ signal,
182
+ maxOutputBytes,
183
+ maxStderrBytes,
184
+ maxStdoutBytes,
185
+ outputSchema,
186
+ } = options;
187
+
188
+ const decoder = provider.createEventDecoder();
189
+ const parserState = invocation.parserState;
190
+ let proc = null;
191
+ let stdoutBytes = 0;
192
+ let stderr = "";
193
+ let stderrTruncated = false;
194
+ let finalText = "";
195
+ let finalTruncated = false;
196
+ let deltaText = "";
197
+ let deltaTruncated = false;
198
+ let resultText = "";
199
+ let resultTruncated = false;
200
+ let usage = null;
201
+ let terminalSeen = false;
202
+ let normalizedFailure = null;
203
+ let spawnFailure = null;
204
+ let closeInfo = null;
205
+ let stdoutEnded = false;
206
+ let timedOut = false;
207
+ let cancelled = false;
208
+ let outputLimit = false;
209
+ let terminationStarted = false;
210
+ let timeoutTimer = null;
211
+ let killTimer = null;
212
+
213
+ function appendText(kind, text) {
214
+ if (kind === "final") {
215
+ const next = appendBounded(finalText, text, maxOutputBytes);
216
+ finalText = next.text;
217
+ finalTruncated ||= next.truncated;
218
+ } else if (kind === "delta") {
219
+ const next = appendBounded(deltaText, text, maxOutputBytes);
220
+ deltaText = next.text;
221
+ deltaTruncated ||= next.truncated;
222
+ } else {
223
+ const next = appendBounded(resultText, text, maxOutputBytes);
224
+ resultText = next.text;
225
+ resultTruncated ||= next.truncated;
226
+ }
227
+ }
228
+
229
+ function handleNormalized(event) {
230
+ if (!event || typeof event !== "object") return;
231
+ if (event.type === "text_final") appendText("final", event.text);
232
+ else if (event.type === "text_delta") appendText("delta", event.text);
233
+ else if (event.type === "result") {
234
+ terminalSeen = true;
235
+ if (event.text) appendText("result", event.text);
236
+ } else if (event.type === "usage") usage = event;
237
+ else if (event.type === "error" && !normalizedFailure) {
238
+ terminalSeen = true;
239
+ normalizedFailure = event;
240
+ }
241
+ }
242
+
243
+ function handleRaw(events) {
244
+ for (const rawEvent of events || []) {
245
+ let normalized;
246
+ try { normalized = provider.normalizeEvent(rawEvent, parserState); }
247
+ catch (error) {
248
+ normalizedFailure ||= { message: `utility event normalization failed: ${error.message}`, authError: false };
249
+ continue;
250
+ }
251
+ for (const event of Array.isArray(normalized) ? normalized : (normalized ? [normalized] : [])) handleNormalized(event);
252
+ }
253
+ }
254
+
255
+ function finishDecoder() {
256
+ if (stdoutEnded) return;
257
+ stdoutEnded = true;
258
+ try { handleRaw(decoder.end()); }
259
+ catch (error) { normalizedFailure ||= { message: `utility stream decoding failed: ${error.message}`, authError: false }; }
260
+ }
261
+
262
+ function terminate(reason) {
263
+ if (!proc || terminationStarted) return;
264
+ terminationStarted = true;
265
+ if (reason === "timeout") timedOut = true;
266
+ else if (reason === "cancel") cancelled = true;
267
+ else if (reason === "output") outputLimit = true;
268
+ try { killTree(proc.pid, "SIGTERM"); } catch (_) { try { proc.kill("SIGTERM"); } catch (_) {} }
269
+ killTimer = setTimeout(() => {
270
+ try { killTree(proc.pid, "SIGKILL"); } catch (_) { try { proc.kill("SIGKILL"); } catch (_) {} }
271
+ }, killGraceMs);
272
+ }
273
+
274
+ return new Promise((resolve) => {
275
+ let closeResolved = false;
276
+ let stdoutResolved = false;
277
+ const maybeFinish = () => {
278
+ if (!closeResolved || !stdoutResolved) return;
279
+ if (timeoutTimer) clearTimeout(timeoutTimer);
280
+ if (killTimer) clearTimeout(killTimer);
281
+ if (signal) signal.removeEventListener("abort", onAbort);
282
+
283
+ const selectedText = selectTerminalText({ resultText, finalText, deltaText });
284
+ const truncated = resultText
285
+ ? resultTruncated
286
+ : (finalText ? finalTruncated : deltaTruncated);
287
+ const safeText = redactSensitive(selectedText.trim());
288
+ const safeStderr = redactSensitive(stderr.trim());
289
+ let failure = null;
290
+ if (spawnFailure) failure = stableError("UTILITY_SPAWN_FAILED", spawnFailure.message);
291
+ else if (cancelled) failure = stableError("UTILITY_CANCELLED", "Utility agent was cancelled");
292
+ else if (timedOut) failure = stableError("UTILITY_TIMEOUT", "Utility agent timed out");
293
+ else if (outputLimit) failure = stableError("UTILITY_OUTPUT_LIMIT", "Utility provider stdout exceeded its hard limit");
294
+ else if (normalizedFailure) failure = stableError(
295
+ normalizedFailure.authError ? "PROVIDER_UNAUTHENTICATED" : "UTILITY_PROVIDER_ERROR",
296
+ normalizedFailure.message || "Utility provider failed",
297
+ );
298
+ else if (closeInfo?.code !== 0) failure = stableError(
299
+ "UTILITY_PROCESS_FAILED",
300
+ safeStderr || `Utility provider exited with code ${closeInfo?.code == null ? "unknown" : closeInfo.code}`,
301
+ );
302
+ else if (!terminalSeen) failure = stableError("UTILITY_MISSING_TERMINAL", "Utility provider closed without a terminal event");
303
+
304
+ let json = null;
305
+ if (!failure && outputSchema) {
306
+ try {
307
+ json = extractJson(safeText);
308
+ const schemaError = validateSchema(json, outputSchema);
309
+ if (schemaError) failure = stableError("UTILITY_SCHEMA_INVALID", schemaError);
310
+ } catch (error) {
311
+ failure = stableError("UTILITY_SCHEMA_INVALID", error.message);
312
+ }
313
+ }
314
+
315
+ resolve({
316
+ ok: !failure,
317
+ status: failure ? "failed" : "succeeded",
318
+ text: safeText,
319
+ json,
320
+ truncated,
321
+ stderr: safeStderr,
322
+ stderrTruncated,
323
+ usage: usage ? { usage: usage.usage, scope: usage.scope } : null,
324
+ exitCode: closeInfo?.code ?? null,
325
+ signal: closeInfo?.signal ?? null,
326
+ timedOut,
327
+ cancelled,
328
+ terminalSeen,
329
+ stdoutEnded,
330
+ error: failure,
331
+ });
332
+ };
333
+
334
+ const onAbort = () => terminate("cancel");
335
+ try {
336
+ proc = spawnProcess(invocation.binary, invocation.args, {
337
+ cwd,
338
+ env: invocation.env,
339
+ stdio: [invocation.stdin === null ? "ignore" : "pipe", "pipe", "pipe"],
340
+ detached: process.platform !== "win32",
341
+ shell: false,
342
+ });
343
+ } catch (error) {
344
+ spawnFailure = error;
345
+ finishDecoder();
346
+ stdoutResolved = true;
347
+ closeInfo = { code: null, signal: null };
348
+ closeResolved = true;
349
+ maybeFinish();
350
+ return;
351
+ }
352
+
353
+ proc.stdout.on("data", (chunk) => {
354
+ stdoutBytes += chunk.length;
355
+ if (stdoutBytes > maxStdoutBytes) {
356
+ terminate("output");
357
+ return;
358
+ }
359
+ try { handleRaw(decoder.push(chunk)); }
360
+ catch (error) { normalizedFailure ||= { message: `utility stream decoding failed: ${error.message}`, authError: false }; }
361
+ });
362
+ proc.stdout.once("end", () => {
363
+ finishDecoder();
364
+ stdoutResolved = true;
365
+ maybeFinish();
366
+ });
367
+ proc.stdout.once("close", () => {
368
+ if (terminationStarted || spawnFailure) {
369
+ finishDecoder();
370
+ stdoutResolved = true;
371
+ maybeFinish();
372
+ }
373
+ });
374
+ proc.stdout.once("error", (error) => {
375
+ normalizedFailure ||= { message: `utility stdout failed: ${error.message}`, authError: false };
376
+ finishDecoder();
377
+ stdoutResolved = true;
378
+ maybeFinish();
379
+ });
380
+ proc.stderr.on("data", (chunk) => {
381
+ const next = appendBounded(stderr, chunk.toString("utf8"), maxStderrBytes);
382
+ stderr = next.text;
383
+ stderrTruncated ||= next.truncated;
384
+ });
385
+ proc.once("error", (error) => {
386
+ spawnFailure = error;
387
+ closeInfo = { code: null, signal: null };
388
+ closeResolved = true;
389
+ try { proc.stdout.destroy(); } catch (_) {}
390
+ finishDecoder();
391
+ stdoutResolved = true;
392
+ maybeFinish();
393
+ });
394
+ proc.once("close", (code, closeSignal) => {
395
+ closeInfo = { code, signal: closeSignal };
396
+ closeResolved = true;
397
+ if (!stdoutResolved && terminationStarted) {
398
+ try { proc.stdout.destroy(); } catch (_) {}
399
+ finishDecoder();
400
+ stdoutResolved = true;
401
+ }
402
+ maybeFinish();
403
+ });
404
+
405
+ if (invocation.stdin !== null && proc.stdin) {
406
+ proc.stdin.once("error", (error) => {
407
+ normalizedFailure ||= { message: `utility stdin failed: ${error.message}`, authError: false };
408
+ });
409
+ proc.stdin.end(invocation.stdin);
410
+ }
411
+ if (signal) {
412
+ signal.addEventListener("abort", onAbort, { once: true });
413
+ if (signal.aborted) onAbort();
414
+ }
415
+ timeoutTimer = setTimeout(() => terminate("timeout"), timeoutMs);
416
+ });
417
+ }
418
+
419
+ function fallbackEligible(errorCode) {
420
+ return new Set([
421
+ "PROVIDER_UNAVAILABLE",
422
+ "PROVIDER_INCOMPATIBLE",
423
+ "PROVIDER_UNAUTHENTICATED",
424
+ "NO_AVAILABLE_PROVIDER",
425
+ "MANDATORY_TOOL_HOOK_UNAVAILABLE",
426
+ "UTILITY_SPAWN_FAILED",
427
+ ]).has(errorCode);
428
+ }
429
+
430
+ function createUtilityAgentService(options = {}) {
431
+ const registry = options.registry || require("./providers");
432
+ const env = options.env || require("./config").config;
433
+ const policy = options.policy || createUtilityPolicy({ registry, env, activeProvider: options.activeProvider });
434
+ const spawnProcess = options.spawnProcess || spawn;
435
+ const killTree = options.killTree || killProcessTree;
436
+ const killGraceMs = positiveInteger(options.killGraceMs, DEFAULT_KILL_GRACE_MS, "killGraceMs");
437
+ const maxOutputBytes = positiveInteger(options.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES, "maxOutputBytes");
438
+ const maxStderrBytes = positiveInteger(options.maxStderrBytes, DEFAULT_MAX_STDERR_BYTES, "maxStderrBytes");
439
+ const maxStdoutBytes = positiveInteger(options.maxStdoutBytes, DEFAULT_MAX_STDOUT_BYTES, "maxStdoutBytes");
440
+ const maxGlobalConcurrency = positiveInteger(
441
+ options.maxGlobalConcurrency ?? env.UTILITY_MAX_CONCURRENCY,
442
+ DEFAULT_GLOBAL_CONCURRENCY,
443
+ "maxGlobalConcurrency",
444
+ );
445
+ const defaultPurposeLimit = positiveInteger(
446
+ options.maxPurposeConcurrency ?? env.UTILITY_MAX_PURPOSE_CONCURRENCY,
447
+ DEFAULT_PURPOSE_CONCURRENCY,
448
+ "maxPurposeConcurrency",
449
+ );
450
+ const maxRecursionDepth = positiveInteger(
451
+ options.maxRecursionDepth ?? env.UTILITY_MAX_RECURSION,
452
+ DEFAULT_RECURSION_DEPTH,
453
+ "maxRecursionDepth",
454
+ );
455
+ const purposeLimits = { ...(options.purposeConcurrency || {}) };
456
+ const context = new AsyncLocalStorage();
457
+ let activeGlobal = 0;
458
+ const activeByPurpose = new Map();
459
+ const activeControllers = new Set();
460
+
461
+ function purposeLimit(purpose) {
462
+ return positiveInteger(purposeLimits[purpose], defaultPurposeLimit, `${purpose} concurrency limit`);
463
+ }
464
+
465
+ function reserve(purpose) {
466
+ const activePurpose = activeByPurpose.get(purpose) || 0;
467
+ if (activeGlobal >= maxGlobalConcurrency || activePurpose >= purposeLimit(purpose)) {
468
+ throw new UtilityAgentError(
469
+ "UTILITY_CONCURRENCY_LIMIT",
470
+ `Utility concurrency limit reached for ${purpose}`,
471
+ null,
472
+ { purpose, activeGlobal, activePurpose },
473
+ );
474
+ }
475
+ activeGlobal += 1;
476
+ activeByPurpose.set(purpose, activePurpose + 1);
477
+ }
478
+
479
+ function release(purpose) {
480
+ activeGlobal = Math.max(0, activeGlobal - 1);
481
+ const activePurpose = activeByPurpose.get(purpose) || 0;
482
+ if (activePurpose <= 1) activeByPurpose.delete(purpose);
483
+ else activeByPurpose.set(purpose, activePurpose - 1);
484
+ }
485
+
486
+ function stats() {
487
+ return {
488
+ activeGlobal,
489
+ activeByPurpose: Object.fromEntries([...activeByPurpose.entries()].sort(([left], [right]) => left.localeCompare(right))),
490
+ };
491
+ }
492
+
493
+ async function spawnUtilityAgent(prompt, request = {}) {
494
+ if (typeof prompt !== "string") throw new TypeError("utility prompt must be a string");
495
+ const purpose = normalizePurpose(request.purpose);
496
+ const inheritedDepth = Number(env.OC_UTILITY_DEPTH || process.env.OC_UTILITY_DEPTH || 0);
497
+ const depth = Math.max(
498
+ context.getStore()?.depth || 0,
499
+ Number.isSafeInteger(inheritedDepth) && inheritedDepth >= 0 ? inheritedDepth : 0,
500
+ );
501
+ if (depth >= maxRecursionDepth) {
502
+ throw new UtilityAgentError("UTILITY_RECURSION_LIMIT", `Utility recursion limit reached for ${purpose}`, null, { purpose, depth });
503
+ }
504
+ if (request.signal?.aborted) {
505
+ throw new UtilityAgentError("UTILITY_CANCELLED", "Utility agent was cancelled before spawn", {
506
+ ok: false, status: "failed", provider: null, purpose, cancelled: true,
507
+ });
508
+ }
509
+ reserve(purpose);
510
+ const externalSignal = request.signal || null;
511
+ const controller = new AbortController();
512
+ const onExternalAbort = () => controller.abort();
513
+ if (externalSignal) externalSignal.addEventListener("abort", onExternalAbort, { once: true });
514
+ activeControllers.add(controller);
515
+ request = { ...request, signal: controller.signal };
516
+ let schemaResource = null;
517
+ try {
518
+ const candidates = policy.candidates({ ...request, purpose });
519
+ if (request.outputSchema) schemaResource = prepareSchemaFile(request.outputSchema);
520
+ let lastResult = null;
521
+ for (let index = 0; index < candidates.length; index += 1) {
522
+ const selection = candidates[index];
523
+ const provider = selection.provider;
524
+ const effort = providerEffort(provider, request.effort);
525
+ const systemPrompt = typeof request.systemPrompt === "string" && request.systemPrompt.trim()
526
+ ? request.systemPrompt
527
+ : DEFAULT_SYSTEM_PROMPT;
528
+ const invocationContext = {
529
+ purpose,
530
+ tier: selection.tier,
531
+ model: selection.model,
532
+ coreInstructions: systemPrompt,
533
+ dynamicContext: "",
534
+ userPrompt: prompt,
535
+ prompt,
536
+ fresh: true,
537
+ readOnly: request.readOnly !== false,
538
+ cwd: request.cwd || process.cwd(),
539
+ ...(request.allowedTools !== undefined ? { allowedTools: request.allowedTools } : {}),
540
+ ...(request.disallowedTools !== undefined ? { disallowedTools: request.disallowedTools } : {}),
541
+ ...(request.outputSchema ? { outputSchema: request.outputSchema, outputSchemaPath: schemaResource.file } : {}),
542
+ ...(effort ? { effort } : {}),
543
+ };
544
+
545
+ let invocation;
546
+ try {
547
+ invocation = validateInvocation(provider.buildUtilityInvocation(invocationContext));
548
+ invocation = validateInvocation({
549
+ ...invocation,
550
+ env: {
551
+ ...invocation.env,
552
+ OC_PROVIDER: selection.providerId,
553
+ OC_UTILITY_DEPTH: String(depth + 1),
554
+ OC_UTILITY_PURPOSE: purpose,
555
+ },
556
+ });
557
+ } catch (error) {
558
+ const result = {
559
+ ok: false,
560
+ status: "failed",
561
+ text: "",
562
+ provider: selection.providerId,
563
+ model: selection.model,
564
+ tier: selection.tier,
565
+ purpose,
566
+ preferredProvider: selection.preferredProviderId,
567
+ fallbackUsed: selection.fallbackUsed,
568
+ effort,
569
+ error: stableError(error.code || "UTILITY_INVOCATION_FAILED", error.message),
570
+ };
571
+ lastResult = result;
572
+ if (index + 1 < candidates.length && fallbackEligible(result.error.code)) continue;
573
+ throw new UtilityAgentError(result.error.code, result.error.message, result);
574
+ }
575
+
576
+ const result = await context.run({ depth: depth + 1, purpose }, () => executeUtilityInvocation({
577
+ provider,
578
+ invocation,
579
+ cwd: request.cwd || process.cwd(),
580
+ spawnProcess,
581
+ killTree,
582
+ killGraceMs,
583
+ timeoutMs: boundedTimeout(request.timeoutMs),
584
+ signal: request.signal || null,
585
+ maxOutputBytes,
586
+ maxStderrBytes,
587
+ maxStdoutBytes,
588
+ outputSchema: request.outputSchema || null,
589
+ }));
590
+ Object.assign(result, {
591
+ provider: selection.providerId,
592
+ model: selection.model,
593
+ tier: selection.tier,
594
+ purpose,
595
+ preferredProvider: selection.preferredProviderId,
596
+ fallbackUsed: selection.fallbackUsed,
597
+ effort,
598
+ noTools: noToolsMetadata(request),
599
+ noProductSideEffects: readOnlyMetadata(provider, request.readOnly !== false),
600
+ });
601
+ lastResult = result;
602
+ if (result.ok) return result;
603
+ if (index + 1 < candidates.length && fallbackEligible(result.error?.code)) continue;
604
+ throw new UtilityAgentError(result.error?.code || "UTILITY_AGENT_FAILED", result.error?.message || "Utility agent failed", result);
605
+ }
606
+ throw new UtilityAgentError(
607
+ lastResult?.error?.code || "NO_AVAILABLE_UTILITY_PROVIDER",
608
+ lastResult?.error?.message || `No provider completed the ${purpose} utility task`,
609
+ lastResult,
610
+ );
611
+ } finally {
612
+ activeControllers.delete(controller);
613
+ if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
614
+ if (schemaResource) schemaResource.cleanup();
615
+ release(purpose);
616
+ }
617
+ }
618
+
619
+ async function cancelAll(options = {}) {
620
+ const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, "cancel timeoutMs");
621
+ for (const controller of activeControllers) controller.abort();
622
+ const deadline = Date.now() + timeoutMs;
623
+ while (activeControllers.size && Date.now() < deadline) {
624
+ await new Promise((resolve) => setTimeout(resolve, Math.min(10, Math.max(1, deadline - Date.now()))));
625
+ }
626
+ return { drained: activeControllers.size === 0, active: activeControllers.size };
627
+ }
628
+
629
+ return {
630
+ cancelAll,
631
+ spawnUtilityAgent,
632
+ stats,
633
+ withUtilityContext(fn) {
634
+ const depth = (context.getStore()?.depth || 0) + 1;
635
+ return context.run({ depth, purpose: context.getStore()?.purpose || "test" }, fn);
636
+ },
637
+ };
638
+ }
639
+
640
+ const defaultService = createUtilityAgentService();
641
+
642
+ module.exports = {
643
+ DEFAULT_MAX_OUTPUT_BYTES,
644
+ DEFAULT_SYSTEM_PROMPT,
645
+ UtilityAgentError,
646
+ createUtilityAgentService,
647
+ executeUtilityInvocation,
648
+ extractJson,
649
+ providerEffort,
650
+ cancelUtilityAgents: defaultService.cancelAll,
651
+ spawnUtilityAgent: defaultService.spawnUtilityAgent,
652
+ utilityAgentStats: defaultService.stats,
653
+ validateSchema,
654
+ };