@vellumai/assistant 0.12.6-dev.202609292116.41f7caa → 0.12.6-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 (58) hide show
  1. package/.env.example +0 -2
  2. package/ARCHITECTURE.md +3 -3
  3. package/openapi.yaml +4 -8
  4. package/package.json +1 -1
  5. package/src/__tests__/list-messages-client-message-id.test.ts +0 -43
  6. package/src/__tests__/openai-provider.test.ts +0 -27
  7. package/src/__tests__/skill-execute-input.test.ts +0 -11
  8. package/src/__tests__/tool-approval-handler.test.ts +0 -4
  9. package/src/__tests__/tool-preview-lifecycle.test.ts +0 -26
  10. package/src/agent/loop.ts +12 -133
  11. package/src/calls/__tests__/call-controller.test.ts +1 -273
  12. package/src/calls/__tests__/media-stream-server-integration.test.ts +0 -40
  13. package/src/calls/__tests__/media-stream-stt-session.test.ts +0 -33
  14. package/src/calls/call-constants.ts +0 -10
  15. package/src/calls/call-controller.ts +25 -359
  16. package/src/calls/media-stream-server.ts +0 -18
  17. package/src/calls/media-stream-stt-session.ts +9 -30
  18. package/src/cli/lib/bundled-marketplace.json +0 -39
  19. package/src/config/feature-flag-registry.json +8 -8
  20. package/src/config/schemas/llm.ts +0 -1
  21. package/src/daemon/conversation-agent-loop-handlers.ts +2 -18
  22. package/src/daemon/conversation-agent-loop.ts +0 -3
  23. package/src/daemon/conversation-messaging.ts +0 -4
  24. package/src/daemon/conversation-process.ts +0 -15
  25. package/src/daemon/conversation-queue-manager.ts +0 -2
  26. package/src/daemon/conversation.ts +0 -2
  27. package/src/daemon/handlers/shared.ts +2 -13
  28. package/src/daemon/process-message.ts +0 -5
  29. package/src/i18n/__tests__/i18n.test.ts +0 -25
  30. package/src/i18n/index.ts +0 -1
  31. package/src/i18n/messages.ts +0 -11
  32. package/src/providers/__tests__/unparseable-tool-args.test.ts +0 -25
  33. package/src/providers/inference/adapter-factory.ts +0 -6
  34. package/src/providers/model-catalog.ts +0 -40
  35. package/src/providers/openai/chat-completions-provider.ts +2 -33
  36. package/src/providers/retry.ts +4 -10
  37. package/src/providers/unparseable-tool-args.ts +5 -29
  38. package/src/runtime/channel-reply-delivery.ts +0 -2
  39. package/src/runtime/http-types.ts +0 -3
  40. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +10 -35
  41. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -48
  42. package/src/runtime/migrations/vbundle-builder.ts +3 -19
  43. package/src/runtime/migrations/vbundle-streaming-importer.ts +19 -14
  44. package/src/runtime/migrations/vbundle-streaming-validator.ts +7 -14
  45. package/src/runtime/migrations/vbundle-validator.ts +1 -54
  46. package/src/runtime/routes/conversation-routes.ts +0 -7
  47. package/src/runtime/routes/inbound-stages/background-dispatch.ts +0 -4
  48. package/src/runtime/routes/migration-routes.ts +1 -3
  49. package/src/tools/host-terminal/host-shell.ts +2 -4
  50. package/src/tools/shared/shell-output.test.ts +0 -42
  51. package/src/tools/shared/shell-output.ts +4 -27
  52. package/src/tools/skills/execute.ts +7 -10
  53. package/src/tools/terminal/shell.ts +0 -2
  54. package/src/tools/tool-approval-handler.ts +1 -9
  55. package/src/agent/loop-unparseable-tool-retry.test.ts +0 -333
  56. package/src/i18n/assistant-text.ts +0 -18
  57. package/src/providers/ionet/client.test.ts +0 -143
  58. package/src/providers/ionet/client.ts +0 -37
package/.env.example CHANGED
@@ -2,8 +2,6 @@
2
2
  ANTHROPIC_API_KEY=sk-ant-...
3
3
  OPENAI_API_KEY=sk-...
4
4
  GEMINI_API_KEY=...
5
- # Optional. IO Intelligence (io.net) open-models endpoint.
6
- IONET_API_KEY=...
7
5
  # Optional. TypeSafe / Jev System One decision model.
8
6
  TYPESAFE_API_KEY=...
9
7
  # Optional for authenticated/proxied Ollama deployments
package/ARCHITECTURE.md CHANGED
@@ -618,12 +618,12 @@ Every phone call connects over Twilio Media Streams: the voice webhook emits `<C
618
618
 
619
619
  Transcription mode is selected once per session in `media-stream-stt-session.ts`:
620
620
 
621
- - **Streaming** (default): when `calls.voice.telephonyStreaming` is enabled and the `telephony` role resolves a streaming transcriber (`resolveStreamingTranscriber({ role: "telephony" })`), inbound audio is decoded (mu-law → PCM16, resampled 8 kHz → 16 kHz) and fed to the provider's realtime adapter. Replies trigger only on utterance-boundary finals (for Deepgram, `speech_final`/`UtteranceEnd`, never mid-sentence `is_final` segments), and barge-in fires from local energy VAD, never from transcriber partials. Interim `partial` events are forwarded to the controller as `handleCallerPartial`: they commit nothing and start no turn, they are never persisted and never reach a setup flow, and they exist so a boundary the front door held mid-thought can be replayed on the caller's newest words rather than only the ones the provider has finalized. While something is interruptible (an assistant turn in flight, thinking or speaking, or a completed turn's tail still playing from Twilio's buffer), caller speech arms the shared sustained-speech barge-in guard (`src/calls/barge-in-guard.ts`, the same accounting live voice uses: speech accumulates toward 250 ms, short gaps are tolerated, a run that is mostly silence resets) and every inbound frame feeds it; outside that window the guard is dropped, so the caller's own utterance never carries into a turn that starts before the local VAD ends it. Only a fired guard reaches `CallController.handleBargeIn`, which interrupts a turn in either phase and ignores an idle controller (the playing tail is cleared instead).
621
+ - **Streaming** (default): when `calls.voice.telephonyStreaming` is enabled and the `telephony` role resolves a streaming transcriber (`resolveStreamingTranscriber({ role: "telephony" })`), inbound audio is decoded (mu-law → PCM16, resampled 8 kHz → 16 kHz) and fed to the provider's realtime adapter. Replies trigger only on utterance-boundary finals (for Deepgram, `speech_final`/`UtteranceEnd`, never mid-sentence `is_final` segments), and barge-in fires from local energy VAD, never from transcriber partials. While something is interruptible (an assistant turn in flight, thinking or speaking, or a completed turn's tail still playing from Twilio's buffer), caller speech arms the shared sustained-speech barge-in guard (`src/calls/barge-in-guard.ts`, the same accounting live voice uses: speech accumulates toward 250 ms, short gaps are tolerated, a run that is mostly silence resets) and every inbound frame feeds it; outside that window the guard is dropped, so the caller's own utterance never carries into a turn that starts before the local VAD ends it. Only a fired guard reaches `CallController.handleBargeIn`, which interrupts a turn in either phase and ignores an idle controller (the playing tail is cleared instead).
622
622
  - **Batch fallback**: otherwise the session segments turns with the energy-based `MediaTurnDetector` and transcribes each completed turn via the same role's batch API. Both halves of a call read the `telephony` role, which is why a role names its consumer rather than a boundary.
623
623
 
624
- Every phone turn runs the same two-leg triage as live voice through `startVoiceTurn` (`src/calls/voice-session-bridge.ts`): `call-controller.ts` opens on a toolless front-door leg (`routingLeg: "front-door"`, the `voiceFrontDoor` call site) and drives it through the shared `createFrontDoorLegCoordinator` (`src/calls/voice-leg-coordinator.ts`), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. A leg that carries the caller's own words is also the turn's endpoint decision, so it is taught the hold verdict: silence alone cannot tell a finished sentence from a thinking pause, and answering a fragment makes the rest of the sentence read as a barge-in that interrupts the assistant's own reply. On a hold the leg is discarded (its persisted user row rolled back through the handle's `discard`), the floor goes straight back to the caller, and the words are carried into their next final so the whole sentence is judged together. If no next words arrive, a replay timer (`voice.frontModel.endpointExtensionMs`) answers the sentence as it stands; any partial re-arms that timer and the next final cancels it. A partial also stretches the wait to at least `calls.voice.utteranceEndMs` plus a margin, because the provider still owes a final for those words and that final is the better boundary: racing it would ask the same words twice, once on the interim guess and once on the committed text. Reaching the timer with a partial outstanding therefore means no final is coming, so the interim text rides along as the only record of what the caller said. `voice.frontModel.endpointMaxExtensions` caps consecutive holds per sentence, after which the leg must answer. Openers, instructions, and other assistant-initiated turns are never dispatched that way. The controller also passes the bridge's turn callbacks (tool activity is recorded as `tool_use_started` / `tool_use_completed` call events, persisted row ids ride the `assistant_spoke` event), `launchedAtMs` for dispatch timing, and a `voiceTelemetry` bag keyed by the call session with a `phone_inbound` / `phone_outbound` entry. Both drivers share the spoken progress narration cadence (`src/calls/voice-progress-cadence.ts`, tuned by `voice.frontModel.progress`): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the `maxSilenceMs` heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from `isPlaybackIdle()` and a running sum of sent frame durations) and how to speak a phrase.
624
+ Every phone turn runs the same two-leg triage as live voice through `startVoiceTurn` (`src/calls/voice-session-bridge.ts`): `call-controller.ts` opens on a toolless front-door leg (`routingLeg: "front-door"`, the `voiceFrontDoor` call site) and drives it through the shared `createFrontDoorLegCoordinator` (`src/calls/voice-leg-coordinator.ts`), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. Phone has no partial transcripts, so the hold verdict is never taught and routing is escalate-only. The controller also passes the bridge's turn callbacks (tool activity is recorded as `tool_use_started` / `tool_use_completed` call events, persisted row ids ride the `assistant_spoke` event), `launchedAtMs` for dispatch timing, and a `voiceTelemetry` bag keyed by the call session with a `phone_inbound` / `phone_outbound` entry. Both drivers share the spoken progress narration cadence (`src/calls/voice-progress-cadence.ts`, tuned by `voice.frontModel.progress`): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the `maxSilenceMs` heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from `isPlaybackIdle()` and a running sum of sent frame durations) and how to speak a phrase.
625
625
 
626
- Both drivers also share the per-turn latency marks (`src/calls/voice-metrics.ts`). The controller opens a turn's marks when it dispatches, seeded with when the caller's transcript arrived and when the provider first had interim words for that same utterance, and stamps the leg dispatch, the first assistant delta, the first audible audio, a barge-in, an endpoint hold, and how the turn settled. A held turn settles as `endpoint_hold` and hands both seeds to the turn that finally answers, so the caller's wait is still measured from the moment they stopped talking. Live voice streams a snapshot to its client on every mark; a call has no client on the line, so the aggregate is logged once per turn, cancelled turns included. Each call also records a `phone_call_started` / `phone_call_ended` pair on the onboarding telemetry substrate (`src/telemetry/phone-call-funnel.ts`), written by the call store at session creation and at the single terminal status transition: duration is the gap between the two rows, and a call that took no caller turn carries why on the end row (`silent_no_connect` when it never connected, `silent_no_turn` when it did and nobody spoke).
626
+ Both drivers also share the per-turn latency marks (`src/calls/voice-metrics.ts`). The controller opens a turn's marks when it dispatches, seeded with when the caller's transcript arrived, and stamps the leg dispatch, the first assistant delta, the first audible audio, a barge-in, and how the turn settled. Live voice streams a snapshot to its client on every mark; a call has no client on the line, so the aggregate is logged once per turn, cancelled turns included. Each call also records a `phone_call_started` / `phone_call_ended` pair on the onboarding telemetry substrate (`src/telemetry/phone-call-funnel.ts`), written by the call store at session creation and at the single terminal status transition: duration is the gap between the two rows, and a call that took no caller turn carries why on the end row (`silent_no_connect` when it never connected, `silent_no_turn` when it did and nobody spoke).
627
627
 
628
628
  A credential preflight (`resolveTelephonyCredentialReadiness()` in `src/calls/telephony-credential-preflight.ts`) gates every call: it requires a credentialed, telephony-capable STT provider **and** a media-stream-playable TTS provider (the configured one or a credentialed playable fallback). Inbound calls that fail the preflight receive `<Say>` setup-required copy plus `<Hangup/>` instead of a doomed stream; outbound placement fails before dialing via `preflightVoiceIngress()` with the same user-facing message.
629
629
 
package/openapi.yaml CHANGED
@@ -6025,7 +6025,6 @@ paths:
6025
6025
  - minimax
6026
6026
  - atlascloud
6027
6027
  - baseten
6028
- - ionet
6029
6028
  - poolside
6030
6029
  connectionName:
6031
6030
  type: string
@@ -16974,8 +16973,8 @@ paths:
16974
16973
  type: string
16975
16974
  description:
16976
16975
  "Filter by provider id. One of: anthropic, openai, gemini, ollama, fireworks, together, openrouter,
16977
- vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten, ionet, poolside,
16978
- typesafe, vellum"
16976
+ vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten, poolside, typesafe,
16977
+ vellum"
16979
16978
  responses:
16980
16979
  "200":
16981
16980
  description: Successful response
@@ -17247,8 +17246,8 @@ paths:
17247
17246
  type: string
17248
17247
  description:
17249
17248
  "Filter by provider. One of: anthropic, openai, gemini, ollama, fireworks, together, openrouter,
17250
- vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten, ionet, poolside,
17251
- typesafe, vellum, chatgpt"
17249
+ vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten, poolside, typesafe,
17250
+ vellum, chatgpt"
17252
17251
  responses:
17253
17252
  "200":
17254
17253
  description: Successful response
@@ -39426,7 +39425,6 @@ components:
39426
39425
  - minimax
39427
39426
  - atlascloud
39428
39427
  - baseten
39429
- - ionet
39430
39428
  - poolside
39431
39429
  - typesafe
39432
39430
  - vellum
@@ -39691,7 +39689,6 @@ components:
39691
39689
  - minimax
39692
39690
  - atlascloud
39693
39691
  - baseten
39694
- - ionet
39695
39692
  - poolside
39696
39693
  connectionName:
39697
39694
  type: string
@@ -40080,7 +40077,6 @@ components:
40080
40077
  - minimax
40081
40078
  - atlascloud
40082
40079
  - baseten
40083
- - ionet
40084
40080
  - poolside
40085
40081
  - type: "null"
40086
40082
  connectionName:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.6-dev.202609292116.41f7caa",
3
+ "version": "0.12.6-staging.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,7 +8,6 @@
8
8
 
9
9
  import { beforeEach, describe, expect, test } from "bun:test";
10
10
 
11
- import { MESSAGE_KEYS } from "../i18n/index.js";
12
11
  import { setConfig } from "./helpers/set-config.js";
13
12
 
14
13
  // Keep the memory system off so addMessage skips indexing side effects.
@@ -32,7 +31,6 @@ function resetTables() {
32
31
 
33
32
  interface MessagePayload {
34
33
  role: string;
35
- textSegments?: string[];
36
34
  clientMessageId?: string;
37
35
  }
38
36
 
@@ -61,47 +59,6 @@ describe("handleListMessages clientMessageId", () => {
61
59
  expect(body.messages[0].clientMessageId).toBe("nonce-abc");
62
60
  });
63
61
 
64
- test("localizes persisted assistant keys without rewriting legacy text", async () => {
65
- const localizedConv = createConversation();
66
- await addMessage(
67
- localizedConv.id,
68
- "assistant",
69
- JSON.stringify([
70
- {
71
- type: "text",
72
- text: MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP,
73
- },
74
- ]),
75
- { skipIndexing: true },
76
- );
77
-
78
- const localizedResponse = await handleListMessages({
79
- queryParams: { conversationId: localizedConv.id },
80
- headers: { "accept-language": "es" },
81
- });
82
- const localizedBody = localizedResponse as { messages: MessagePayload[] };
83
- expect(localizedBody.messages[0]?.textSegments).toEqual([
84
- "Me detuve después de varias llamadas de herramienta con formato incorrecto para evitar que esta conversación creciera indefinidamente. Inténtalo de nuevo o cambia de modelo si el problema continúa.",
85
- ]);
86
-
87
- const legacyConv = createConversation();
88
- const legacy =
89
- "I stopped after repeated malformed tool calls to keep this conversation from growing indefinitely. Please try again, or switch models if the problem continues.";
90
- await addMessage(
91
- legacyConv.id,
92
- "assistant",
93
- JSON.stringify([{ type: "text", text: legacy }]),
94
- { skipIndexing: true },
95
- );
96
-
97
- const legacyResponse = await handleListMessages({
98
- queryParams: { conversationId: legacyConv.id },
99
- headers: { "accept-language": "es" },
100
- });
101
- const legacyBody = legacyResponse as { messages: MessagePayload[] };
102
- expect(legacyBody.messages[0]?.textSegments).toEqual([legacy]);
103
- });
104
-
105
62
  test("omits clientMessageId when the row was persisted without one", async () => {
106
63
  // GIVEN a user message persisted without a client nonce (e.g. a channel send)
107
64
  const conv = createConversation();
@@ -1178,33 +1178,6 @@ describe("OpenAIProvider", () => {
1178
1178
  });
1179
1179
  });
1180
1180
 
1181
- test("bounds malformed arguments in the diagnostic raw response only", async () => {
1182
- const raw = '{"content":"' + "x".repeat(1_000);
1183
- fakeChunks = [
1184
- ...toolCallChunks([{ id: "call_large_bad", name: "test", args: raw }]),
1185
- usageChunk(10, 5),
1186
- ];
1187
-
1188
- const result = await provider.sendMessage([userMsg("test")]);
1189
-
1190
- expect(result.content[0]).toEqual({
1191
- type: "tool_use",
1192
- id: "call_large_bad",
1193
- name: "test",
1194
- input: { _raw: raw },
1195
- });
1196
- const rawResponse = result.rawResponse as {
1197
- choices: Array<{
1198
- message: {
1199
- tool_calls: Array<{ function: { arguments: string } }>;
1200
- };
1201
- }>;
1202
- };
1203
- expect(
1204
- rawResponse.choices[0]!.message.tool_calls[0]!.function.arguments,
1205
- ).toBe(raw.slice(0, 200) + "…");
1206
- });
1207
-
1208
1181
  // -----------------------------------------------------------------------
1209
1182
  // stream_options and model
1210
1183
  // -----------------------------------------------------------------------
@@ -188,17 +188,6 @@ describe("recoverSkillExecuteEnvelope", () => {
188
188
  expect(recoverSkillExecuteEnvelope(wrapped)).toBe(wrapped);
189
189
  });
190
190
 
191
- test("keeps recovered JSON wrapped when it lacks a string tool name", () => {
192
- for (const raw of [
193
- JSON.stringify({ input: { content: "hi" }, activity: "x" }),
194
- JSON.stringify({ tool: 42, input: {}, activity: "x" }),
195
- JSON.stringify(["document_update"]),
196
- ]) {
197
- const wrapped = { _raw: raw };
198
- expect(recoverSkillExecuteEnvelope(wrapped)).toBe(wrapped);
199
- }
200
- });
201
-
202
191
  test("passes a normal envelope through untouched", () => {
203
192
  const envelope = {
204
193
  tool: "document_update",
@@ -1357,10 +1357,6 @@ describe("ToolApprovalHandler / unparseable tool args gate", () => {
1357
1357
  }
1358
1358
  expect(result.result.content).not.toContain(raw);
1359
1359
  expect(result.result.content).toContain("…");
1360
- expect(auditCalls.error).toHaveLength(1);
1361
- expect(auditCalls.error[0].input).toEqual({
1362
- _raw: raw.slice(0, 200) + "…",
1363
- });
1364
1360
  });
1365
1361
 
1366
1362
  test("legitimate input containing a _raw field among others is not rejected", async () => {
@@ -341,32 +341,6 @@ describe("tool preview lifecycle", () => {
341
341
  expect((emitted as any).conversationId).toBe("test-session-id");
342
342
  });
343
343
 
344
- test("caps cumulative app preview payloads and stops forwarding growth", () => {
345
- const collector = createEventCollector();
346
- const deps = createMockDeps({ onEvent: collector.onEvent });
347
- const accumulatedJson = '{"code":"' + "x".repeat(300_000);
348
-
349
- handleInputJsonDelta(state, deps, {
350
- type: "input_json_delta",
351
- toolName: "app_create",
352
- toolUseId: "toolu_large_app",
353
- accumulatedJson,
354
- });
355
- handleInputJsonDelta(state, deps, {
356
- type: "input_json_delta",
357
- toolName: "app_create",
358
- toolUseId: "toolu_large_app",
359
- accumulatedJson: accumulatedJson + "more",
360
- });
361
-
362
- expect(collector.events).toHaveLength(1);
363
- const emitted = collector.events[0] as Extract<
364
- AssistantEvent,
365
- { type: "tool_input_delta" }
366
- >;
367
- expect(emitted.content).toBe(accumulatedJson.slice(0, 256_000));
368
- });
369
-
370
344
  test("a streaming ui_show visual announces its surface exactly once", () => {
371
345
  const collector = createEventCollector();
372
346
  const deps = createMockDeps({ onEvent: collector.onEvent });
package/src/agent/loop.ts CHANGED
@@ -28,12 +28,6 @@ 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";
37
31
  import {
38
32
  timeSyncSection,
39
33
  traceAsyncSection,
@@ -58,10 +52,6 @@ import type {
58
52
  ToolResultContent,
59
53
  } from "../providers/types.js";
60
54
  import { NATIVE_WEB_SEARCH_TOOL_NAME } from "../providers/types.js";
61
- import {
62
- boundUnparseableToolArgs,
63
- isUnparseableToolArgs,
64
- } from "../providers/unparseable-tool-args.js";
65
55
  import { recordWatchdogEvent } from "../telemetry/watchdog-events-store.js";
66
56
  import {
67
57
  ABORT_SETTLE_GRACE_MS,
@@ -74,7 +64,6 @@ import {
74
64
  applyStreamingSubstitution,
75
65
  applySubstitutions,
76
66
  } from "../tools/sensitive-output-placeholders.js";
77
- import { recoverSkillExecuteEnvelope } from "../tools/skills/execute.js";
78
67
  import {
79
68
  abortedToolResultText,
80
69
  isPreemptedByNewMessage,
@@ -94,8 +83,6 @@ const log = getLogger("agent-loop");
94
83
 
95
84
  /** Watchdog check name for both tool-gated reply outcomes. */
96
85
  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";
99
86
 
100
87
  /**
101
88
  * The user-facing message a `send_user_message` block carries, or null when
@@ -709,8 +696,6 @@ interface AgentLoopRunOptionsBase {
709
696
  injectionLedgerResets?: Set<string>;
710
697
  signal?: AbortSignal;
711
698
  requestId: string;
712
- /** Locale for daemon-authored text emitted during this run. */
713
- locale?: SupportedLocale;
714
699
  /**
715
700
  * Explicit model override (provider/model string) for every LLM call in
716
701
  * this run. When omitted, the model is resolved through the normal
@@ -1026,31 +1011,12 @@ function toolResultEventFields(
1026
1011
  interface NormalizedToolUse {
1027
1012
  /** Assistant content with at most one `tool_use` block per call id. */
1028
1013
  content: ContentBlock[];
1029
- /** The bounded `tool_use` blocks in `content`, in order. */
1014
+ /** The `tool_use` blocks in `content`, in order. */
1030
1015
  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;
1035
1016
  /** Coalesced copies: a call id and name for each block dropped. */
1036
1017
  duplicates: Array<{ id: string; name: string }>;
1037
1018
  }
1038
1019
 
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
-
1054
1020
  /**
1055
1021
  * Resolves an assistant reply's `tool_use` blocks into an executable set keyed
1056
1022
  * by call id: a block with no id gets one, and a repeat of an id already in the
@@ -1064,10 +1030,8 @@ function normalizeToolUseBlocks(
1064
1030
  ): NormalizedToolUse {
1065
1031
  const nextContent: ContentBlock[] = [];
1066
1032
  const toolUseBlocks: ToolUseBlock[] = [];
1067
- const executionInputsById = new Map<string, Record<string, unknown>>();
1068
1033
  const duplicates: Array<{ id: string; name: string }> = [];
1069
1034
  const seenIds = new Set<string>();
1070
- let unparseableCount = 0;
1071
1035
 
1072
1036
  for (const block of content) {
1073
1037
  if (block.type !== "tool_use") {
@@ -1078,28 +1042,14 @@ function normalizeToolUseBlocks(
1078
1042
  duplicates.push({ id: block.id, name: block.name });
1079
1043
  continue;
1080
1044
  }
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);
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);
1094
1050
  }
1095
1051
 
1096
- return {
1097
- content: nextContent,
1098
- toolUseBlocks,
1099
- executionInputsById,
1100
- unparseableCount,
1101
- duplicates,
1102
- };
1052
+ return { content: nextContent, toolUseBlocks, duplicates };
1103
1053
  }
1104
1054
 
1105
1055
  /**
@@ -1536,7 +1486,6 @@ export class AgentLoop {
1536
1486
  let newMessagesStart = history.length;
1537
1487
  let toolUseTurns = 0;
1538
1488
  let postModelCallContinues = 0;
1539
- let consecutiveUnparseableToolTurns = 0;
1540
1489
  // Whether the user has been told the OUTCOME of the work so far, not
1541
1490
  // merely that work started. True only while the most recent tool-bearing
1542
1491
  // response was `send_user_message` and nothing else: a response that sends
@@ -1792,7 +1741,6 @@ export class AgentLoop {
1792
1741
  // request from a generation that died mid-flight. Declared here so that
1793
1742
  // catch can reach it.
1794
1743
  let streamedTokens = false;
1795
- let unparseableToolRetryLimitReached = false;
1796
1744
 
1797
1745
  try {
1798
1746
  // ── Pre-call budget gate ─────────────────────────────────────
@@ -2780,44 +2728,6 @@ export class AgentLoop {
2780
2728
  content: normalizedToolUse.content,
2781
2729
  };
2782
2730
  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
- }
2821
2731
 
2822
2732
  // At the no-tool stop boundary the retry decision is actionable: a
2823
2733
  // recovery hook may repair history and ask to re-query (a tool-bearing
@@ -2826,7 +2736,6 @@ export class AgentLoop {
2826
2736
  // persisting it; the per-run backstop keeps a misbehaving hook from
2827
2737
  // spinning forever.
2828
2738
  if (
2829
- !unparseableToolRetryLimitReached &&
2830
2739
  toolUseBlocks.length === 0 &&
2831
2740
  postModelCallDecision === "continue"
2832
2741
  ) {
@@ -2888,40 +2797,11 @@ export class AgentLoop {
2888
2797
  // Surface the finalized text if the client saw nothing live (a
2889
2798
  // deferred stream, a hook-rewritten empty turn, or the tool-gated
2890
2799
  // fallback where no `send_user_message` call ever reached the user).
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, {
2800
+ const textVisibility = textVisibilityOf(
2801
+ emitFinalAssistantText(assistantMessage.content, {
2921
2802
  turnEnding: toolUseBlocks.length === 0,
2922
- });
2923
- }
2924
- const textVisibility = textVisibilityOf(fallbackSurfaced);
2803
+ }),
2804
+ );
2925
2805
 
2926
2806
  history.push(assistantMessage);
2927
2807
 
@@ -3034,8 +2914,7 @@ export class AgentLoop {
3034
2914
  }
3035
2915
  const result = await this.toolExecutor!(
3036
2916
  toolUse.name,
3037
- normalizedToolUse.executionInputsById.get(toolUse.id) ??
3038
- toolUse.input,
2917
+ toolUse.input,
3039
2918
  (chunk) => {
3040
2919
  onEvent({
3041
2920
  type: "tool_output_chunk",