@vellumai/assistant 0.12.6-staging.3 → 0.12.7-staging.1

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 (97) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +14 -14
  3. package/docs/architecture/integrations.md +5 -5
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  8. package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
  9. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
  10. package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
  11. package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  13. package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  14. package/openapi.yaml +153 -5
  15. package/package.json +1 -1
  16. package/src/__tests__/channel-policy.test.ts +7 -0
  17. package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
  18. package/src/__tests__/identity-routes.test.ts +1 -0
  19. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  20. package/src/__tests__/openai-provider.test.ts +27 -0
  21. package/src/__tests__/skill-execute-input.test.ts +11 -0
  22. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  23. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  24. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  25. package/src/agent/loop.ts +133 -12
  26. package/src/calls/__tests__/call-controller.test.ts +273 -1
  27. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  28. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  29. package/src/calls/call-constants.ts +10 -0
  30. package/src/calls/call-controller.ts +359 -25
  31. package/src/calls/media-stream-server.ts +18 -0
  32. package/src/calls/media-stream-stt-session.ts +30 -9
  33. package/src/channels/config.ts +8 -0
  34. package/src/cli/lib/bundled-marketplace.json +39 -0
  35. package/src/config/composer-preferences.test.ts +97 -0
  36. package/src/config/composer-preferences.ts +95 -0
  37. package/src/config/feature-flag-registry.json +16 -8
  38. package/src/config/schemas/llm.ts +1 -0
  39. package/src/contacts/types.ts +1 -0
  40. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  41. package/src/daemon/conversation-agent-loop.ts +3 -0
  42. package/src/daemon/conversation-messaging.ts +4 -0
  43. package/src/daemon/conversation-process.ts +15 -0
  44. package/src/daemon/conversation-queue-manager.ts +2 -0
  45. package/src/daemon/conversation.ts +2 -0
  46. package/src/daemon/handlers/shared.ts +13 -2
  47. package/src/daemon/message-types/sync.ts +1 -0
  48. package/src/daemon/process-message.ts +5 -0
  49. package/src/i18n/__tests__/i18n.test.ts +25 -0
  50. package/src/i18n/assistant-text.ts +18 -0
  51. package/src/i18n/index.ts +1 -0
  52. package/src/i18n/messages.ts +11 -0
  53. package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
  54. package/src/ipc/routes/route-adapter.ts +9 -0
  55. package/src/notifications/README.md +1 -1
  56. package/src/notifications/signal.ts +4 -0
  57. package/src/persistence/conversation-key-store.ts +12 -11
  58. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  59. package/src/providers/inference/adapter-factory.ts +6 -0
  60. package/src/providers/ionet/client.test.ts +143 -0
  61. package/src/providers/ionet/client.ts +37 -0
  62. package/src/providers/model-catalog.ts +40 -0
  63. package/src/providers/openai/chat-completions-provider.ts +33 -2
  64. package/src/providers/retry.ts +10 -4
  65. package/src/providers/unparseable-tool-args.ts +29 -5
  66. package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
  67. package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
  68. package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
  69. package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
  70. package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
  71. package/src/runtime/auth/route-policy.ts +44 -0
  72. package/src/runtime/auth/scopes.ts +15 -0
  73. package/src/runtime/auth/types.ts +1 -0
  74. package/src/runtime/channel-reply-delivery.ts +2 -0
  75. package/src/runtime/http-router.ts +61 -1
  76. package/src/runtime/http-types.ts +3 -0
  77. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  78. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  79. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  80. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  81. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  82. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  83. package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
  84. package/src/runtime/routes/composer-settings-routes.ts +76 -0
  85. package/src/runtime/routes/conversation-routes.ts +54 -14
  86. package/src/runtime/routes/identity-routes.ts +2 -0
  87. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  88. package/src/runtime/routes/index.ts +2 -0
  89. package/src/runtime/routes/migration-routes.ts +3 -1
  90. package/src/runtime/routes/types.ts +4 -1
  91. package/src/runtime/shared-principal-lookup.ts +235 -0
  92. package/src/tools/host-terminal/host-shell.ts +4 -2
  93. package/src/tools/shared/shell-output.test.ts +42 -0
  94. package/src/tools/shared/shell-output.ts +27 -4
  95. package/src/tools/skills/execute.ts +10 -7
  96. package/src/tools/terminal/shell.ts +2 -0
  97. package/src/tools/tool-approval-handler.ts +9 -1
@@ -0,0 +1,333 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { createMockProvider } from "../__tests__/helpers/mock-provider.js";
4
+ import { MESSAGE_CATALOGS, MESSAGE_KEYS } from "../i18n/index.js";
5
+ import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
6
+ import type {
7
+ ContentBlock,
8
+ Message,
9
+ ProviderResponse,
10
+ } from "../providers/types.js";
11
+ import { wrapUnparseableToolArgs } from "../providers/unparseable-tool-args.js";
12
+ import { type AgentEvent, AgentLoop } from "./loop.js";
13
+
14
+ const userMessage: Message = {
15
+ role: "user",
16
+ content: [{ type: "text", text: "Write the file" }],
17
+ };
18
+
19
+ function malformedToolResponse(
20
+ id: string,
21
+ raw: string,
22
+ options: { name?: string; text?: string } = {},
23
+ ): ProviderResponse {
24
+ return {
25
+ content: [
26
+ ...(options.text ? [{ type: "text" as const, text: options.text }] : []),
27
+ {
28
+ type: "tool_use",
29
+ id,
30
+ name: options.name ?? "file_write",
31
+ input: wrapUnparseableToolArgs(raw),
32
+ },
33
+ ],
34
+ model: "mock-model",
35
+ usage: { inputTokens: 1, outputTokens: 1 },
36
+ stopReason: "tool_use",
37
+ };
38
+ }
39
+
40
+ function toolBlocks(
41
+ history: Message[],
42
+ type: "tool_use" | "tool_result",
43
+ ): ContentBlock[] {
44
+ return history
45
+ .flatMap((message) => message.content)
46
+ .filter((block) => {
47
+ return block.type === type;
48
+ });
49
+ }
50
+
51
+ describe("AgentLoop: unparseable tool retries", () => {
52
+ beforeEach(() => {
53
+ resetPluginRegistryAndRegisterDefaults();
54
+ });
55
+
56
+ test("bounds persisted input and stops after three consecutive malformed turns", async () => {
57
+ const raw =
58
+ '{"path":"/workspace/file.md","content":"' + "x".repeat(2_000_000);
59
+ const { provider, calls } = createMockProvider([
60
+ malformedToolResponse("bad-1", raw),
61
+ malformedToolResponse("bad-2", raw),
62
+ malformedToolResponse("bad-3", raw, { text: "Still trying." }),
63
+ malformedToolResponse("bad-4", raw),
64
+ ]);
65
+ const executionInputs: Record<string, unknown>[] = [];
66
+ const events: AgentEvent[] = [];
67
+ const loop = new AgentLoop({
68
+ provider,
69
+ systemPrompt: "system",
70
+ conversationId: "conversation-123",
71
+ tools: [
72
+ {
73
+ name: "file_write",
74
+ description: "",
75
+ input_schema: { type: "object" },
76
+ },
77
+ ],
78
+ toolExecutor: async (_name, input) => {
79
+ executionInputs.push(input);
80
+ return { content: "invalid JSON", isError: true };
81
+ },
82
+ });
83
+
84
+ const result = await loop.run({
85
+ requestId: "request-123",
86
+ messages: [userMessage],
87
+ onEvent: (event) => {
88
+ events.push(event);
89
+ },
90
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
91
+ });
92
+
93
+ expect(calls).toHaveLength(3);
94
+ expect(executionInputs).toHaveLength(2);
95
+ expect(executionInputs[0]).toEqual(wrapUnparseableToolArgs(raw));
96
+ expect(toolBlocks(result.history, "tool_use")).toHaveLength(2);
97
+ expect(toolBlocks(result.history, "tool_result")).toHaveLength(2);
98
+
99
+ const serializedHistory = JSON.stringify(result.history);
100
+ expect(serializedHistory.length).toBeLessThan(5_000);
101
+ expect(serializedHistory).not.toContain("x".repeat(1_000));
102
+ expect(result.history.at(-1)?.content).toEqual([
103
+ { type: "text", text: "Still trying." },
104
+ {
105
+ type: "text",
106
+ text: `\n\n${MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP}`,
107
+ },
108
+ ]);
109
+ expect(events.filter((event) => event.type === "tool_use")).toHaveLength(2);
110
+ expect(
111
+ events
112
+ .filter((event) => event.type === "text_delta")
113
+ .map((event) => event.text)
114
+ .join(""),
115
+ ).toEndWith(
116
+ `Still trying.\n\n${
117
+ MESSAGE_CATALOGS.en[MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP]
118
+ }`,
119
+ );
120
+ });
121
+
122
+ test("surfaces only the stop notice when assistant text is tool-gated", async () => {
123
+ const raw = "{" + "x".repeat(1_000);
124
+ const { provider } = createMockProvider([
125
+ malformedToolResponse("bad-1", raw, { text: "Private notes one." }),
126
+ malformedToolResponse("bad-2", raw, { text: "Private notes two." }),
127
+ malformedToolResponse("bad-3", raw, { text: "Private notes three." }),
128
+ ]);
129
+ const events: AgentEvent[] = [];
130
+ const loop = new AgentLoop({
131
+ provider,
132
+ systemPrompt: "system",
133
+ conversationId: "conversation-gated",
134
+ tools: [
135
+ {
136
+ name: "file_write",
137
+ description: "",
138
+ input_schema: { type: "object" },
139
+ },
140
+ ],
141
+ toolExecutor: async () => ({ content: "invalid JSON", isError: true }),
142
+ });
143
+
144
+ const result = await loop.run({
145
+ requestId: "request-gated",
146
+ messages: [userMessage],
147
+ onEvent: (event) => {
148
+ events.push(event);
149
+ },
150
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
151
+ suppressAssistantText: true,
152
+ });
153
+
154
+ expect(result.history.at(-1)?.content).toEqual([
155
+ {
156
+ type: "text",
157
+ text: MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP,
158
+ },
159
+ ]);
160
+ expect(
161
+ events
162
+ .filter((event) => event.type === "text_delta")
163
+ .map((event) => event.text)
164
+ .join(""),
165
+ ).toBe(
166
+ MESSAGE_CATALOGS.en[MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP],
167
+ );
168
+ const finalMessage = events
169
+ .filter((event) => event.type === "message_complete")
170
+ .at(-1);
171
+ expect(finalMessage?.assistantTextVisibility).toBe("visible");
172
+ });
173
+
174
+ test("streams the localized stop notice while persisting its key", async () => {
175
+ const raw = "{" + "x".repeat(1_000);
176
+ const { provider } = createMockProvider([
177
+ malformedToolResponse("bad-1", raw),
178
+ malformedToolResponse("bad-2", raw),
179
+ malformedToolResponse("bad-3", raw),
180
+ ]);
181
+ const events: AgentEvent[] = [];
182
+ const loop = new AgentLoop({
183
+ provider,
184
+ systemPrompt: "system",
185
+ conversationId: "conversation-spanish",
186
+ tools: [
187
+ {
188
+ name: "file_write",
189
+ description: "",
190
+ input_schema: { type: "object" },
191
+ },
192
+ ],
193
+ toolExecutor: async () => ({ content: "invalid JSON", isError: true }),
194
+ });
195
+
196
+ const result = await loop.run({
197
+ requestId: "request-spanish",
198
+ messages: [userMessage],
199
+ onEvent: (event) => {
200
+ events.push(event);
201
+ },
202
+ locale: "es",
203
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
204
+ });
205
+
206
+ expect(result.history.at(-1)?.content).toEqual([
207
+ {
208
+ type: "text",
209
+ text: MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP,
210
+ },
211
+ ]);
212
+ expect(
213
+ events
214
+ .filter((event) => event.type === "text_delta")
215
+ .map((event) => event.text)
216
+ .join(""),
217
+ ).toBe(
218
+ MESSAGE_CATALOGS.es[MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP],
219
+ );
220
+ });
221
+
222
+ test("does not count skill_execute envelopes recovered from valid outer JSON", async () => {
223
+ const recoverableRaw = JSON.stringify({
224
+ tool: "document_update",
225
+ input: "body",
226
+ activity: "Updating the document",
227
+ });
228
+ const malformedRaw = "{" + "x".repeat(1_000);
229
+ const done: ProviderResponse = {
230
+ content: [{ type: "text", text: "Done" }],
231
+ model: "mock-model",
232
+ usage: { inputTokens: 1, outputTokens: 1 },
233
+ stopReason: "end_turn",
234
+ };
235
+ const { provider, calls } = createMockProvider([
236
+ malformedToolResponse("recover-1", recoverableRaw, {
237
+ name: "skill_execute",
238
+ }),
239
+ malformedToolResponse("bad-1", malformedRaw),
240
+ malformedToolResponse("recover-2", recoverableRaw, {
241
+ name: "skill_execute",
242
+ }),
243
+ malformedToolResponse("bad-2", malformedRaw),
244
+ done,
245
+ ]);
246
+ const loop = new AgentLoop({
247
+ provider,
248
+ systemPrompt: "system",
249
+ conversationId: "conversation-789",
250
+ tools: [
251
+ {
252
+ name: "skill_execute",
253
+ description: "",
254
+ input_schema: { type: "object" },
255
+ },
256
+ {
257
+ name: "file_write",
258
+ description: "",
259
+ input_schema: { type: "object" },
260
+ },
261
+ ],
262
+ toolExecutor: async () => ({ content: "result", isError: false }),
263
+ });
264
+
265
+ const result = await loop.run({
266
+ requestId: "request-789",
267
+ messages: [userMessage],
268
+ onEvent: () => {},
269
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
270
+ });
271
+
272
+ expect(calls).toHaveLength(5);
273
+ expect(result.history.at(-1)?.content).toEqual([
274
+ { type: "text", text: "Done" },
275
+ ]);
276
+ });
277
+
278
+ test("resets the streak after a well-formed tool turn", async () => {
279
+ const raw = "{" + "x".repeat(1_000);
280
+ const valid: ProviderResponse = {
281
+ content: [
282
+ {
283
+ type: "tool_use",
284
+ id: "valid",
285
+ name: "file_write",
286
+ input: { path: "/workspace/file.md", content: "ok" },
287
+ },
288
+ ],
289
+ model: "mock-model",
290
+ usage: { inputTokens: 1, outputTokens: 1 },
291
+ stopReason: "tool_use",
292
+ };
293
+ const done: ProviderResponse = {
294
+ content: [{ type: "text", text: "Done" }],
295
+ model: "mock-model",
296
+ usage: { inputTokens: 1, outputTokens: 1 },
297
+ stopReason: "end_turn",
298
+ };
299
+ const { provider, calls } = createMockProvider([
300
+ malformedToolResponse("bad-1", raw),
301
+ malformedToolResponse("bad-2", raw),
302
+ valid,
303
+ malformedToolResponse("bad-3", raw),
304
+ malformedToolResponse("bad-4", raw),
305
+ done,
306
+ ]);
307
+ const loop = new AgentLoop({
308
+ provider,
309
+ systemPrompt: "system",
310
+ conversationId: "conversation-456",
311
+ tools: [
312
+ {
313
+ name: "file_write",
314
+ description: "",
315
+ input_schema: { type: "object" },
316
+ },
317
+ ],
318
+ toolExecutor: async () => ({ content: "result", isError: false }),
319
+ });
320
+
321
+ const result = await loop.run({
322
+ requestId: "request-456",
323
+ messages: [userMessage],
324
+ onEvent: () => {},
325
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
326
+ });
327
+
328
+ expect(calls).toHaveLength(6);
329
+ expect(result.history.at(-1)?.content).toEqual([
330
+ { type: "text", text: "Done" },
331
+ ]);
332
+ });
333
+ });
package/src/agent/loop.ts CHANGED
@@ -28,6 +28,12 @@ import type {
28
28
  PreModelCallInputContext,
29
29
  StopInputContext,
30
30
  } from "../hooks/types.js";
31
+ import {
32
+ MESSAGE_KEYS,
33
+ resolveStoredMessageText,
34
+ type SupportedLocale,
35
+ t,
36
+ } from "../i18n/index.js";
31
37
  import {
32
38
  timeSyncSection,
33
39
  traceAsyncSection,
@@ -52,6 +58,10 @@ import type {
52
58
  ToolResultContent,
53
59
  } from "../providers/types.js";
54
60
  import { NATIVE_WEB_SEARCH_TOOL_NAME } from "../providers/types.js";
61
+ import {
62
+ boundUnparseableToolArgs,
63
+ isUnparseableToolArgs,
64
+ } from "../providers/unparseable-tool-args.js";
55
65
  import { recordWatchdogEvent } from "../telemetry/watchdog-events-store.js";
56
66
  import {
57
67
  ABORT_SETTLE_GRACE_MS,
@@ -64,6 +74,7 @@ import {
64
74
  applyStreamingSubstitution,
65
75
  applySubstitutions,
66
76
  } from "../tools/sensitive-output-placeholders.js";
77
+ import { recoverSkillExecuteEnvelope } from "../tools/skills/execute.js";
67
78
  import {
68
79
  abortedToolResultText,
69
80
  isPreemptedByNewMessage,
@@ -83,6 +94,8 @@ const log = getLogger("agent-loop");
83
94
 
84
95
  /** Watchdog check name for both tool-gated reply outcomes. */
85
96
  const SEND_USER_MESSAGE_CHECK = "send_user_message_delivery";
97
+ const MAX_CONSECUTIVE_UNPARSEABLE_TOOL_TURNS = 3;
98
+ const SKILL_EXECUTE_TOOL_NAME = "skill_execute";
86
99
 
87
100
  /**
88
101
  * The user-facing message a `send_user_message` block carries, or null when
@@ -696,6 +709,8 @@ interface AgentLoopRunOptionsBase {
696
709
  injectionLedgerResets?: Set<string>;
697
710
  signal?: AbortSignal;
698
711
  requestId: string;
712
+ /** Locale for daemon-authored text emitted during this run. */
713
+ locale?: SupportedLocale;
699
714
  /**
700
715
  * Explicit model override (provider/model string) for every LLM call in
701
716
  * this run. When omitted, the model is resolved through the normal
@@ -1011,12 +1026,31 @@ function toolResultEventFields(
1011
1026
  interface NormalizedToolUse {
1012
1027
  /** Assistant content with at most one `tool_use` block per call id. */
1013
1028
  content: ContentBlock[];
1014
- /** The `tool_use` blocks in `content`, in order. */
1029
+ /** The bounded `tool_use` blocks in `content`, in order. */
1015
1030
  toolUseBlocks: ToolUseBlock[];
1031
+ /** Original inputs retained only until this iteration's execution attempt. */
1032
+ executionInputsById: Map<string, Record<string, unknown>>;
1033
+ /** Number of executable calls whose provider arguments did not parse. */
1034
+ unparseableCount: number;
1016
1035
  /** Coalesced copies: a call id and name for each block dropped. */
1017
1036
  duplicates: Array<{ id: string; name: string }>;
1018
1037
  }
1019
1038
 
1039
+ function isRetryableUnparseableToolCall(block: ToolUseBlock): boolean {
1040
+ if (!isUnparseableToolArgs(block.input)) {
1041
+ return false;
1042
+ }
1043
+ if (block.name !== SKILL_EXECUTE_TOOL_NAME) {
1044
+ return true;
1045
+ }
1046
+ const recovered = recoverSkillExecuteEnvelope(block.input);
1047
+ return (
1048
+ recovered === block.input ||
1049
+ typeof recovered.tool !== "string" ||
1050
+ recovered.tool.length === 0
1051
+ );
1052
+ }
1053
+
1020
1054
  /**
1021
1055
  * Resolves an assistant reply's `tool_use` blocks into an executable set keyed
1022
1056
  * by call id: a block with no id gets one, and a repeat of an id already in the
@@ -1030,8 +1064,10 @@ function normalizeToolUseBlocks(
1030
1064
  ): NormalizedToolUse {
1031
1065
  const nextContent: ContentBlock[] = [];
1032
1066
  const toolUseBlocks: ToolUseBlock[] = [];
1067
+ const executionInputsById = new Map<string, Record<string, unknown>>();
1033
1068
  const duplicates: Array<{ id: string; name: string }> = [];
1034
1069
  const seenIds = new Set<string>();
1070
+ let unparseableCount = 0;
1035
1071
 
1036
1072
  for (const block of content) {
1037
1073
  if (block.type !== "tool_use") {
@@ -1042,14 +1078,28 @@ function normalizeToolUseBlocks(
1042
1078
  duplicates.push({ id: block.id, name: block.name });
1043
1079
  continue;
1044
1080
  }
1045
- const normalized: ToolUseBlock =
1046
- block.id.length === 0 ? { ...block, id: crypto.randomUUID() } : block;
1047
- seenIds.add(normalized.id);
1048
- nextContent.push(normalized);
1049
- toolUseBlocks.push(normalized);
1081
+ const normalizedId = block.id.length === 0 ? crypto.randomUUID() : block.id;
1082
+ const bounded: ToolUseBlock = {
1083
+ ...block,
1084
+ id: normalizedId,
1085
+ input: boundUnparseableToolArgs(block.input),
1086
+ };
1087
+ seenIds.add(normalizedId);
1088
+ executionInputsById.set(normalizedId, block.input);
1089
+ if (isRetryableUnparseableToolCall(block)) {
1090
+ unparseableCount++;
1091
+ }
1092
+ nextContent.push(bounded);
1093
+ toolUseBlocks.push(bounded);
1050
1094
  }
1051
1095
 
1052
- return { content: nextContent, toolUseBlocks, duplicates };
1096
+ return {
1097
+ content: nextContent,
1098
+ toolUseBlocks,
1099
+ executionInputsById,
1100
+ unparseableCount,
1101
+ duplicates,
1102
+ };
1053
1103
  }
1054
1104
 
1055
1105
  /**
@@ -1486,6 +1536,7 @@ export class AgentLoop {
1486
1536
  let newMessagesStart = history.length;
1487
1537
  let toolUseTurns = 0;
1488
1538
  let postModelCallContinues = 0;
1539
+ let consecutiveUnparseableToolTurns = 0;
1489
1540
  // Whether the user has been told the OUTCOME of the work so far, not
1490
1541
  // merely that work started. True only while the most recent tool-bearing
1491
1542
  // response was `send_user_message` and nothing else: a response that sends
@@ -1741,6 +1792,7 @@ export class AgentLoop {
1741
1792
  // request from a generation that died mid-flight. Declared here so that
1742
1793
  // catch can reach it.
1743
1794
  let streamedTokens = false;
1795
+ let unparseableToolRetryLimitReached = false;
1744
1796
 
1745
1797
  try {
1746
1798
  // ── Pre-call budget gate ─────────────────────────────────────
@@ -2728,6 +2780,44 @@ export class AgentLoop {
2728
2780
  content: normalizedToolUse.content,
2729
2781
  };
2730
2782
  toolUseBlocks = normalizedToolUse.toolUseBlocks;
2783
+ if (normalizedToolUse.unparseableCount > 0) {
2784
+ consecutiveUnparseableToolTurns++;
2785
+ } else {
2786
+ consecutiveUnparseableToolTurns = 0;
2787
+ }
2788
+ if (
2789
+ consecutiveUnparseableToolTurns >=
2790
+ MAX_CONSECUTIVE_UNPARSEABLE_TOOL_TURNS
2791
+ ) {
2792
+ rlog.warn(
2793
+ {
2794
+ turn: toolUseTurns,
2795
+ consecutiveUnparseableToolTurns,
2796
+ unparseableToolCount: normalizedToolUse.unparseableCount,
2797
+ },
2798
+ "Repeated unparseable tool calls reached the per-run limit",
2799
+ );
2800
+ unparseableToolRetryLimitReached = true;
2801
+ const visiblePrefix = suppressAssistantText
2802
+ ? []
2803
+ : assistantMessage.content.filter(
2804
+ (block) => block.type !== "tool_use",
2805
+ );
2806
+ assistantMessage = {
2807
+ role: "assistant",
2808
+ content: [
2809
+ ...visiblePrefix,
2810
+ {
2811
+ type: "text",
2812
+ text: `${
2813
+ hasVisibleText(visiblePrefix) ? "\n\n" : ""
2814
+ }${MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP}`,
2815
+ },
2816
+ ],
2817
+ };
2818
+ toolUseBlocks = [];
2819
+ userToldOutcome = false;
2820
+ }
2731
2821
 
2732
2822
  // At the no-tool stop boundary the retry decision is actionable: a
2733
2823
  // recovery hook may repair history and ask to re-query (a tool-bearing
@@ -2736,6 +2826,7 @@ export class AgentLoop {
2736
2826
  // persisting it; the per-run backstop keeps a misbehaving hook from
2737
2827
  // spinning forever.
2738
2828
  if (
2829
+ !unparseableToolRetryLimitReached &&
2739
2830
  toolUseBlocks.length === 0 &&
2740
2831
  postModelCallDecision === "continue"
2741
2832
  ) {
@@ -2797,11 +2888,40 @@ export class AgentLoop {
2797
2888
  // Surface the finalized text if the client saw nothing live (a
2798
2889
  // deferred stream, a hook-rewritten empty turn, or the tool-gated
2799
2890
  // fallback where no `send_user_message` call ever reached the user).
2800
- const textVisibility = textVisibilityOf(
2801
- emitFinalAssistantText(assistantMessage.content, {
2891
+ let fallbackSurfaced = false;
2892
+ if (unparseableToolRetryLimitReached && streamedVisibleText) {
2893
+ onEvent({
2894
+ type: "text_delta",
2895
+ text: resolveStoredMessageText(
2896
+ assistantTextOf(assistantMessage.content.slice(-1)),
2897
+ options.locale,
2898
+ ),
2899
+ });
2900
+ fallbackSurfaced = suppressAssistantText;
2901
+ } else {
2902
+ const displayContent = unparseableToolRetryLimitReached
2903
+ ? assistantMessage.content.map((block) =>
2904
+ block.type === "text" &&
2905
+ block.text.trim() ===
2906
+ MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP
2907
+ ? {
2908
+ ...block,
2909
+ text: block.text.replace(
2910
+ MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP,
2911
+ t(
2912
+ MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP,
2913
+ options.locale,
2914
+ ),
2915
+ ),
2916
+ }
2917
+ : block,
2918
+ )
2919
+ : assistantMessage.content;
2920
+ fallbackSurfaced = emitFinalAssistantText(displayContent, {
2802
2921
  turnEnding: toolUseBlocks.length === 0,
2803
- }),
2804
- );
2922
+ });
2923
+ }
2924
+ const textVisibility = textVisibilityOf(fallbackSurfaced);
2805
2925
 
2806
2926
  history.push(assistantMessage);
2807
2927
 
@@ -2914,7 +3034,8 @@ export class AgentLoop {
2914
3034
  }
2915
3035
  const result = await this.toolExecutor!(
2916
3036
  toolUse.name,
2917
- toolUse.input,
3037
+ normalizedToolUse.executionInputsById.get(toolUse.id) ??
3038
+ toolUse.input,
2918
3039
  (chunk) => {
2919
3040
  onEvent({
2920
3041
  type: "tool_output_chunk",