@juspay/neurolink 10.10.0 → 10.10.2
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/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +394 -392
- package/dist/context/stages/structuredSummarizer.js +15 -3
- package/dist/context/summarizationEngine.js +12 -2
- package/dist/context/toolPairRepair.d.ts +34 -5
- package/dist/context/toolPairRepair.js +218 -43
- package/dist/core/modules/GenerationHandler.js +21 -2
- package/dist/core/modules/structuredOutputPolicy.d.ts +8 -0
- package/dist/core/modules/structuredOutputPolicy.js +8 -0
- package/dist/core/redisConversationMemoryManager.js +8 -0
- package/dist/lib/context/stages/structuredSummarizer.js +15 -3
- package/dist/lib/context/summarizationEngine.js +12 -2
- package/dist/lib/context/toolPairRepair.d.ts +34 -5
- package/dist/lib/context/toolPairRepair.js +218 -43
- package/dist/lib/core/modules/GenerationHandler.js +21 -2
- package/dist/lib/core/modules/structuredOutputPolicy.d.ts +8 -0
- package/dist/lib/core/modules/structuredOutputPolicy.js +8 -0
- package/dist/lib/core/redisConversationMemoryManager.js +8 -0
- package/dist/lib/providers/anthropic/client.js +105 -3
- package/dist/lib/providers/anthropic/structuredOutput.d.ts +58 -0
- package/dist/lib/providers/anthropic/structuredOutput.js +98 -0
- package/dist/lib/types/context.d.ts +12 -0
- package/dist/lib/types/conversation.d.ts +11 -0
- package/dist/lib/types/generate.d.ts +11 -0
- package/dist/lib/utils/conversationMemory.js +18 -2
- package/dist/providers/anthropic/client.js +105 -3
- package/dist/providers/anthropic/structuredOutput.d.ts +58 -0
- package/dist/providers/anthropic/structuredOutput.js +97 -0
- package/dist/types/context.d.ts +12 -0
- package/dist/types/conversation.d.ts +11 -0
- package/dist/types/generate.d.ts +11 -0
- package/dist/utils/conversationMemory.js +18 -2
- package/package.json +4 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
import { inlineJsonSchema } from "../../utils/schemaConversion.js";
|
|
3
|
+
/**
|
|
4
|
+
* Additive structured output for the native Anthropic Messages API.
|
|
5
|
+
*
|
|
6
|
+
* Anthropic has no `response_format`, so a schema has to be expressed as a
|
|
7
|
+
* tool. The provider's pre-existing `responseFormat` path does that by
|
|
8
|
+
* REPLACING the tools array with a single json tool and pinning `tool_choice`
|
|
9
|
+
* to it — correct for a schema-only call, but mutually exclusive with real
|
|
10
|
+
* tools, so agent/MCP turns that pass both silently lost the schema.
|
|
11
|
+
*
|
|
12
|
+
* The additive pattern here APPENDS a `final_result` tool to the caller's
|
|
13
|
+
* tools and leaves `tool_choice` on auto: the model keeps calling real tools
|
|
14
|
+
* for as long as it needs, then emits its answer as `final_result` arguments
|
|
15
|
+
* that already conform to the schema. This mirrors the native
|
|
16
|
+
* Claude-on-Vertex loop in `googleVertex/client.ts`, which has used the same
|
|
17
|
+
* tool name, description, and instruction wording since it shipped.
|
|
18
|
+
*/
|
|
19
|
+
/** Internal tool name — filtered out of every returned tool call / execution. */
|
|
20
|
+
export const FINAL_RESULT_TOOL_NAME = "final_result";
|
|
21
|
+
const FINAL_RESULT_TOOL_DESCRIPTION = "Return the final structured result. You MUST call this tool when you have gathered all information and are ready to provide the final answer. The arguments should contain the structured data matching the expected schema.";
|
|
22
|
+
/** Appended to the system prompt whenever the final_result tool is in play. */
|
|
23
|
+
export const FINAL_RESULT_INSTRUCTION = "\n\nIMPORTANT: You MUST call the 'final_result' tool to return your response in the required structured format. Do not respond with plain text - always use the final_result tool.";
|
|
24
|
+
/**
|
|
25
|
+
* Build the `final_result` tool definition from a JSON Schema.
|
|
26
|
+
*
|
|
27
|
+
* `$ref`s are inlined and `$schema` dropped — Anthropic's `input_schema` must
|
|
28
|
+
* be a self-contained object schema. Schemas that are not object-rooted (a
|
|
29
|
+
* bare array/string schema) are wrapped so `input_schema.type` is always
|
|
30
|
+
* "object", which the Messages API requires.
|
|
31
|
+
*/
|
|
32
|
+
export function buildFinalResultTool(jsonSchema) {
|
|
33
|
+
const inlined = inlineJsonSchema({ ...jsonSchema });
|
|
34
|
+
delete inlined.$schema;
|
|
35
|
+
const properties = inlined.properties;
|
|
36
|
+
const input_schema = {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: properties ?? inlined,
|
|
39
|
+
required: Array.isArray(inlined.required) ? inlined.required : [],
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
name: FINAL_RESULT_TOOL_NAME,
|
|
43
|
+
description: FINAL_RESULT_TOOL_DESCRIPTION,
|
|
44
|
+
input_schema,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Append `final_result` to an Anthropic tool list.
|
|
49
|
+
*
|
|
50
|
+
* Returns a NEW array so the caller's tool list is never mutated, and reports
|
|
51
|
+
* `applied: false` (with the list unchanged) when the pattern must not run:
|
|
52
|
+
* there are no real tools to preserve, or the caller already exposes a tool of
|
|
53
|
+
* that name — shadowing a caller's tool would break their turn.
|
|
54
|
+
*/
|
|
55
|
+
export function appendFinalResultTool(tools, jsonSchema) {
|
|
56
|
+
if (!tools || tools.length === 0) {
|
|
57
|
+
return { tools, applied: false };
|
|
58
|
+
}
|
|
59
|
+
if (tools.some((tool) => tool.name === FINAL_RESULT_TOOL_NAME)) {
|
|
60
|
+
logger.warn("[Anthropic] A caller tool is already named 'final_result'; skipping the additive structured-output tool");
|
|
61
|
+
return { tools, applied: false };
|
|
62
|
+
}
|
|
63
|
+
// Appended LAST so any cache_control breakpoint an upstream layer placed on
|
|
64
|
+
// the previously-last tool keeps marking the same prefix boundary.
|
|
65
|
+
return { tools: [...tools, buildFinalResultTool(jsonSchema)], applied: true };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Append the final_result instruction to an Anthropic `system` value.
|
|
69
|
+
*
|
|
70
|
+
* The block-array form gets a NEW trailing block rather than an edit to the
|
|
71
|
+
* existing one: rewriting a block that carries a `cache_control` marker would
|
|
72
|
+
* change the cached prefix and invalidate the prompt cache on every turn.
|
|
73
|
+
*/
|
|
74
|
+
export function appendFinalResultInstruction(system) {
|
|
75
|
+
if (system === undefined) {
|
|
76
|
+
return FINAL_RESULT_INSTRUCTION.trim();
|
|
77
|
+
}
|
|
78
|
+
if (typeof system === "string") {
|
|
79
|
+
return system + FINAL_RESULT_INSTRUCTION;
|
|
80
|
+
}
|
|
81
|
+
return [...system, { type: "text", text: FINAL_RESULT_INSTRUCTION.trim() }];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Canonical JSON text for a `final_result` payload.
|
|
85
|
+
*
|
|
86
|
+
* Accepts the raw accumulated `input_json` from a stream so a payload
|
|
87
|
+
* truncated by the token cap is still returned verbatim — the caller's
|
|
88
|
+
* coercion layer can repair it, whereas dropping it loses the whole answer.
|
|
89
|
+
*/
|
|
90
|
+
export function stringifyFinalResultInput(inputJson) {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.stringify(JSON.parse(inputJson || "{}"));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return inputJson;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=structuredOutput.js.map
|
|
@@ -412,6 +412,18 @@ export type RepairResult = {
|
|
|
412
412
|
orphanedCallsFixed: number;
|
|
413
413
|
orphanedResultsFixed: number;
|
|
414
414
|
};
|
|
415
|
+
/**
|
|
416
|
+
* One contiguous tool batch: the run of `tool_call` messages emitted by a
|
|
417
|
+
* single agent step, plus the run of `tool_result` messages that follows it.
|
|
418
|
+
* A step with parallel tool calls writes every call before any result, so the
|
|
419
|
+
* batch — not adjacency — is the unit that pairing and truncation operate on.
|
|
420
|
+
* `endIndex` is exclusive.
|
|
421
|
+
*/
|
|
422
|
+
export type RepairToolBatch = {
|
|
423
|
+
calls: ChatMessage[];
|
|
424
|
+
results: ChatMessage[];
|
|
425
|
+
endIndex: number;
|
|
426
|
+
};
|
|
415
427
|
/** Options for summarization prompt building. */
|
|
416
428
|
export type SummarizationPromptOptions = {
|
|
417
429
|
/**
|
|
@@ -284,6 +284,17 @@ export type ChatMessage = {
|
|
|
284
284
|
timestamp?: string;
|
|
285
285
|
/** Tool name (optional) - for tool_call/tool_result messages */
|
|
286
286
|
tool?: string;
|
|
287
|
+
/**
|
|
288
|
+
* Provider tool-call correlation ID, carried on BOTH the `tool_call` and its
|
|
289
|
+
* matching `tool_result`. This is the only reliable way to pair the two:
|
|
290
|
+
* a step with parallel tool calls is persisted as every `tool_call` followed
|
|
291
|
+
* by every `tool_result` (see flushPendingToolData), so adjacency does
|
|
292
|
+
* NOT imply pairing and position-based matching corrupts the batch.
|
|
293
|
+
*
|
|
294
|
+
* Optional for backward compatibility — sessions written before this field
|
|
295
|
+
* existed pair positionally within a batch (see repairToolPairs legacy mode).
|
|
296
|
+
*/
|
|
297
|
+
toolCallId?: string;
|
|
287
298
|
/** Tool arguments (optional) - for tool_call messages */
|
|
288
299
|
args?: Record<string, unknown>;
|
|
289
300
|
/** Tool result metadata (optional) - for tool_result messages */
|
|
@@ -269,6 +269,11 @@ export type GenerateOptions = {
|
|
|
269
269
|
* (see `coerceJsonToSchema`), and `disableTools: true` remains available as
|
|
270
270
|
* an explicit override.
|
|
271
271
|
*
|
|
272
|
+
* On the native Anthropic Messages surface (provider "anthropic", including
|
|
273
|
+
* via a proxy) tools + schema are honored through an internal `final_result`
|
|
274
|
+
* tool the model calls with the structured answer — invisible to callers: it
|
|
275
|
+
* never appears in `toolCalls` / `toolExecutions`.
|
|
276
|
+
*
|
|
272
277
|
* @example
|
|
273
278
|
* ```typescript
|
|
274
279
|
* // ✅ Vertex + Claude: tools AND schema together are fully supported
|
|
@@ -278,6 +283,12 @@ export type GenerateOptions = {
|
|
|
278
283
|
* model: "claude-sonnet-4-6",
|
|
279
284
|
* });
|
|
280
285
|
*
|
|
286
|
+
* // ✅ Direct Anthropic + tools: schema honored via the final_result tool
|
|
287
|
+
* const result = await neurolink.generate({
|
|
288
|
+
* schema: MySchema,
|
|
289
|
+
* provider: "anthropic",
|
|
290
|
+
* });
|
|
291
|
+
*
|
|
281
292
|
* // ✅ Gemini + tools: SDK auto-falls back to coerced text-mode JSON
|
|
282
293
|
* const result = await neurolink.generate({
|
|
283
294
|
* schema: MySchema,
|
|
@@ -9,6 +9,7 @@ import { safeDebugSerialize, sanitizeRecord } from "./logSanitize.js";
|
|
|
9
9
|
import { DEFAULT_FALLBACK_THRESHOLD, getConversationMemoryDefaults, MEMORY_THRESHOLD_PERCENTAGE, } from "../config/conversationMemory.js";
|
|
10
10
|
import { getAvailableInputTokens } from "../constants/contextWindows.js";
|
|
11
11
|
import { buildSummarizationPrompt } from "../context/prompts/summarizationPrompt.js";
|
|
12
|
+
import { repairToolPairs } from "../context/toolPairRepair.js";
|
|
12
13
|
import { logger } from "./logger.js";
|
|
13
14
|
const memoryTracer = tracers.memory;
|
|
14
15
|
/**
|
|
@@ -164,8 +165,23 @@ export async function getConversationMessages(conversationMemory, options) {
|
|
|
164
165
|
// against any future "fabricate-on-error" regression. Telemetry
|
|
165
166
|
// attributes record how many turns were dropped so polluted sessions
|
|
166
167
|
// are visible in Langfuse traces.
|
|
167
|
-
const
|
|
168
|
-
|
|
168
|
+
const filtered = rawMessages.filter((msg) => !isPollutedAssistantTurn(msg));
|
|
169
|
+
// Pair repair on READ, not just after compaction. buildContextFromPointer
|
|
170
|
+
// slices the history at the summary pointer, and a session interrupted
|
|
171
|
+
// mid-tool-batch is stored with calls whose results never arrived —
|
|
172
|
+
// either way the provider receives an orphan and hard-rejects the turn.
|
|
173
|
+
// No-ops (single linear scan) when the slice holds no tool messages.
|
|
174
|
+
const repair = repairToolPairs(filtered);
|
|
175
|
+
const messages = repair.messages;
|
|
176
|
+
if (repair.repaired) {
|
|
177
|
+
span.setAttribute("neurolink.memory.tool_pairs_repaired", repair.orphanedCallsFixed + repair.orphanedResultsFixed);
|
|
178
|
+
logger.debug("[conversationMemoryUtils] Repaired orphaned tool pairs on read", {
|
|
179
|
+
sessionId,
|
|
180
|
+
orphanedCallsFixed: repair.orphanedCallsFixed,
|
|
181
|
+
orphanedResultsFixed: repair.orphanedResultsFixed,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const droppedCount = rawMessages.length - filtered.length;
|
|
169
185
|
if (droppedCount > 0) {
|
|
170
186
|
// Span attribute is always set so polluted sessions stay visible in
|
|
171
187
|
// Langfuse traces on every read — that's the persistent debugging
|
|
@@ -30,6 +30,7 @@ import { toAnthropicImageBlock, fileToAnthropicBlock, } from "../anthropicImageB
|
|
|
30
30
|
import { resolveSamplingParams } from "../../models/modelRegistry.js";
|
|
31
31
|
import { createChunkQueue, createDeferredAnalytics, stringifyToolInput, } from "../openaiChatCompletionsClient.js";
|
|
32
32
|
import { ANTHROPIC_BETA_HEADERS } from "./constants.js";
|
|
33
|
+
import { appendFinalResultInstruction, appendFinalResultTool, FINAL_RESULT_TOOL_NAME, stringifyFinalResultInput, } from "./structuredOutput.js";
|
|
33
34
|
// AnthropicProviderConfig is imported from types/providers.ts
|
|
34
35
|
// Re-export for backward compatibility
|
|
35
36
|
// Configuration helpers - now using consolidated utility
|
|
@@ -1063,7 +1064,11 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1063
1064
|
supportedUrls: {},
|
|
1064
1065
|
doGenerate: async (options) => {
|
|
1065
1066
|
await refreshAuth();
|
|
1066
|
-
const
|
|
1067
|
+
const built = messagesToAnthropic(options.prompt);
|
|
1068
|
+
const messages = built.messages;
|
|
1069
|
+
// `let`: the additive structured-output path below appends the
|
|
1070
|
+
// final_result instruction to the system prompt.
|
|
1071
|
+
let system = built.system;
|
|
1067
1072
|
let tools = (options.tools ?? [])
|
|
1068
1073
|
.filter((t) => t.type === "function")
|
|
1069
1074
|
.map((t) => {
|
|
@@ -1107,6 +1112,24 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1107
1112
|
];
|
|
1108
1113
|
toolChoice = { type: "tool", name: jsonTool };
|
|
1109
1114
|
}
|
|
1115
|
+
// Additive structured output: when the caller wants a schema AND real
|
|
1116
|
+
// tools, the forced-json path above cannot be used (it replaces the
|
|
1117
|
+
// tools array), and the AI-SDK experimental_output path is excluded
|
|
1118
|
+
// for this surface by structuredOutputPolicy. GenerationHandler hands
|
|
1119
|
+
// the JSON Schema down here instead, and we APPEND a `final_result`
|
|
1120
|
+
// tool — tool_choice stays auto, so every real tool keeps working and
|
|
1121
|
+
// the model self-selects final_result when it is ready to answer.
|
|
1122
|
+
const finalResultSchema = options.providerOptions?.anthropic
|
|
1123
|
+
?.finalResultSchema;
|
|
1124
|
+
let finalResultActive = false;
|
|
1125
|
+
if (!jsonTool && finalResultSchema) {
|
|
1126
|
+
const appended = appendFinalResultTool(tools, finalResultSchema);
|
|
1127
|
+
tools = appended.tools;
|
|
1128
|
+
finalResultActive = appended.applied;
|
|
1129
|
+
if (appended.applied) {
|
|
1130
|
+
system = appendFinalResultInstruction(system);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1110
1133
|
// Extended thinking passthrough (providerOptions.anthropic.thinking).
|
|
1111
1134
|
const thinking = options.providerOptions?.anthropic?.thinking;
|
|
1112
1135
|
// Prompt-cache parity with the native Vertex+Claude path: upstream
|
|
@@ -1179,6 +1202,7 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1179
1202
|
timeoutController?.cleanup();
|
|
1180
1203
|
}
|
|
1181
1204
|
const content = [];
|
|
1205
|
+
let finalResultText;
|
|
1182
1206
|
for (const block of response.content) {
|
|
1183
1207
|
if (block.type === "thinking") {
|
|
1184
1208
|
content.push({ type: "reasoning", text: block.thinking });
|
|
@@ -1198,6 +1222,12 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1198
1222
|
text: stringifyToolInput(block.input),
|
|
1199
1223
|
});
|
|
1200
1224
|
}
|
|
1225
|
+
else if (finalResultActive &&
|
|
1226
|
+
block.name === FINAL_RESULT_TOOL_NAME) {
|
|
1227
|
+
// Internal pattern: never surfaced as a tool call. Its arguments
|
|
1228
|
+
// ARE the structured answer.
|
|
1229
|
+
finalResultText = stringifyToolInput(block.input);
|
|
1230
|
+
}
|
|
1201
1231
|
else {
|
|
1202
1232
|
content.push({
|
|
1203
1233
|
type: "tool-call",
|
|
@@ -1208,12 +1238,29 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1208
1238
|
}
|
|
1209
1239
|
}
|
|
1210
1240
|
}
|
|
1241
|
+
// final_result is terminal — parity with the native Claude-on-Vertex
|
|
1242
|
+
// and Gemini loops, which break out of the tool loop the moment it
|
|
1243
|
+
// arrives. Reasoning blocks are kept; any prose preamble and any tool
|
|
1244
|
+
// calls issued alongside it are dropped so `text` is exactly the
|
|
1245
|
+
// structured payload and the AI-SDK loop stops here.
|
|
1246
|
+
if (finalResultText !== undefined) {
|
|
1247
|
+
const reasoning = content.filter((part) => part.type === "reasoning");
|
|
1248
|
+
content.length = 0;
|
|
1249
|
+
content.push(...reasoning, { type: "text", text: finalResultText });
|
|
1250
|
+
logger.debug("[Anthropic] Extracted structured output from final_result tool (generate)", { chars: finalResultText.length });
|
|
1251
|
+
}
|
|
1211
1252
|
const cacheRead = response.usage.cache_read_input_tokens ?? 0;
|
|
1212
1253
|
const cacheWrite = response.usage.cache_creation_input_tokens ?? 0;
|
|
1213
1254
|
return {
|
|
1214
1255
|
content,
|
|
1215
1256
|
finishReason: {
|
|
1216
|
-
|
|
1257
|
+
// A final_result call ends the turn: the provider reports
|
|
1258
|
+
// stop_reason "tool_use", but no tool call is surfaced, so
|
|
1259
|
+
// reporting "tool-calls" would misread as a step-capped turn.
|
|
1260
|
+
// `raw` still carries the provider's verbatim stop_reason.
|
|
1261
|
+
unified: finalResultText !== undefined
|
|
1262
|
+
? "stop"
|
|
1263
|
+
: mapAnthropicStopReason(response.stop_reason),
|
|
1217
1264
|
raw: response.stop_reason ?? "stop",
|
|
1218
1265
|
},
|
|
1219
1266
|
usage: {
|
|
@@ -1341,6 +1388,9 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1341
1388
|
let anthropicTools;
|
|
1342
1389
|
let payload;
|
|
1343
1390
|
let shouldUseTools;
|
|
1391
|
+
// True once the additive `final_result` tool is in the request — the
|
|
1392
|
+
// streaming twin of the doGenerate path above.
|
|
1393
|
+
let finalResultActive = false;
|
|
1344
1394
|
try {
|
|
1345
1395
|
// options.tools is pre-merged by BaseProvider.stream() with base tools
|
|
1346
1396
|
// (MCP/built-in) + user-provided tools (RAG, etc.)
|
|
@@ -1355,6 +1405,18 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1355
1405
|
// convert to the Anthropic Messages payload (system + content blocks).
|
|
1356
1406
|
const built = await this.buildMessagesForStream(options);
|
|
1357
1407
|
payload = messagesToAnthropic(built);
|
|
1408
|
+
// Schema + tools: append final_result rather than pinning tool_choice to
|
|
1409
|
+
// a json tool, so the real tools stay callable for the whole turn.
|
|
1410
|
+
// Unlike generate, no plumbing is needed — this is a native loop, so the
|
|
1411
|
+
// caller's Zod/JSON schema is right here on the options.
|
|
1412
|
+
if (options.schema && anthropicTools && anthropicTools.length > 0) {
|
|
1413
|
+
const appended = appendFinalResultTool(anthropicTools, convertZodToJsonSchema(options.schema));
|
|
1414
|
+
anthropicTools = appended.tools;
|
|
1415
|
+
finalResultActive = appended.applied;
|
|
1416
|
+
if (appended.applied) {
|
|
1417
|
+
payload.system = appendFinalResultInstruction(payload.system);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1358
1420
|
}
|
|
1359
1421
|
catch (setupErr) {
|
|
1360
1422
|
timeoutController?.cleanup();
|
|
@@ -1439,6 +1501,14 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1439
1501
|
...(totalCacheRead > 0 ? { cacheReadTokens: totalCacheRead } : {}),
|
|
1440
1502
|
...(totalCacheWrite > 0 ? { cacheCreationTokens: totalCacheWrite } : {}),
|
|
1441
1503
|
});
|
|
1504
|
+
// Structured-output turns are delivered as ONE chunk, not incrementally:
|
|
1505
|
+
// a caller that passed a schema needs parseable JSON, and text deltas
|
|
1506
|
+
// emitted before the model calls final_result would prefix the payload
|
|
1507
|
+
// with prose and break every JSON.parse on the consumer side. Same
|
|
1508
|
+
// contract as the native Vertex loops. Non-schema streams are untouched
|
|
1509
|
+
// and stay fully incremental.
|
|
1510
|
+
let bufferedText = "";
|
|
1511
|
+
let finalResultText;
|
|
1442
1512
|
const runLoop = async () => {
|
|
1443
1513
|
const conversation = payload.messages.slice();
|
|
1444
1514
|
for (let step = 0; step < maxSteps; step++) {
|
|
@@ -1532,7 +1602,12 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1532
1602
|
const delta = event.delta;
|
|
1533
1603
|
if (delta.type === "text_delta") {
|
|
1534
1604
|
textAcc.set(event.index, (textAcc.get(event.index) ?? "") + delta.text);
|
|
1535
|
-
|
|
1605
|
+
if (finalResultActive) {
|
|
1606
|
+
bufferedText += delta.text;
|
|
1607
|
+
}
|
|
1608
|
+
else {
|
|
1609
|
+
pushChunk({ content: delta.text });
|
|
1610
|
+
}
|
|
1536
1611
|
}
|
|
1537
1612
|
else if (delta.type === "thinking_delta") {
|
|
1538
1613
|
const acc = thinkingAcc.get(event.index) ?? {
|
|
@@ -1568,6 +1643,20 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1568
1643
|
}
|
|
1569
1644
|
}
|
|
1570
1645
|
lastStop = stopReason;
|
|
1646
|
+
// final_result is terminal: its arguments ARE the answer, so the turn
|
|
1647
|
+
// ends here and any tool calls issued alongside it are not executed
|
|
1648
|
+
// (parity with the native Vertex loops). It is never executed as a
|
|
1649
|
+
// tool, never recorded in toolsUsed, and never stored as a tool
|
|
1650
|
+
// execution — the pattern stays invisible to callers.
|
|
1651
|
+
if (finalResultActive) {
|
|
1652
|
+
const finalCall = [...toolAcc.values()].find((acc) => acc.name === FINAL_RESULT_TOOL_NAME);
|
|
1653
|
+
if (finalCall) {
|
|
1654
|
+
finalResultText = stringifyFinalResultInput(finalCall.inputJson);
|
|
1655
|
+
lastStop = "end_turn";
|
|
1656
|
+
logger.debug("[Anthropic] Extracted structured output from final_result tool (stream)", { chars: finalResultText.length });
|
|
1657
|
+
break;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1571
1660
|
if (stopReason !== "tool_use" || toolAcc.size === 0) {
|
|
1572
1661
|
break;
|
|
1573
1662
|
}
|
|
@@ -1710,6 +1799,19 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1710
1799
|
throw this.formatProviderError(error);
|
|
1711
1800
|
})
|
|
1712
1801
|
.finally(() => {
|
|
1802
|
+
// Deliver the buffered structured-output turn: `finalResultText` when
|
|
1803
|
+
// the model called final_result, otherwise the prose it produced
|
|
1804
|
+
// instead — never nothing, so a model that ignores the instruction
|
|
1805
|
+
// degrades to today's plain-text behaviour rather than an empty
|
|
1806
|
+
// stream. In `finally` so a turn that dies mid-loop still surfaces
|
|
1807
|
+
// the text it had already buffered, exactly as the unbuffered path
|
|
1808
|
+
// surfaces its partial deltas.
|
|
1809
|
+
if (finalResultActive) {
|
|
1810
|
+
const output = finalResultText ?? bufferedText;
|
|
1811
|
+
if (output.length > 0) {
|
|
1812
|
+
pushChunk({ content: output });
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1713
1815
|
timeoutController?.cleanup();
|
|
1714
1816
|
pushChunk({ done: true });
|
|
1715
1817
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* Additive structured output for the native Anthropic Messages API.
|
|
4
|
+
*
|
|
5
|
+
* Anthropic has no `response_format`, so a schema has to be expressed as a
|
|
6
|
+
* tool. The provider's pre-existing `responseFormat` path does that by
|
|
7
|
+
* REPLACING the tools array with a single json tool and pinning `tool_choice`
|
|
8
|
+
* to it — correct for a schema-only call, but mutually exclusive with real
|
|
9
|
+
* tools, so agent/MCP turns that pass both silently lost the schema.
|
|
10
|
+
*
|
|
11
|
+
* The additive pattern here APPENDS a `final_result` tool to the caller's
|
|
12
|
+
* tools and leaves `tool_choice` on auto: the model keeps calling real tools
|
|
13
|
+
* for as long as it needs, then emits its answer as `final_result` arguments
|
|
14
|
+
* that already conform to the schema. This mirrors the native
|
|
15
|
+
* Claude-on-Vertex loop in `googleVertex/client.ts`, which has used the same
|
|
16
|
+
* tool name, description, and instruction wording since it shipped.
|
|
17
|
+
*/
|
|
18
|
+
/** Internal tool name — filtered out of every returned tool call / execution. */
|
|
19
|
+
export declare const FINAL_RESULT_TOOL_NAME = "final_result";
|
|
20
|
+
/** Appended to the system prompt whenever the final_result tool is in play. */
|
|
21
|
+
export declare const FINAL_RESULT_INSTRUCTION = "\n\nIMPORTANT: You MUST call the 'final_result' tool to return your response in the required structured format. Do not respond with plain text - always use the final_result tool.";
|
|
22
|
+
/**
|
|
23
|
+
* Build the `final_result` tool definition from a JSON Schema.
|
|
24
|
+
*
|
|
25
|
+
* `$ref`s are inlined and `$schema` dropped — Anthropic's `input_schema` must
|
|
26
|
+
* be a self-contained object schema. Schemas that are not object-rooted (a
|
|
27
|
+
* bare array/string schema) are wrapped so `input_schema.type` is always
|
|
28
|
+
* "object", which the Messages API requires.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildFinalResultTool(jsonSchema: Record<string, unknown>): Anthropic.Messages.Tool;
|
|
31
|
+
/**
|
|
32
|
+
* Append `final_result` to an Anthropic tool list.
|
|
33
|
+
*
|
|
34
|
+
* Returns a NEW array so the caller's tool list is never mutated, and reports
|
|
35
|
+
* `applied: false` (with the list unchanged) when the pattern must not run:
|
|
36
|
+
* there are no real tools to preserve, or the caller already exposes a tool of
|
|
37
|
+
* that name — shadowing a caller's tool would break their turn.
|
|
38
|
+
*/
|
|
39
|
+
export declare function appendFinalResultTool(tools: Anthropic.Messages.Tool[] | undefined, jsonSchema: Record<string, unknown>): {
|
|
40
|
+
tools: Anthropic.Messages.Tool[] | undefined;
|
|
41
|
+
applied: boolean;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Append the final_result instruction to an Anthropic `system` value.
|
|
45
|
+
*
|
|
46
|
+
* The block-array form gets a NEW trailing block rather than an edit to the
|
|
47
|
+
* existing one: rewriting a block that carries a `cache_control` marker would
|
|
48
|
+
* change the cached prefix and invalidate the prompt cache on every turn.
|
|
49
|
+
*/
|
|
50
|
+
export declare function appendFinalResultInstruction(system: string | Anthropic.Messages.TextBlockParam[] | undefined): string | Anthropic.Messages.TextBlockParam[];
|
|
51
|
+
/**
|
|
52
|
+
* Canonical JSON text for a `final_result` payload.
|
|
53
|
+
*
|
|
54
|
+
* Accepts the raw accumulated `input_json` from a stream so a payload
|
|
55
|
+
* truncated by the token cap is still returned verbatim — the caller's
|
|
56
|
+
* coercion layer can repair it, whereas dropping it loses the whole answer.
|
|
57
|
+
*/
|
|
58
|
+
export declare function stringifyFinalResultInput(inputJson: string): string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
import { inlineJsonSchema } from "../../utils/schemaConversion.js";
|
|
3
|
+
/**
|
|
4
|
+
* Additive structured output for the native Anthropic Messages API.
|
|
5
|
+
*
|
|
6
|
+
* Anthropic has no `response_format`, so a schema has to be expressed as a
|
|
7
|
+
* tool. The provider's pre-existing `responseFormat` path does that by
|
|
8
|
+
* REPLACING the tools array with a single json tool and pinning `tool_choice`
|
|
9
|
+
* to it — correct for a schema-only call, but mutually exclusive with real
|
|
10
|
+
* tools, so agent/MCP turns that pass both silently lost the schema.
|
|
11
|
+
*
|
|
12
|
+
* The additive pattern here APPENDS a `final_result` tool to the caller's
|
|
13
|
+
* tools and leaves `tool_choice` on auto: the model keeps calling real tools
|
|
14
|
+
* for as long as it needs, then emits its answer as `final_result` arguments
|
|
15
|
+
* that already conform to the schema. This mirrors the native
|
|
16
|
+
* Claude-on-Vertex loop in `googleVertex/client.ts`, which has used the same
|
|
17
|
+
* tool name, description, and instruction wording since it shipped.
|
|
18
|
+
*/
|
|
19
|
+
/** Internal tool name — filtered out of every returned tool call / execution. */
|
|
20
|
+
export const FINAL_RESULT_TOOL_NAME = "final_result";
|
|
21
|
+
const FINAL_RESULT_TOOL_DESCRIPTION = "Return the final structured result. You MUST call this tool when you have gathered all information and are ready to provide the final answer. The arguments should contain the structured data matching the expected schema.";
|
|
22
|
+
/** Appended to the system prompt whenever the final_result tool is in play. */
|
|
23
|
+
export const FINAL_RESULT_INSTRUCTION = "\n\nIMPORTANT: You MUST call the 'final_result' tool to return your response in the required structured format. Do not respond with plain text - always use the final_result tool.";
|
|
24
|
+
/**
|
|
25
|
+
* Build the `final_result` tool definition from a JSON Schema.
|
|
26
|
+
*
|
|
27
|
+
* `$ref`s are inlined and `$schema` dropped — Anthropic's `input_schema` must
|
|
28
|
+
* be a self-contained object schema. Schemas that are not object-rooted (a
|
|
29
|
+
* bare array/string schema) are wrapped so `input_schema.type` is always
|
|
30
|
+
* "object", which the Messages API requires.
|
|
31
|
+
*/
|
|
32
|
+
export function buildFinalResultTool(jsonSchema) {
|
|
33
|
+
const inlined = inlineJsonSchema({ ...jsonSchema });
|
|
34
|
+
delete inlined.$schema;
|
|
35
|
+
const properties = inlined.properties;
|
|
36
|
+
const input_schema = {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: properties ?? inlined,
|
|
39
|
+
required: Array.isArray(inlined.required) ? inlined.required : [],
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
name: FINAL_RESULT_TOOL_NAME,
|
|
43
|
+
description: FINAL_RESULT_TOOL_DESCRIPTION,
|
|
44
|
+
input_schema,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Append `final_result` to an Anthropic tool list.
|
|
49
|
+
*
|
|
50
|
+
* Returns a NEW array so the caller's tool list is never mutated, and reports
|
|
51
|
+
* `applied: false` (with the list unchanged) when the pattern must not run:
|
|
52
|
+
* there are no real tools to preserve, or the caller already exposes a tool of
|
|
53
|
+
* that name — shadowing a caller's tool would break their turn.
|
|
54
|
+
*/
|
|
55
|
+
export function appendFinalResultTool(tools, jsonSchema) {
|
|
56
|
+
if (!tools || tools.length === 0) {
|
|
57
|
+
return { tools, applied: false };
|
|
58
|
+
}
|
|
59
|
+
if (tools.some((tool) => tool.name === FINAL_RESULT_TOOL_NAME)) {
|
|
60
|
+
logger.warn("[Anthropic] A caller tool is already named 'final_result'; skipping the additive structured-output tool");
|
|
61
|
+
return { tools, applied: false };
|
|
62
|
+
}
|
|
63
|
+
// Appended LAST so any cache_control breakpoint an upstream layer placed on
|
|
64
|
+
// the previously-last tool keeps marking the same prefix boundary.
|
|
65
|
+
return { tools: [...tools, buildFinalResultTool(jsonSchema)], applied: true };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Append the final_result instruction to an Anthropic `system` value.
|
|
69
|
+
*
|
|
70
|
+
* The block-array form gets a NEW trailing block rather than an edit to the
|
|
71
|
+
* existing one: rewriting a block that carries a `cache_control` marker would
|
|
72
|
+
* change the cached prefix and invalidate the prompt cache on every turn.
|
|
73
|
+
*/
|
|
74
|
+
export function appendFinalResultInstruction(system) {
|
|
75
|
+
if (system === undefined) {
|
|
76
|
+
return FINAL_RESULT_INSTRUCTION.trim();
|
|
77
|
+
}
|
|
78
|
+
if (typeof system === "string") {
|
|
79
|
+
return system + FINAL_RESULT_INSTRUCTION;
|
|
80
|
+
}
|
|
81
|
+
return [...system, { type: "text", text: FINAL_RESULT_INSTRUCTION.trim() }];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Canonical JSON text for a `final_result` payload.
|
|
85
|
+
*
|
|
86
|
+
* Accepts the raw accumulated `input_json` from a stream so a payload
|
|
87
|
+
* truncated by the token cap is still returned verbatim — the caller's
|
|
88
|
+
* coercion layer can repair it, whereas dropping it loses the whole answer.
|
|
89
|
+
*/
|
|
90
|
+
export function stringifyFinalResultInput(inputJson) {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.stringify(JSON.parse(inputJson || "{}"));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return inputJson;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/types/context.d.ts
CHANGED
|
@@ -412,6 +412,18 @@ export type RepairResult = {
|
|
|
412
412
|
orphanedCallsFixed: number;
|
|
413
413
|
orphanedResultsFixed: number;
|
|
414
414
|
};
|
|
415
|
+
/**
|
|
416
|
+
* One contiguous tool batch: the run of `tool_call` messages emitted by a
|
|
417
|
+
* single agent step, plus the run of `tool_result` messages that follows it.
|
|
418
|
+
* A step with parallel tool calls writes every call before any result, so the
|
|
419
|
+
* batch — not adjacency — is the unit that pairing and truncation operate on.
|
|
420
|
+
* `endIndex` is exclusive.
|
|
421
|
+
*/
|
|
422
|
+
export type RepairToolBatch = {
|
|
423
|
+
calls: ChatMessage[];
|
|
424
|
+
results: ChatMessage[];
|
|
425
|
+
endIndex: number;
|
|
426
|
+
};
|
|
415
427
|
/** Options for summarization prompt building. */
|
|
416
428
|
export type SummarizationPromptOptions = {
|
|
417
429
|
/**
|
|
@@ -284,6 +284,17 @@ export type ChatMessage = {
|
|
|
284
284
|
timestamp?: string;
|
|
285
285
|
/** Tool name (optional) - for tool_call/tool_result messages */
|
|
286
286
|
tool?: string;
|
|
287
|
+
/**
|
|
288
|
+
* Provider tool-call correlation ID, carried on BOTH the `tool_call` and its
|
|
289
|
+
* matching `tool_result`. This is the only reliable way to pair the two:
|
|
290
|
+
* a step with parallel tool calls is persisted as every `tool_call` followed
|
|
291
|
+
* by every `tool_result` (see flushPendingToolData), so adjacency does
|
|
292
|
+
* NOT imply pairing and position-based matching corrupts the batch.
|
|
293
|
+
*
|
|
294
|
+
* Optional for backward compatibility — sessions written before this field
|
|
295
|
+
* existed pair positionally within a batch (see repairToolPairs legacy mode).
|
|
296
|
+
*/
|
|
297
|
+
toolCallId?: string;
|
|
287
298
|
/** Tool arguments (optional) - for tool_call messages */
|
|
288
299
|
args?: Record<string, unknown>;
|
|
289
300
|
/** Tool result metadata (optional) - for tool_result messages */
|
package/dist/types/generate.d.ts
CHANGED
|
@@ -269,6 +269,11 @@ export type GenerateOptions = {
|
|
|
269
269
|
* (see `coerceJsonToSchema`), and `disableTools: true` remains available as
|
|
270
270
|
* an explicit override.
|
|
271
271
|
*
|
|
272
|
+
* On the native Anthropic Messages surface (provider "anthropic", including
|
|
273
|
+
* via a proxy) tools + schema are honored through an internal `final_result`
|
|
274
|
+
* tool the model calls with the structured answer — invisible to callers: it
|
|
275
|
+
* never appears in `toolCalls` / `toolExecutions`.
|
|
276
|
+
*
|
|
272
277
|
* @example
|
|
273
278
|
* ```typescript
|
|
274
279
|
* // ✅ Vertex + Claude: tools AND schema together are fully supported
|
|
@@ -278,6 +283,12 @@ export type GenerateOptions = {
|
|
|
278
283
|
* model: "claude-sonnet-4-6",
|
|
279
284
|
* });
|
|
280
285
|
*
|
|
286
|
+
* // ✅ Direct Anthropic + tools: schema honored via the final_result tool
|
|
287
|
+
* const result = await neurolink.generate({
|
|
288
|
+
* schema: MySchema,
|
|
289
|
+
* provider: "anthropic",
|
|
290
|
+
* });
|
|
291
|
+
*
|
|
281
292
|
* // ✅ Gemini + tools: SDK auto-falls back to coerced text-mode JSON
|
|
282
293
|
* const result = await neurolink.generate({
|
|
283
294
|
* schema: MySchema,
|
|
@@ -9,6 +9,7 @@ import { safeDebugSerialize, sanitizeRecord } from "./logSanitize.js";
|
|
|
9
9
|
import { DEFAULT_FALLBACK_THRESHOLD, getConversationMemoryDefaults, MEMORY_THRESHOLD_PERCENTAGE, } from "../config/conversationMemory.js";
|
|
10
10
|
import { getAvailableInputTokens } from "../constants/contextWindows.js";
|
|
11
11
|
import { buildSummarizationPrompt } from "../context/prompts/summarizationPrompt.js";
|
|
12
|
+
import { repairToolPairs } from "../context/toolPairRepair.js";
|
|
12
13
|
import { logger } from "./logger.js";
|
|
13
14
|
const memoryTracer = tracers.memory;
|
|
14
15
|
/**
|
|
@@ -164,8 +165,23 @@ export async function getConversationMessages(conversationMemory, options) {
|
|
|
164
165
|
// against any future "fabricate-on-error" regression. Telemetry
|
|
165
166
|
// attributes record how many turns were dropped so polluted sessions
|
|
166
167
|
// are visible in Langfuse traces.
|
|
167
|
-
const
|
|
168
|
-
|
|
168
|
+
const filtered = rawMessages.filter((msg) => !isPollutedAssistantTurn(msg));
|
|
169
|
+
// Pair repair on READ, not just after compaction. buildContextFromPointer
|
|
170
|
+
// slices the history at the summary pointer, and a session interrupted
|
|
171
|
+
// mid-tool-batch is stored with calls whose results never arrived —
|
|
172
|
+
// either way the provider receives an orphan and hard-rejects the turn.
|
|
173
|
+
// No-ops (single linear scan) when the slice holds no tool messages.
|
|
174
|
+
const repair = repairToolPairs(filtered);
|
|
175
|
+
const messages = repair.messages;
|
|
176
|
+
if (repair.repaired) {
|
|
177
|
+
span.setAttribute("neurolink.memory.tool_pairs_repaired", repair.orphanedCallsFixed + repair.orphanedResultsFixed);
|
|
178
|
+
logger.debug("[conversationMemoryUtils] Repaired orphaned tool pairs on read", {
|
|
179
|
+
sessionId,
|
|
180
|
+
orphanedCallsFixed: repair.orphanedCallsFixed,
|
|
181
|
+
orphanedResultsFixed: repair.orphanedResultsFixed,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const droppedCount = rawMessages.length - filtered.length;
|
|
169
185
|
if (droppedCount > 0) {
|
|
170
186
|
// Span attribute is always set so polluted sessions stay visible in
|
|
171
187
|
// Langfuse traces on every read — that's the persistent debugging
|