melchizedek-agents 0.9.0
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 +182 -0
- package/DOCUMENTATION.md +614 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +164 -0
- package/README.md +151 -0
- package/config/agents/README.md +27 -0
- package/config/agents/examples/ares.yaml +74 -0
- package/config/agents/examples/cartographers.yaml +115 -0
- package/config/agents/examples/claude.yaml +17 -0
- package/config/agents/examples/council.yaml +65 -0
- package/config/agents/examples/critic.yaml +74 -0
- package/config/agents/examples/delegation.yaml +29 -0
- package/config/agents/examples/hierarchical.yaml +26 -0
- package/config/agents/examples/image_production.yaml +112 -0
- package/config/agents/examples/librarian.yaml +75 -0
- package/config/agents/examples/model_zoo.yaml +81 -0
- package/config/agents/examples/patient_advocate.yaml +107 -0
- package/config/agents/examples/scriptorium.yaml +96 -0
- package/config/agents/examples/style_council.yaml +83 -0
- package/config/agents/examples/syndicate.yaml +34 -0
- package/config/agents/examples/tutor.yaml +66 -0
- package/config/agents/syndicateSchema.yaml +291 -0
- package/dist/lib/config.d.ts +108 -0
- package/dist/lib/config.js +110 -0
- package/dist/lib/dispatch.d.ts +133 -0
- package/dist/lib/dispatch.js +182 -0
- package/dist/lib/grounding.d.ts +28 -0
- package/dist/lib/grounding.js +61 -0
- package/dist/lib/index.d.ts +14 -0
- package/dist/lib/index.js +13 -0
- package/dist/lib/loadEnv.d.ts +21 -0
- package/dist/lib/loadEnv.js +58 -0
- package/dist/lib/loadSyndicate.d.ts +177 -0
- package/dist/lib/loadSyndicate.js +264 -0
- package/dist/lib/memory/supabaseMemoryService.d.ts +118 -0
- package/dist/lib/memory/supabaseMemoryService.js +499 -0
- package/dist/lib/models/claudeLlm.d.ts +93 -0
- package/dist/lib/models/claudeLlm.js +332 -0
- package/dist/lib/models/gptLlm.d.ts +110 -0
- package/dist/lib/models/gptLlm.js +497 -0
- package/dist/lib/models/grokLlm.d.ts +64 -0
- package/dist/lib/models/grokLlm.js +86 -0
- package/dist/lib/models/ollamaLlm.d.ts +105 -0
- package/dist/lib/models/ollamaLlm.js +134 -0
- package/dist/lib/models/openAiCompatibleLlm.d.ts +125 -0
- package/dist/lib/models/openAiCompatibleLlm.js +331 -0
- package/dist/lib/models/providerMap.d.ts +28 -0
- package/dist/lib/models/providerMap.js +45 -0
- package/dist/lib/models/registry.d.ts +71 -0
- package/dist/lib/models/registry.js +159 -0
- package/dist/lib/models/schemaNormalize.d.ts +22 -0
- package/dist/lib/models/schemaNormalize.js +58 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +35 -0
- package/dist/lib/observability/supabaseSpanExporter.js +115 -0
- package/dist/lib/observability/tracer.d.ts +44 -0
- package/dist/lib/observability/tracer.js +242 -0
- package/dist/lib/persistence/supabaseProvider.d.ts +44 -0
- package/dist/lib/persistence/supabaseProvider.js +88 -0
- package/dist/lib/session/supabaseSessionService.d.ts +26 -0
- package/dist/lib/session/supabaseSessionService.js +193 -0
- package/dist/lib/session/transcript.d.ts +193 -0
- package/dist/lib/session/transcript.js +309 -0
- package/dist/lib/toolRegistry.d.ts +15 -0
- package/dist/lib/toolRegistry.js +58 -0
- package/dist/lib/tools/collectionsSearchTool.d.ts +55 -0
- package/dist/lib/tools/collectionsSearchTool.js +86 -0
- package/dist/lib/tools/generateImageTool.d.ts +16 -0
- package/dist/lib/tools/generateImageTool.js +119 -0
- package/dist/lib/tools/inspectImageTool.d.ts +21 -0
- package/dist/lib/tools/inspectImageTool.js +97 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
- package/dist/lib/tools/mcpToolFactory.js +116 -0
- package/dist/lib/tools/toolContract.d.ts +78 -0
- package/dist/lib/tools/toolContract.js +129 -0
- package/dist/lib/tools/webExtractTool.d.ts +70 -0
- package/dist/lib/tools/webExtractTool.js +461 -0
- package/dist/lib/tools/webSearchTool.d.ts +53 -0
- package/dist/lib/tools/webSearchTool.js +126 -0
- package/dist/lib/tools/wikiTools.d.ts +112 -0
- package/dist/lib/tools/wikiTools.js +697 -0
- package/dist/lib/tools/xSearchTool.d.ts +61 -0
- package/dist/lib/tools/xSearchTool.js +125 -0
- package/dist/lib/wiki/agentRun.d.ts +39 -0
- package/dist/lib/wiki/agentRun.js +79 -0
- package/dist/lib/wiki/builder.d.ts +71 -0
- package/dist/lib/wiki/builder.js +165 -0
- package/dist/lib/wiki/dive.d.ts +56 -0
- package/dist/lib/wiki/dive.js +208 -0
- package/dist/lib/wiki/entities.d.ts +220 -0
- package/dist/lib/wiki/entities.js +661 -0
- package/dist/lib/wiki/extract.d.ts +97 -0
- package/dist/lib/wiki/extract.js +305 -0
- package/dist/lib/wiki/fill.d.ts +37 -0
- package/dist/lib/wiki/fill.js +175 -0
- package/dist/lib/wiki/format.d.ts +132 -0
- package/dist/lib/wiki/format.js +177 -0
- package/dist/lib/wiki/graph.d.ts +80 -0
- package/dist/lib/wiki/graph.js +147 -0
- package/dist/lib/wiki/lint.d.ts +58 -0
- package/dist/lib/wiki/lint.js +267 -0
- package/dist/lib/wiki/markdown.d.ts +127 -0
- package/dist/lib/wiki/markdown.js +336 -0
- package/dist/lib/wiki/pipeline.d.ts +69 -0
- package/dist/lib/wiki/pipeline.js +119 -0
- package/dist/lib/wiki/vault.d.ts +84 -0
- package/dist/lib/wiki/vault.js +194 -0
- package/dist/scripts/a2a_server.d.ts +2 -0
- package/dist/scripts/a2a_server.js +997 -0
- package/dist/scripts/syndicate_chat.d.ts +2 -0
- package/dist/scripts/syndicate_chat.js +485 -0
- package/package.json +96 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/models/schemaNormalize.ts — JSON-Schema dialect bridge.
|
|
3
|
+
*
|
|
4
|
+
* WHY this file exists:
|
|
5
|
+
* The ADK is Gemini-native, and Gemini's Schema type spells JSON-Schema
|
|
6
|
+
* types in UPPERCASE ('OBJECT', 'STRING', …). Every other provider this
|
|
7
|
+
* framework routes to (Anthropic, OpenAI, xAI, Ollama's OpenAI-compatible
|
|
8
|
+
* endpoint) requires standard lowercase JSON-Schema types and rejects the
|
|
9
|
+
* Gemini spelling — Anthropic, for example, with:
|
|
10
|
+
* 400 invalid_request_error: tools.N.custom.input_schema.type:
|
|
11
|
+
* Input should be 'object'
|
|
12
|
+
* Tools are declared once (YAML / MCP discovery) in the Gemini dialect, so
|
|
13
|
+
* every non-Gemini adapter runs its tool schemas through this function at
|
|
14
|
+
* request-build time. See DOCUMENTATION.md §7.1.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Deep-clones a Gemini/ADK-style JSON schema, lowercasing every `type` value
|
|
18
|
+
* ('OBJECT' → 'object', ['STRING','NULL'] → ['string','null']) while leaving
|
|
19
|
+
* `description`, `enum`, `required`, `format`, and unknown keywords untouched.
|
|
20
|
+
* Never mutates the input — a Gemini agent may hold the same tool object.
|
|
21
|
+
*/
|
|
22
|
+
export function toLowercaseJsonSchema(schema) {
|
|
23
|
+
const result = normalizeNode(schema);
|
|
24
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
return { type: 'object', properties: {} };
|
|
28
|
+
}
|
|
29
|
+
function normalizeNode(node) {
|
|
30
|
+
if (Array.isArray(node))
|
|
31
|
+
return node.map(normalizeNode);
|
|
32
|
+
if (!node || typeof node !== 'object')
|
|
33
|
+
return node;
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [key, value] of Object.entries(node)) {
|
|
36
|
+
if (key === 'type') {
|
|
37
|
+
// `type` may be a string or an array of strings (nullable unions).
|
|
38
|
+
if (typeof value === 'string') {
|
|
39
|
+
out[key] = value.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
else if (Array.isArray(value)) {
|
|
42
|
+
out[key] = value.map((v) => (typeof v === 'string' ? v.toLowerCase() : v));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
out[key] = value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (key === 'enum' || key === 'required') {
|
|
49
|
+
// Value lists, not schema nodes — copy verbatim (enum values are data
|
|
50
|
+
// and must keep their original casing).
|
|
51
|
+
out[key] = Array.isArray(value) ? [...value] : value;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
out[key] = normalizeNode(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/observability/supabaseSpanExporter.ts — optional OpenTelemetry sink
|
|
3
|
+
* that persists model-request telemetry to Supabase.
|
|
4
|
+
*
|
|
5
|
+
* WHY this file exists:
|
|
6
|
+
* The console exporter in tracer.ts prints every span as an
|
|
7
|
+
* [OTEL_SPAN_JSON] line — good for a terminal, gone when it scrolls.
|
|
8
|
+
* Operators who want durable usage data (tokens per model, per provider,
|
|
9
|
+
* over time) can opt in with TELEMETRY_SUPABASE=true, and every
|
|
10
|
+
* `llm.request` and `Syndicate Execution:*` span is also inserted into
|
|
11
|
+
* the adk_telemetry table (schema: db/telemetry.sql, RLS: db/hardening.sql).
|
|
12
|
+
*
|
|
13
|
+
* DESIGN NOTES:
|
|
14
|
+
* - Fire-and-forget: an insert failure is logged once and never thrown
|
|
15
|
+
* into the tracing pipeline — telemetry must not break inference.
|
|
16
|
+
* - Only spans that carry usage are exported (name filter below); ADK's
|
|
17
|
+
* internal spans stay console-only to keep row volume sane.
|
|
18
|
+
*/
|
|
19
|
+
import type { SpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
20
|
+
/** True when the operator has opted into the Supabase telemetry sink. */
|
|
21
|
+
export declare function telemetrySinkEnabled(): boolean;
|
|
22
|
+
export declare class SupabaseSpanExporter implements SpanExporter {
|
|
23
|
+
/** Lazily created on first export so constructing the exporter is free
|
|
24
|
+
* and synchronous (NodeTracerProvider wants processors at construction). */
|
|
25
|
+
private clientPromise;
|
|
26
|
+
private insertFailureLogged;
|
|
27
|
+
private pendingInserts;
|
|
28
|
+
private getClient;
|
|
29
|
+
export(spans: ReadableSpan[], resultCallback: (result: any) => void): void;
|
|
30
|
+
/** Waits for in-flight inserts so short-lived scripts don't drop rows. */
|
|
31
|
+
shutdown(): Promise<void>;
|
|
32
|
+
forceFlush(): Promise<void>;
|
|
33
|
+
/** Flatten a span into one adk_telemetry row. */
|
|
34
|
+
private toRow;
|
|
35
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/observability/supabaseSpanExporter.ts — optional OpenTelemetry sink
|
|
3
|
+
* that persists model-request telemetry to Supabase.
|
|
4
|
+
*
|
|
5
|
+
* WHY this file exists:
|
|
6
|
+
* The console exporter in tracer.ts prints every span as an
|
|
7
|
+
* [OTEL_SPAN_JSON] line — good for a terminal, gone when it scrolls.
|
|
8
|
+
* Operators who want durable usage data (tokens per model, per provider,
|
|
9
|
+
* over time) can opt in with TELEMETRY_SUPABASE=true, and every
|
|
10
|
+
* `llm.request` and `Syndicate Execution:*` span is also inserted into
|
|
11
|
+
* the adk_telemetry table (schema: db/telemetry.sql, RLS: db/hardening.sql).
|
|
12
|
+
*
|
|
13
|
+
* DESIGN NOTES:
|
|
14
|
+
* - Fire-and-forget: an insert failure is logged once and never thrown
|
|
15
|
+
* into the tracing pipeline — telemetry must not break inference.
|
|
16
|
+
* - Only spans that carry usage are exported (name filter below); ADK's
|
|
17
|
+
* internal spans stay console-only to keep row volume sane.
|
|
18
|
+
*/
|
|
19
|
+
import core from '@opentelemetry/core';
|
|
20
|
+
const { ExportResultCode } = core;
|
|
21
|
+
import { hasSupabaseCredentials } from "../persistence/supabaseProvider.js";
|
|
22
|
+
/** True when the operator has opted into the Supabase telemetry sink. */
|
|
23
|
+
export function telemetrySinkEnabled() {
|
|
24
|
+
return process.env.TELEMETRY_SUPABASE === 'true' && hasSupabaseCredentials();
|
|
25
|
+
}
|
|
26
|
+
const EXPORTED_SPAN_NAMES = /^(llm\.request$|Syndicate Execution: )/;
|
|
27
|
+
export class SupabaseSpanExporter {
|
|
28
|
+
/** Lazily created on first export so constructing the exporter is free
|
|
29
|
+
* and synchronous (NodeTracerProvider wants processors at construction). */
|
|
30
|
+
clientPromise;
|
|
31
|
+
insertFailureLogged = false;
|
|
32
|
+
pendingInserts = new Set();
|
|
33
|
+
getClient() {
|
|
34
|
+
if (!this.clientPromise) {
|
|
35
|
+
this.clientPromise = import('@supabase/supabase-js').then(({ createClient }) => createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY));
|
|
36
|
+
}
|
|
37
|
+
return this.clientPromise;
|
|
38
|
+
}
|
|
39
|
+
export(spans, resultCallback) {
|
|
40
|
+
const rows = spans
|
|
41
|
+
.filter((span) => EXPORTED_SPAN_NAMES.test(span.name))
|
|
42
|
+
.map((span) => this.toRow(span));
|
|
43
|
+
if (rows.length === 0) {
|
|
44
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// Fire-and-forget: report success to the pipeline immediately; log the
|
|
48
|
+
// first insert failure so a misconfigured table is visible without
|
|
49
|
+
// spamming every span batch.
|
|
50
|
+
const insert = this.getClient()
|
|
51
|
+
.then((client) => client.from('adk_telemetry').insert(rows))
|
|
52
|
+
.then(({ error }) => {
|
|
53
|
+
if (error && !this.insertFailureLogged) {
|
|
54
|
+
this.insertFailureLogged = true;
|
|
55
|
+
console.warn(`[TELEMETRY] Supabase insert failed (${error.message}). ` +
|
|
56
|
+
'Has db/telemetry.sql been applied? Telemetry rows are being dropped; ' +
|
|
57
|
+
'console [OTEL_SPAN_JSON] output is unaffected.');
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.catch((err) => {
|
|
61
|
+
if (!this.insertFailureLogged) {
|
|
62
|
+
this.insertFailureLogged = true;
|
|
63
|
+
console.warn(`[TELEMETRY] Supabase sink error: ${err instanceof Error ? err.message : err}`);
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.finally(() => this.pendingInserts.delete(insert));
|
|
67
|
+
this.pendingInserts.add(insert);
|
|
68
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
69
|
+
}
|
|
70
|
+
/** Waits for in-flight inserts so short-lived scripts don't drop rows. */
|
|
71
|
+
async shutdown() {
|
|
72
|
+
await Promise.allSettled([...this.pendingInserts]);
|
|
73
|
+
}
|
|
74
|
+
async forceFlush() {
|
|
75
|
+
await Promise.allSettled([...this.pendingInserts]);
|
|
76
|
+
}
|
|
77
|
+
/** Flatten a span into one adk_telemetry row. */
|
|
78
|
+
toRow(span) {
|
|
79
|
+
const attrs = span.attributes;
|
|
80
|
+
const num = (key) => typeof attrs[key] === 'number' ? attrs[key] : null;
|
|
81
|
+
const str = (key) => typeof attrs[key] === 'string' ? attrs[key] : null;
|
|
82
|
+
// Root spans are named "Syndicate Execution: <name>"; llm.request spans
|
|
83
|
+
// carry syndicate context only if the caller set syndicate.name.
|
|
84
|
+
const syndicate = str('syndicate.name') ??
|
|
85
|
+
(span.name.startsWith('Syndicate Execution: ')
|
|
86
|
+
? span.name.slice('Syndicate Execution: '.length)
|
|
87
|
+
: null);
|
|
88
|
+
return {
|
|
89
|
+
ts: new Date(span.startTime[0] * 1000 + span.startTime[1] / 1e6).toISOString(),
|
|
90
|
+
trace_id: span.spanContext().traceId,
|
|
91
|
+
span_id: span.spanContext().spanId,
|
|
92
|
+
span_name: span.name,
|
|
93
|
+
syndicate,
|
|
94
|
+
agent: str('llm.agent'),
|
|
95
|
+
provider: str('llm.provider'),
|
|
96
|
+
model: str('llm.model'),
|
|
97
|
+
input_tokens: num('llm.tokens.input') ?? num('syndicate.tokens.input'),
|
|
98
|
+
output_tokens: num('llm.tokens.output') ?? num('syndicate.tokens.output'),
|
|
99
|
+
thinking_tokens: num('llm.tokens.thinking') ?? num('syndicate.tokens.thinking'),
|
|
100
|
+
latency_ms: span.duration[0] * 1000 + span.duration[1] / 1e6,
|
|
101
|
+
span: {
|
|
102
|
+
traceId: span.spanContext().traceId,
|
|
103
|
+
spanId: span.spanContext().spanId,
|
|
104
|
+
name: span.name,
|
|
105
|
+
durationMs: span.duration[0] * 1000 + span.duration[1] / 1e6,
|
|
106
|
+
attributes: attrs,
|
|
107
|
+
events: span.events.map((e) => ({
|
|
108
|
+
name: e.name,
|
|
109
|
+
attributes: e.attributes ?? {},
|
|
110
|
+
})),
|
|
111
|
+
status: span.status,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
2
|
+
import type { Event, LlmResponse } from '@google/adk';
|
|
3
|
+
/**
|
|
4
|
+
* Flushes buffered span processors (console is synchronous; the Supabase
|
|
5
|
+
* batch sink is not). Call before process exit in short-lived scripts so
|
|
6
|
+
* telemetry rows aren't dropped.
|
|
7
|
+
*/
|
|
8
|
+
export declare function flushTracing(): Promise<void>;
|
|
9
|
+
/** Subscribe to finished spans. Returns an unsubscribe function. */
|
|
10
|
+
export declare function onSpanEnd(listener: (span: ReadableSpan) => void): () => void;
|
|
11
|
+
export declare function initializeTracing(): void;
|
|
12
|
+
export interface TraceMetadata {
|
|
13
|
+
syndicateName: string;
|
|
14
|
+
bindings?: Record<string, any>;
|
|
15
|
+
input?: any;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Wraps an ADK `Runner.runAsync` stream with a root OpenTelemetry span.
|
|
19
|
+
* Accumulates token counts, records variables (bindings) and the raw input/output.
|
|
20
|
+
*/
|
|
21
|
+
export declare function traceAgentRun(stream: AsyncIterableIterator<Event>, metadata: TraceMetadata): AsyncGenerator<Event, void, void>;
|
|
22
|
+
export interface LlmCallMeta {
|
|
23
|
+
/** Provider id, e.g. 'anthropic', 'openai', 'xai', 'ollama', 'gemini'. */
|
|
24
|
+
provider: string;
|
|
25
|
+
/** The model id as declared in the agent YAML (e.g. 'ollama/qwen3:8b'). */
|
|
26
|
+
model: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Wraps one adapter `generateContentAsync` invocation in an `llm.request`
|
|
30
|
+
* span — one span per model call, for EVERY provider. Records provider,
|
|
31
|
+
* model, input/output/thinking token counts, latency, and a truncated
|
|
32
|
+
* thinking summary as a span event. Adapters call this around their own
|
|
33
|
+
* generator; TracedGemini (lib/models/registry.ts) does the same for Gemini,
|
|
34
|
+
* so per-request telemetry is uniform across the fleet.
|
|
35
|
+
*
|
|
36
|
+
* Adapters can decorate the active llm.request span with extra attributes
|
|
37
|
+
* (e.g. llm.web_search.native) via setLlmSpanAttribute below.
|
|
38
|
+
*/
|
|
39
|
+
export declare function traceLlmGeneration(meta: LlmCallMeta, inner: AsyncGenerator<LlmResponse, void>): AsyncGenerator<LlmResponse, void>;
|
|
40
|
+
/**
|
|
41
|
+
* Sets an attribute on the currently active llm.request span, if any.
|
|
42
|
+
* Used by adapters for per-request flags like llm.web_search.omitted.
|
|
43
|
+
*/
|
|
44
|
+
export declare function setLlmSpanAttribute(key: string, value: string | number | boolean): void;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { trace, context } from '@opentelemetry/api';
|
|
2
|
+
import sdkNode from '@opentelemetry/sdk-trace-node';
|
|
3
|
+
const { NodeTracerProvider } = sdkNode;
|
|
4
|
+
import sdkBase from '@opentelemetry/sdk-trace-base';
|
|
5
|
+
const { SimpleSpanProcessor } = sdkBase;
|
|
6
|
+
import core from '@opentelemetry/core';
|
|
7
|
+
const { ExportResultCode } = core;
|
|
8
|
+
import { SupabaseSpanExporter, telemetrySinkEnabled, } from "./supabaseSpanExporter.js";
|
|
9
|
+
let isOtelInitialized = false;
|
|
10
|
+
let tracerProvider;
|
|
11
|
+
let telemetryExporter;
|
|
12
|
+
/**
|
|
13
|
+
* Flushes buffered span processors (console is synchronous; the Supabase
|
|
14
|
+
* batch sink is not). Call before process exit in short-lived scripts so
|
|
15
|
+
* telemetry rows aren't dropped.
|
|
16
|
+
*/
|
|
17
|
+
export async function flushTracing() {
|
|
18
|
+
await tracerProvider?.forceFlush?.();
|
|
19
|
+
await telemetryExporter?.forceFlush?.();
|
|
20
|
+
}
|
|
21
|
+
// ── In-process span listeners ────────────────────────────────────────────────
|
|
22
|
+
// Lets a script (e.g. scripts/demo_model_optionality.ts) read finished spans
|
|
23
|
+
// without scraping its own stdout for [OTEL_SPAN_JSON] lines.
|
|
24
|
+
const spanEndListeners = new Set();
|
|
25
|
+
/** Subscribe to finished spans. Returns an unsubscribe function. */
|
|
26
|
+
export function onSpanEnd(listener) {
|
|
27
|
+
spanEndListeners.add(listener);
|
|
28
|
+
return () => spanEndListeners.delete(listener);
|
|
29
|
+
}
|
|
30
|
+
class JsonConsoleExporter {
|
|
31
|
+
export(spans, resultCallback) {
|
|
32
|
+
for (const span of spans) {
|
|
33
|
+
for (const listener of spanEndListeners) {
|
|
34
|
+
try {
|
|
35
|
+
listener(span);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* a listener bug must not break the export pipeline */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Create a clean JSON representation of the span
|
|
42
|
+
const jsonSpan = {
|
|
43
|
+
traceId: span.spanContext().traceId,
|
|
44
|
+
spanId: span.spanContext().spanId,
|
|
45
|
+
name: span.name,
|
|
46
|
+
startTime: span.startTime,
|
|
47
|
+
endTime: span.endTime,
|
|
48
|
+
durationMs: span.duration[0] * 1000 + span.duration[1] / 1000000,
|
|
49
|
+
attributes: span.attributes,
|
|
50
|
+
events: span.events.map(e => ({
|
|
51
|
+
name: e.name,
|
|
52
|
+
time: e.time,
|
|
53
|
+
attributes: e.attributes || {}
|
|
54
|
+
})),
|
|
55
|
+
status: span.status,
|
|
56
|
+
};
|
|
57
|
+
// Print to stdout with a specific prefix so test_local.py can easily extract it
|
|
58
|
+
console.log(`[OTEL_SPAN_JSON] ${JSON.stringify(jsonSpan)}`);
|
|
59
|
+
}
|
|
60
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
61
|
+
}
|
|
62
|
+
shutdown() {
|
|
63
|
+
return Promise.resolve();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function initializeTracing() {
|
|
67
|
+
if (isOtelInitialized)
|
|
68
|
+
return;
|
|
69
|
+
// Set up the global OpenTelemetry provider
|
|
70
|
+
// Since we register this globally, any internal ADK spans (e.g. tool calls, model calls)
|
|
71
|
+
// will also automatically route to this provider and show up in the console.
|
|
72
|
+
const spanProcessors = [
|
|
73
|
+
new SimpleSpanProcessor(new JsonConsoleExporter()),
|
|
74
|
+
];
|
|
75
|
+
// Optional durable sink (TELEMETRY_SUPABASE=true + Supabase credentials):
|
|
76
|
+
// llm.request and Syndicate Execution spans also land in adk_telemetry.
|
|
77
|
+
// Batched so inserts never sit on the inference hot path; the exporter
|
|
78
|
+
// creates its Supabase client lazily on first export.
|
|
79
|
+
if (telemetrySinkEnabled()) {
|
|
80
|
+
const { BatchSpanProcessor } = sdkBase;
|
|
81
|
+
telemetryExporter = new SupabaseSpanExporter();
|
|
82
|
+
spanProcessors.push(new BatchSpanProcessor(telemetryExporter));
|
|
83
|
+
console.log('[TELEMETRY] Supabase sink enabled → adk_telemetry');
|
|
84
|
+
}
|
|
85
|
+
const provider = new NodeTracerProvider({ spanProcessors });
|
|
86
|
+
provider.register();
|
|
87
|
+
tracerProvider = provider;
|
|
88
|
+
isOtelInitialized = true;
|
|
89
|
+
}
|
|
90
|
+
const tracer = trace.getTracer('melchizedek-tracer');
|
|
91
|
+
/**
|
|
92
|
+
* Wraps an ADK `Runner.runAsync` stream with a root OpenTelemetry span.
|
|
93
|
+
* Accumulates token counts, records variables (bindings) and the raw input/output.
|
|
94
|
+
*/
|
|
95
|
+
export async function* traceAgentRun(stream, metadata) {
|
|
96
|
+
initializeTracing();
|
|
97
|
+
const span = tracer.startSpan(`Syndicate Execution: ${metadata.syndicateName}`);
|
|
98
|
+
if (metadata.bindings) {
|
|
99
|
+
span.setAttribute('syndicate.bindings', JSON.stringify(metadata.bindings));
|
|
100
|
+
}
|
|
101
|
+
if (metadata.input) {
|
|
102
|
+
span.setAttribute('syndicate.input', JSON.stringify(metadata.input));
|
|
103
|
+
}
|
|
104
|
+
let totalInputTokens = 0;
|
|
105
|
+
let totalOutputTokens = 0;
|
|
106
|
+
let totalThinkingTokens = 0;
|
|
107
|
+
let outputText = '';
|
|
108
|
+
const ctx = trace.setSpan(context.active(), span);
|
|
109
|
+
try {
|
|
110
|
+
const wrappedStream = {
|
|
111
|
+
[Symbol.asyncIterator]() {
|
|
112
|
+
return {
|
|
113
|
+
async next() { return context.with(ctx, () => stream.next()); },
|
|
114
|
+
async return(value) { return stream.return ? context.with(ctx, () => stream.return(value)) : { done: true, value }; },
|
|
115
|
+
async throw(e) { return stream.throw ? context.with(ctx, () => stream.throw(e)) : Promise.reject(e); }
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
for await (const event of wrappedStream) {
|
|
120
|
+
if (event.usageMetadata) {
|
|
121
|
+
// Aggregation caveat: a multi-turn run repeats the growing prompt on
|
|
122
|
+
// every model call, so Math.max under-reports true billed input. The
|
|
123
|
+
// per-call llm.request spans (traceLlmGeneration) carry the honest
|
|
124
|
+
// per-request numbers; this root-span figure is the peak context size.
|
|
125
|
+
if (event.usageMetadata.promptTokenCount) {
|
|
126
|
+
totalInputTokens = Math.max(totalInputTokens, event.usageMetadata.promptTokenCount);
|
|
127
|
+
}
|
|
128
|
+
if (event.usageMetadata.candidatesTokenCount) {
|
|
129
|
+
totalOutputTokens += event.usageMetadata.candidatesTokenCount;
|
|
130
|
+
}
|
|
131
|
+
if (event.usageMetadata.thoughtsTokenCount) {
|
|
132
|
+
totalThinkingTokens += event.usageMetadata.thoughtsTokenCount;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (event.content && event.content.parts) {
|
|
136
|
+
for (const part of event.content.parts) {
|
|
137
|
+
const p = part;
|
|
138
|
+
if (p.text && !p.thought) {
|
|
139
|
+
outputText += p.text;
|
|
140
|
+
}
|
|
141
|
+
if (p.functionCall) {
|
|
142
|
+
span.addEvent('ToolCall', {
|
|
143
|
+
'tool.name': p.functionCall.name,
|
|
144
|
+
'tool.args': JSON.stringify(p.functionCall.args || {})
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (p.functionResponse) {
|
|
148
|
+
let respData = p.functionResponse.response;
|
|
149
|
+
if (typeof respData !== 'string') {
|
|
150
|
+
respData = JSON.stringify(respData);
|
|
151
|
+
}
|
|
152
|
+
span.addEvent('ToolResponse', {
|
|
153
|
+
'tool.name': p.functionResponse.name,
|
|
154
|
+
'tool.data_gathered': respData
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
yield event;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
span.recordException(error);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
span.setAttribute('syndicate.name', metadata.syndicateName);
|
|
168
|
+
span.setAttribute('syndicate.tokens.input', totalInputTokens);
|
|
169
|
+
span.setAttribute('syndicate.tokens.output', totalOutputTokens);
|
|
170
|
+
span.setAttribute('syndicate.tokens.thinking', totalThinkingTokens);
|
|
171
|
+
span.setAttribute('syndicate.output', outputText);
|
|
172
|
+
span.end();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const THINKING_EVENT_MAX_CHARS = 600;
|
|
176
|
+
/**
|
|
177
|
+
* Wraps one adapter `generateContentAsync` invocation in an `llm.request`
|
|
178
|
+
* span — one span per model call, for EVERY provider. Records provider,
|
|
179
|
+
* model, input/output/thinking token counts, latency, and a truncated
|
|
180
|
+
* thinking summary as a span event. Adapters call this around their own
|
|
181
|
+
* generator; TracedGemini (lib/models/registry.ts) does the same for Gemini,
|
|
182
|
+
* so per-request telemetry is uniform across the fleet.
|
|
183
|
+
*
|
|
184
|
+
* Adapters can decorate the active llm.request span with extra attributes
|
|
185
|
+
* (e.g. llm.web_search.native) via setLlmSpanAttribute below.
|
|
186
|
+
*/
|
|
187
|
+
export async function* traceLlmGeneration(meta, inner) {
|
|
188
|
+
initializeTracing();
|
|
189
|
+
const span = tracer.startSpan('llm.request');
|
|
190
|
+
span.setAttribute('llm.provider', meta.provider);
|
|
191
|
+
span.setAttribute('llm.model', meta.model);
|
|
192
|
+
let inputTokens = 0;
|
|
193
|
+
let outputTokens = 0;
|
|
194
|
+
let thinkingTokens = 0;
|
|
195
|
+
let thinkingPreview = '';
|
|
196
|
+
const ctx = trace.setSpan(context.active(), span);
|
|
197
|
+
try {
|
|
198
|
+
while (true) {
|
|
199
|
+
const { value: resp, done } = await context.with(ctx, () => inner.next());
|
|
200
|
+
if (done)
|
|
201
|
+
break;
|
|
202
|
+
if (resp.usageMetadata) {
|
|
203
|
+
// One llm.request span covers one API call; the last usage report
|
|
204
|
+
// for that call is authoritative (streaming may repeat partials).
|
|
205
|
+
inputTokens = resp.usageMetadata.promptTokenCount ?? inputTokens;
|
|
206
|
+
outputTokens = resp.usageMetadata.candidatesTokenCount ?? outputTokens;
|
|
207
|
+
thinkingTokens = resp.usageMetadata.thoughtsTokenCount ?? thinkingTokens;
|
|
208
|
+
}
|
|
209
|
+
if (resp.errorCode) {
|
|
210
|
+
span.setAttribute('llm.error_code', resp.errorCode);
|
|
211
|
+
}
|
|
212
|
+
for (const part of resp.content?.parts ?? []) {
|
|
213
|
+
const p = part;
|
|
214
|
+
if (p.thought && p.text && thinkingPreview.length < THINKING_EVENT_MAX_CHARS) {
|
|
215
|
+
thinkingPreview = (thinkingPreview + p.text).slice(0, THINKING_EVENT_MAX_CHARS);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
yield resp;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
span.recordException(error);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
if (thinkingPreview) {
|
|
227
|
+
span.addEvent('llm.thinking', { 'thinking.preview': thinkingPreview });
|
|
228
|
+
}
|
|
229
|
+
span.setAttribute('llm.tokens.input', inputTokens);
|
|
230
|
+
span.setAttribute('llm.tokens.output', outputTokens);
|
|
231
|
+
span.setAttribute('llm.tokens.thinking', thinkingTokens);
|
|
232
|
+
// llm.latency_ms mirrors the span duration for the flat adk_telemetry row.
|
|
233
|
+
span.end();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Sets an attribute on the currently active llm.request span, if any.
|
|
238
|
+
* Used by adapters for per-request flags like llm.web_search.omitted.
|
|
239
|
+
*/
|
|
240
|
+
export function setLlmSpanAttribute(key, value) {
|
|
241
|
+
trace.getActiveSpan()?.setAttribute(key, value);
|
|
242
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/persistence/supabaseProvider.ts — Supabase initialization factory.
|
|
3
|
+
*
|
|
4
|
+
* WHY this file exists:
|
|
5
|
+
* This module encapsulates ALL Supabase-specific concerns:
|
|
6
|
+
* - Credential detection (reads env vars)
|
|
7
|
+
* - App initialization
|
|
8
|
+
* - Service construction (SupabaseSessionService, SupabaseVectorMemoryService)
|
|
9
|
+
*/
|
|
10
|
+
import type { BaseSessionService, BaseMemoryService } from '@google/adk';
|
|
11
|
+
export interface RlsHardeningStatus {
|
|
12
|
+
/** true only when db/hardening.sql has been applied and RLS is on for both tables. */
|
|
13
|
+
applied: boolean;
|
|
14
|
+
/** Human-readable explanation for logs. */
|
|
15
|
+
detail: string;
|
|
16
|
+
}
|
|
17
|
+
export interface PersistenceServices {
|
|
18
|
+
sessionService: BaseSessionService;
|
|
19
|
+
memoryService: BaseMemoryService | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Checks whether the database hardening in db/hardening.sql has been
|
|
22
|
+
* applied (RLS enabled on adk_memory_facts, adk_sessions and — where it
|
|
23
|
+
* exists — adk_agent_registry). Never
|
|
24
|
+
* throws — callers use this to warn operators at boot, not to gate.
|
|
25
|
+
*/
|
|
26
|
+
checkRlsHardening: () => Promise<RlsHardeningStatus>;
|
|
27
|
+
}
|
|
28
|
+
export interface SupabaseProviderOptions {
|
|
29
|
+
/** The Gemini API key passed to SupabaseVectorMemoryService for embeddings. */
|
|
30
|
+
apiKey: string;
|
|
31
|
+
/** Whether to construct the SupabaseVectorMemoryService (long-term memory). */
|
|
32
|
+
withMemory: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Returns true if all required Supabase credentials are present in the
|
|
36
|
+
* environment. Does NOT throw — callers use this to decide whether to
|
|
37
|
+
* fall back to in-memory services.
|
|
38
|
+
*/
|
|
39
|
+
export declare function hasSupabaseCredentials(): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Initializes the Supabase client and constructs the
|
|
42
|
+
* SupabaseSessionService and optionally the SupabaseVectorMemoryService.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createSupabaseServices(options: SupabaseProviderOptions): Promise<PersistenceServices>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/persistence/supabaseProvider.ts — Supabase initialization factory.
|
|
3
|
+
*
|
|
4
|
+
* WHY this file exists:
|
|
5
|
+
* This module encapsulates ALL Supabase-specific concerns:
|
|
6
|
+
* - Credential detection (reads env vars)
|
|
7
|
+
* - App initialization
|
|
8
|
+
* - Service construction (SupabaseSessionService, SupabaseVectorMemoryService)
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Returns true if all required Supabase credentials are present in the
|
|
12
|
+
* environment. Does NOT throw — callers use this to decide whether to
|
|
13
|
+
* fall back to in-memory services.
|
|
14
|
+
*/
|
|
15
|
+
export function hasSupabaseCredentials() {
|
|
16
|
+
return !!(process.env.SUPABASE_URL &&
|
|
17
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Initializes the Supabase client and constructs the
|
|
21
|
+
* SupabaseSessionService and optionally the SupabaseVectorMemoryService.
|
|
22
|
+
*/
|
|
23
|
+
export async function createSupabaseServices(options) {
|
|
24
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
25
|
+
const supabaseUrl = process.env.SUPABASE_URL;
|
|
26
|
+
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
27
|
+
const supabase = createClient(supabaseUrl, supabaseKey);
|
|
28
|
+
// ── Session service ────────────────────────────────────────────────────────
|
|
29
|
+
const { SupabaseSessionService } = await import("../session/supabaseSessionService.js");
|
|
30
|
+
const sessionService = new SupabaseSessionService(supabase);
|
|
31
|
+
// ── Memory service (optional) ──────────────────────────────────────────────
|
|
32
|
+
let memoryService;
|
|
33
|
+
if (options.withMemory) {
|
|
34
|
+
const { SupabaseVectorMemoryService } = await import("../memory/supabaseMemoryService.js");
|
|
35
|
+
memoryService = new SupabaseVectorMemoryService({ apiKey: options.apiKey }, supabase);
|
|
36
|
+
}
|
|
37
|
+
// ── Hardening probe ─────────────────────────────────────────────────────────
|
|
38
|
+
// Calls the melchizedek_rls_status() function created by db/hardening.sql.
|
|
39
|
+
// If the function is missing, hardening was never run — that is itself the
|
|
40
|
+
// answer, not an error.
|
|
41
|
+
const checkRlsHardening = async () => {
|
|
42
|
+
try {
|
|
43
|
+
const { data, error } = await supabase.rpc('melchizedek_rls_status');
|
|
44
|
+
if (error) {
|
|
45
|
+
return {
|
|
46
|
+
applied: false,
|
|
47
|
+
detail: 'db/hardening.sql has not been applied (status function missing)',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const rows = (data ?? []);
|
|
51
|
+
const unprotected = ['adk_memory_facts', 'adk_sessions'].filter((t) => !rows.some((r) => r.table_name === t && r.rls_enabled));
|
|
52
|
+
if (unprotected.length > 0) {
|
|
53
|
+
return {
|
|
54
|
+
applied: false,
|
|
55
|
+
detail: `RLS is disabled on: ${unprotected.join(', ')}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// adk_agent_registry is optional (only deployments that boot
|
|
59
|
+
// `registry:<id>` create it) but NOT advisory: an unprotected registry
|
|
60
|
+
// is agent takeover via the anon key, so a present-and-open table
|
|
61
|
+
// fails the check outright. Absent table => no row => nothing to say.
|
|
62
|
+
const registryRow = rows.find((r) => r.table_name === 'adk_agent_registry');
|
|
63
|
+
if (registryRow && !registryRow.rls_enabled) {
|
|
64
|
+
return {
|
|
65
|
+
applied: false,
|
|
66
|
+
detail: 'RLS is disabled on: adk_agent_registry (agent definitions are ' +
|
|
67
|
+
'writable with the anon key) — re-run db/hardening.sql',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Advisory only — adk_telemetry is optional (db/telemetry.sql). If it
|
|
71
|
+
// exists but hardening.sql wasn't re-run afterwards, say so without
|
|
72
|
+
// failing the check (the mandatory tables are protected).
|
|
73
|
+
const telemetryRow = rows.find((r) => r.table_name === 'adk_telemetry');
|
|
74
|
+
if (telemetryRow && !telemetryRow.rls_enabled) {
|
|
75
|
+
return {
|
|
76
|
+
applied: true,
|
|
77
|
+
detail: 'RLS enabled on adk_memory_facts and adk_sessions — but NOT on adk_telemetry; re-run db/hardening.sql',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { applied: true, detail: 'RLS enabled on adk_memory_facts and adk_sessions' };
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
84
|
+
return { applied: false, detail: `hardening check failed: ${msg}` };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return { sessionService, memoryService, checkRlsHardening };
|
|
88
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { BaseSessionService } from '@google/adk';
|
|
2
|
+
import type { CreateSessionRequest, GetSessionRequest, ListSessionsRequest, ListSessionsResponse, DeleteSessionRequest, Session, Event } from '@google/adk';
|
|
3
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
4
|
+
export declare class SupabaseSessionService extends BaseSessionService {
|
|
5
|
+
private supabase;
|
|
6
|
+
constructor(supabaseClient: SupabaseClient);
|
|
7
|
+
private getDbId;
|
|
8
|
+
createSession(request: CreateSessionRequest): Promise<Session>;
|
|
9
|
+
getSession(request: GetSessionRequest): Promise<Session | undefined>;
|
|
10
|
+
/**
|
|
11
|
+
* ADK's ListSessions contract carries paging: `limit` with either `page`
|
|
12
|
+
* (1-based, wins) or `offset` (0-based), plus an optional sort by last
|
|
13
|
+
* update, and the response must report where in the set the caller is.
|
|
14
|
+
* The window is pushed down to Postgres via .range(), and `count:
|
|
15
|
+
* 'exact'` returns the TOTAL matching rows alongside the page — so
|
|
16
|
+
* totalItems is the real total, not the slice length. With no `limit`,
|
|
17
|
+
* the whole set is one page, which is what the ADK's own docs specify
|
|
18
|
+
* ("Page size used. Equals `totalItems` when no limit was requested").
|
|
19
|
+
*/
|
|
20
|
+
listSessions(request: ListSessionsRequest): Promise<ListSessionsResponse>;
|
|
21
|
+
deleteSession(request: DeleteSessionRequest): Promise<void>;
|
|
22
|
+
appendEvent(request: {
|
|
23
|
+
session: Session;
|
|
24
|
+
event: Event;
|
|
25
|
+
}): Promise<Event>;
|
|
26
|
+
}
|