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.
Files changed (36) hide show
  1. package/AGENT_SETUP.md +14 -2
  2. package/DOCUMENTATION.md +13 -22
  3. package/QUICKSTART.md +35 -6
  4. package/README.md +22 -19
  5. package/config/agents/examples/ares.yaml +0 -2
  6. package/config/agents/examples/augustin.yaml +248 -0
  7. package/config/agents/examples/cartographers.yaml +0 -3
  8. package/config/agents/examples/council.yaml +1 -1
  9. package/config/agents/examples/image_production.yaml +0 -1
  10. package/config/agents/examples/librarian.yaml +0 -2
  11. package/config/agents/examples/patient_advocate.yaml +0 -2
  12. package/config/agents/examples/scriptorium.yaml +0 -3
  13. package/config/agents/examples/style_council.yaml +1 -5
  14. package/config/agents/examples/syndicate.yaml +0 -3
  15. package/config/agents/examples/tutor.yaml +1 -1
  16. package/config/agents/syndicateSchema.yaml +4 -2
  17. package/dist/lib/compile.d.ts +64 -0
  18. package/dist/lib/compile.js +105 -0
  19. package/dist/lib/models/claudeLlm.js +25 -1
  20. package/dist/lib/models/gptLlm.js +5 -2
  21. package/dist/lib/models/ollamaLlm.d.ts +2 -0
  22. package/dist/lib/models/ollamaLlm.js +4 -0
  23. package/dist/lib/models/openAiCompatibleLlm.d.ts +2 -0
  24. package/dist/lib/models/openAiCompatibleLlm.js +14 -5
  25. package/dist/lib/models/schemaNormalize.d.ts +9 -0
  26. package/dist/lib/models/schemaNormalize.js +34 -0
  27. package/dist/lib/observability/embeddings.d.ts +21 -0
  28. package/dist/lib/observability/embeddings.js +47 -0
  29. package/dist/lib/observability/lineage.d.ts +35 -0
  30. package/dist/lib/observability/lineage.js +96 -0
  31. package/dist/lib/observability/supabaseSpanExporter.d.ts +186 -18
  32. package/dist/lib/observability/supabaseSpanExporter.js +377 -80
  33. package/dist/lib/observability/tracer.d.ts +74 -0
  34. package/dist/lib/observability/tracer.js +282 -1
  35. package/dist/scripts/a2a_server.js +85 -75
  36. 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: anthropicTools.length > 0 ? anthropicTools : undefined,
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
- schema: toLowercaseJsonSchema(cfg.responseSchema),
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
- // critic-style structured output: responseMimeType JSON → json mode
119
- ...(cfg.responseMimeType === 'application/json'
120
- ? { response_format: { type: 'json_object' } }
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
+ }