melchizedek-agents 0.9.2 → 0.9.4
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/AGENT_SETUP.md +2 -2
- package/DOCUMENTATION.md +17 -24
- package/QUICKSTART.md +3 -2
- package/README.md +11 -9
- package/config/agents/examples/ares.yaml +0 -2
- package/config/agents/examples/augustin.yaml +0 -2
- package/config/agents/examples/cartographers.yaml +2 -5
- package/config/agents/examples/council.yaml +1 -1
- package/config/agents/examples/image_production.yaml +0 -1
- package/config/agents/examples/librarian.yaml +2 -4
- package/config/agents/examples/patient_advocate.yaml +2 -4
- package/config/agents/examples/scriptorium.yaml +2 -5
- package/config/agents/examples/style_council.yaml +2 -6
- package/config/agents/examples/syndicate.yaml +0 -3
- package/config/agents/examples/tutor.yaml +62 -32
- package/config/agents/syndicateSchema.yaml +4 -2
- package/dist/lib/compile.d.ts +64 -0
- package/dist/lib/compile.js +105 -0
- package/dist/lib/models/claudeLlm.js +34 -4
- package/dist/lib/models/gptLlm.js +6 -3
- package/dist/lib/models/ollamaLlm.d.ts +2 -0
- package/dist/lib/models/ollamaLlm.js +5 -1
- package/dist/lib/models/openAiCompatibleLlm.d.ts +49 -0
- package/dist/lib/models/openAiCompatibleLlm.js +233 -9
- package/dist/lib/models/registry.js +1 -1
- package/dist/lib/models/schemaNormalize.d.ts +9 -0
- package/dist/lib/models/schemaNormalize.js +34 -0
- package/dist/lib/observability/embeddings.d.ts +21 -0
- package/dist/lib/observability/embeddings.js +47 -0
- package/dist/lib/observability/lineage.d.ts +35 -0
- package/dist/lib/observability/lineage.js +96 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +191 -18
- package/dist/lib/observability/supabaseSpanExporter.js +388 -80
- package/dist/lib/observability/tracer.d.ts +82 -0
- package/dist/lib/observability/tracer.js +348 -4
- package/dist/scripts/a2a_server.d.ts +10 -0
- package/dist/scripts/a2a_server.js +132 -75
- package/dist/scripts/syndicate_chat.js +41 -10
- package/package.json +8 -4
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/compile.ts — the one YAML → ADK agent-graph compiler.
|
|
3
|
+
*
|
|
4
|
+
* WHY this file exists:
|
|
5
|
+
* The A2A server carried this logic as two closures inside its executor,
|
|
6
|
+
* and the observatory (observatory/, the eval harness) needs to run the
|
|
7
|
+
* EXACT graph production runs — same subagent wiring, same tool
|
|
8
|
+
* resolution, same generateContentConfig merge, same nested
|
|
9
|
+
* `yaml_reference` handling. A second copy would have been a second place
|
|
10
|
+
* for the two to drift, which is how toolRegistry.ts came to exist.
|
|
11
|
+
* So the compiler moved here and both callers import it.
|
|
12
|
+
*
|
|
13
|
+
* What it does NOT own: model resolution and nested-config loading. Both are
|
|
14
|
+
* injected, because the two callers differ precisely there:
|
|
15
|
+
* - the A2A server resolves `model` to a provider INSTANCE carrying the
|
|
16
|
+
* caller's BYOK key (lib/models/registry.ts resolveModel);
|
|
17
|
+
* - the observatory passes the model id through as a string (the
|
|
18
|
+
* LLMRegistry routes it) and loads nested syndicates with its
|
|
19
|
+
* per-variant overrides already applied.
|
|
20
|
+
*
|
|
21
|
+
* ── Two orchestration methods, one compiler ───────────────────────────────
|
|
22
|
+
* DELEGATE (no `dispatch:` block): every subagent becomes an AgentTool on
|
|
23
|
+
* the orchestrator. PLAN-DISPATCH (`dispatch:` present): the orchestrator
|
|
24
|
+
* is compiled WITHOUT subagent tools — it is a pure classifier — and the
|
|
25
|
+
* caller runs `compileSubagent(route)` directly for the chosen route. Both
|
|
26
|
+
* paths build subagents through the same function, so the agent a route
|
|
27
|
+
* dispatches to is identical to the one DELEGATE would have wrapped.
|
|
28
|
+
* Contract and rationale: lib/dispatch.ts.
|
|
29
|
+
*/
|
|
30
|
+
import { AgentTool, LlmAgent } from '@google/adk';
|
|
31
|
+
import { isDispatchSyndicate } from "./dispatch.js";
|
|
32
|
+
import { loadSyndicate } from "./loadSyndicate.js";
|
|
33
|
+
import { resolveTools as resolveNamedTools } from "./toolRegistry.js";
|
|
34
|
+
import { createMcpTools } from "./tools/mcpToolFactory.js";
|
|
35
|
+
/** generateContentConfig as every entrypoint has always sent it to ADK. */
|
|
36
|
+
function withServerSideToolInvocations(generateContentConfig) {
|
|
37
|
+
const cfg = (generateContentConfig ?? {});
|
|
38
|
+
return {
|
|
39
|
+
...cfg,
|
|
40
|
+
toolConfig: {
|
|
41
|
+
...(cfg.toolConfig ?? {}),
|
|
42
|
+
includeServerSideToolInvocations: true,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function resolveAgentTools(toolNames, mcpServerUrl, opts) {
|
|
47
|
+
const tools = resolveNamedTools(toolNames, opts.onUnknownTool);
|
|
48
|
+
if (mcpServerUrl) {
|
|
49
|
+
opts.log?.(`Loading MCP tools: ${mcpServerUrl}`);
|
|
50
|
+
const mcpTools = await createMcpTools(mcpServerUrl);
|
|
51
|
+
for (const mcpTool of mcpTools) {
|
|
52
|
+
if (!tools.some((t) => t.name === mcpTool.name))
|
|
53
|
+
tools.push(mcpTool);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return tools;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Builds ONE runnable agent from a subagent entry. A `yaml_reference` entry
|
|
60
|
+
* compiles the nested syndicate's whole graph under this entry's name and
|
|
61
|
+
* description, so the parent sees one tool (or one route) either way.
|
|
62
|
+
*/
|
|
63
|
+
export async function compileSubagent(subCfg, opts = {}) {
|
|
64
|
+
if (subCfg.yaml_reference) {
|
|
65
|
+
opts.log?.(`Loading nested syndicate: ${subCfg.yaml_reference}`);
|
|
66
|
+
const nested = (opts.loadNested ?? loadSyndicate)(subCfg.yaml_reference);
|
|
67
|
+
return compileGraph(nested, opts, subCfg.name, subCfg.description);
|
|
68
|
+
}
|
|
69
|
+
const tools = await resolveAgentTools(subCfg.tools, subCfg.mcp_server_url, opts);
|
|
70
|
+
const resolveModel = opts.resolveModel ?? ((m) => m);
|
|
71
|
+
return new LlmAgent({
|
|
72
|
+
name: subCfg.name,
|
|
73
|
+
description: subCfg.description,
|
|
74
|
+
model: resolveModel(subCfg.model),
|
|
75
|
+
instruction: subCfg.instruction,
|
|
76
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
77
|
+
outputSchema: subCfg.outputSchema,
|
|
78
|
+
generateContentConfig: withServerSideToolInvocations(subCfg.generateContentConfig),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Compiles a syndicate's orchestrator. In DELEGATE mode its subagents are
|
|
83
|
+
* attached as AgentTools; in PLAN-DISPATCH mode it gets none (ADK refuses
|
|
84
|
+
* outputSchema + AgentTool on one agent — see config/agents/critic.yaml),
|
|
85
|
+
* and the caller dispatches to `compileSubagent(route)` itself.
|
|
86
|
+
*/
|
|
87
|
+
export async function compileGraph(config, opts = {}, overrideName, overrideDescription) {
|
|
88
|
+
const compiledTools = isDispatchSyndicate(config)
|
|
89
|
+
? []
|
|
90
|
+
: await Promise.all((config.subagents ?? []).map(async (subCfg) => new AgentTool({ agent: await compileSubagent(subCfg, opts) })));
|
|
91
|
+
// Orchestrator tools are registry names only — no entrypoint has ever
|
|
92
|
+
// attached an MCP server to an orchestrator, and this compiler preserves
|
|
93
|
+
// that exactly rather than widening the contract in passing.
|
|
94
|
+
compiledTools.push(...(await resolveAgentTools(config.orchestrator.tools, undefined, opts)));
|
|
95
|
+
const resolveModel = opts.resolveModel ?? ((m) => m);
|
|
96
|
+
return new LlmAgent({
|
|
97
|
+
name: overrideName || config.orchestrator.name,
|
|
98
|
+
description: overrideDescription || config.orchestrator.description,
|
|
99
|
+
model: resolveModel(config.orchestrator.model),
|
|
100
|
+
instruction: config.orchestrator.instruction,
|
|
101
|
+
tools: compiledTools.length > 0 ? compiledTools : undefined,
|
|
102
|
+
outputSchema: config.orchestrator.outputSchema,
|
|
103
|
+
generateContentConfig: withServerSideToolInvocations(config.orchestrator.generateContentConfig),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -50,6 +50,8 @@ import { traceLlmGeneration, setLlmSpanAttribute, } from "../observability/trace
|
|
|
50
50
|
import { wantsWebSearch, isWebSearchSentinel, } from "../tools/webSearchTool.js";
|
|
51
51
|
import { toLowercaseJsonSchema } from "./schemaNormalize.js";
|
|
52
52
|
/** Anthropic's minimum extended-thinking budget. */
|
|
53
|
+
/** Name of the synthetic tool that carries an ADK outputSchema answer. */
|
|
54
|
+
const STRUCTURED_OUTPUT_TOOL = 'structured_output';
|
|
53
55
|
const MIN_THINKING_BUDGET = 1024;
|
|
54
56
|
// ── Request building (exported for offline tests) ────────────────────────────
|
|
55
57
|
/** ADK toolsDict → Anthropic tool definitions (lowercase schemas; the
|
|
@@ -100,7 +102,7 @@ export class ClaudeLlm extends BaseLlm {
|
|
|
100
102
|
* llm.request OpenTelemetry span (provider/model/tokens/latency).
|
|
101
103
|
*/
|
|
102
104
|
async *generateContentAsync(llmRequest, stream = false) {
|
|
103
|
-
yield* traceLlmGeneration({ provider: 'anthropic', model: this.model }, this.generateInner(llmRequest, stream));
|
|
105
|
+
yield* traceLlmGeneration({ provider: 'anthropic', model: this.model, llmRequest }, this.generateInner(llmRequest, stream));
|
|
104
106
|
}
|
|
105
107
|
async *generateInner(llmRequest, stream) {
|
|
106
108
|
const apiKey = this.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
@@ -199,12 +201,30 @@ export class ClaudeLlm extends BaseLlm {
|
|
|
199
201
|
// Anthropic requires max_tokens to exceed the thinking budget.
|
|
200
202
|
maxTokens = Math.max(maxTokens, budget + 2048);
|
|
201
203
|
}
|
|
204
|
+
// ── Structured output (ADK outputSchema → forced tool use) ──────────────
|
|
205
|
+
// The Messages API has no response_format. Left to prose, Claude returns
|
|
206
|
+
// JSON whose KEYS drift from the schema ("grade" for "correctness"), which
|
|
207
|
+
// silently breaks any consumer that reads fields by name — the
|
|
208
|
+
// observatory's rubric judges first of all. A tool whose input_schema IS
|
|
209
|
+
// the output schema, with tool_choice forcing it, makes the API validate
|
|
210
|
+
// the shape; finalResponse() turns the tool_use block back into text.
|
|
211
|
+
// Forced tool_choice is incompatible with extended thinking, so with a
|
|
212
|
+
// thinking budget the tool is offered under 'auto' instead.
|
|
213
|
+
const structuredTool = cfg.responseSchema
|
|
214
|
+
? {
|
|
215
|
+
name: STRUCTURED_OUTPUT_TOOL,
|
|
216
|
+
description: 'Return the final answer as a JSON object matching this schema. Call this exactly once.',
|
|
217
|
+
input_schema: toLowercaseJsonSchema(cfg.responseSchema),
|
|
218
|
+
}
|
|
219
|
+
: undefined;
|
|
220
|
+
const allTools = structuredTool ? [...anthropicTools, structuredTool] : anthropicTools;
|
|
202
221
|
const requestBase = {
|
|
203
222
|
model: this.model,
|
|
204
223
|
max_tokens: maxTokens,
|
|
205
224
|
system: systemParts.join('\n\n') || undefined,
|
|
206
225
|
messages,
|
|
207
|
-
tools:
|
|
226
|
+
tools: allTools.length > 0 ? allTools : undefined,
|
|
227
|
+
...(structuredTool && !thinking ? { tool_choice: { type: 'tool', name: STRUCTURED_OUTPUT_TOOL } } : {}),
|
|
208
228
|
...(thinking ? { thinking } : {}),
|
|
209
229
|
};
|
|
210
230
|
// ── Call Anthropic API ────────────────────────────────────────────────────
|
|
@@ -234,9 +254,15 @@ export class ClaudeLlm extends BaseLlm {
|
|
|
234
254
|
}
|
|
235
255
|
}
|
|
236
256
|
}
|
|
237
|
-
// Final message: tool_use blocks, thinking stash,
|
|
257
|
+
// Final message: full text, tool_use blocks, thinking stash, usage.
|
|
258
|
+
//
|
|
259
|
+
// WHY includeText is true even though the text was just streamed:
|
|
260
|
+
// ADK's runner persists only NON-partial events, so a final without
|
|
261
|
+
// text would render the reply on screen and lose it from session
|
|
262
|
+
// history — the next turn would have no record the model answered.
|
|
263
|
+
// Printers drop text on a turnComplete event they already streamed.
|
|
238
264
|
const final = await streamResponse.finalMessage();
|
|
239
|
-
yield this.finalResponse(final, /*includeText=*/
|
|
265
|
+
yield this.finalResponse(final, /*includeText=*/ true);
|
|
240
266
|
}
|
|
241
267
|
else {
|
|
242
268
|
// Non-streaming path
|
|
@@ -271,6 +297,10 @@ export class ClaudeLlm extends BaseLlm {
|
|
|
271
297
|
if (block.type === 'text' && includeText) {
|
|
272
298
|
parts.push({ text: block.text });
|
|
273
299
|
}
|
|
300
|
+
else if (block.type === 'tool_use' && block.name === STRUCTURED_OUTPUT_TOOL) {
|
|
301
|
+
// The schema-validated answer, as the JSON text every consumer expects.
|
|
302
|
+
parts.push({ text: JSON.stringify(block.input ?? {}) });
|
|
303
|
+
}
|
|
274
304
|
else if (block.type === 'tool_use') {
|
|
275
305
|
parts.push({
|
|
276
306
|
functionCall: { name: block.name, args: block.input, id: block.id },
|
|
@@ -48,7 +48,7 @@ import { wantsWebSearch, isWebSearchSentinel, xaiWebSearchParamsFromEnv, } from
|
|
|
48
48
|
import { wantsXSearch, isXSearchSentinel, xSearchParamsFromEnv, } from "../tools/xSearchTool.js";
|
|
49
49
|
import { wantsCollectionsSearch, isCollectionsSearchSentinel, collectionIdsFromEnv, collectionsMaxResultsFromEnv, } from "../tools/collectionsSearchTool.js";
|
|
50
50
|
import { providerForModel } from "./providerMap.js";
|
|
51
|
-
import { toLowercaseJsonSchema } from "./schemaNormalize.js";
|
|
51
|
+
import { toLowercaseJsonSchema, toStrictJsonSchema } from "./schemaNormalize.js";
|
|
52
52
|
/** Reasoning-capable ids: o-series and the gpt-5 family. The reasoning
|
|
53
53
|
* param is also dropped and retried once on a 400, so a miss here only
|
|
54
54
|
* costs one extra round trip. */
|
|
@@ -243,7 +243,7 @@ export class GptLlm extends BaseLlm {
|
|
|
243
243
|
}
|
|
244
244
|
// ── Generation ─────────────────────────────────────────────────────────────
|
|
245
245
|
async *generateContentAsync(llmRequest, stream = false) {
|
|
246
|
-
yield* traceLlmGeneration({ provider: this.providerId(), model: this.model }, this.generateInner(llmRequest, stream));
|
|
246
|
+
yield* traceLlmGeneration({ provider: this.providerId(), model: this.model, llmRequest }, this.generateInner(llmRequest, stream));
|
|
247
247
|
}
|
|
248
248
|
async *generateInner(llmRequest, stream) {
|
|
249
249
|
const apiKey = this.apiKey || this.apiKeyFromEnv();
|
|
@@ -303,7 +303,10 @@ export class GptLlm extends BaseLlm {
|
|
|
303
303
|
format: {
|
|
304
304
|
type: 'json_schema',
|
|
305
305
|
name: 'response',
|
|
306
|
-
|
|
306
|
+
// Strict: the API enforces the exact property names, so a
|
|
307
|
+
// judge rubric's fields arrive as declared, never renamed.
|
|
308
|
+
strict: true,
|
|
309
|
+
schema: toStrictJsonSchema(cfg.responseSchema),
|
|
307
310
|
},
|
|
308
311
|
},
|
|
309
312
|
}
|
|
@@ -75,6 +75,8 @@
|
|
|
75
75
|
import type { LlmRequest, LlmResponse } from '@google/adk';
|
|
76
76
|
import { OpenAiCompatibleLlm } from './openAiCompatibleLlm.ts';
|
|
77
77
|
export declare class OllamaLlm extends OpenAiCompatibleLlm {
|
|
78
|
+
/** Ollama's OpenAI-compatible endpoint takes json_object, not json_schema. */
|
|
79
|
+
protected supportsJsonSchemaFormat(): boolean;
|
|
78
80
|
/**
|
|
79
81
|
* Model ids namespaced "ollama/<model>" route here after registration,
|
|
80
82
|
* e.g. model: "ollama/qwen3:8b" in a syndicate YAML.
|
|
@@ -77,6 +77,10 @@ import { OpenAiCompatibleLlm } from "./openAiCompatibleLlm.js";
|
|
|
77
77
|
const DEFAULT_BASE_URL = 'http://localhost:11434/v1';
|
|
78
78
|
// ── OllamaLlm ─────────────────────────────────────────────────────────────────
|
|
79
79
|
export class OllamaLlm extends OpenAiCompatibleLlm {
|
|
80
|
+
/** Ollama's OpenAI-compatible endpoint takes json_object, not json_schema. */
|
|
81
|
+
supportsJsonSchemaFormat() {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
80
84
|
/**
|
|
81
85
|
* Model ids namespaced "ollama/<model>" route here after registration,
|
|
82
86
|
* e.g. model: "ollama/qwen3:8b" in a syndicate YAML.
|
|
@@ -105,7 +109,7 @@ export class OllamaLlm extends OpenAiCompatibleLlm {
|
|
|
105
109
|
httpError(status, detail) {
|
|
106
110
|
return {
|
|
107
111
|
errorCode: 'OLLAMA_HTTP_ERROR',
|
|
108
|
-
errorMessage: `Ollama returned ${status}: ${detail.slice(0,
|
|
112
|
+
errorMessage: `Ollama returned ${status}: ${detail.slice(0, 4000)}. Is the model pulled? Try: ollama pull ${this.wireModelName()}`,
|
|
109
113
|
};
|
|
110
114
|
}
|
|
111
115
|
unreachable(message) {
|
|
@@ -66,6 +66,33 @@ export declare function splitThinkBlocks(text: string): {
|
|
|
66
66
|
reasoning: string;
|
|
67
67
|
answer: string;
|
|
68
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* Incremental counterpart to splitThinkBlocks, for the SSE path.
|
|
71
|
+
*
|
|
72
|
+
* WHY a state machine rather than the regex: on a stream the closing
|
|
73
|
+
* `</think>` has usually NOT arrived yet, so the regex matches nothing and
|
|
74
|
+
* the whole scratchpad would be printed as the reply. A tag can also be
|
|
75
|
+
* split mid-delta ("<thi" + "nk>"), so any tail that could still grow into
|
|
76
|
+
* a tag is held back instead of being emitted as answer text.
|
|
77
|
+
*
|
|
78
|
+
* Providers that report reasoning in a discrete field (Ollama's
|
|
79
|
+
* `delta.reasoning`, others' `reasoning_content`) never open a think block
|
|
80
|
+
* and pass straight through this untouched.
|
|
81
|
+
*/
|
|
82
|
+
export declare class ThinkStreamSplitter {
|
|
83
|
+
private inThink;
|
|
84
|
+
private held;
|
|
85
|
+
/** Routes one content delta into reasoning/answer text. */
|
|
86
|
+
push(chunk: string): {
|
|
87
|
+
reasoning: string;
|
|
88
|
+
answer: string;
|
|
89
|
+
};
|
|
90
|
+
/** Releases the held tail; the stream ended, so it was never a tag. */
|
|
91
|
+
flush(): {
|
|
92
|
+
reasoning: string;
|
|
93
|
+
answer: string;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
69
96
|
export declare abstract class OpenAiCompatibleLlm extends BaseLlm {
|
|
70
97
|
private webSearchWarned;
|
|
71
98
|
/** Provider id for telemetry, e.g. 'ollama', 'xai'. */
|
|
@@ -77,6 +104,13 @@ export declare abstract class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
77
104
|
/** Model id as the wire expects it (e.g. strip the "ollama/" namespace). */
|
|
78
105
|
protected wireModelName(): string;
|
|
79
106
|
/** Provider-specific request body fields (merged last). */
|
|
107
|
+
/** Whether the endpoint accepts response_format json_schema (strict). */
|
|
108
|
+
protected supportsJsonSchemaFormat(): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Whether the endpoint honors stream_options.include_usage. Without it an
|
|
111
|
+
* SSE turn reports no token counts at all; Ollama and xAI both support it.
|
|
112
|
+
*/
|
|
113
|
+
protected supportsStreamUsage(): boolean;
|
|
80
114
|
protected extraBodyFields(_llmRequest: LlmRequest): Record<string, unknown>;
|
|
81
115
|
/**
|
|
82
116
|
* Body fields enabling the provider's NATIVE web search, or null when the
|
|
@@ -105,6 +139,21 @@ export declare abstract class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
105
139
|
protected unreachable(message: string): LlmResponse;
|
|
106
140
|
generateContentAsync(llmRequest: LlmRequest, stream?: boolean): AsyncGenerator<LlmResponse, void>;
|
|
107
141
|
private generateInner;
|
|
142
|
+
/**
|
|
143
|
+
* SSE path: display-only partials as the tokens land, then ONE final
|
|
144
|
+
* non-partial response carrying the complete text, tool calls and usage.
|
|
145
|
+
*
|
|
146
|
+
* WHY the final repeats text already streamed: ADK's runner persists only
|
|
147
|
+
* non-partial events (`if (!event.partial) appendEvent(...)`), so a final
|
|
148
|
+
* without text would show the reply on screen and lose it from session
|
|
149
|
+
* history. Printers therefore skip text on a turnComplete event whose
|
|
150
|
+
* partials they already rendered — see scripts/syndicate_chat.ts.
|
|
151
|
+
*
|
|
152
|
+
* Thinking is deliberately NOT carried on the final: the scratchpad is
|
|
153
|
+
* display-only and stays out of history, which is what keeps it from
|
|
154
|
+
* being replayed to the model on the next turn.
|
|
155
|
+
*/
|
|
156
|
+
private streamResponse;
|
|
108
157
|
/** ADK Contents (Google GenAI format) → OpenAI-compatible chat messages. */
|
|
109
158
|
protected buildMessages(llmRequest: LlmRequest): OpenAiMessage[];
|
|
110
159
|
/** ADK toolsDict → OpenAI function-tool definitions (lowercase schemas). */
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
import { BaseLlm } from '@google/adk';
|
|
31
31
|
import { traceLlmGeneration, setLlmSpanAttribute, } from "../observability/tracer.js";
|
|
32
32
|
import { wantsWebSearch, isWebSearchSentinel, } from "../tools/webSearchTool.js";
|
|
33
|
-
import { toLowercaseJsonSchema } from "./schemaNormalize.js";
|
|
33
|
+
import { toLowercaseJsonSchema, toStrictJsonSchema } from "./schemaNormalize.js";
|
|
34
34
|
/** Splits "<think>…</think>" scratchpad from the reply. */
|
|
35
35
|
export function splitThinkBlocks(text) {
|
|
36
36
|
const blocks = [];
|
|
@@ -42,6 +42,101 @@ export function splitThinkBlocks(text) {
|
|
|
42
42
|
.trimStart();
|
|
43
43
|
return { reasoning: blocks.join('\n\n'), answer };
|
|
44
44
|
}
|
|
45
|
+
const OPEN_THINK = '<think>';
|
|
46
|
+
const CLOSE_THINK = '</think>';
|
|
47
|
+
/** Length of the longest suffix of `s` that is a proper prefix of `tag`. */
|
|
48
|
+
function partialTagSuffix(s, tag) {
|
|
49
|
+
const max = Math.min(s.length, tag.length - 1);
|
|
50
|
+
for (let n = max; n > 0; n--) {
|
|
51
|
+
if (s.endsWith(tag.slice(0, n)))
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Incremental counterpart to splitThinkBlocks, for the SSE path.
|
|
58
|
+
*
|
|
59
|
+
* WHY a state machine rather than the regex: on a stream the closing
|
|
60
|
+
* `</think>` has usually NOT arrived yet, so the regex matches nothing and
|
|
61
|
+
* the whole scratchpad would be printed as the reply. A tag can also be
|
|
62
|
+
* split mid-delta ("<thi" + "nk>"), so any tail that could still grow into
|
|
63
|
+
* a tag is held back instead of being emitted as answer text.
|
|
64
|
+
*
|
|
65
|
+
* Providers that report reasoning in a discrete field (Ollama's
|
|
66
|
+
* `delta.reasoning`, others' `reasoning_content`) never open a think block
|
|
67
|
+
* and pass straight through this untouched.
|
|
68
|
+
*/
|
|
69
|
+
export class ThinkStreamSplitter {
|
|
70
|
+
inThink = false;
|
|
71
|
+
held = '';
|
|
72
|
+
/** Routes one content delta into reasoning/answer text. */
|
|
73
|
+
push(chunk) {
|
|
74
|
+
let buf = this.held + chunk;
|
|
75
|
+
let reasoning = '';
|
|
76
|
+
let answer = '';
|
|
77
|
+
for (;;) {
|
|
78
|
+
const tag = this.inThink ? CLOSE_THINK : OPEN_THINK;
|
|
79
|
+
const at = buf.indexOf(tag);
|
|
80
|
+
if (at === -1)
|
|
81
|
+
break;
|
|
82
|
+
if (this.inThink)
|
|
83
|
+
reasoning += buf.slice(0, at);
|
|
84
|
+
else
|
|
85
|
+
answer += buf.slice(0, at);
|
|
86
|
+
buf = buf.slice(at + tag.length);
|
|
87
|
+
this.inThink = !this.inThink;
|
|
88
|
+
}
|
|
89
|
+
// Hold back only what could still become the tag we're watching for.
|
|
90
|
+
const pending = this.inThink ? CLOSE_THINK : OPEN_THINK;
|
|
91
|
+
const keep = partialTagSuffix(buf, pending);
|
|
92
|
+
this.held = keep > 0 ? buf.slice(buf.length - keep) : '';
|
|
93
|
+
const emit = keep > 0 ? buf.slice(0, buf.length - keep) : buf;
|
|
94
|
+
if (this.inThink)
|
|
95
|
+
reasoning += emit;
|
|
96
|
+
else
|
|
97
|
+
answer += emit;
|
|
98
|
+
return { reasoning, answer };
|
|
99
|
+
}
|
|
100
|
+
/** Releases the held tail; the stream ended, so it was never a tag. */
|
|
101
|
+
flush() {
|
|
102
|
+
const rest = this.held;
|
|
103
|
+
this.held = '';
|
|
104
|
+
return this.inThink
|
|
105
|
+
? { reasoning: rest, answer: '' }
|
|
106
|
+
: { reasoning: '', answer: rest };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Yields parsed `data:` payloads from an SSE response body. */
|
|
110
|
+
async function* sseChunks(res) {
|
|
111
|
+
const reader = res.body?.getReader?.();
|
|
112
|
+
if (!reader)
|
|
113
|
+
return;
|
|
114
|
+
const decoder = new TextDecoder();
|
|
115
|
+
let buf = '';
|
|
116
|
+
for (;;) {
|
|
117
|
+
const { value, done } = await reader.read();
|
|
118
|
+
if (done)
|
|
119
|
+
break;
|
|
120
|
+
buf += decoder.decode(value, { stream: true });
|
|
121
|
+
let nl;
|
|
122
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
123
|
+
const line = buf.slice(0, nl).trim();
|
|
124
|
+
buf = buf.slice(nl + 1);
|
|
125
|
+
// Blank separators and ":" keep-alive comments carry no payload.
|
|
126
|
+
if (!line.startsWith('data:'))
|
|
127
|
+
continue;
|
|
128
|
+
const payload = line.slice('data:'.length).trim();
|
|
129
|
+
if (payload === '[DONE]')
|
|
130
|
+
return;
|
|
131
|
+
try {
|
|
132
|
+
yield JSON.parse(payload);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* a chunk that isn't valid JSON is not worth killing the turn for */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
45
140
|
// ── OpenAiCompatibleLlm ──────────────────────────────────────────────────────
|
|
46
141
|
export class OpenAiCompatibleLlm extends BaseLlm {
|
|
47
142
|
webSearchWarned = false;
|
|
@@ -50,6 +145,17 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
50
145
|
return this.model;
|
|
51
146
|
}
|
|
52
147
|
/** Provider-specific request body fields (merged last). */
|
|
148
|
+
/** Whether the endpoint accepts response_format json_schema (strict). */
|
|
149
|
+
supportsJsonSchemaFormat() {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Whether the endpoint honors stream_options.include_usage. Without it an
|
|
154
|
+
* SSE turn reports no token counts at all; Ollama and xAI both support it.
|
|
155
|
+
*/
|
|
156
|
+
supportsStreamUsage() {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
53
159
|
extraBodyFields(_llmRequest) {
|
|
54
160
|
return {};
|
|
55
161
|
}
|
|
@@ -82,7 +188,7 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
82
188
|
httpError(status, detail) {
|
|
83
189
|
return {
|
|
84
190
|
errorCode: `${this.providerId().toUpperCase()}_HTTP_ERROR`,
|
|
85
|
-
errorMessage: `${this.providerId()} returned ${status}: ${detail.slice(0,
|
|
191
|
+
errorMessage: `${this.providerId()} returned ${status}: ${detail.slice(0, 4000)}`,
|
|
86
192
|
};
|
|
87
193
|
}
|
|
88
194
|
/** Error response when the endpoint can't be reached at all. */
|
|
@@ -94,9 +200,9 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
94
200
|
}
|
|
95
201
|
// ── Generation ─────────────────────────────────────────────────────────────
|
|
96
202
|
async *generateContentAsync(llmRequest, stream = false) {
|
|
97
|
-
yield* traceLlmGeneration({ provider: this.providerId(), model: this.model }, this.generateInner(llmRequest, stream));
|
|
203
|
+
yield* traceLlmGeneration({ provider: this.providerId(), model: this.model, llmRequest }, this.generateInner(llmRequest, stream));
|
|
98
204
|
}
|
|
99
|
-
async *generateInner(llmRequest,
|
|
205
|
+
async *generateInner(llmRequest, stream) {
|
|
100
206
|
const missing = this.missingRequirement();
|
|
101
207
|
if (missing) {
|
|
102
208
|
yield missing;
|
|
@@ -108,17 +214,34 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
108
214
|
const body = {
|
|
109
215
|
model: this.wireModelName(),
|
|
110
216
|
messages,
|
|
111
|
-
stream
|
|
217
|
+
stream,
|
|
218
|
+
// SSE reports token usage only if asked, in a final choices-less chunk.
|
|
219
|
+
...(stream && this.supportsStreamUsage()
|
|
220
|
+
? { stream_options: { include_usage: true } }
|
|
221
|
+
: {}),
|
|
112
222
|
...(cfg.temperature !== undefined ? { temperature: cfg.temperature } : {}),
|
|
113
223
|
...(cfg.topP !== undefined ? { top_p: cfg.topP } : {}),
|
|
114
224
|
...(cfg.maxOutputTokens !== undefined
|
|
115
225
|
? { max_tokens: cfg.maxOutputTokens }
|
|
116
226
|
: {}),
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
227
|
+
// Reasoning budget. Qwen3-family models think before answering, and on
|
|
228
|
+
// a constraint-dense prompt they can spend the whole budget doing it
|
|
229
|
+
// and return an empty turn. Ollama's OpenAI-compatible endpoint honors
|
|
230
|
+
// `reasoning_effort` and IGNORES the native `think` field, so this is
|
|
231
|
+
// the only lever on this path. Opt-in per agent; omitted = unchanged.
|
|
232
|
+
...(cfg.reasoningEffort !== undefined
|
|
233
|
+
? { reasoning_effort: cfg.reasoningEffort }
|
|
121
234
|
: {}),
|
|
235
|
+
...(openAiTools.length > 0 ? { tools: openAiTools } : {}),
|
|
236
|
+
// Structured output: an ADK outputSchema becomes a strict json_schema
|
|
237
|
+
// response_format where the endpoint supports it (xAI does; Ollama's
|
|
238
|
+
// OpenAI-compatible endpoint does not, see supportsJsonSchemaFormat),
|
|
239
|
+
// else plain JSON mode.
|
|
240
|
+
...(cfg.responseSchema && this.supportsJsonSchemaFormat()
|
|
241
|
+
? { response_format: { type: 'json_schema', json_schema: { name: 'response', strict: true, schema: toStrictJsonSchema(cfg.responseSchema) } } }
|
|
242
|
+
: cfg.responseMimeType === 'application/json' || cfg.responseSchema
|
|
243
|
+
? { response_format: { type: 'json_object' } }
|
|
244
|
+
: {}),
|
|
122
245
|
...this.extraBodyFields(llmRequest),
|
|
123
246
|
};
|
|
124
247
|
// web_search: enable the provider's native search, or omit with a warning.
|
|
@@ -148,6 +271,10 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
148
271
|
yield this.httpError(res.status, detail);
|
|
149
272
|
return;
|
|
150
273
|
}
|
|
274
|
+
if (stream) {
|
|
275
|
+
yield* this.streamResponse(res);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
151
278
|
const data = await res.json();
|
|
152
279
|
const message = data.choices?.[0]?.message ?? {};
|
|
153
280
|
const usageMetadata = mapUsage(data.usage);
|
|
@@ -192,6 +319,103 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
192
319
|
yield this.unreachable(msg);
|
|
193
320
|
}
|
|
194
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* SSE path: display-only partials as the tokens land, then ONE final
|
|
324
|
+
* non-partial response carrying the complete text, tool calls and usage.
|
|
325
|
+
*
|
|
326
|
+
* WHY the final repeats text already streamed: ADK's runner persists only
|
|
327
|
+
* non-partial events (`if (!event.partial) appendEvent(...)`), so a final
|
|
328
|
+
* without text would show the reply on screen and lose it from session
|
|
329
|
+
* history. Printers therefore skip text on a turnComplete event whose
|
|
330
|
+
* partials they already rendered — see scripts/syndicate_chat.ts.
|
|
331
|
+
*
|
|
332
|
+
* Thinking is deliberately NOT carried on the final: the scratchpad is
|
|
333
|
+
* display-only and stays out of history, which is what keeps it from
|
|
334
|
+
* being replayed to the model on the next turn.
|
|
335
|
+
*/
|
|
336
|
+
async *streamResponse(res) {
|
|
337
|
+
const splitter = new ThinkStreamSplitter();
|
|
338
|
+
const toolCalls = new Map();
|
|
339
|
+
let answer = '';
|
|
340
|
+
let answerStarted = false;
|
|
341
|
+
let usage;
|
|
342
|
+
const partial = (text, thought) => ({
|
|
343
|
+
content: {
|
|
344
|
+
role: 'model',
|
|
345
|
+
parts: [thought ? { text, thought: true } : { text }],
|
|
346
|
+
},
|
|
347
|
+
partial: true,
|
|
348
|
+
});
|
|
349
|
+
// The scratchpad is usually trailed by blank lines; the reply must not
|
|
350
|
+
// open with them, but only the FIRST answer text may be trimmed.
|
|
351
|
+
const openAnswer = (text) => answerStarted ? text : text.trimStart();
|
|
352
|
+
for await (const chunk of sseChunks(res)) {
|
|
353
|
+
if (chunk.usage)
|
|
354
|
+
usage = chunk.usage;
|
|
355
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
356
|
+
if (!delta)
|
|
357
|
+
continue;
|
|
358
|
+
// Reasoning as a discrete field (Ollama `reasoning`, others
|
|
359
|
+
// `reasoning_content`) — already separated, no tag parsing needed.
|
|
360
|
+
const fieldReasoning = delta.reasoning ?? delta.reasoning_content ?? '';
|
|
361
|
+
if (fieldReasoning)
|
|
362
|
+
yield partial(fieldReasoning, true);
|
|
363
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
364
|
+
const split = splitter.push(delta.content);
|
|
365
|
+
if (split.reasoning)
|
|
366
|
+
yield partial(split.reasoning, true);
|
|
367
|
+
if (split.answer) {
|
|
368
|
+
const text = openAnswer(split.answer);
|
|
369
|
+
if (text) {
|
|
370
|
+
answerStarted = true;
|
|
371
|
+
answer += text;
|
|
372
|
+
yield partial(text, false);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Tool arguments arrive as fragments keyed by index, not whole.
|
|
377
|
+
for (const call of delta.tool_calls ?? []) {
|
|
378
|
+
const index = call.index ?? 0;
|
|
379
|
+
const acc = toolCalls.get(index) ?? { args: '' };
|
|
380
|
+
if (call.id)
|
|
381
|
+
acc.id = call.id;
|
|
382
|
+
if (call.function?.name)
|
|
383
|
+
acc.name = call.function.name;
|
|
384
|
+
if (call.function?.arguments)
|
|
385
|
+
acc.args += call.function.arguments;
|
|
386
|
+
toolCalls.set(index, acc);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const tail = splitter.flush();
|
|
390
|
+
if (tail.reasoning)
|
|
391
|
+
yield partial(tail.reasoning, true);
|
|
392
|
+
if (tail.answer) {
|
|
393
|
+
const text = openAnswer(tail.answer);
|
|
394
|
+
if (text) {
|
|
395
|
+
answer += text;
|
|
396
|
+
yield partial(text, false);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const parts = [];
|
|
400
|
+
if (answer)
|
|
401
|
+
parts.push({ text: answer });
|
|
402
|
+
for (const [, acc] of [...toolCalls.entries()].sort((a, b) => a[0] - b[0])) {
|
|
403
|
+
let args = {};
|
|
404
|
+
try {
|
|
405
|
+
args = JSON.parse(acc.args || '{}');
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
args = { raw: acc.args };
|
|
409
|
+
}
|
|
410
|
+
parts.push({ functionCall: { name: acc.name, args, id: acc.id } });
|
|
411
|
+
}
|
|
412
|
+
const usageMetadata = mapUsage(usage);
|
|
413
|
+
yield {
|
|
414
|
+
content: { role: 'model', parts },
|
|
415
|
+
turnComplete: true,
|
|
416
|
+
...(usageMetadata ? { usageMetadata } : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
195
419
|
// ── Translation helpers ────────────────────────────────────────────────────
|
|
196
420
|
/** ADK Contents (Google GenAI format) → OpenAI-compatible chat messages. */
|
|
197
421
|
buildMessages(llmRequest) {
|
|
@@ -40,7 +40,7 @@ export class TracedGemini extends Gemini {
|
|
|
40
40
|
// built-in Gemini entries instead of adding shadowed duplicates.
|
|
41
41
|
static supportedModels = Gemini.supportedModels;
|
|
42
42
|
async *generateContentAsync(llmRequest, stream) {
|
|
43
|
-
yield* traceLlmGeneration({ provider: 'gemini', model: this.model }, this.tagAndGenerate(llmRequest, stream));
|
|
43
|
+
yield* traceLlmGeneration({ provider: 'gemini', model: this.model, llmRequest }, this.tagAndGenerate(llmRequest, stream));
|
|
44
44
|
}
|
|
45
45
|
/** Tags the llm.request span (web_search = Gemini grounding), then
|
|
46
46
|
* delegates to ADK's Gemini. Runs inside the span context. */
|
|
@@ -20,3 +20,12 @@
|
|
|
20
20
|
* Never mutates the input — a Gemini agent may hold the same tool object.
|
|
21
21
|
*/
|
|
22
22
|
export declare function toLowercaseJsonSchema(schema: unknown): Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* The lowercase schema in the shape OpenAI-style "strict" structured output
|
|
25
|
+
* demands: every object node carries `additionalProperties: false` and lists
|
|
26
|
+
* ALL of its properties as required. Optional fields are expressed by the
|
|
27
|
+
* model emitting a null/empty value, not by omission — that is the strict
|
|
28
|
+
* contract, and it is what makes a judge's rubric fields arrive under the
|
|
29
|
+
* names the harness expects rather than improvised ones.
|
|
30
|
+
*/
|
|
31
|
+
export declare function toStrictJsonSchema(schema: unknown): Record<string, unknown>;
|