melchizedek-agents 0.9.0 → 0.9.3
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 +14 -2
- package/DOCUMENTATION.md +13 -22
- package/QUICKSTART.md +35 -6
- package/README.md +22 -19
- package/config/agents/examples/ares.yaml +0 -2
- package/config/agents/examples/augustin.yaml +248 -0
- package/config/agents/examples/cartographers.yaml +0 -3
- package/config/agents/examples/council.yaml +1 -1
- package/config/agents/examples/image_production.yaml +0 -1
- package/config/agents/examples/librarian.yaml +0 -2
- package/config/agents/examples/patient_advocate.yaml +0 -2
- package/config/agents/examples/scriptorium.yaml +0 -3
- package/config/agents/examples/style_council.yaml +1 -5
- package/config/agents/examples/syndicate.yaml +0 -3
- package/config/agents/examples/tutor.yaml +1 -1
- 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 +25 -1
- package/dist/lib/models/gptLlm.js +5 -2
- package/dist/lib/models/ollamaLlm.d.ts +2 -0
- package/dist/lib/models/ollamaLlm.js +4 -0
- package/dist/lib/models/openAiCompatibleLlm.d.ts +2 -0
- package/dist/lib/models/openAiCompatibleLlm.js +14 -5
- 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 +186 -18
- package/dist/lib/observability/supabaseSpanExporter.js +377 -80
- package/dist/lib/observability/tracer.d.ts +74 -0
- package/dist/lib/observability/tracer.js +282 -1
- package/dist/scripts/a2a_server.js +85 -75
- package/package.json +9 -4
|
@@ -0,0 +1,64 @@
|
|
|
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 { LlmAgent } from '@google/adk';
|
|
31
|
+
import type { BaseLlm } from '@google/adk';
|
|
32
|
+
import type { SubagentYamlConfig, SyndicateYamlConfig } from './loadSyndicate.ts';
|
|
33
|
+
export interface CompileOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Turns the YAML `model` string into what LlmAgent receives. Default:
|
|
36
|
+
* identity — the string goes straight to ADK's LLMRegistry, which needs
|
|
37
|
+
* `registerAvailableProviders()` to have run (lib/models/registry.ts).
|
|
38
|
+
* The A2A server substitutes a BYOK instance factory here.
|
|
39
|
+
*/
|
|
40
|
+
resolveModel?: (model: string | undefined) => string | BaseLlm | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Loads a nested `yaml_reference:` syndicate. Default: `loadSyndicate(ref)`
|
|
43
|
+
* with no bindings — the same call the server makes. The observatory wraps
|
|
44
|
+
* this to apply its variant overrides to nested syndicates too.
|
|
45
|
+
*/
|
|
46
|
+
loadNested?: (ref: string) => SyndicateYamlConfig;
|
|
47
|
+
/** Called once per YAML tool name that no registry entry matches. */
|
|
48
|
+
onUnknownTool?: (name: string) => void;
|
|
49
|
+
/** Progress/diagnostic line sink (nested loads, MCP discovery). */
|
|
50
|
+
log?: (message: string) => void;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Builds ONE runnable agent from a subagent entry. A `yaml_reference` entry
|
|
54
|
+
* compiles the nested syndicate's whole graph under this entry's name and
|
|
55
|
+
* description, so the parent sees one tool (or one route) either way.
|
|
56
|
+
*/
|
|
57
|
+
export declare function compileSubagent(subCfg: SubagentYamlConfig, opts?: CompileOptions): Promise<LlmAgent>;
|
|
58
|
+
/**
|
|
59
|
+
* Compiles a syndicate's orchestrator. In DELEGATE mode its subagents are
|
|
60
|
+
* attached as AgentTools; in PLAN-DISPATCH mode it gets none (ADK refuses
|
|
61
|
+
* outputSchema + AgentTool on one agent — see config/agents/critic.yaml),
|
|
62
|
+
* and the caller dispatches to `compileSubagent(route)` itself.
|
|
63
|
+
*/
|
|
64
|
+
export declare function compileGraph(config: SyndicateYamlConfig, opts?: CompileOptions, overrideName?: string, overrideDescription?: string): Promise<LlmAgent>;
|
|
@@ -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
|
|
@@ -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 ────────────────────────────────────────────────────
|
|
@@ -271,6 +291,10 @@ export class ClaudeLlm extends BaseLlm {
|
|
|
271
291
|
if (block.type === 'text' && includeText) {
|
|
272
292
|
parts.push({ text: block.text });
|
|
273
293
|
}
|
|
294
|
+
else if (block.type === 'tool_use' && block.name === STRUCTURED_OUTPUT_TOOL) {
|
|
295
|
+
// The schema-validated answer, as the JSON text every consumer expects.
|
|
296
|
+
parts.push({ text: JSON.stringify(block.input ?? {}) });
|
|
297
|
+
}
|
|
274
298
|
else if (block.type === 'tool_use') {
|
|
275
299
|
parts.push({
|
|
276
300
|
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. */
|
|
@@ -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.
|
|
@@ -77,6 +77,8 @@ export declare abstract class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
77
77
|
/** Model id as the wire expects it (e.g. strip the "ollama/" namespace). */
|
|
78
78
|
protected wireModelName(): string;
|
|
79
79
|
/** Provider-specific request body fields (merged last). */
|
|
80
|
+
/** Whether the endpoint accepts response_format json_schema (strict). */
|
|
81
|
+
protected supportsJsonSchemaFormat(): boolean;
|
|
80
82
|
protected extraBodyFields(_llmRequest: LlmRequest): Record<string, unknown>;
|
|
81
83
|
/**
|
|
82
84
|
* Body fields enabling the provider's NATIVE web search, or null when the
|
|
@@ -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 = [];
|
|
@@ -50,6 +50,10 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
50
50
|
return this.model;
|
|
51
51
|
}
|
|
52
52
|
/** Provider-specific request body fields (merged last). */
|
|
53
|
+
/** Whether the endpoint accepts response_format json_schema (strict). */
|
|
54
|
+
supportsJsonSchemaFormat() {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
53
57
|
extraBodyFields(_llmRequest) {
|
|
54
58
|
return {};
|
|
55
59
|
}
|
|
@@ -115,10 +119,15 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
115
119
|
? { max_tokens: cfg.maxOutputTokens }
|
|
116
120
|
: {}),
|
|
117
121
|
...(openAiTools.length > 0 ? { tools: openAiTools } : {}),
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
// Structured output: an ADK outputSchema becomes a strict json_schema
|
|
123
|
+
// response_format where the endpoint supports it (xAI does; Ollama's
|
|
124
|
+
// OpenAI-compatible endpoint does not, see supportsJsonSchemaFormat),
|
|
125
|
+
// else plain JSON mode.
|
|
126
|
+
...(cfg.responseSchema && this.supportsJsonSchemaFormat()
|
|
127
|
+
? { response_format: { type: 'json_schema', json_schema: { name: 'response', strict: true, schema: toStrictJsonSchema(cfg.responseSchema) } } }
|
|
128
|
+
: cfg.responseMimeType === 'application/json' || cfg.responseSchema
|
|
129
|
+
? { response_format: { type: 'json_object' } }
|
|
130
|
+
: {}),
|
|
122
131
|
...this.extraBodyFields(llmRequest),
|
|
123
132
|
};
|
|
124
133
|
// web_search: enable the provider's native search, or omit with a warning.
|
|
@@ -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>;
|
|
@@ -56,3 +56,37 @@ function normalizeNode(node) {
|
|
|
56
56
|
}
|
|
57
57
|
return out;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* The lowercase schema in the shape OpenAI-style "strict" structured output
|
|
61
|
+
* demands: every object node carries `additionalProperties: false` and lists
|
|
62
|
+
* ALL of its properties as required. Optional fields are expressed by the
|
|
63
|
+
* model emitting a null/empty value, not by omission — that is the strict
|
|
64
|
+
* contract, and it is what makes a judge's rubric fields arrive under the
|
|
65
|
+
* names the harness expects rather than improvised ones.
|
|
66
|
+
*/
|
|
67
|
+
export function toStrictJsonSchema(schema) {
|
|
68
|
+
return strictNode(toLowercaseJsonSchema(schema));
|
|
69
|
+
}
|
|
70
|
+
function strictNode(node) {
|
|
71
|
+
if (Array.isArray(node))
|
|
72
|
+
return node.map(strictNode);
|
|
73
|
+
if (!node || typeof node !== 'object')
|
|
74
|
+
return node;
|
|
75
|
+
const out = { ...node };
|
|
76
|
+
const type = out.type;
|
|
77
|
+
const isObject = type === 'object' || (Array.isArray(type) && type.includes('object')) || isPlainObject(out.properties);
|
|
78
|
+
if (isObject && isPlainObject(out.properties)) {
|
|
79
|
+
const props = {};
|
|
80
|
+
for (const [k, v] of Object.entries(out.properties))
|
|
81
|
+
props[k] = strictNode(v);
|
|
82
|
+
out.properties = props;
|
|
83
|
+
out.required = Object.keys(props);
|
|
84
|
+
out.additionalProperties = false;
|
|
85
|
+
}
|
|
86
|
+
if (out.items !== undefined)
|
|
87
|
+
out.items = strictNode(out.items);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function isPlainObject(v) {
|
|
91
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/observability/embeddings.ts — embeddings for ledger search.
|
|
3
|
+
*
|
|
4
|
+
* The same model and dimensionality as long-term memory (lib/config.ts), so
|
|
5
|
+
* a query embedded here is comparable to a turn embedded by
|
|
6
|
+
* `npm run telemetry:embed`, and the `match_turns` RPC (db/telemetry.sql)
|
|
7
|
+
* compares apples to apples. Kept out of the exporter on purpose: an API
|
|
8
|
+
* call per turn would put inference on the export path; a job embeds in
|
|
9
|
+
* batches after the fact.
|
|
10
|
+
*/
|
|
11
|
+
import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL } from '../config.ts';
|
|
12
|
+
export interface EmbedOptions {
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
/** Characters kept per text (long answers are truncated, not skipped). */
|
|
15
|
+
maxChars?: number;
|
|
16
|
+
}
|
|
17
|
+
/** Embeds each text; a failed text yields an empty vector rather than a throw. */
|
|
18
|
+
export declare function embedTexts(texts: string[], options?: EmbedOptions): Promise<number[][]>;
|
|
19
|
+
/** The text a turn is embedded by: the question and the answer, in that order. */
|
|
20
|
+
export declare function turnEmbeddingText(input: string | null | undefined, output: string | null | undefined): string;
|
|
21
|
+
export { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/observability/embeddings.ts — embeddings for ledger search.
|
|
3
|
+
*
|
|
4
|
+
* The same model and dimensionality as long-term memory (lib/config.ts), so
|
|
5
|
+
* a query embedded here is comparable to a turn embedded by
|
|
6
|
+
* `npm run telemetry:embed`, and the `match_turns` RPC (db/telemetry.sql)
|
|
7
|
+
* compares apples to apples. Kept out of the exporter on purpose: an API
|
|
8
|
+
* call per turn would put inference on the export path; a job embeds in
|
|
9
|
+
* batches after the fact.
|
|
10
|
+
*/
|
|
11
|
+
import { GoogleGenAI } from '@google/genai';
|
|
12
|
+
import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL } from "../config.js";
|
|
13
|
+
const MAX_CHARS = 8_000;
|
|
14
|
+
/** Embeds each text; a failed text yields an empty vector rather than a throw. */
|
|
15
|
+
export async function embedTexts(texts, options = {}) {
|
|
16
|
+
const apiKey = options.apiKey ?? process.env.GOOGLE_GENAI_API_KEY ?? process.env.GEMINI_API_KEY;
|
|
17
|
+
if (!apiKey)
|
|
18
|
+
throw new Error('GOOGLE_GENAI_API_KEY is required for embeddings');
|
|
19
|
+
const genai = new GoogleGenAI({ apiKey });
|
|
20
|
+
const limit = options.maxChars ?? MAX_CHARS;
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const text of texts) {
|
|
23
|
+
const clipped = (text ?? '').slice(0, limit);
|
|
24
|
+
if (!clipped.trim()) {
|
|
25
|
+
out.push([]);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const response = await genai.models.embedContent({
|
|
30
|
+
model: EMBEDDING_MODEL,
|
|
31
|
+
contents: clipped,
|
|
32
|
+
config: { outputDimensionality: EMBEDDING_DIMENSIONS },
|
|
33
|
+
});
|
|
34
|
+
out.push(response.embeddings?.[0]?.values ?? []);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
console.warn(`[embeddings] failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
38
|
+
out.push([]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** The text a turn is embedded by: the question and the answer, in that order. */
|
|
44
|
+
export function turnEmbeddingText(input, output) {
|
|
45
|
+
return `${(input ?? '').trim()}\n\n${(output ?? '').trim()}`.trim();
|
|
46
|
+
}
|
|
47
|
+
export { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/observability/lineage.ts — provenance stamps for the ledger.
|
|
3
|
+
*
|
|
4
|
+
* A stored output is evidence only if you know exactly what produced it.
|
|
5
|
+
* Every turn row carries two stamps computed here:
|
|
6
|
+
*
|
|
7
|
+
* config_hash SHA-256 (first 16 hex) of the RESOLVED syndicate config
|
|
8
|
+
* with every nested `yaml_reference` inlined — the exact
|
|
9
|
+
* prompts, models, tools and generation settings that ran.
|
|
10
|
+
* Two deployments serving "the same" syndicate with a
|
|
11
|
+
* drifted registry row or a stale file produce different
|
|
12
|
+
* hashes, which is the first time that drift is visible in
|
|
13
|
+
* stored data. Eval runs hash the overridden config, so a
|
|
14
|
+
* variant's rows are distinguishable from production's.
|
|
15
|
+
* engine_version package version plus the git commit (or the platform's
|
|
16
|
+
* SOURCE_VERSION / HEROKU_SLUG_COMMIT), so a behaviour
|
|
17
|
+
* change can be pinned to a deploy.
|
|
18
|
+
*
|
|
19
|
+
* Both are deterministic and cheap; neither touches the network.
|
|
20
|
+
*/
|
|
21
|
+
import type { SyndicateYamlConfig } from '../loadSyndicate.ts';
|
|
22
|
+
/** Schema version stamped on every ledger row (adk_turns / adk_payloads). */
|
|
23
|
+
export declare const TELEMETRY_SCHEMA_VERSION = 2;
|
|
24
|
+
/** JSON with object keys sorted at every depth — stable across reloads. */
|
|
25
|
+
export declare function stableStringify(value: unknown): string;
|
|
26
|
+
/** The config with every `yaml_reference` replaced by the loaded syndicate. */
|
|
27
|
+
export declare function inlineReferences(config: SyndicateYamlConfig, loadNested: (ref: string) => SyndicateYamlConfig, seen?: Set<string>): Record<string, unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* Hash of the fully resolved config. `loadNested` is the same loader the
|
|
30
|
+
* caller compiles with (the server's plain loadSyndicate; the observatory's
|
|
31
|
+
* override-applying loader), so the hash describes what actually ran.
|
|
32
|
+
*/
|
|
33
|
+
export declare function configDigest(config: SyndicateYamlConfig, loadNested: (ref: string) => SyndicateYamlConfig): string;
|
|
34
|
+
/** "<package version>+<commit>" — commit from the platform env or git, else "local". */
|
|
35
|
+
export declare function engineVersion(): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/observability/lineage.ts — provenance stamps for the ledger.
|
|
3
|
+
*
|
|
4
|
+
* A stored output is evidence only if you know exactly what produced it.
|
|
5
|
+
* Every turn row carries two stamps computed here:
|
|
6
|
+
*
|
|
7
|
+
* config_hash SHA-256 (first 16 hex) of the RESOLVED syndicate config
|
|
8
|
+
* with every nested `yaml_reference` inlined — the exact
|
|
9
|
+
* prompts, models, tools and generation settings that ran.
|
|
10
|
+
* Two deployments serving "the same" syndicate with a
|
|
11
|
+
* drifted registry row or a stale file produce different
|
|
12
|
+
* hashes, which is the first time that drift is visible in
|
|
13
|
+
* stored data. Eval runs hash the overridden config, so a
|
|
14
|
+
* variant's rows are distinguishable from production's.
|
|
15
|
+
* engine_version package version plus the git commit (or the platform's
|
|
16
|
+
* SOURCE_VERSION / HEROKU_SLUG_COMMIT), so a behaviour
|
|
17
|
+
* change can be pinned to a deploy.
|
|
18
|
+
*
|
|
19
|
+
* Both are deterministic and cheap; neither touches the network.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { execSync } from 'node:child_process';
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
/** Schema version stamped on every ledger row (adk_turns / adk_payloads). */
|
|
26
|
+
export const TELEMETRY_SCHEMA_VERSION = 2;
|
|
27
|
+
/** JSON with object keys sorted at every depth — stable across reloads. */
|
|
28
|
+
export function stableStringify(value) {
|
|
29
|
+
if (value === null || typeof value !== 'object')
|
|
30
|
+
return JSON.stringify(value);
|
|
31
|
+
if (Array.isArray(value))
|
|
32
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
33
|
+
const obj = value;
|
|
34
|
+
const keys = Object.keys(obj)
|
|
35
|
+
.filter((k) => obj[k] !== undefined)
|
|
36
|
+
.sort();
|
|
37
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
|
|
38
|
+
}
|
|
39
|
+
/** The config with every `yaml_reference` replaced by the loaded syndicate. */
|
|
40
|
+
export function inlineReferences(config, loadNested, seen = new Set()) {
|
|
41
|
+
return {
|
|
42
|
+
...config,
|
|
43
|
+
subagents: (config.subagents ?? []).map((sub) => {
|
|
44
|
+
if (!sub.yaml_reference || seen.has(sub.yaml_reference))
|
|
45
|
+
return sub;
|
|
46
|
+
const next = new Set(seen);
|
|
47
|
+
next.add(sub.yaml_reference);
|
|
48
|
+
let nested;
|
|
49
|
+
try {
|
|
50
|
+
nested = inlineReferences(loadNested(sub.yaml_reference), loadNested, next);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
nested = { unresolved: sub.yaml_reference, error: err instanceof Error ? err.message : String(err) };
|
|
54
|
+
}
|
|
55
|
+
return { ...sub, resolved_reference: nested };
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Hash of the fully resolved config. `loadNested` is the same loader the
|
|
61
|
+
* caller compiles with (the server's plain loadSyndicate; the observatory's
|
|
62
|
+
* override-applying loader), so the hash describes what actually ran.
|
|
63
|
+
*/
|
|
64
|
+
export function configDigest(config, loadNested) {
|
|
65
|
+
const resolved = inlineReferences(config, loadNested);
|
|
66
|
+
return createHash('sha256').update(stableStringify(resolved)).digest('hex').slice(0, 16);
|
|
67
|
+
}
|
|
68
|
+
let cachedEngineVersion;
|
|
69
|
+
/** "<package version>+<commit>" — commit from the platform env or git, else "local". */
|
|
70
|
+
export function engineVersion() {
|
|
71
|
+
if (cachedEngineVersion)
|
|
72
|
+
return cachedEngineVersion;
|
|
73
|
+
let pkgVersion = '0.0.0';
|
|
74
|
+
try {
|
|
75
|
+
const pkgPath = join(process.cwd(), 'package.json');
|
|
76
|
+
if (existsSync(pkgPath)) {
|
|
77
|
+
pkgVersion = String(JSON.parse(readFileSync(pkgPath, 'utf-8')).version ?? pkgVersion);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* no package.json in cwd — version stays 0.0.0 */
|
|
82
|
+
}
|
|
83
|
+
let commit = process.env.SOURCE_VERSION ?? process.env.HEROKU_SLUG_COMMIT ?? process.env.GIT_COMMIT ?? '';
|
|
84
|
+
if (!commit) {
|
|
85
|
+
try {
|
|
86
|
+
commit = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
87
|
+
.toString()
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
commit = 'local';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
cachedEngineVersion = `${pkgVersion}+${commit.slice(0, 12)}`;
|
|
95
|
+
return cachedEngineVersion;
|
|
96
|
+
}
|