@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.
- package/.env.example +2 -0
- package/ARCHITECTURE.md +14 -14
- package/docs/architecture/integrations.md +5 -5
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
- package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
- package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/openapi.yaml +153 -5
- package/package.json +1 -1
- package/src/__tests__/channel-policy.test.ts +7 -0
- package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
- package/src/__tests__/openai-provider.test.ts +27 -0
- package/src/__tests__/skill-execute-input.test.ts +11 -0
- package/src/__tests__/tool-approval-handler.test.ts +4 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
- package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
- package/src/agent/loop.ts +133 -12
- package/src/calls/__tests__/call-controller.test.ts +273 -1
- package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
- package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-controller.ts +359 -25
- package/src/calls/media-stream-server.ts +18 -0
- package/src/calls/media-stream-stt-session.ts +30 -9
- package/src/channels/config.ts +8 -0
- package/src/cli/lib/bundled-marketplace.json +39 -0
- package/src/config/composer-preferences.test.ts +97 -0
- package/src/config/composer-preferences.ts +95 -0
- package/src/config/feature-flag-registry.json +16 -8
- package/src/config/schemas/llm.ts +1 -0
- package/src/contacts/types.ts +1 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
- package/src/daemon/conversation-agent-loop.ts +3 -0
- package/src/daemon/conversation-messaging.ts +4 -0
- package/src/daemon/conversation-process.ts +15 -0
- package/src/daemon/conversation-queue-manager.ts +2 -0
- package/src/daemon/conversation.ts +2 -0
- package/src/daemon/handlers/shared.ts +13 -2
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/process-message.ts +5 -0
- package/src/i18n/__tests__/i18n.test.ts +25 -0
- package/src/i18n/assistant-text.ts +18 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/messages.ts +11 -0
- package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
- package/src/ipc/routes/route-adapter.ts +9 -0
- package/src/notifications/README.md +1 -1
- package/src/notifications/signal.ts +4 -0
- package/src/persistence/conversation-key-store.ts +12 -11
- package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
- package/src/providers/inference/adapter-factory.ts +6 -0
- package/src/providers/ionet/client.test.ts +143 -0
- package/src/providers/ionet/client.ts +37 -0
- package/src/providers/model-catalog.ts +40 -0
- package/src/providers/openai/chat-completions-provider.ts +33 -2
- package/src/providers/retry.ts +10 -4
- package/src/providers/unparseable-tool-args.ts +29 -5
- package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
- package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
- package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
- package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
- package/src/runtime/auth/route-policy.ts +44 -0
- package/src/runtime/auth/scopes.ts +15 -0
- package/src/runtime/auth/types.ts +1 -0
- package/src/runtime/channel-reply-delivery.ts +2 -0
- package/src/runtime/http-router.ts +61 -1
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
- package/src/runtime/migrations/vbundle-builder.ts +19 -3
- package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
- package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
- package/src/runtime/migrations/vbundle-validator.ts +54 -1
- package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
- package/src/runtime/routes/composer-settings-routes.ts +76 -0
- package/src/runtime/routes/conversation-routes.ts +54 -14
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/migration-routes.ts +3 -1
- package/src/runtime/routes/types.ts +4 -1
- package/src/runtime/shared-principal-lookup.ts +235 -0
- package/src/tools/host-terminal/host-shell.ts +4 -2
- package/src/tools/shared/shell-output.test.ts +42 -0
- package/src/tools/shared/shell-output.ts +27 -4
- package/src/tools/skills/execute.ts +10 -7
- package/src/tools/terminal/shell.ts +2 -0
- 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
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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 {
|
|
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
|
-
|
|
2801
|
-
|
|
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.
|
|
3037
|
+
normalizedToolUse.executionInputsById.get(toolUse.id) ??
|
|
3038
|
+
toolUse.input,
|
|
2918
3039
|
(chunk) => {
|
|
2919
3040
|
onEvent({
|
|
2920
3041
|
type: "tool_output_chunk",
|