@mcoda/codali 0.1.108 → 0.1.110
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/dist/agents/AgentInventory.d.ts +56 -0
- package/dist/agents/AgentInventory.d.ts.map +1 -0
- package/dist/agents/AgentInventory.js +131 -0
- package/dist/agents/RoleResolution.d.ts +49 -0
- package/dist/agents/RoleResolution.d.ts.map +1 -0
- package/dist/agents/RoleResolution.js +166 -0
- package/dist/api/ChatCompletionsAdapter.d.ts +76 -0
- package/dist/api/ChatCompletionsAdapter.d.ts.map +1 -0
- package/dist/api/ChatCompletionsAdapter.js +54 -0
- package/dist/api/CodaliApi.d.ts +78 -0
- package/dist/api/CodaliApi.d.ts.map +1 -0
- package/dist/api/CodaliApi.js +273 -0
- package/dist/api/CodaliServer.d.ts +42 -0
- package/dist/api/CodaliServer.d.ts.map +1 -0
- package/dist/api/CodaliServer.js +118 -0
- package/dist/cli/AskCommand.d.ts +66 -0
- package/dist/cli/AskCommand.d.ts.map +1 -0
- package/dist/cli/AskCommand.js +536 -0
- package/dist/cli/AuthCommand.d.ts +19 -0
- package/dist/cli/AuthCommand.d.ts.map +1 -0
- package/dist/cli/AuthCommand.js +161 -0
- package/dist/cli/ServeCommand.d.ts +5 -0
- package/dist/cli/ServeCommand.d.ts.map +1 -0
- package/dist/cli/ServeCommand.js +87 -0
- package/dist/cli/ToolsCommand.d.ts +22 -0
- package/dist/cli/ToolsCommand.d.ts.map +1 -0
- package/dist/cli/ToolsCommand.js +208 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +29 -1
- package/dist/connectors/http/HttpToolDefinition.d.ts +74 -0
- package/dist/connectors/http/HttpToolDefinition.d.ts.map +1 -0
- package/dist/connectors/http/HttpToolDefinition.js +190 -0
- package/dist/connectors/http/HttpToolSource.d.ts +18 -0
- package/dist/connectors/http/HttpToolSource.d.ts.map +1 -0
- package/dist/connectors/http/HttpToolSource.js +29 -0
- package/dist/connectors/mcp/McpClient.d.ts +132 -0
- package/dist/connectors/mcp/McpClient.d.ts.map +1 -0
- package/dist/connectors/mcp/McpClient.js +215 -0
- package/dist/connectors/mcp/McpServerRegistry.d.ts +75 -0
- package/dist/connectors/mcp/McpServerRegistry.d.ts.map +1 -0
- package/dist/connectors/mcp/McpServerRegistry.js +180 -0
- package/dist/connectors/mcp/McpToolAdapter.d.ts +48 -0
- package/dist/connectors/mcp/McpToolAdapter.d.ts.map +1 -0
- package/dist/connectors/mcp/McpToolAdapter.js +101 -0
- package/dist/connectors/mcp/McpToolSource.d.ts +27 -0
- package/dist/connectors/mcp/McpToolSource.d.ts.map +1 -0
- package/dist/connectors/mcp/McpToolSource.js +33 -0
- package/dist/connectors/media/ImageGenerationTool.d.ts +43 -0
- package/dist/connectors/media/ImageGenerationTool.d.ts.map +1 -0
- package/dist/connectors/media/ImageGenerationTool.js +122 -0
- package/dist/connectors/oauth/DeviceCodeAuth.d.ts +74 -0
- package/dist/connectors/oauth/DeviceCodeAuth.d.ts.map +1 -0
- package/dist/connectors/oauth/DeviceCodeAuth.js +153 -0
- package/dist/docdex/DocdexClient.d.ts +2 -0
- package/dist/docdex/DocdexClient.d.ts.map +1 -1
- package/dist/docdex/DocdexClient.js +4 -0
- package/dist/gateway/CodaliGateway.d.ts +51 -3
- package/dist/gateway/CodaliGateway.d.ts.map +1 -1
- package/dist/gateway/CodaliGateway.js +241 -3
- package/dist/gateway/CodaliGatewayTypes.d.ts +34 -0
- package/dist/gateway/CodaliGatewayTypes.d.ts.map +1 -1
- package/dist/gateway/EvidenceNormalizer.d.ts.map +1 -1
- package/dist/gateway/EvidenceNormalizer.js +98 -1
- package/dist/gateway/Finalizer.d.ts +50 -0
- package/dist/gateway/Finalizer.d.ts.map +1 -0
- package/dist/gateway/Finalizer.js +79 -0
- package/dist/gateway/GatewayPlanner.d.ts +13 -0
- package/dist/gateway/GatewayPlanner.d.ts.map +1 -1
- package/dist/gateway/GatewayPlanner.js +81 -16
- package/dist/gateway/GatewayStateMachine.d.ts.map +1 -1
- package/dist/gateway/GatewayStateMachine.js +6 -0
- package/dist/gateway/GatewayTracer.d.ts +93 -0
- package/dist/gateway/GatewayTracer.d.ts.map +1 -0
- package/dist/gateway/GatewayTracer.js +214 -0
- package/dist/gateway/LocalGatewayProvider.d.ts +59 -0
- package/dist/gateway/LocalGatewayProvider.d.ts.map +1 -0
- package/dist/gateway/LocalGatewayProvider.js +195 -0
- package/dist/gateway/LocalGatewayTaskRunner.d.ts +79 -0
- package/dist/gateway/LocalGatewayTaskRunner.d.ts.map +1 -0
- package/dist/gateway/LocalGatewayTaskRunner.js +361 -0
- package/dist/gateway/RelevanceJudge.d.ts +25 -0
- package/dist/gateway/RelevanceJudge.d.ts.map +1 -0
- package/dist/gateway/RelevanceJudge.js +124 -0
- package/dist/gateway/ResponseSchema.d.ts +32 -0
- package/dist/gateway/ResponseSchema.d.ts.map +1 -0
- package/dist/gateway/ResponseSchema.js +120 -0
- package/dist/gateway/TemporalContext.d.ts +36 -0
- package/dist/gateway/TemporalContext.d.ts.map +1 -0
- package/dist/gateway/TemporalContext.js +115 -0
- package/dist/gateway/ToolCapabilityCompiler.d.ts +5 -0
- package/dist/gateway/ToolCapabilityCompiler.d.ts.map +1 -1
- package/dist/gateway/ToolCapabilityCompiler.js +27 -2
- package/dist/gateway/ToolDescriptorSource.d.ts +6 -0
- package/dist/gateway/ToolDescriptorSource.d.ts.map +1 -0
- package/dist/gateway/ToolDescriptorSource.js +30 -0
- package/dist/gateway/ToolExposure.d.ts +36 -0
- package/dist/gateway/ToolExposure.d.ts.map +1 -0
- package/dist/gateway/ToolExposure.js +154 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -0
- package/dist/providers/ClaudeCliProvider.d.ts +2 -0
- package/dist/providers/ClaudeCliProvider.d.ts.map +1 -1
- package/dist/providers/ClaudeCliProvider.js +2 -0
- package/dist/providers/CodexCliProvider.d.ts +2 -0
- package/dist/providers/CodexCliProvider.d.ts.map +1 -1
- package/dist/providers/CodexCliProvider.js +2 -0
- package/dist/providers/MswarmWorkerProvider.d.ts +2 -0
- package/dist/providers/MswarmWorkerProvider.d.ts.map +1 -1
- package/dist/providers/MswarmWorkerProvider.js +2 -0
- package/dist/providers/OllamaRemoteProvider.d.ts +2 -0
- package/dist/providers/OllamaRemoteProvider.d.ts.map +1 -1
- package/dist/providers/OllamaRemoteProvider.js +2 -0
- package/dist/providers/OpenAiCompatibleProvider.d.ts +2 -0
- package/dist/providers/OpenAiCompatibleProvider.d.ts.map +1 -1
- package/dist/providers/OpenAiCompatibleProvider.js +2 -0
- package/dist/providers/ProviderTypes.d.ts +13 -0
- package/dist/providers/ProviderTypes.d.ts.map +1 -1
- package/dist/providers/ProviderTypes.js +1 -1
- package/dist/runcontext/CredentialFile.d.ts +12 -0
- package/dist/runcontext/CredentialFile.d.ts.map +1 -0
- package/dist/runcontext/CredentialFile.js +81 -0
- package/dist/runcontext/RunContextResolver.d.ts +126 -0
- package/dist/runcontext/RunContextResolver.d.ts.map +1 -0
- package/dist/runcontext/RunContextResolver.js +389 -0
- package/dist/storage/CodaliDatasetPrivacyEngine.d.ts +1 -1
- package/dist/storage/CodaliDatasetPrivacyEngine.d.ts.map +1 -1
- package/dist/storage/CodaliDatasetPrivacyEngine.js +12 -0
- package/dist/tools/ToolRegistry.d.ts +14 -1
- package/dist/tools/ToolRegistry.d.ts.map +1 -1
- package/dist/tools/ToolRegistry.js +38 -1
- package/dist/tools/ToolTypes.d.ts +35 -0
- package/dist/tools/ToolTypes.d.ts.map +1 -1
- package/dist/tools/ToolTypes.js +21 -0
- package/dist/tools/TruncateResult.d.ts +29 -0
- package/dist/tools/TruncateResult.d.ts.map +1 -0
- package/dist/tools/TruncateResult.js +91 -0
- package/dist/tools/diff/DiffTool.d.ts.map +1 -1
- package/dist/tools/diff/DiffTool.js +2 -0
- package/dist/tools/docdex/DocdexTools.d.ts +21 -1
- package/dist/tools/docdex/DocdexTools.d.ts.map +1 -1
- package/dist/tools/docdex/DocdexTools.js +151 -17
- package/dist/tools/filesystem/FileTools.d.ts.map +1 -1
- package/dist/tools/filesystem/FileTools.js +8 -0
- package/dist/tools/search/SearchTool.d.ts.map +1 -1
- package/dist/tools/search/SearchTool.js +3 -1
- package/dist/tools/shell/ShellTool.d.ts.map +1 -1
- package/dist/tools/shell/ShellTool.js +2 -0
- package/package.json +4 -3
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { truncateToolResult } from "../tools/TruncateResult.js";
|
|
2
|
+
import { CODALI_GATEWAY_SECURITY_PROMPT_HARDENING } from "./GatewaySecurityPolicy.js";
|
|
3
|
+
/**
|
|
4
|
+
* Bounded worker-task runner.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists
|
|
7
|
+
*
|
|
8
|
+
* Codali previously executed every gateway worker task by handing it to the
|
|
9
|
+
* full `CodaliRuntime` agent loop (see `createGatewayTaskRunner` in
|
|
10
|
+
* `packages/mswarm/src/codali-executor.ts`). That nested one open-ended loop
|
|
11
|
+
* inside another: the gateway decided how many *rounds* to run, while the
|
|
12
|
+
* runtime independently decided how many *steps* to take inside each task.
|
|
13
|
+
* Neither could bound the other, so a single request could fan out
|
|
14
|
+
* unpredictably and a runaway task was near-impossible to attribute.
|
|
15
|
+
*
|
|
16
|
+
* This runner removes the inner loop. Exactly one component - the gateway state
|
|
17
|
+
* machine - owns iteration. A task here is a single bounded pass:
|
|
18
|
+
*
|
|
19
|
+
* 1. ask the model what it needs (one model call)
|
|
20
|
+
* 2. execute the tool calls it asked for, in parallel where independent
|
|
21
|
+
* 3. ask the model to turn the results into the task output (one model call)
|
|
22
|
+
*
|
|
23
|
+
* There is no step 4 and no "decide whether to keep going". If the evidence is
|
|
24
|
+
* insufficient, that is the verifier's judgement to make, and the gateway will
|
|
25
|
+
* schedule another round against `maxIterations`. A worker never extends its
|
|
26
|
+
* own budget.
|
|
27
|
+
*
|
|
28
|
+
* The upper bound on work per task is therefore fixed and knowable:
|
|
29
|
+
* 2 model calls and `min(remainingToolCalls, MAX_TOOL_CALLS_PER_TASK)` tool
|
|
30
|
+
* calls.
|
|
31
|
+
*/
|
|
32
|
+
/** Hard ceiling on tool calls in a single task, independent of run budget. */
|
|
33
|
+
export const MAX_TOOL_CALLS_PER_TASK = 8;
|
|
34
|
+
/** Model calls per task: one to select tools, one to summarize results. */
|
|
35
|
+
export const MAX_MODEL_CALLS_PER_TASK = 2;
|
|
36
|
+
const DEFAULT_TASK_TIMEOUT_MS = 60000;
|
|
37
|
+
const DEFAULT_MAX_TOKENS = 1200;
|
|
38
|
+
const DEFAULT_TEMPERATURE = 0;
|
|
39
|
+
/**
|
|
40
|
+
* Tool output beyond this is truncated before it reaches the model.
|
|
41
|
+
*
|
|
42
|
+
* Connector list endpoints are the pressure here: a GitHub `list_commits` for a
|
|
43
|
+
* fortnight of activity runs well past the old 8k cap, and the model then
|
|
44
|
+
* summarized only the commits that survived — accurately, but incompletely.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_TOOL_OUTPUT_CHARS = 24000;
|
|
47
|
+
const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
48
|
+
const truncate = (value, limit = MAX_TOOL_OUTPUT_CHARS) => value.length > limit
|
|
49
|
+
? `${value.slice(0, limit)}\n\n[TRUNCATED: ${value.length - limit} more characters were cut. This result is INCOMPLETE — say so explicitly in your summary and do not present it as the full set.]`
|
|
50
|
+
: value;
|
|
51
|
+
const positiveInteger = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0
|
|
52
|
+
? Math.floor(value)
|
|
53
|
+
: fallback;
|
|
54
|
+
const nonNegativeInteger = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
55
|
+
? Math.floor(value)
|
|
56
|
+
: fallback;
|
|
57
|
+
const errorMessageFor = (error) => error instanceof Error ? error.message : String(error);
|
|
58
|
+
/**
|
|
59
|
+
* Tool definitions offered to the worker model, narrowed to what the planner
|
|
60
|
+
* allowed for this task. A tool absent from `allowedTools` is not merely
|
|
61
|
+
* discouraged - the model never sees it.
|
|
62
|
+
*/
|
|
63
|
+
const toolDefinitionsFor = (registry, allowedTools) => registry.catalog(allowedTools).map((descriptor) => ({
|
|
64
|
+
name: descriptor.name,
|
|
65
|
+
description: descriptor.description,
|
|
66
|
+
inputSchema: descriptor.inputSchema,
|
|
67
|
+
}));
|
|
68
|
+
const buildTaskMessages = (input, hasTools) => {
|
|
69
|
+
const system = [
|
|
70
|
+
"You are a Codali gateway worker.",
|
|
71
|
+
"Gather evidence for one bounded sub-task. Do not answer the user's overall question.",
|
|
72
|
+
hasTools
|
|
73
|
+
? "Call the tools you need in a single batch. You get exactly one opportunity to call tools, so request everything you need at once."
|
|
74
|
+
: "No tools are available. Answer from the task description alone or state what is missing.",
|
|
75
|
+
"Report only what the tool results actually support. Never invent file paths, identifiers, figures, or quotations.",
|
|
76
|
+
"If the results are insufficient, say so plainly instead of guessing; another round may be scheduled.",
|
|
77
|
+
CODALI_GATEWAY_SECURITY_PROMPT_HARDENING.toolOutputBoundary,
|
|
78
|
+
CODALI_GATEWAY_SECURITY_PROMPT_HARDENING.policyImmutability,
|
|
79
|
+
CODALI_GATEWAY_SECURITY_PROMPT_HARDENING.tenantScope,
|
|
80
|
+
].join("\n");
|
|
81
|
+
return [
|
|
82
|
+
{ role: "system", content: system },
|
|
83
|
+
{ role: "user", content: input.prompt },
|
|
84
|
+
];
|
|
85
|
+
};
|
|
86
|
+
export class LocalGatewayTaskRunner {
|
|
87
|
+
constructor(options) {
|
|
88
|
+
this.options = options;
|
|
89
|
+
}
|
|
90
|
+
async run(input) {
|
|
91
|
+
const startedMs = Date.now();
|
|
92
|
+
const taskId = input.task.id;
|
|
93
|
+
const deadlineMs = startedMs + positiveInteger(input.timeoutMs, DEFAULT_TASK_TIMEOUT_MS);
|
|
94
|
+
const allowedTools = input.allowedTools ?? [];
|
|
95
|
+
const toolBudget = Math.min(nonNegativeInteger(input.remainingToolCalls, 0), MAX_TOOL_CALLS_PER_TASK);
|
|
96
|
+
const modelBudget = Math.min(nonNegativeInteger(input.remainingModelCalls, MAX_MODEL_CALLS_PER_TASK), MAX_MODEL_CALLS_PER_TASK);
|
|
97
|
+
const toolCalls = [];
|
|
98
|
+
const modelCalls = [];
|
|
99
|
+
this.options.onEvent?.({
|
|
100
|
+
type: "task_start",
|
|
101
|
+
taskId,
|
|
102
|
+
workerRole: input.task.workerRole,
|
|
103
|
+
allowedTools: [...allowedTools],
|
|
104
|
+
});
|
|
105
|
+
const finish = (result) => {
|
|
106
|
+
this.options.onEvent?.({
|
|
107
|
+
type: "task_end",
|
|
108
|
+
taskId,
|
|
109
|
+
status: result.status,
|
|
110
|
+
toolCallCount: toolCalls.length,
|
|
111
|
+
modelCallCount: modelCalls.length,
|
|
112
|
+
durationMs: Date.now() - startedMs,
|
|
113
|
+
});
|
|
114
|
+
return { ...result, toolCalls, modelCalls };
|
|
115
|
+
};
|
|
116
|
+
if (modelBudget <= 0) {
|
|
117
|
+
return finish({
|
|
118
|
+
status: "failed",
|
|
119
|
+
errorCode: "GATEWAY_MODEL_BUDGET_EXCEEDED",
|
|
120
|
+
errorMessage: "No model calls remain for this worker task.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const offerTools = toolBudget > 0 && allowedTools.length > 0;
|
|
124
|
+
const toolDefinitions = offerTools
|
|
125
|
+
? toolDefinitionsFor(this.options.registry, allowedTools)
|
|
126
|
+
: [];
|
|
127
|
+
const messages = buildTaskMessages(input, toolDefinitions.length > 0);
|
|
128
|
+
// ---- Pass 1: ask the model what it needs -----------------------------
|
|
129
|
+
let selection;
|
|
130
|
+
const selectionStartedMs = Date.now();
|
|
131
|
+
try {
|
|
132
|
+
selection = await this.options.provider.generate({
|
|
133
|
+
messages,
|
|
134
|
+
tools: toolDefinitions.length > 0 ? toolDefinitions : undefined,
|
|
135
|
+
toolChoice: toolDefinitions.length > 0 ? "auto" : "none",
|
|
136
|
+
maxTokens: positiveInteger(this.options.maxTokens, DEFAULT_MAX_TOKENS),
|
|
137
|
+
temperature: this.options.temperature ?? DEFAULT_TEMPERATURE,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
modelCalls.push({
|
|
142
|
+
role: "worker",
|
|
143
|
+
status: "failed",
|
|
144
|
+
latencyMs: Date.now() - selectionStartedMs,
|
|
145
|
+
provider: this.options.provider.name,
|
|
146
|
+
errorCode: "GATEWAY_WORKER_MODEL_FAILED",
|
|
147
|
+
errorMessage: errorMessageFor(error),
|
|
148
|
+
});
|
|
149
|
+
return finish({
|
|
150
|
+
status: "failed",
|
|
151
|
+
errorCode: "GATEWAY_WORKER_MODEL_FAILED",
|
|
152
|
+
errorMessage: errorMessageFor(error),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
modelCalls.push({
|
|
156
|
+
role: "worker",
|
|
157
|
+
status: "success",
|
|
158
|
+
latencyMs: Date.now() - selectionStartedMs,
|
|
159
|
+
provider: this.options.provider.name,
|
|
160
|
+
output: selection.message.content,
|
|
161
|
+
metadata: {
|
|
162
|
+
pass: "tool_selection",
|
|
163
|
+
requestedToolCalls: selection.toolCalls?.length ?? 0,
|
|
164
|
+
usage: selection.usage,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const requested = (selection.toolCalls ?? []).slice(0, toolBudget);
|
|
168
|
+
const droppedForBudget = (selection.toolCalls ?? []).length - requested.length;
|
|
169
|
+
// No tools requested: the first answer is the task output. One model call,
|
|
170
|
+
// and we return rather than looping to look for more work.
|
|
171
|
+
if (requested.length === 0) {
|
|
172
|
+
const output = selection.message.content.trim();
|
|
173
|
+
return finish({
|
|
174
|
+
status: "succeeded",
|
|
175
|
+
output,
|
|
176
|
+
metadata: {
|
|
177
|
+
pass: "direct",
|
|
178
|
+
modelCallCount: modelCalls.length,
|
|
179
|
+
toolCallCount: 0,
|
|
180
|
+
...(droppedForBudget > 0 ? { droppedToolCalls: droppedForBudget } : {}),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// ---- Execute the batch ------------------------------------------------
|
|
185
|
+
// Independent read-only calls run in parallel. Ordering between them is not
|
|
186
|
+
// meaningful; each is validated against the registry schema before running.
|
|
187
|
+
const executions = await Promise.all(requested.map(async (call) => {
|
|
188
|
+
if (Date.now() > deadlineMs) {
|
|
189
|
+
return {
|
|
190
|
+
call,
|
|
191
|
+
record: {
|
|
192
|
+
tool: call.name,
|
|
193
|
+
status: "failed",
|
|
194
|
+
latencyMs: 0,
|
|
195
|
+
args: call.args,
|
|
196
|
+
errorCode: "tool_timeout",
|
|
197
|
+
errorMessage: "Task deadline elapsed before the tool was invoked.",
|
|
198
|
+
},
|
|
199
|
+
output: "Tool skipped: task deadline elapsed.",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
this.options.onEvent?.({ type: "tool_call", taskId, tool: call.name, args: call.args });
|
|
203
|
+
const toolStartedMs = Date.now();
|
|
204
|
+
if (!allowedTools.includes(call.name)) {
|
|
205
|
+
// The model named a tool outside the planner's allowance. Refuse it
|
|
206
|
+
// and tell the model, rather than silently widening the policy.
|
|
207
|
+
const latencyMs = Date.now() - toolStartedMs;
|
|
208
|
+
this.options.onEvent?.({
|
|
209
|
+
type: "tool_result",
|
|
210
|
+
taskId,
|
|
211
|
+
tool: call.name,
|
|
212
|
+
ok: false,
|
|
213
|
+
latencyMs,
|
|
214
|
+
errorCode: "tool_permission_denied",
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
call,
|
|
218
|
+
record: {
|
|
219
|
+
tool: call.name,
|
|
220
|
+
status: "failed",
|
|
221
|
+
latencyMs,
|
|
222
|
+
args: call.args,
|
|
223
|
+
errorCode: "tool_permission_denied",
|
|
224
|
+
errorMessage: `Tool ${call.name} is not allowed for this task.`,
|
|
225
|
+
},
|
|
226
|
+
output: `Tool ${call.name} is not allowed for this task.`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const result = await this.options.registry.execute(call.name, call.args, this.options.toolContext);
|
|
230
|
+
const latencyMs = Date.now() - toolStartedMs;
|
|
231
|
+
this.options.onEvent?.({
|
|
232
|
+
type: "tool_result",
|
|
233
|
+
taskId,
|
|
234
|
+
tool: call.name,
|
|
235
|
+
ok: result.ok,
|
|
236
|
+
latencyMs,
|
|
237
|
+
errorCode: result.error?.code,
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
call,
|
|
241
|
+
record: {
|
|
242
|
+
tool: call.name,
|
|
243
|
+
status: result.ok ? "success" : "failed",
|
|
244
|
+
latencyMs,
|
|
245
|
+
args: call.args,
|
|
246
|
+
result: result.ok ? (result.data ?? result.output) : undefined,
|
|
247
|
+
errorCode: result.error?.code,
|
|
248
|
+
errorMessage: result.error?.message,
|
|
249
|
+
},
|
|
250
|
+
// Structural where the tool returned data: a 258 KB web-research
|
|
251
|
+
// payload cut at a character count is unparseable, and the model
|
|
252
|
+
// then reports it found nothing. Applied here so it covers docdex
|
|
253
|
+
// and built-in tools too, not just the connectors.
|
|
254
|
+
output: result.ok
|
|
255
|
+
? (result.data !== undefined
|
|
256
|
+
? truncateToolResult(result.data, result.output, MAX_TOOL_OUTPUT_CHARS).text
|
|
257
|
+
: truncate(result.output))
|
|
258
|
+
: `Tool failed (${result.error?.code ?? "unknown"}): ${result.error?.message ?? ""}`,
|
|
259
|
+
};
|
|
260
|
+
}));
|
|
261
|
+
for (const execution of executions) {
|
|
262
|
+
toolCalls.push(execution.record);
|
|
263
|
+
}
|
|
264
|
+
const succeededCount = executions.filter((execution) => execution.record.status === "success").length;
|
|
265
|
+
// Every tool failed: report the failure rather than asking the model to
|
|
266
|
+
// narrate an empty result set, which is where fabrication starts.
|
|
267
|
+
if (succeededCount === 0) {
|
|
268
|
+
const first = executions[0]?.record;
|
|
269
|
+
return finish({
|
|
270
|
+
status: "failed",
|
|
271
|
+
errorCode: first?.errorCode ?? "GATEWAY_WORKER_TOOLS_FAILED",
|
|
272
|
+
errorMessage: first?.errorMessage ?? "All tool calls for this worker task failed.",
|
|
273
|
+
metadata: { pass: "tools_failed", toolCallCount: toolCalls.length },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (modelCalls.length >= modelBudget) {
|
|
277
|
+
// No budget left to summarize. Return the raw tool outputs as the task
|
|
278
|
+
// output; the finalizer, not the user, is what sees this.
|
|
279
|
+
return finish({
|
|
280
|
+
status: "succeeded",
|
|
281
|
+
output: executions.map((execution) => `${execution.call.name}: ${execution.output}`).join("\n\n"),
|
|
282
|
+
metadata: {
|
|
283
|
+
pass: "unsummarized",
|
|
284
|
+
reason: "model_budget_exhausted",
|
|
285
|
+
toolCallCount: toolCalls.length,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
// ---- Pass 2: turn the results into the task output --------------------
|
|
290
|
+
const resultMessages = [
|
|
291
|
+
...messages,
|
|
292
|
+
{
|
|
293
|
+
role: "assistant",
|
|
294
|
+
content: selection.message.content ||
|
|
295
|
+
`Requested tools: ${requested.map((call) => call.name).join(", ")}`,
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
role: "user",
|
|
299
|
+
content: [
|
|
300
|
+
"Tool results:",
|
|
301
|
+
...executions.map((execution) => `--- ${execution.call.name} ---\n${execution.output}`),
|
|
302
|
+
"",
|
|
303
|
+
"Summarize what these results establish for this sub-task.",
|
|
304
|
+
"If any result is marked TRUNCATED, state that the data is incomplete and roughly what is missing. Never present a truncated list as complete.",
|
|
305
|
+
"Attribute each statement to the tool that produced it.",
|
|
306
|
+
"State explicitly what remains unknown. Do not speculate beyond the results.",
|
|
307
|
+
].join("\n"),
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
const summaryStartedMs = Date.now();
|
|
311
|
+
try {
|
|
312
|
+
const summary = await this.options.provider.generate({
|
|
313
|
+
messages: resultMessages,
|
|
314
|
+
toolChoice: "none",
|
|
315
|
+
maxTokens: positiveInteger(this.options.maxTokens, DEFAULT_MAX_TOKENS),
|
|
316
|
+
temperature: this.options.temperature ?? DEFAULT_TEMPERATURE,
|
|
317
|
+
});
|
|
318
|
+
modelCalls.push({
|
|
319
|
+
role: "worker",
|
|
320
|
+
status: "success",
|
|
321
|
+
latencyMs: Date.now() - summaryStartedMs,
|
|
322
|
+
provider: this.options.provider.name,
|
|
323
|
+
output: summary.message.content,
|
|
324
|
+
metadata: { pass: "summary", usage: summary.usage },
|
|
325
|
+
});
|
|
326
|
+
return finish({
|
|
327
|
+
status: "succeeded",
|
|
328
|
+
output: summary.message.content.trim(),
|
|
329
|
+
metadata: {
|
|
330
|
+
pass: "tools_summarized",
|
|
331
|
+
toolCallCount: toolCalls.length,
|
|
332
|
+
succeededToolCalls: succeededCount,
|
|
333
|
+
...(droppedForBudget > 0 ? { droppedToolCalls: droppedForBudget } : {}),
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
modelCalls.push({
|
|
339
|
+
role: "worker",
|
|
340
|
+
status: "failed",
|
|
341
|
+
latencyMs: Date.now() - summaryStartedMs,
|
|
342
|
+
provider: this.options.provider.name,
|
|
343
|
+
errorCode: "GATEWAY_WORKER_SUMMARY_FAILED",
|
|
344
|
+
errorMessage: errorMessageFor(error),
|
|
345
|
+
});
|
|
346
|
+
// Summarization failed but the evidence is real. Return it unsummarized
|
|
347
|
+
// rather than discarding a successful tool batch.
|
|
348
|
+
return finish({
|
|
349
|
+
status: "succeeded",
|
|
350
|
+
output: executions.map((execution) => `${execution.call.name}: ${execution.output}`).join("\n\n"),
|
|
351
|
+
metadata: {
|
|
352
|
+
pass: "unsummarized",
|
|
353
|
+
reason: "summary_failed",
|
|
354
|
+
toolCallCount: toolCalls.length,
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
export const createLocalGatewayTaskRunner = (options) => new LocalGatewayTaskRunner(options);
|
|
361
|
+
export const isRecordValue = isRecord;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Provider } from "../providers/ProviderTypes.js";
|
|
2
|
+
import type { RelevanceJudge } from "../tools/docdex/DocdexTools.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reduces a result payload to a short list of titles. The judge needs to know
|
|
5
|
+
* *what* was found, not the contents — sending snippets would cost more than
|
|
6
|
+
* the web call being avoided.
|
|
7
|
+
*/
|
|
8
|
+
export declare const summarizeResultTitles: (results: unknown) => string[];
|
|
9
|
+
export interface RelevanceJudgeOptions {
|
|
10
|
+
provider: Provider;
|
|
11
|
+
maxTokens?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Whether any result title shares a distinctive word with the question.
|
|
15
|
+
*
|
|
16
|
+
* A cheap pre-check that exists because the judge was answering obvious cases
|
|
17
|
+
* wrongly and expensively: asked whether `LocalGatewayTaskRunner.ts` could
|
|
18
|
+
* answer "what does LocalGatewayTaskRunner do", a small model said no, which
|
|
19
|
+
* forced a 45-second web search for a local class. Lexical overlap settles
|
|
20
|
+
* that case without a model call, leaving the judge for genuinely ambiguous
|
|
21
|
+
* results — which is where it earns its keep.
|
|
22
|
+
*/
|
|
23
|
+
export declare const titlesShareTermsWithQuery: (query: string, titles: readonly string[]) => boolean;
|
|
24
|
+
export declare const createRelevanceJudge: (options: RelevanceJudgeOptions) => RelevanceJudge;
|
|
25
|
+
//# sourceMappingURL=RelevanceJudge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RelevanceJudge.d.ts","sourceRoot":"","sources":["../../src/gateway/RelevanceJudge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAyBrE;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAAI,SAAS,OAAO,KAAG,MAAM,EAqB9D,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,QAAQ,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAeD;;;;;;;;;GASG;AACH,eAAO,MAAM,yBAAyB,GACpC,OAAO,MAAM,EACb,QAAQ,SAAS,MAAM,EAAE,KACxB,OAWF,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAC/B,SAAS,qBAAqB,KAC7B,cAyCF,CAAC"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides whether local search results actually answer the question.
|
|
3
|
+
*
|
|
4
|
+
* The alternative approaches are both worse:
|
|
5
|
+
*
|
|
6
|
+
* - **Score thresholds** do not discriminate. Measured against this repository,
|
|
7
|
+
* "GDP of France 2025" scores 29.07 while "gateway planner class" scores
|
|
8
|
+
* 14.35 — the irrelevant query scores higher, because a test fixture contains
|
|
9
|
+
* the phrase. Any threshold would be arbitrary and frequently wrong.
|
|
10
|
+
* - **Keyword rules** ("if the query mentions GDP or news, go to the web") are
|
|
11
|
+
* precisely the hardcoding this project rules out: they work for the examples
|
|
12
|
+
* someone thought of and fail on the next question.
|
|
13
|
+
*
|
|
14
|
+
* A model reading the titles is generic and costs one short call. It is asked
|
|
15
|
+
* for a single token so a small local model can answer it quickly.
|
|
16
|
+
*/
|
|
17
|
+
const MAX_TITLES = 8;
|
|
18
|
+
const MAX_TITLE_CHARS = 90;
|
|
19
|
+
const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
20
|
+
/**
|
|
21
|
+
* Reduces a result payload to a short list of titles. The judge needs to know
|
|
22
|
+
* *what* was found, not the contents — sending snippets would cost more than
|
|
23
|
+
* the web call being avoided.
|
|
24
|
+
*/
|
|
25
|
+
export const summarizeResultTitles = (results) => {
|
|
26
|
+
if (!isRecord(results))
|
|
27
|
+
return [];
|
|
28
|
+
const hits = ["hits", "results", "matches"]
|
|
29
|
+
.map((key) => results[key])
|
|
30
|
+
.find((value) => Array.isArray(value) && value.length > 0);
|
|
31
|
+
if (!hits)
|
|
32
|
+
return [];
|
|
33
|
+
const titles = [];
|
|
34
|
+
for (const hit of hits.slice(0, MAX_TITLES)) {
|
|
35
|
+
if (!isRecord(hit))
|
|
36
|
+
continue;
|
|
37
|
+
const title = (typeof hit.rel_path === "string" && hit.rel_path) ||
|
|
38
|
+
(typeof hit.relPath === "string" && hit.relPath) ||
|
|
39
|
+
(typeof hit.path === "string" && hit.path) ||
|
|
40
|
+
(typeof hit.title === "string" && hit.title) ||
|
|
41
|
+
(typeof hit.url === "string" && hit.url) ||
|
|
42
|
+
(typeof hit.doc_id === "string" && hit.doc_id) ||
|
|
43
|
+
"";
|
|
44
|
+
if (title)
|
|
45
|
+
titles.push(String(title).slice(0, MAX_TITLE_CHARS));
|
|
46
|
+
}
|
|
47
|
+
return titles;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Words too common to signal relevance on their own.
|
|
51
|
+
*
|
|
52
|
+
* Not a keyword rule about any subject — just the connectives that appear in
|
|
53
|
+
* every question and would otherwise match any file path.
|
|
54
|
+
*/
|
|
55
|
+
const STOPWORDS = new Set([
|
|
56
|
+
"what", "which", "where", "when", "does", "did", "the", "and", "for", "with",
|
|
57
|
+
"from", "that", "this", "how", "why", "who", "are", "was", "were", "its",
|
|
58
|
+
"class", "file", "code", "show", "list", "find", "tell", "give", "about",
|
|
59
|
+
"purpose", "exists", "existence", "summary", "summarize", "explain",
|
|
60
|
+
]);
|
|
61
|
+
/**
|
|
62
|
+
* Whether any result title shares a distinctive word with the question.
|
|
63
|
+
*
|
|
64
|
+
* A cheap pre-check that exists because the judge was answering obvious cases
|
|
65
|
+
* wrongly and expensively: asked whether `LocalGatewayTaskRunner.ts` could
|
|
66
|
+
* answer "what does LocalGatewayTaskRunner do", a small model said no, which
|
|
67
|
+
* forced a 45-second web search for a local class. Lexical overlap settles
|
|
68
|
+
* that case without a model call, leaving the judge for genuinely ambiguous
|
|
69
|
+
* results — which is where it earns its keep.
|
|
70
|
+
*/
|
|
71
|
+
export const titlesShareTermsWithQuery = (query, titles) => {
|
|
72
|
+
const terms = query
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.split(/[^a-z0-9]+/)
|
|
75
|
+
.filter((word) => word.length > 3 && !STOPWORDS.has(word));
|
|
76
|
+
if (terms.length === 0)
|
|
77
|
+
return false;
|
|
78
|
+
// Split titles the same way so `LocalGatewayTaskRunner.ts` matches
|
|
79
|
+
// `localgatewaytaskrunner` from the question.
|
|
80
|
+
const haystack = titles.join(" ").toLowerCase().replace(/[^a-z0-9]+/g, " ");
|
|
81
|
+
return terms.some((term) => haystack.includes(term));
|
|
82
|
+
};
|
|
83
|
+
export const createRelevanceJudge = (options) => async ({ query, results }) => {
|
|
84
|
+
const titles = summarizeResultTitles(results);
|
|
85
|
+
// Nothing to judge; the caller's presence check already decided.
|
|
86
|
+
if (titles.length === 0)
|
|
87
|
+
return true;
|
|
88
|
+
// An obvious match needs no model call, and no chance of getting it wrong.
|
|
89
|
+
if (titlesShareTermsWithQuery(query, titles))
|
|
90
|
+
return true;
|
|
91
|
+
const response = await options.provider.generate({
|
|
92
|
+
messages: [
|
|
93
|
+
{
|
|
94
|
+
role: "system",
|
|
95
|
+
content: [
|
|
96
|
+
"You decide whether local search results are relevant to a question.",
|
|
97
|
+
"You are shown only the titles or file paths that were found.",
|
|
98
|
+
"Answer with exactly one word: YES if these look like they could answer the question, NO if they are about something else.",
|
|
99
|
+
"A repository file that merely happens to mention a word from the question is NO.",
|
|
100
|
+
].join("\n"),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
role: "user",
|
|
104
|
+
content: [
|
|
105
|
+
`Question: ${query}`,
|
|
106
|
+
"",
|
|
107
|
+
"Results found locally:",
|
|
108
|
+
...titles.map((title) => `- ${title}`),
|
|
109
|
+
"",
|
|
110
|
+
"Could these answer the question? Reply YES or NO.",
|
|
111
|
+
].join("\n"),
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
toolChoice: "none",
|
|
115
|
+
maxTokens: options.maxTokens ?? 5,
|
|
116
|
+
temperature: 0,
|
|
117
|
+
});
|
|
118
|
+
const verdict = response.message.content.trim().toUpperCase();
|
|
119
|
+
// Default to keeping the local result when the answer is unreadable: a
|
|
120
|
+
// needless web call is a worse failure than a slightly weak local hit.
|
|
121
|
+
if (verdict.startsWith("NO"))
|
|
122
|
+
return false;
|
|
123
|
+
return true;
|
|
124
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforcement for `CodaliGatewayRequest.response.schema`.
|
|
3
|
+
*
|
|
4
|
+
* The field has been part of the request contract for a while but was never
|
|
5
|
+
* validated — only `response.format === "json"` was read, so a caller could ask
|
|
6
|
+
* for a schema and receive anything at all. That pushes parsing and correction
|
|
7
|
+
* logic into every consuming product, which is precisely the duplication Codali
|
|
8
|
+
* exists to remove.
|
|
9
|
+
*
|
|
10
|
+
* Validation reuses the same structural rules as tool-argument validation so a
|
|
11
|
+
* schema behaves identically whether it describes a tool input or a response.
|
|
12
|
+
*/
|
|
13
|
+
export interface ResponseSchemaViolation {
|
|
14
|
+
path: string;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ResponseSchemaValidation {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
value?: unknown;
|
|
20
|
+
violations: ResponseSchemaViolation[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Extracts a JSON value from a model response. Models routinely wrap JSON in
|
|
24
|
+
* prose or a fenced block even when told not to, and rejecting those outright
|
|
25
|
+
* would burn a repair attempt on a formatting quirk rather than a real
|
|
26
|
+
* structural failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare const extractJsonPayload: (content: string) => unknown;
|
|
29
|
+
export declare const validateResponseAgainstSchema: (content: string, schema: Record<string, unknown> | undefined) => ResponseSchemaValidation;
|
|
30
|
+
/** Human-readable violation list, used to prompt one repair attempt. */
|
|
31
|
+
export declare const describeViolations: (violations: ResponseSchemaViolation[]) => string;
|
|
32
|
+
//# sourceMappingURL=ResponseSchema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ResponseSchema.d.ts","sourceRoot":"","sources":["../../src/gateway/ResponseSchema.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,wBAAwB;IACvC,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,uBAAuB,EAAE,CAAC;CACvC;AAwED;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,SAAS,MAAM,KAAG,OAuBpD,CAAC;AAUF,eAAO,MAAM,6BAA6B,GACxC,SAAS,MAAM,EACf,QAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,KAC1C,wBAmBF,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,kBAAkB,GAAI,YAAY,uBAAuB,EAAE,KAAG,MACY,CAAC"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
2
|
+
const typeOf = (value) => {
|
|
3
|
+
if (value === null)
|
|
4
|
+
return "null";
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return "array";
|
|
7
|
+
if (typeof value === "number")
|
|
8
|
+
return Number.isInteger(value) ? "integer" : "number";
|
|
9
|
+
return typeof value;
|
|
10
|
+
};
|
|
11
|
+
const typeMatches = (value, expected) => {
|
|
12
|
+
const actual = typeOf(value);
|
|
13
|
+
if (expected === actual)
|
|
14
|
+
return true;
|
|
15
|
+
// An integer satisfies "number"; a whole float satisfies "integer".
|
|
16
|
+
if (expected === "number" && actual === "integer")
|
|
17
|
+
return true;
|
|
18
|
+
if (expected === "integer" && actual === "number")
|
|
19
|
+
return Number.isInteger(value);
|
|
20
|
+
return false;
|
|
21
|
+
};
|
|
22
|
+
const validateAgainst = (value, schema, path, violations) => {
|
|
23
|
+
const expectedTypes = schema.type
|
|
24
|
+
? (Array.isArray(schema.type) ? schema.type : [schema.type])
|
|
25
|
+
: undefined;
|
|
26
|
+
if (expectedTypes && !expectedTypes.some((type) => typeMatches(value, type))) {
|
|
27
|
+
violations.push({
|
|
28
|
+
path,
|
|
29
|
+
message: `expected ${expectedTypes.join(" or ")}, received ${typeOf(value)}`,
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (schema.enum && !schema.enum.some((entry) => entry === value)) {
|
|
34
|
+
violations.push({
|
|
35
|
+
path,
|
|
36
|
+
message: `value is not one of ${JSON.stringify(schema.enum)}`,
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(value) && schema.items) {
|
|
41
|
+
value.forEach((entry, index) => {
|
|
42
|
+
validateAgainst(entry, schema.items, `${path}[${index}]`, violations);
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (isRecord(value)) {
|
|
47
|
+
for (const key of schema.required ?? []) {
|
|
48
|
+
if (!(key in value)) {
|
|
49
|
+
violations.push({ path: `${path}.${key}`, message: "required property is missing" });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const properties = schema.properties ?? {};
|
|
53
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
54
|
+
const child = properties[key];
|
|
55
|
+
if (child) {
|
|
56
|
+
validateAgainst(entry, child, `${path}.${key}`, violations);
|
|
57
|
+
}
|
|
58
|
+
else if (schema.additionalProperties === false) {
|
|
59
|
+
violations.push({ path: `${path}.${key}`, message: "unexpected property" });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Extracts a JSON value from a model response. Models routinely wrap JSON in
|
|
66
|
+
* prose or a fenced block even when told not to, and rejecting those outright
|
|
67
|
+
* would burn a repair attempt on a formatting quirk rather than a real
|
|
68
|
+
* structural failure.
|
|
69
|
+
*/
|
|
70
|
+
export const extractJsonPayload = (content) => {
|
|
71
|
+
const trimmed = content.trim();
|
|
72
|
+
if (!trimmed)
|
|
73
|
+
return undefined;
|
|
74
|
+
const direct = tryParse(trimmed);
|
|
75
|
+
if (direct !== undefined)
|
|
76
|
+
return direct;
|
|
77
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
78
|
+
if (fenced?.[1]) {
|
|
79
|
+
const parsed = tryParse(fenced[1].trim());
|
|
80
|
+
if (parsed !== undefined)
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
const firstBrace = trimmed.search(/[[{]/);
|
|
84
|
+
if (firstBrace >= 0) {
|
|
85
|
+
const lastBrace = Math.max(trimmed.lastIndexOf("}"), trimmed.lastIndexOf("]"));
|
|
86
|
+
if (lastBrace > firstBrace) {
|
|
87
|
+
const parsed = tryParse(trimmed.slice(firstBrace, lastBrace + 1));
|
|
88
|
+
if (parsed !== undefined)
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
};
|
|
94
|
+
const tryParse = (value) => {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(value);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
export const validateResponseAgainstSchema = (content, schema) => {
|
|
103
|
+
if (!schema) {
|
|
104
|
+
return { ok: true, value: content, violations: [] };
|
|
105
|
+
}
|
|
106
|
+
const payload = extractJsonPayload(content);
|
|
107
|
+
if (payload === undefined) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
violations: [{ path: "$", message: "response was not parseable as JSON" }],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const violations = [];
|
|
114
|
+
validateAgainst(payload, schema, "$", violations);
|
|
115
|
+
return violations.length === 0
|
|
116
|
+
? { ok: true, value: payload, violations: [] }
|
|
117
|
+
: { ok: false, value: payload, violations };
|
|
118
|
+
};
|
|
119
|
+
/** Human-readable violation list, used to prompt one repair attempt. */
|
|
120
|
+
export const describeViolations = (violations) => violations.map((violation) => `- ${violation.path}: ${violation.message}`).join("\n");
|