openmausbot 0.1.78 → 0.1.80

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 (56) hide show
  1. package/dist/assets/index-Cu8BkIfo.js +305 -0
  2. package/dist/assets/{index-BqDJqf2C.js → index-D6RZKWks.js} +1 -1
  3. package/dist/assets/index-tfpeJAIG.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +11 -5
  6. package/dist-server/drivers/agents-proxy.js +21 -6
  7. package/dist-server/enterprise/server/index.js +0 -1
  8. package/dist-server/index.js +15942 -6859
  9. package/dist-server/local-computer.js +0 -1
  10. package/dist-server/mcp-server.js +8 -1
  11. package/dist-server/openmausbot.js +9303 -786
  12. package/dist-server/pair-cli.js +9303 -786
  13. package/dist-server/proxy-paths.js +0 -1
  14. package/dist-server/server/box.js +98 -41
  15. package/dist-server/server/browser-engine.js +1 -1
  16. package/dist-server/server/browser-runtime.js +11 -3
  17. package/dist-server/server/browser-tool-shape.js +72 -0
  18. package/dist-server/server/claude-accounts.js +1 -0
  19. package/dist-server/server/cli-setup.js +1 -1
  20. package/dist-server/server/composio.js +18 -0
  21. package/dist-server/server/config.js +16 -4
  22. package/dist-server/server/container-computer.js +0 -12
  23. package/dist-server/server/drivers/acp/core.js +4 -19
  24. package/dist-server/server/drivers/agents-proxy.js +27 -2
  25. package/dist-server/server/drivers/boxagent.js +48 -4
  26. package/dist-server/server/drivers/chat-mcp-tools.js +368 -0
  27. package/dist-server/server/drivers/chat-tool-approval.js +42 -0
  28. package/dist-server/server/drivers/claude.js +86 -38
  29. package/dist-server/server/drivers/codex.js +225 -75
  30. package/dist-server/server/drivers/grok.js +4 -0
  31. package/dist-server/server/drivers/minimax.js +11 -3
  32. package/dist-server/server/drivers/openai-chat-protocol.js +92 -0
  33. package/dist-server/server/drivers/openai-chat.js +332 -88
  34. package/dist-server/server/drivers/openai-compat.js +4 -0
  35. package/dist-server/server/drivers/pi.js +1 -9
  36. package/dist-server/server/harness/registry.js +2 -2
  37. package/dist-server/server/index.js +267 -50
  38. package/dist-server/server/managed-desktop.js +205 -0
  39. package/dist-server/server/model-context-window.js +20 -0
  40. package/dist-server/server/proxy-paths.js +0 -1
  41. package/dist-server/server/redact.js +1 -0
  42. package/dist-server/server/room-handoffs.js +57 -9
  43. package/dist-server/server/store.js +13 -1
  44. package/dist-server/server/tts/chatterbox.js +83 -0
  45. package/dist-server/server/tts/index.js +45 -12
  46. package/dist-server/server/workspace.js +12 -2
  47. package/dist-server/shared/ask-question.js +28 -0
  48. package/dist-server/shared/computer-contention.js +17 -0
  49. package/dist-server/vps-container-mcp.js +11 -5
  50. package/enterprise/server/index.js +0 -1
  51. package/package.json +1 -1
  52. package/dist/assets/index-DjwAroIZ.js +0 -305
  53. package/dist/assets/index-Pbb6Ao0s.css +0 -1
  54. package/dist-server/computer-proxy.js +0 -1196
  55. package/dist-server/server/computer-proxy.js +0 -1070
  56. package/dist-server/server/remote-computer.js +0 -170
@@ -0,0 +1,92 @@
1
+ export const MAX_CHAT_TOOL_CALLS = 32;
2
+ const MAX_ARGUMENT_CHARS = 256_000;
3
+ export class ChatProtocolError extends Error {
4
+ }
5
+ export function object(value) {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value)
7
+ ? value : undefined;
8
+ }
9
+ /** Opaque provider reasoning blocks must keep their original order/signatures.
10
+ * OpenRouter streams the sequence as arrays of blocks, not tool-call deltas. */
11
+ export class ChatReasoningDetails {
12
+ blocks = [];
13
+ chars = 0;
14
+ add(value) {
15
+ if (value === undefined || value === null)
16
+ return;
17
+ if (!Array.isArray(value) || value.some((block) => !object(block))) {
18
+ throw new ChatProtocolError("provider returned invalid reasoning details");
19
+ }
20
+ this.chars += JSON.stringify(value).length;
21
+ if (this.chars > 256_000)
22
+ throw new ChatProtocolError("provider reasoning details exceeded the size limit");
23
+ this.blocks.push(...value);
24
+ }
25
+ }
26
+ /** One accumulator per completion: indexes identify deltas; IDs correlate results. */
27
+ export class ChatToolCalls {
28
+ calls = new Map();
29
+ argumentChars = 0;
30
+ add(value, streaming) {
31
+ if (value === undefined || value === null)
32
+ return;
33
+ if (!Array.isArray(value))
34
+ throw new ChatProtocolError("provider returned invalid tool_calls");
35
+ for (const [position, raw] of value.entries()) {
36
+ const delta = object(raw);
37
+ const index = streaming ? delta?.index : position;
38
+ if (!delta || !Number.isInteger(index) || Number(index) < 0 || Number(index) >= MAX_CHAT_TOOL_CALLS) {
39
+ throw new ChatProtocolError("provider returned an invalid tool-call index");
40
+ }
41
+ const call = this.calls.get(Number(index)) ?? { id: "", type: "function", function: { name: "", arguments: "" } };
42
+ if (delta.type !== undefined && delta.type !== "function")
43
+ throw new ChatProtocolError("unsupported tool-call type");
44
+ if (delta.id !== undefined) {
45
+ if (typeof delta.id !== "string" || !delta.id || delta.id.length > 256 || (call.id && call.id !== delta.id)) {
46
+ throw new ChatProtocolError("provider changed or omitted a tool-call ID");
47
+ }
48
+ call.id = delta.id;
49
+ }
50
+ if (delta.function !== undefined) {
51
+ const fn = object(delta.function);
52
+ if (!fn)
53
+ throw new ChatProtocolError("provider returned an invalid tool-call function");
54
+ for (const field of ["name", "arguments"]) {
55
+ if (fn[field] === undefined)
56
+ continue;
57
+ if (typeof fn[field] !== "string")
58
+ throw new ChatProtocolError("provider returned invalid tool-call arguments or name");
59
+ call.function[field] += fn[field];
60
+ if (field === "arguments")
61
+ this.argumentChars += fn[field].length;
62
+ }
63
+ }
64
+ if (call.function.name.length > 64 || this.argumentChars > MAX_ARGUMENT_CHARS) {
65
+ throw new ChatProtocolError("provider tool calls exceeded the size limit");
66
+ }
67
+ this.calls.set(Number(index), call);
68
+ }
69
+ }
70
+ finish(reason, malformedFrame) {
71
+ const calls = [...this.calls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call);
72
+ if (malformedFrame)
73
+ throw new ChatProtocolError("provider stream contained a malformed completion frame");
74
+ if (reason === "function_call")
75
+ throw new ChatProtocolError("legacy function_call is unsupported; use structured tool_calls");
76
+ if (reason === "tool_calls" && !calls.length)
77
+ throw new ChatProtocolError("provider ended for tool calls without a complete call");
78
+ if (!calls.length)
79
+ return calls;
80
+ if (reason !== null && reason !== "tool_calls" && reason !== "stop") {
81
+ throw new ChatProtocolError("provider tool calls were truncated or malformed");
82
+ }
83
+ const ids = new Set();
84
+ for (const call of calls) {
85
+ if (!call.id || ids.has(call.id) || !/^[A-Za-z0-9_-]{1,64}$/.test(call.function.name)) {
86
+ throw new ChatProtocolError("provider returned an incomplete or duplicate tool call");
87
+ }
88
+ ids.add(call.id);
89
+ }
90
+ return calls;
91
+ }
92
+ }
@@ -1,6 +1,26 @@
1
1
  import { newEventId, newId } from "../contracts.js";
2
+ import { redactSecretsInText } from "../redact.js";
3
+ import { toolDetailPreview } from "../tool-summary.js";
4
+ import { ChatToolSessionError, mountChatTools } from "./chat-mcp-tools.js";
5
+ import { createChatToolApproval } from "./chat-tool-approval.js";
6
+ import { ChatProtocolError, ChatReasoningDetails, ChatToolCalls, MAX_CHAT_TOOL_CALLS, object } from "./openai-chat-protocol.js";
2
7
  import { appendNative } from "./native.js";
3
8
  import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS } from "./retry.js";
9
+ /** The message of a JSON error body a provider returned with HTTP 200.
10
+ * MiniMax reports auth, balance, and parameter failures as `base_resp`. */
11
+ function providerError(json) {
12
+ if (typeof json.error === "string")
13
+ return json.error;
14
+ const error = object(json.error);
15
+ if (error)
16
+ return typeof error.message === "string" ? error.message : "unknown error";
17
+ const code = json.base_resp?.status_code;
18
+ if (typeof code === "number" && code !== 0) {
19
+ const msg = typeof json.base_resp?.status_msg === "string" ? json.base_resp.status_msg : "";
20
+ return `upstream error ${code}${msg ? `: ${msg}` : ""}`;
21
+ }
22
+ return null;
23
+ }
4
24
  const usageFrom = (usage) => usage
5
25
  ? { input: usage.prompt_tokens ?? 0, output: usage.completion_tokens ?? 0 }
6
26
  : null;
@@ -21,7 +41,7 @@ export function createOpenAIChatRuntime(options) {
21
41
  turnId,
22
42
  createdAt: new Date().toISOString(),
23
43
  });
24
- const complete = async (messages, model, stream, signal, onDelta) => {
44
+ const complete = async (messages, model, stream, signal, onDelta, tools = []) => {
25
45
  // Idle timer that is renewed on every received chunk during streaming
26
46
  const timeoutController = new AbortController();
27
47
  let idleTimer = null;
@@ -40,22 +60,44 @@ export function createOpenAIChatRuntime(options) {
40
60
  const response = await fetch(`${options.apiUrl}/chat/completions`, {
41
61
  method: "POST",
42
62
  headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
43
- body: JSON.stringify(options.requestBody(model, messages, stream)),
63
+ body: JSON.stringify({
64
+ ...options.requestBody(model, messages, stream),
65
+ ...(tools.length ? { tools } : {}),
66
+ }),
44
67
  signal: activeSignal,
45
68
  });
46
69
  if (!response.ok) {
47
70
  const body = await response.text().catch(() => "");
48
71
  throw new Error(`${options.httpErrorLabel} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
49
72
  }
50
- if (!stream) {
73
+ if (!stream || response.headers.get("content-type")?.includes("application/json")) {
51
74
  const json = await response.json();
75
+ const bodyError = providerError(json);
76
+ if (bodyError)
77
+ throw new ChatProtocolError(`provider returned a completion error: ${bodyError.slice(0, 200)}`);
52
78
  const message = json.choices?.[0]?.message;
79
+ if (!message || !object(message) || !["content", "reasoning_content", "reasoning", "reasoning_details", "tool_calls", "function_call"].some((key) => key in message)) {
80
+ throw new ChatProtocolError("provider returned no completion message");
81
+ }
82
+ if (message?.function_call)
83
+ throw new ChatProtocolError("legacy function_call is unsupported; use structured tool_calls");
84
+ const calls = new ChatToolCalls();
85
+ calls.add(message?.tool_calls, false);
86
+ const details = new ChatReasoningDetails();
87
+ details.add(message.reasoning_details);
88
+ const reasoning = message.reasoning_content ?? message.reasoning;
89
+ const finishReason = json.choices?.[0]?.finish_reason ?? null;
90
+ activeSignal.throwIfAborted();
53
91
  return {
54
92
  text: typeof message?.content === "string" ? message.content : "",
55
- reasoning: options.reasoning && typeof message?.reasoning_content === "string"
56
- ? message.reasoning_content
93
+ reasoning: options.reasoning && typeof reasoning === "string"
94
+ ? reasoning
57
95
  : "",
58
96
  usage: usageFrom(json.usage),
97
+ toolCalls: calls.finish(finishReason, false),
98
+ finishReason,
99
+ protocolReasoning: typeof reasoning === "string" ? reasoning : "",
100
+ protocolReasoningDetails: details.blocks,
59
101
  };
60
102
  }
61
103
  if (!response.body) {
@@ -63,7 +105,13 @@ export function createOpenAIChatRuntime(options) {
63
105
  }
64
106
  let text = "";
65
107
  let reasoning = "";
108
+ let protocolReasoning = "";
66
109
  let usage = null;
110
+ let finishReason = null;
111
+ let malformedFrame = false;
112
+ let sawChoice = false;
113
+ const calls = new ChatToolCalls();
114
+ const details = new ChatReasoningDetails();
67
115
  const reader = response.body.getReader();
68
116
  const decoder = new TextDecoder();
69
117
  let buffer = "";
@@ -71,12 +119,29 @@ export function createOpenAIChatRuntime(options) {
71
119
  readLoop: for (;;) {
72
120
  const { done, value } = await reader.read();
73
121
  if (done) {
74
- if (buffer.trim() === "data: [DONE]")
122
+ buffer += decoder.decode();
123
+ // MiniMax's api.minimax.io/v1 closes the connection after the
124
+ // finish_reason chunk and never sends `[DONE]`.
125
+ if (buffer.trim() === "data: [DONE]" || finishReason)
75
126
  break;
76
- throw new DOMException("Stream ended before completion", "AbortError");
127
+ if (!sawChoice) {
128
+ let body;
129
+ try {
130
+ body = JSON.parse(buffer);
131
+ }
132
+ catch {
133
+ body = undefined;
134
+ }
135
+ const bodyError = body ? providerError(body) : null;
136
+ if (bodyError)
137
+ throw new ChatProtocolError(`provider returned a completion error: ${bodyError.slice(0, 200)}`);
138
+ }
139
+ throw new ChatProtocolError("Stream ended before completion");
77
140
  }
78
141
  resetIdleTimer();
79
142
  buffer += decoder.decode(value, { stream: true });
143
+ if (buffer.length > 2_000_000)
144
+ throw new ChatProtocolError("provider stream frame exceeded the size limit");
80
145
  let newline;
81
146
  while ((newline = buffer.indexOf("\n")) !== -1) {
82
147
  const line = buffer.slice(0, newline).trim();
@@ -91,11 +156,27 @@ export function createOpenAIChatRuntime(options) {
91
156
  chunk = JSON.parse(data);
92
157
  }
93
158
  catch {
159
+ malformedFrame = true;
94
160
  continue;
95
161
  }
96
- const delta = chunk.choices?.[0]?.delta;
97
- const reasoningDelta = options.reasoning && typeof delta?.reasoning_content === "string"
98
- ? delta.reasoning_content
162
+ const chunkError = providerError(chunk);
163
+ if (chunkError)
164
+ throw new ChatProtocolError(`provider returned a streaming completion error: ${chunkError.slice(0, 200)}`);
165
+ const choice = chunk.choices?.find((row) => row.index === undefined || row.index === 0);
166
+ const delta = choice?.delta;
167
+ if (object(delta))
168
+ sawChoice = true;
169
+ if (delta?.function_call)
170
+ throw new ChatProtocolError("legacy function_call is unsupported; use structured tool_calls");
171
+ calls.add(delta?.tool_calls, true);
172
+ details.add(delta?.reasoning_details);
173
+ if (choice?.finish_reason)
174
+ finishReason = choice.finish_reason;
175
+ const reasoningPart = delta?.reasoning_content ?? delta?.reasoning;
176
+ if (typeof reasoningPart === "string")
177
+ protocolReasoning += reasoningPart;
178
+ const reasoningDelta = options.reasoning && typeof reasoningPart === "string"
179
+ ? reasoningPart
99
180
  : "";
100
181
  const contentDelta = typeof delta?.content === "string" ? delta.content : "";
101
182
  if (reasoningDelta) {
@@ -114,7 +195,10 @@ export function createOpenAIChatRuntime(options) {
114
195
  finally {
115
196
  await reader.cancel().catch(() => { });
116
197
  }
117
- return { text, reasoning, usage };
198
+ activeSignal.throwIfAborted();
199
+ if (!sawChoice)
200
+ throw new ChatProtocolError("provider returned no streaming completion choice");
201
+ return { text, reasoning, usage, toolCalls: calls.finish(finishReason, malformedFrame), finishReason, protocolReasoning, protocolReasoningDetails: details.blocks };
118
202
  }
119
203
  finally {
120
204
  if (idleTimer)
@@ -138,85 +222,233 @@ export function createOpenAIChatRuntime(options) {
138
222
  const abort = new AbortController();
139
223
  const messages = messagesFor(turn);
140
224
  const model = turn.model || options.models().default;
141
- active.set(turn.threadId, abort);
142
- appendNative(turn.threadId, {
143
- dir: "out",
144
- source: options.nativeLog.source,
145
- msg: options.nativeLog.outgoing(turn, messages, model),
225
+ const secrets = [options.apiKey];
226
+ for (const integration of Object.values(turn.integrations ?? {})) {
227
+ const entries = object(integration);
228
+ const specs = entries && "command" in entries ? [entries] : Object.values(entries ?? {}).map(object);
229
+ for (const spec of specs) {
230
+ for (const [key, value] of Object.entries(object(spec?.env) ?? {})) {
231
+ if (/key|token|password|secret|authorization/i.test(key) && typeof value === "string" && value)
232
+ secrets.push(value);
233
+ }
234
+ }
235
+ }
236
+ const safeText = (text) => {
237
+ let safe = text;
238
+ for (const secret of secrets)
239
+ if (secret)
240
+ safe = safe.split(secret).join("[redacted]");
241
+ return redactSecretsInText(safe);
242
+ };
243
+ const preview = (value) => toolDetailPreview(JSON.parse(JSON.stringify(value, (_key, part) => typeof part === "string" ? safeText(part) : part)));
244
+ const native = (dir, msg) => appendNative(turn.threadId, {
245
+ dir, source: options.nativeLog.source,
246
+ msg: JSON.parse(JSON.stringify(msg, (_key, part) => typeof part === "string" ? safeText(part) : part)),
247
+ });
248
+ const approval = createChatToolApproval({
249
+ signal: abort.signal,
250
+ open: (ask) => emit({
251
+ ...base(turn.threadId, turnId), type: "request.opened", requestType: "permission",
252
+ requestId: ask.id, tool: ask.tool, summary: ask.summary, allowSession: false,
253
+ }),
254
+ resolved: (ask, allowed, source) => emit({
255
+ ...base(turn.threadId, turnId), type: "request.resolved", requestId: ask.id,
256
+ behavior: allowed ? "allow" : "deny", source,
257
+ }),
146
258
  });
259
+ let resolveDone;
260
+ const done = new Promise((resolve) => { resolveDone = resolve; });
261
+ active.set(turn.threadId, { abort, turnId, done, approval });
147
262
  emit({ ...base(turn.threadId, turnId), type: "turn.started" });
148
263
  emit({ ...base(turn.threadId, turnId), type: "session.started", sessionId: null, model });
149
264
  void (async () => {
150
- let attempt = 0;
151
- let streamedText = false;
152
- for (;;) {
153
- try {
154
- const completion = await complete(messages, model, true, abort.signal, (delta, streamKind) => {
155
- if (streamKind === "assistant_text")
156
- streamedText = true;
157
- emit({ ...base(turn.threadId, turnId), type: "content.delta", streamKind, delta });
158
- });
159
- appendNative(turn.threadId, {
160
- dir: "in",
161
- source: options.nativeLog.source,
162
- msg: options.nativeLog.incoming(completion),
163
- });
164
- const reply = completion.text.trim() ? completion.text : completion.reasoning;
165
- if (reply.trim()) {
166
- emit({ ...base(turn.threadId, turnId), type: "item.completed", itemType: "assistant_text", text: reply });
265
+ let tools;
266
+ const usage = { input: 0, output: 0 };
267
+ let hasUsage = false;
268
+ let ok = false;
269
+ let stopReason = null;
270
+ let failure;
271
+ let toolFailed = false;
272
+ const denials = [];
273
+ const seenCalls = new Set();
274
+ try {
275
+ tools = await mountChatTools(options.tools === false ? undefined : turn.integrations, abort.signal);
276
+ for (let round = 0; round < 16; round++) {
277
+ abort.signal.throwIfAborted();
278
+ native("out", options.nativeLog.outgoing(turn, messages, model));
279
+ let attempt = 0;
280
+ let completion;
281
+ for (;;) {
282
+ let streamed = false;
283
+ const pending = { assistant_text: "", reasoning_text: "" };
284
+ const delta = (text, streamKind, flush = false) => {
285
+ let combined = pending[streamKind] + text;
286
+ // Mask complete matches before holding a suffix: otherwise a key
287
+ // such as "abab" could be split at its own repeated prefix.
288
+ for (const secret of secrets)
289
+ if (secret)
290
+ combined = combined.split(secret).join("[redacted]");
291
+ let hold = 0;
292
+ // A configured credential can straddle chunks. Hold any suffix
293
+ // that could be its prefix until the next chunk disambiguates it.
294
+ if (!flush)
295
+ for (const secret of secrets) {
296
+ for (let length = Math.min(secret.length - 1, combined.length); length > hold; length--) {
297
+ if (combined.endsWith(secret.slice(0, length))) {
298
+ hold = length;
299
+ break;
300
+ }
301
+ }
302
+ }
303
+ pending[streamKind] = hold ? combined.slice(-hold) : "";
304
+ const visible = safeText(hold ? combined.slice(0, -hold) : combined);
305
+ if (visible)
306
+ emit({ ...base(turn.threadId, turnId), type: "content.delta", streamKind, delta: visible });
307
+ };
308
+ try {
309
+ completion = await complete(messages, model, true, abort.signal, (text, streamKind) => {
310
+ streamed = true;
311
+ delta(text, streamKind);
312
+ }, tools.definitions);
313
+ delta("", "assistant_text", true);
314
+ delta("", "reasoning_text", true);
315
+ break;
316
+ }
317
+ catch (value) {
318
+ const error = asError(value);
319
+ const verdict = classifyError(error);
320
+ // Once a call has been handled, never replay it through a turn retry.
321
+ if (options.retryScale === undefined || abort.signal.aborted || streamed || seenCalls.size ||
322
+ error instanceof ChatProtocolError || !verdict.transient || attempt >= RETRY_MAX_ATTEMPTS - 1)
323
+ throw error;
324
+ const delayMs = computeBackoff(attempt++);
325
+ emit({ ...base(turn.threadId, turnId), type: "turn.retrying", attempt, delayMs, reason: verdict.reason });
326
+ await interruptibleDelay(delayMs * options.retryScale, abort.signal).promise;
327
+ abort.signal.throwIfAborted();
328
+ }
167
329
  }
330
+ native("in", options.nativeLog.incoming(completion));
168
331
  if (completion.usage) {
169
- emit({ ...base(turn.threadId, turnId), type: "thread.token-usage.updated", ...completion.usage });
332
+ usage.input += completion.usage.input;
333
+ usage.output += completion.usage.output;
334
+ hasUsage = true;
335
+ emit({ ...base(turn.threadId, turnId), type: "thread.token-usage.updated", ...usage });
170
336
  }
171
- active.delete(turn.threadId);
172
- const completed = {
173
- ...base(turn.threadId, turnId),
174
- type: "turn.completed",
175
- ok: true,
176
- stopReason: null,
177
- cost: null,
178
- };
179
- emit(options.includeUsageInCompleted && completion.usage
180
- ? { ...completed, usage: completion.usage }
181
- : completed);
182
- return;
183
- }
184
- catch (value) {
185
- const error = asError(value);
186
- const aborted = error.name === "AbortError";
187
- const verdict = classifyError(error);
188
- if (options.retryScale !== undefined &&
189
- !aborted &&
190
- !streamedText &&
191
- verdict.transient &&
192
- attempt < RETRY_MAX_ATTEMPTS - 1) {
193
- const delayMs = computeBackoff(attempt++);
194
- emit({
195
- ...base(turn.threadId, turnId),
196
- type: "turn.retrying",
197
- attempt,
198
- delayMs,
199
- reason: verdict.reason,
200
- });
201
- const outcome = await interruptibleDelay(delayMs * options.retryScale, abort.signal).promise;
202
- if (outcome === "elapsed" && !abort.signal.aborted)
203
- continue;
204
- active.delete(turn.threadId);
205
- emit({ ...base(turn.threadId, turnId), type: "turn.completed", ok: false, stopReason: "interrupted", cost: null });
206
- return;
337
+ // Reasoning on a tool-call round belongs to the protocol, not a final reply.
338
+ const reply = completion.text.trim() ? completion.text : completion.toolCalls.length ? "" : completion.reasoning;
339
+ if (reply.trim())
340
+ emit({ ...base(turn.threadId, turnId), type: "item.completed", itemType: "assistant_text", text: safeText(reply) });
341
+ abort.signal.throwIfAborted();
342
+ if (!completion.toolCalls.length) {
343
+ if (!reply.trim())
344
+ throw new ChatProtocolError("provider returned an empty response");
345
+ if (completion.finishReason && completion.finishReason !== "stop") {
346
+ throw new ChatProtocolError(`provider did not finish the response (${completion.finishReason})`);
347
+ }
348
+ if (toolFailed) {
349
+ stopReason = "tool_error";
350
+ throw new ChatProtocolError("One or more tool operations failed or were denied. See the tool results; the final response is not an execution receipt.");
351
+ }
352
+ ok = true;
353
+ break;
354
+ }
355
+ if (!tools.definitions.length)
356
+ throw new ChatProtocolError("provider returned tool calls, but no tools are available for this turn");
357
+ // Validate IDs for the entire batch before executing any of its calls.
358
+ for (const call of completion.toolCalls) {
359
+ if (seenCalls.has(call.id))
360
+ throw new ChatProtocolError("provider reused a tool-call ID; refusing to repeat an operation");
361
+ seenCalls.add(call.id);
207
362
  }
208
- active.delete(turn.threadId);
209
- if (!aborted)
210
- emit({ ...base(turn.threadId, turnId), type: "runtime.error", message: error.message });
211
- emit({
212
- ...base(turn.threadId, turnId),
213
- type: "turn.completed",
214
- ok: false,
215
- stopReason: aborted ? "interrupted" : "error",
216
- cost: null,
363
+ if (seenCalls.size > MAX_CHAT_TOOL_CALLS)
364
+ throw new ChatProtocolError("tool-call limit reached");
365
+ messages.push({ role: "assistant", content: completion.text || null, tool_calls: completion.toolCalls,
366
+ ...(completion.protocolReasoning ? { reasoning_content: completion.protocolReasoning } : {}),
367
+ ...(completion.protocolReasoningDetails.length ? { reasoning_details: completion.protocolReasoningDetails } : {}),
217
368
  });
218
- return;
369
+ for (const call of completion.toolCalls) {
370
+ abort.signal.throwIfAborted();
371
+ let result;
372
+ let started = false;
373
+ let fatal;
374
+ try {
375
+ let args;
376
+ try {
377
+ args = JSON.parse(call.function.arguments);
378
+ }
379
+ catch {
380
+ throw new ChatProtocolError("tool arguments are not complete JSON");
381
+ }
382
+ if (!object(args))
383
+ throw new ChatProtocolError("tool arguments must be a JSON object");
384
+ tools.validate(call.function.name, args);
385
+ const inputPreview = preview(args);
386
+ const allowed = await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.");
387
+ abort.signal.throwIfAborted();
388
+ emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id,
389
+ title: call.function.name, ...(inputPreview ? { input: inputPreview } : {}),
390
+ });
391
+ started = true;
392
+ if (allowed) {
393
+ result = await tools.execute(call.function.name, args, abort.signal);
394
+ }
395
+ else {
396
+ denials.push(call.function.name);
397
+ result = { ok: false, text: "Permission denied or expired; the tool was not executed." };
398
+ }
399
+ }
400
+ catch (error) {
401
+ if (error instanceof ChatToolSessionError)
402
+ fatal = error;
403
+ result = { ok: false, text: abort.signal.aborted
404
+ ? "Tool interrupted; an operation already dispatched may have taken effect. Verify its state before retrying."
405
+ : safeText(asError(error).message).slice(0, 2_000) };
406
+ }
407
+ if (!started)
408
+ emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id, title: call.function.name });
409
+ const text = safeText(result.text);
410
+ const output = preview({ ok: result.ok, result: text });
411
+ emit({ ...base(turn.threadId, turnId), type: "item.completed", itemType: "tool", itemId: call.id, ok: result.ok, output });
412
+ if (!result.ok)
413
+ toolFailed = true;
414
+ messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ ok: result.ok, result: text }) });
415
+ abort.signal.throwIfAborted();
416
+ if (fatal)
417
+ throw fatal;
418
+ }
419
+ }
420
+ if (!ok)
421
+ throw new ChatProtocolError("model-call limit reached before a final response");
422
+ }
423
+ catch (value) {
424
+ stopReason = abort.signal.aborted ? "interrupted" : stopReason ?? "error";
425
+ failure = safeText(asError(value).message).slice(0, 2_000);
426
+ }
427
+ finally {
428
+ approval.close();
429
+ let cleanupFailed = false;
430
+ try {
431
+ await tools?.close();
432
+ }
433
+ catch {
434
+ ok = false;
435
+ stopReason = "error";
436
+ cleanupFailed = true;
437
+ failure = "Tool processes could not be stopped; their execution state is uncertain.";
219
438
  }
439
+ if (abort.signal.aborted) {
440
+ ok = false;
441
+ stopReason = "interrupted";
442
+ }
443
+ if (failure && (!abort.signal.aborted || cleanupFailed)) {
444
+ emit({ ...base(turn.threadId, turnId), type: "runtime.error", message: failure, terminal: !abort.signal.aborted });
445
+ }
446
+ active.delete(turn.threadId);
447
+ emit({ ...base(turn.threadId, turnId), type: "turn.completed", ok, stopReason, cost: null,
448
+ ...(hasUsage && (options.includeUsageInCompleted || seenCalls.size) ? { usage } : {}),
449
+ ...(denials.length ? { denials } : {}),
450
+ });
451
+ resolveDone();
220
452
  }
221
453
  })();
222
454
  return { turnId };
@@ -235,14 +467,22 @@ export function createOpenAIChatRuntime(options) {
235
467
  : { state: "unavailable", reason: options.unavailableReason },
236
468
  adapter: {
237
469
  provider: options.driverKind,
238
- capabilities: { sessionModelSwitch: "in-session" },
470
+ capabilities: { sessionModelSwitch: "in-session", customMcp: options.tools !== false, agentsMcp: options.tools !== false, composioMcp: options.tools !== false },
239
471
  sendTurn,
240
- interruptTurn: async (threadId) => active.get(threadId)?.abort(),
241
- respondToRequest: async () => "unavailable",
472
+ interruptTurn: async (threadId, turnId) => {
473
+ const turn = active.get(threadId);
474
+ if (!turn || (turnId && turn.turnId !== turnId))
475
+ return;
476
+ turn.abort.abort();
477
+ await turn.done;
478
+ },
479
+ respondToRequest: async (threadId, requestId, decision) => active.get(threadId)?.approval.answer(requestId, decision.behavior) ?? "unavailable",
242
480
  hasSession: (threadId) => active.has(threadId),
243
481
  stopAll: async () => {
244
- for (const abort of active.values())
245
- abort.abort();
482
+ const turns = [...active.values()];
483
+ for (const turn of turns)
484
+ turn.abort.abort();
485
+ await Promise.all(turns.map((turn) => turn.done));
246
486
  },
247
487
  onEvent: (listener) => {
248
488
  listeners.add(listener);
@@ -251,12 +491,16 @@ export function createOpenAIChatRuntime(options) {
251
491
  },
252
492
  generateText: async (prompt) => {
253
493
  const model = options.generateModel?.() ?? options.models().default;
254
- const { text, reasoning } = await complete([{ role: "user", content: prompt }], model, false);
494
+ const { text, reasoning, toolCalls } = await complete([{ role: "user", content: prompt }], model, false);
495
+ if (toolCalls.length)
496
+ throw new ChatProtocolError("provider returned tool calls to a text-only helper");
255
497
  return text.trim() ? text : reasoning;
256
498
  },
257
499
  dispose: async () => {
258
- for (const abort of active.values())
259
- abort.abort();
500
+ const turns = [...active.values()];
501
+ for (const turn of turns)
502
+ turn.abort.abort();
503
+ await Promise.all(turns.map((turn) => turn.done));
260
504
  listeners.clear();
261
505
  },
262
506
  };
@@ -26,8 +26,11 @@ function isOpenRouterUrl(url) {
26
26
  }
27
27
  function decodeConfig(raw) {
28
28
  const config = (raw ?? {});
29
+ if (config.tools !== undefined && typeof config.tools !== "boolean")
30
+ throw new Error("tools must be a boolean");
29
31
  const envUrl = process.env.OPENAI_COMPAT_URL;
30
32
  return {
33
+ ...(config.tools !== undefined ? { tools: config.tools } : {}),
31
34
  url: (typeof config.url === "string" && config.url ? config.url : envUrl || "https://openrouter.ai/api/v1")
32
35
  .replace(/\/+$/, ""),
33
36
  apiKeyEnv: typeof config.apiKeyEnv === "string" && config.apiKeyEnv
@@ -122,6 +125,7 @@ export const OpenAICompatDriver = {
122
125
  driverKind: DRIVER_KIND,
123
126
  apiKey,
124
127
  apiUrl: config.url,
128
+ tools: config.tools,
125
129
  models: () => catalog,
126
130
  refreshModels: fetchModels,
127
131
  requestBody: (model, messages, stream) => ({