melchizedek-agents 1.7.0 → 1.10.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/CHANGELOG.md +161 -1
- package/DOCUMENTATION.md +77 -10
- package/README.md +2 -0
- package/config/agents/syndicate.schema.json +69 -0
- package/config/agents/syndicateSchema.yaml +17 -0
- package/db/hardening.sql +3 -1
- package/db/migrations/0016_span_tree.sql +52 -0
- package/db/migrations/0017_cache_pricing.sql +146 -0
- package/db/migrations/0018_queue_view.sql +82 -0
- package/db/telemetry.sql +36 -8
- package/dist/lib/a2a/app.d.ts +26 -4
- package/dist/lib/a2a/app.js +41 -6
- package/dist/lib/a2a/executor.d.ts +28 -1
- package/dist/lib/a2a/executor.js +65 -2
- package/dist/lib/a2a/feedback.d.ts +121 -0
- package/dist/lib/a2a/feedback.js +219 -0
- package/dist/lib/a2a/policy.d.ts +55 -5
- package/dist/lib/a2a/policy.js +56 -12
- package/dist/lib/compile.d.ts +2 -0
- package/dist/lib/compile.js +2 -0
- package/dist/lib/compileNative.js +2 -0
- package/dist/lib/doctor.d.ts +10 -0
- package/dist/lib/doctor.js +33 -4
- package/dist/lib/index.d.ts +6 -2
- package/dist/lib/index.js +3 -0
- package/dist/lib/loadSyndicate.d.ts +14 -0
- package/dist/lib/loadSyndicate.js +22 -5
- package/dist/lib/memory/providers.js +7 -3
- package/dist/lib/observability/lineage.d.ts +19 -0
- package/dist/lib/observability/lineage.js +31 -0
- package/dist/lib/observability/metadata.d.ts +69 -0
- package/dist/lib/observability/metadata.js +153 -0
- package/dist/lib/observability/otlpFilter.js +1 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +31 -2
- package/dist/lib/observability/supabaseSpanExporter.js +45 -6
- package/dist/lib/observability/tracer.d.ts +17 -1
- package/dist/lib/observability/tracer.js +61 -3
- package/dist/lib/project.d.ts +116 -0
- package/dist/lib/project.js +274 -0
- package/dist/lib/runtime/native/compaction.js +7 -3
- package/dist/lib/runtime/native/request.d.ts +2 -0
- package/dist/lib/runtime/native/step.js +1 -1
- package/dist/lib/runtime/native/telemetry.d.ts +10 -3
- package/dist/lib/runtime/native/telemetry.js +49 -4
- package/dist/lib/runtime/syndicateTurn.js +5 -1
- package/dist/lib/storage/postgres/index.d.ts +10 -0
- package/dist/lib/storage/postgres/index.js +6 -1
- package/dist/lib/syndicateSchema.d.ts +5 -0
- package/dist/lib/syndicateSchema.js +13 -0
- package/dist/lib/tools/taskTools.d.ts +6 -0
- package/dist/lib/tools/taskTools.js +24 -5
- package/dist/lib/workflow/toolNode.d.ts +1 -0
- package/dist/lib/workflow/toolNode.js +1 -1
- package/dist/scripts/a2a_server.js +3 -3
- package/dist/scripts/doctor.js +2 -1
- package/dist/scripts/mcp_server.js +2 -1
- package/package.json +6 -4
package/dist/lib/doctor.js
CHANGED
|
@@ -42,6 +42,7 @@ import { OAUTH_HOSTS_ENV } from './tools/oauthHosts.js';
|
|
|
42
42
|
import { CREDENTIAL_HOSTS_ENV, credentialHosts } from './tools/credentialHosts.js';
|
|
43
43
|
import { syndicateCredentialUses } from './tools/credentialUses.js';
|
|
44
44
|
import { credentialHostEnvProblems, unboundCredentialWarning } from './a2a/oauthSetup.js';
|
|
45
|
+
import { checkProject, findProject, isIgnored, ProjectManifestError, resolveAgentsDir } from './project.js';
|
|
45
46
|
/** The `gateway.id` the doctor reports when `MODEL_GATEWAY` names no known gateway; the value itself is never kept. */
|
|
46
47
|
const UNRECOGNISED_GATEWAY = 'unrecognised';
|
|
47
48
|
function resolvable(module) {
|
|
@@ -457,10 +458,11 @@ export function flagMemoryNamespaces(syndicates) {
|
|
|
457
458
|
}
|
|
458
459
|
}
|
|
459
460
|
export function runDoctor(options = {}) {
|
|
460
|
-
const agentsDir =
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
461
|
+
const agentsDir = resolveAgentsDir(options.agentsDir);
|
|
462
|
+
const { project, report: projectReport } = projectFromCwd();
|
|
463
|
+
// The manifest's ignore list applies when the doctor reads the project's own folder.
|
|
464
|
+
const listed = listSyndicateFiles(agentsDir).filter((f) => !project || agentsDir !== project.agentsDir || !isIgnored(project, path.relative(project.root, path.join(agentsDir, f))));
|
|
465
|
+
const syndicates = listed.map((f) => diagnoseSyndicate(f, agentsDir, options.scripts));
|
|
464
466
|
// Which single variable unlocks what. A syndicate counts toward a
|
|
465
467
|
// variable only when that variable is ALL it is missing.
|
|
466
468
|
const byEnv = new Map();
|
|
@@ -541,8 +543,22 @@ export function runDoctor(options = {}) {
|
|
|
541
543
|
providers: providerPaths(),
|
|
542
544
|
...(serving ? { serving } : {}),
|
|
543
545
|
...(chatgpt ? { chatgpt } : {}),
|
|
546
|
+
...(projectReport ? { project: projectReport } : {}),
|
|
544
547
|
};
|
|
545
548
|
}
|
|
549
|
+
/** The project at or above the working directory, checked; a broken manifest becomes a problem, not a throw. */
|
|
550
|
+
function projectFromCwd() {
|
|
551
|
+
try {
|
|
552
|
+
const project = findProject();
|
|
553
|
+
return project ? { project, report: checkProject(project) } : {};
|
|
554
|
+
}
|
|
555
|
+
catch (e) {
|
|
556
|
+
if (!(e instanceof ProjectManifestError))
|
|
557
|
+
throw e;
|
|
558
|
+
const problem = `melchizedek.json: ${e.issues.join('; ')}`;
|
|
559
|
+
return { report: { root: path.dirname(e.file), error: problem, problems: [problem], warnings: [] } };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
546
562
|
/**
|
|
547
563
|
* The Sign in with ChatGPT line (ADR 0126), when a credential file is present
|
|
548
564
|
* or one of its variables is set. `served`: a serving variable is set, so
|
|
@@ -613,6 +629,8 @@ export function checkProblems(result) {
|
|
|
613
629
|
problems.push(`credentials: ${p}`);
|
|
614
630
|
for (const p of result.chatgpt?.problems ?? [])
|
|
615
631
|
problems.push(`chatgpt: ${p}`);
|
|
632
|
+
for (const p of result.project?.problems ?? [])
|
|
633
|
+
problems.push(`project: ${p}`);
|
|
616
634
|
for (const s of result.syndicates) {
|
|
617
635
|
for (const g of s.grants ?? [])
|
|
618
636
|
for (const p of g.hostProblems ?? [])
|
|
@@ -635,6 +653,17 @@ export function renderDoctor(result, opts = {}) {
|
|
|
635
653
|
: `${c.green}${text}${c.reset}`;
|
|
636
654
|
lines.push(`${c.bold}melchizedek doctor${c.reset} ${c.dim}· ${result.agentsDir}${c.reset}`);
|
|
637
655
|
lines.push('');
|
|
656
|
+
// The project manifest (ADR 0138): the engine pin, and what in it is stale.
|
|
657
|
+
if (result.project) {
|
|
658
|
+
const p = result.project;
|
|
659
|
+
const pin = 'engine' in p ? `${p.engine.matches ? c.green + '✓' : c.red + '✗'}${c.reset} engine ${p.engine.pinned}${p.engine.matches ? '' : `${c.red}, ${p.engine.running} running${c.reset}`} · ${p.syndicates.length} syndicate(s) ` : '';
|
|
660
|
+
lines.push(`project ${pin}${c.dim}${p.root}${c.reset}`);
|
|
661
|
+
for (const x of p.problems)
|
|
662
|
+
if (!x.startsWith('engine:'))
|
|
663
|
+
lines.push(` ${c.red}✗ ${x}${c.reset}`);
|
|
664
|
+
for (const x of p.warnings)
|
|
665
|
+
lines.push(` ${c.yellow}! ${x}${c.reset}`);
|
|
666
|
+
}
|
|
638
667
|
// The runtime a turn would run on, and where that came from (ADR 0107).
|
|
639
668
|
if (result.runtime) {
|
|
640
669
|
const rt = result.runtime;
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -38,15 +38,18 @@ export { RUN_CODE, runCodeTool } from './tools/runCodeTool.ts';
|
|
|
38
38
|
export type { CodeRun, RunCodeOutput } from './tools/runCodeTool.ts';
|
|
39
39
|
export { createA2AApp, compileAgentCard, currentRequestContext } from './a2a/app.ts';
|
|
40
40
|
export type { A2AApp, A2AAppOptions, RequestIdentity } from './a2a/app.ts';
|
|
41
|
-
export type { A2AContext } from './a2a/executor.ts';
|
|
41
|
+
export type { A2AContext, TaskStateName, TaskStateRecord } from './a2a/executor.ts';
|
|
42
42
|
export { callerTokens, firstOf, hashCallerToken, jwtIdentity, parseCallers, scopeSegment, sharedSecret, trustedHeader, } from './a2a/identity.ts';
|
|
43
43
|
export type { Authenticator, CallerEntry, IdentityScheme, JwtIdentityOptions } from './a2a/identity.ts';
|
|
44
44
|
export { budgets, memoryUsageStore, parseBudgets, postgresUsageStore, supabaseUsageStore } from './a2a/policy.ts';
|
|
45
|
-
export type { BudgetConfig, BudgetLimits, DailyUsage, Policy, PolicyDecision, PolicySubject, UsageStore } from './a2a/policy.ts';
|
|
45
|
+
export type { BudgetConfig, BudgetLevel, BudgetLimits, BudgetOptions, BudgetState, DailyUsage, HardStop, Policy, PolicyDecision, PolicySubject, UsageStore, } from './a2a/policy.ts';
|
|
46
|
+
export { END_USER_LABELER, feedbackRow, parseFeedback, postgresFeedbackStore } from './a2a/feedback.ts';
|
|
47
|
+
export type { FeedbackRow, FeedbackScore, FeedbackStore, FeedbackTurn } from './a2a/feedback.ts';
|
|
46
48
|
export { createMetrics } from './observability/metrics.ts';
|
|
47
49
|
export type { Metrics, TaskRecord } from './observability/metrics.ts';
|
|
48
50
|
export { patternRedactor, redactRow, setTelemetryRedactor, telemetryRedactor } from './observability/redact.ts';
|
|
49
51
|
export type { Redactor } from './observability/redact.ts';
|
|
52
|
+
export type { Metadata, MetadataValue } from './observability/metadata.ts';
|
|
50
53
|
export { RemoteA2AAgent, remoteAgentTool } from './a2a/remoteAgent.ts';
|
|
51
54
|
export type { RemoteAnswer } from './a2a/remoteAgent.ts';
|
|
52
55
|
export { resolveTools, registerTool, registeredToolNames } from './toolRegistry.ts';
|
|
@@ -67,3 +70,4 @@ export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logP
|
|
|
67
70
|
export { loadEnv } from './loadEnv.ts';
|
|
68
71
|
export { setCredentialSource, credentialValue } from './models/credentialSource.ts';
|
|
69
72
|
export type { CredentialSource } from './models/credentialSource.ts';
|
|
73
|
+
export { PROJECT_MANIFEST, projectManifestSchema, ProjectManifestError, findProject, findProjectRoot, loadProject, parseProjectManifest, resolveAgentsDir, listProjectSyndicates, checkProject, type Project, type ProjectManifest, type ProjectCheck, } from './project.ts';
|
package/dist/lib/index.js
CHANGED
|
@@ -32,6 +32,7 @@ export { RUN_CODE, runCodeTool } from './tools/runCodeTool.js';
|
|
|
32
32
|
export { createA2AApp, compileAgentCard, currentRequestContext } from './a2a/app.js';
|
|
33
33
|
export { callerTokens, firstOf, hashCallerToken, jwtIdentity, parseCallers, scopeSegment, sharedSecret, trustedHeader, } from './a2a/identity.js';
|
|
34
34
|
export { budgets, memoryUsageStore, parseBudgets, postgresUsageStore, supabaseUsageStore } from './a2a/policy.js';
|
|
35
|
+
export { END_USER_LABELER, feedbackRow, parseFeedback, postgresFeedbackStore } from './a2a/feedback.js';
|
|
35
36
|
export { createMetrics } from './observability/metrics.js';
|
|
36
37
|
export { patternRedactor, redactRow, setTelemetryRedactor, telemetryRedactor } from './observability/redact.js';
|
|
37
38
|
export { RemoteA2AAgent, remoteAgentTool } from './a2a/remoteAgent.js';
|
|
@@ -55,3 +56,5 @@ describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayPr
|
|
|
55
56
|
export { loadEnv } from './loadEnv.js';
|
|
56
57
|
// Where credentials are read, by environment variable name (ADR 0133).
|
|
57
58
|
export { setCredentialSource, credentialValue } from './models/credentialSource.js';
|
|
59
|
+
// Project manifest, melchizedek.json (ADR 0138).
|
|
60
|
+
export { PROJECT_MANIFEST, projectManifestSchema, ProjectManifestError, findProject, findProjectRoot, loadProject, parseProjectManifest, resolveAgentsDir, listProjectSyndicates, checkProject, } from './project.js';
|
|
@@ -13,6 +13,7 @@ import type { ReasoningSetting } from './models/contract.ts';
|
|
|
13
13
|
export type { WorkflowConfig } from './workflowConfig.ts';
|
|
14
14
|
export type { ScriptConfig } from './script/config.ts';
|
|
15
15
|
import type { ScriptConfig } from './script/config.ts';
|
|
16
|
+
import type { Metadata } from './observability/metadata.ts';
|
|
16
17
|
export interface ThinkingConfig {
|
|
17
18
|
/** Token budget for internal reasoning. 0 = off, -1 = dynamic. */
|
|
18
19
|
thinkingBudget?: number;
|
|
@@ -191,6 +192,12 @@ export interface AgentYamlConfig {
|
|
|
191
192
|
role?: 'primary' | 'sub-agent';
|
|
192
193
|
delegates?: string[];
|
|
193
194
|
};
|
|
195
|
+
/**
|
|
196
|
+
* Static labels for the ledger (ADR 0141): each becomes `meta.agent.<key>`
|
|
197
|
+
* on this agent's `agent.invoke` span. At most 32 keys; a credential-shaped
|
|
198
|
+
* value is dropped at load with a warning.
|
|
199
|
+
*/
|
|
200
|
+
metadata?: Metadata;
|
|
194
201
|
}
|
|
195
202
|
export interface SubagentYamlConfig extends Partial<AgentYamlConfig> {
|
|
196
203
|
/** Required when used as AgentTool — orchestrator reads this to delegate. */
|
|
@@ -254,6 +261,13 @@ export interface SyndicateYamlConfig {
|
|
|
254
261
|
script?: ScriptConfig;
|
|
255
262
|
/** Optional default variable definitions for {{token}} interpolation. */
|
|
256
263
|
variables?: VariableMap;
|
|
264
|
+
/**
|
|
265
|
+
* Static labels for the ledger (ADR 0141): each becomes `meta.<key>` on
|
|
266
|
+
* every root span of a turn, so on its adk_turns row. At most 32 keys; a
|
|
267
|
+
* credential-shaped value is dropped at load with a warning. A key named
|
|
268
|
+
* here cannot be set by an A2A caller's request metadata.
|
|
269
|
+
*/
|
|
270
|
+
metadata?: Metadata;
|
|
257
271
|
/** Defines the persistence and semantic memory layer for the syndicate. */
|
|
258
272
|
memory_system?: 'internal-only' | 'session-only' | 'long-term';
|
|
259
273
|
/**
|
|
@@ -5,6 +5,8 @@ import { createClient } from '@supabase/supabase-js';
|
|
|
5
5
|
import { hasSupabaseCredentials } from './persistence/supabaseProvider.js';
|
|
6
6
|
import { validateSyndicateConfig } from './syndicateSchema.js';
|
|
7
7
|
import { v1SpellingsOf } from './agentDialect.js';
|
|
8
|
+
import { resolveAgentsDir } from './project.js';
|
|
9
|
+
import { dropSecretMetadata } from './observability/metadata.js';
|
|
8
10
|
// ── Variable Injection (private) ──────────────────────────
|
|
9
11
|
const TOKEN_RE = /\{\{(\w+)\}\}/g;
|
|
10
12
|
const FULL_TOKEN_RE = /^\{\{(\w+)\}\}$/;
|
|
@@ -100,7 +102,7 @@ function resolveAndValidate(raw, label, options) {
|
|
|
100
102
|
}
|
|
101
103
|
const valid = validateSyndicateConfig(bundle !== undefined && isPlainObject(interpolated) ? { ...interpolated, bundled_references: bundle } : interpolated, label);
|
|
102
104
|
const { variables: _stripped, ...clean } = valid;
|
|
103
|
-
let config = clean;
|
|
105
|
+
let config = withoutSecretMetadata(clean, label, options.onWarning);
|
|
104
106
|
// The script reads the bound variables (ADR 0137 item 6): the YAML's, then the caller's bindings, as {{token}} resolves them.
|
|
105
107
|
if (config.script) {
|
|
106
108
|
const yamlBound = isPlainObject(interpolated) && isPlainObject(interpolated.variables) ? interpolated.variables : {};
|
|
@@ -111,6 +113,23 @@ function resolveAndValidate(raw, label, options) {
|
|
|
111
113
|
}
|
|
112
114
|
return config;
|
|
113
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* `metadata:` with every credential-shaped value dropped (ADR 0141), on the
|
|
118
|
+
* syndicate and on each agent, one warning per key naming the file and the
|
|
119
|
+
* key path, never the value. A `{{token}}` that resolved to a key is caught
|
|
120
|
+
* too: this runs on the interpolated config.
|
|
121
|
+
*/
|
|
122
|
+
function withoutSecretMetadata(config, label, onWarning = console.warn) {
|
|
123
|
+
const meta = dropSecretMetadata(config.metadata, `${label}: metadata`, onWarning);
|
|
124
|
+
const agent = (a, path) => {
|
|
125
|
+
const m = dropSecretMetadata(a.metadata, `${label}: ${path}.metadata`, onWarning);
|
|
126
|
+
return m === a.metadata ? a : { ...a, metadata: m };
|
|
127
|
+
};
|
|
128
|
+
const orchestrator = agent(config.orchestrator, 'orchestrator');
|
|
129
|
+
const subagents = (config.subagents ?? []).map((s, i) => agent(s, `subagents.${i}`));
|
|
130
|
+
const changed = meta !== config.metadata || orchestrator !== config.orchestrator || subagents.some((s, i) => s !== config.subagents?.[i]);
|
|
131
|
+
return changed ? { ...config, ...(meta ? { metadata: meta } : {}), orchestrator, subagents } : config;
|
|
132
|
+
}
|
|
114
133
|
/** Labels already warned about, so a server loading one file per request warns once per process. */
|
|
115
134
|
const warnedV1 = new Set();
|
|
116
135
|
/**
|
|
@@ -290,10 +309,8 @@ export function readSyndicateFile(filename, options = {}) {
|
|
|
290
309
|
// Security: confine reads to the agents root. `filename` can originate from
|
|
291
310
|
// a network-controlled path segment (see a2a_server dynamic routes), so we
|
|
292
311
|
// must reject any value that resolves outside that root (e.g. "../../.env").
|
|
293
|
-
// The root itself is trusted configuration: option → env → cwd default.
|
|
294
|
-
const agentsDir =
|
|
295
|
-
process.env.MELCHIZEDEK_AGENTS_DIR ??
|
|
296
|
-
path.join(process.cwd(), 'config', 'agents'));
|
|
312
|
+
// The root itself is trusted configuration: option → env → project manifest → cwd default (lib/project.ts).
|
|
313
|
+
const agentsDir = resolveAgentsDir(options.agentsDir);
|
|
297
314
|
let filePath = path.resolve(agentsDir, filename);
|
|
298
315
|
if (filePath !== agentsDir && !filePath.startsWith(agentsDir + path.sep)) {
|
|
299
316
|
throw new Error(`Invalid syndicate path: '${filename}' resolves outside the agents directory`);
|
|
@@ -59,12 +59,16 @@ export function modelExtractor(opts = {}) {
|
|
|
59
59
|
toolsDict: {},
|
|
60
60
|
};
|
|
61
61
|
const request = llmRequestToModelRequest(llmRequest, { model, stream: false });
|
|
62
|
+
const sources = new WeakMap();
|
|
62
63
|
async function* inner() {
|
|
63
|
-
for await (const response of adapter.generate(request))
|
|
64
|
-
|
|
64
|
+
for await (const response of adapter.generate(request)) {
|
|
65
|
+
const mapped = modelResponseToLlmResponse(response);
|
|
66
|
+
sources.set(mapped, response);
|
|
67
|
+
yield mapped;
|
|
68
|
+
}
|
|
65
69
|
}
|
|
66
70
|
let text = '';
|
|
67
|
-
for await (const response of traceLlmGeneration({ provider: adapter.provider, model, request }, inner())) {
|
|
71
|
+
for await (const response of traceLlmGeneration({ provider: adapter.provider, model, request, source: (r) => sources.get(r) }, inner())) {
|
|
68
72
|
if (response.errorCode) {
|
|
69
73
|
throw new Error(`${model}: ${response.errorCode}${response.errorMessage ? ` — ${response.errorMessage}` : ''}`);
|
|
70
74
|
}
|
|
@@ -35,6 +35,25 @@ export declare function agentOfSpanName(name: string): string | null;
|
|
|
35
35
|
export declare function isToolSpanName(name: string): boolean;
|
|
36
36
|
/** True for one agent step's model-call span: ADK's `call_llm`, the loop's `model.call`. */
|
|
37
37
|
export declare function isModelCallSpan(name: string, scope: string): boolean;
|
|
38
|
+
/** What a span the ledger keeps is, as `adk_telemetry.kind` records it (ADR 0139). */
|
|
39
|
+
export type LedgerSpanKind = 'turn' | 'agent' | 'model' | 'tool' | 'script' | 'node';
|
|
40
|
+
/**
|
|
41
|
+
* The kind of a span the ledger keeps a row for, from its name, else null:
|
|
42
|
+
* the turn's root (`Syndicate Execution: <name>`), an agent's run
|
|
43
|
+
* (`agent.invoke`), a model step and each provider call in it (`model.call`,
|
|
44
|
+
* `llm.request`), a tool call (`tool.execute`), a script's run and calls
|
|
45
|
+
* (`script.invoke`, `script.call`), and a workflow's walk and node runs
|
|
46
|
+
* (`workflow.invoke`, `node.execute`). Every other span (a credential
|
|
47
|
+
* refresh, a library's own) stays out of the ledger.
|
|
48
|
+
*/
|
|
49
|
+
export declare function ledgerSpanKind(name: string): LedgerSpanKind | null;
|
|
50
|
+
/**
|
|
51
|
+
* The attribute a span carries when its parent in the ledger is not its
|
|
52
|
+
* direct parent: the nearest ancestor the ledger keeps, which the tracer
|
|
53
|
+
* reads from its span lineage when the span starts, while every ancestor is
|
|
54
|
+
* still open (ADR 0139).
|
|
55
|
+
*/
|
|
56
|
+
export declare const LEDGER_PARENT_ATTRIBUTE = "ledger.parent_span_id";
|
|
38
57
|
/** JSON with object keys sorted at every depth — stable across reloads. */
|
|
39
58
|
export declare function stableStringify(value: unknown): string;
|
|
40
59
|
/** The config with every `yaml_reference` replaced by the loaded syndicate. */
|
|
@@ -58,6 +58,37 @@ export function isToolSpanName(name) {
|
|
|
58
58
|
export function isModelCallSpan(name, scope) {
|
|
59
59
|
return (name === 'call_llm' && scope === ADK_SPAN_SCOPE) || (name === 'model.call' && scope === RUNTIME_SPAN_SCOPE);
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The kind of a span the ledger keeps a row for, from its name, else null:
|
|
63
|
+
* the turn's root (`Syndicate Execution: <name>`), an agent's run
|
|
64
|
+
* (`agent.invoke`), a model step and each provider call in it (`model.call`,
|
|
65
|
+
* `llm.request`), a tool call (`tool.execute`), a script's run and calls
|
|
66
|
+
* (`script.invoke`, `script.call`), and a workflow's walk and node runs
|
|
67
|
+
* (`workflow.invoke`, `node.execute`). Every other span (a credential
|
|
68
|
+
* refresh, a library's own) stays out of the ledger.
|
|
69
|
+
*/
|
|
70
|
+
export function ledgerSpanKind(name) {
|
|
71
|
+
if (name.startsWith('Syndicate Execution: '))
|
|
72
|
+
return 'turn';
|
|
73
|
+
if (name === 'llm.request' || name === 'model.call')
|
|
74
|
+
return 'model';
|
|
75
|
+
if (name.startsWith('agent.invoke '))
|
|
76
|
+
return 'agent';
|
|
77
|
+
if (name.startsWith('tool.execute '))
|
|
78
|
+
return 'tool';
|
|
79
|
+
if (name.startsWith('script.invoke ') || name.startsWith('script.call '))
|
|
80
|
+
return 'script';
|
|
81
|
+
if (name.startsWith('workflow.invoke ') || name.startsWith('node.execute '))
|
|
82
|
+
return 'node';
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The attribute a span carries when its parent in the ledger is not its
|
|
87
|
+
* direct parent: the nearest ancestor the ledger keeps, which the tracer
|
|
88
|
+
* reads from its span lineage when the span starts, while every ancestor is
|
|
89
|
+
* still open (ADR 0139).
|
|
90
|
+
*/
|
|
91
|
+
export const LEDGER_PARENT_ATTRIBUTE = 'ledger.parent_span_id';
|
|
61
92
|
/** JSON with object keys sorted at every depth — stable across reloads. */
|
|
62
93
|
export function stableStringify(value) {
|
|
63
94
|
if (value === null || typeof value !== 'object')
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/observability/metadata.ts — labels a turn carries into the ledger as
|
|
3
|
+
* `meta.<key>` root-span attributes (ADR 0141): static ones from the YAML
|
|
4
|
+
* (`metadata:` on the syndicate, and on an agent as `meta.agent.<key>` on its
|
|
5
|
+
* agent span), and per-request ones an A2A caller sends (the message's
|
|
6
|
+
* `metadata` object and the `X-Melch-Meta-*` headers).
|
|
7
|
+
*
|
|
8
|
+
* The limits are one set wherever metadata comes from: a key is letters,
|
|
9
|
+
* digits and `_ . : -` (at most 64, not starting with a punctuation mark), a
|
|
10
|
+
* value is a string (at most 256 characters), a finite number or a boolean,
|
|
11
|
+
* and a turn carries at most 32 keys. A value shaped like a credential
|
|
12
|
+
* (REDACTION_PATTERNS.secret) is never kept: metadata is a label, and a key
|
|
13
|
+
* pasted into one is dropped with a warning that names the key, never the
|
|
14
|
+
* value. String values then pass through the telemetry redactor in force.
|
|
15
|
+
*/
|
|
16
|
+
import type { Redactor } from './redact.ts';
|
|
17
|
+
export type MetadataValue = string | number | boolean;
|
|
18
|
+
export type Metadata = Record<string, MetadataValue>;
|
|
19
|
+
/** The attribute prefix: `meta.<key>` on the root span, `meta.agent.<key>` on an agent span. */
|
|
20
|
+
export declare const META_PREFIX = "meta.";
|
|
21
|
+
export declare const META_AGENT_PREFIX = "meta.agent.";
|
|
22
|
+
export declare const MAX_META_KEYS = 32;
|
|
23
|
+
export declare const MAX_META_KEY_CHARS = 64;
|
|
24
|
+
export declare const MAX_META_VALUE_CHARS = 256;
|
|
25
|
+
/** A metadata key: the same rule the desktop app's filters check before a key reaches SQL. */
|
|
26
|
+
export declare const META_KEY: RegExp;
|
|
27
|
+
/** The request header family: `X-Melch-Meta-<key>: <value>` (Node lowercases header names, so the key arrives lowercase). */
|
|
28
|
+
export declare const META_HEADER_PREFIX = "x-melch-meta-";
|
|
29
|
+
/** True when the value looks like a credential (the redactor's `secret` pattern). */
|
|
30
|
+
export declare function isSecretShaped(value: string): boolean;
|
|
31
|
+
/** Why one entry is not metadata, or undefined when it is. Never quotes the value. */
|
|
32
|
+
export declare function metadataEntryProblem(key: string, value: unknown): string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The entries whose value looks like a credential, dropped: the rest, and
|
|
35
|
+
* one warning per dropped key (`where` names the place, e.g. a file and key path).
|
|
36
|
+
*/
|
|
37
|
+
export declare function dropSecretMetadata(meta: Metadata | undefined, where: string, warn: (message: string) => void): Metadata | undefined;
|
|
38
|
+
/** Metadata as span attributes under `prefix`, string values through the telemetry redactor in force. */
|
|
39
|
+
export declare function metaAttributes(meta: Metadata | undefined, prefix?: string, redactor?: Redactor | undefined): Record<string, MetadataValue>;
|
|
40
|
+
/**
|
|
41
|
+
* The `X-Melch-Meta-*` headers as metadata, or why the request is refused.
|
|
42
|
+
* A header is an explicit act of this API, so a malformed one is refused
|
|
43
|
+
* rather than dropped (as X-Surface is): a key outside the rule, a value
|
|
44
|
+
* over the limit, more than 32 of them. A credential-shaped value is
|
|
45
|
+
* dropped, its key listed in `dropped`, for the caller to warn about once
|
|
46
|
+
* the request is authenticated (an anonymous request must not write logs).
|
|
47
|
+
*/
|
|
48
|
+
export declare function headerMetadata(headers: Record<string, string | string[] | undefined>): {
|
|
49
|
+
meta?: Metadata;
|
|
50
|
+
dropped?: string[];
|
|
51
|
+
error?: string;
|
|
52
|
+
};
|
|
53
|
+
/** The warning for a header `headerMetadata` dropped: the key, never the value. */
|
|
54
|
+
export declare const droppedHeaderWarning: (key: string) => string;
|
|
55
|
+
/**
|
|
56
|
+
* A request's metadata from its sources, in precedence order (the first to
|
|
57
|
+
* name a key wins): only scalar entries that pass the rules, never a key in
|
|
58
|
+
* `reserved` (the YAML's own: a caller cannot relabel an operator's
|
|
59
|
+
* metadata), never one starting `agent.`, at most `room` keys. A2A's
|
|
60
|
+
* `Message.metadata` is shared by protocol extensions, so an entry that is
|
|
61
|
+
* not metadata (a nested object, a long value, a key outside the rule) is
|
|
62
|
+
* skipped, not refused; the warning counts what was skipped and names valid
|
|
63
|
+
* keys only.
|
|
64
|
+
*/
|
|
65
|
+
export declare function requestMetadata(sources: ReadonlyArray<unknown>, options?: {
|
|
66
|
+
reserved?: ReadonlySet<string>;
|
|
67
|
+
room?: number;
|
|
68
|
+
warn?: (message: string) => void;
|
|
69
|
+
}): Metadata;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/observability/metadata.ts — labels a turn carries into the ledger as
|
|
3
|
+
* `meta.<key>` root-span attributes (ADR 0141): static ones from the YAML
|
|
4
|
+
* (`metadata:` on the syndicate, and on an agent as `meta.agent.<key>` on its
|
|
5
|
+
* agent span), and per-request ones an A2A caller sends (the message's
|
|
6
|
+
* `metadata` object and the `X-Melch-Meta-*` headers).
|
|
7
|
+
*
|
|
8
|
+
* The limits are one set wherever metadata comes from: a key is letters,
|
|
9
|
+
* digits and `_ . : -` (at most 64, not starting with a punctuation mark), a
|
|
10
|
+
* value is a string (at most 256 characters), a finite number or a boolean,
|
|
11
|
+
* and a turn carries at most 32 keys. A value shaped like a credential
|
|
12
|
+
* (REDACTION_PATTERNS.secret) is never kept: metadata is a label, and a key
|
|
13
|
+
* pasted into one is dropped with a warning that names the key, never the
|
|
14
|
+
* value. String values then pass through the telemetry redactor in force.
|
|
15
|
+
*/
|
|
16
|
+
import { REDACTION_PATTERNS, telemetryRedactor } from './redact.js';
|
|
17
|
+
/** The attribute prefix: `meta.<key>` on the root span, `meta.agent.<key>` on an agent span. */
|
|
18
|
+
export const META_PREFIX = 'meta.';
|
|
19
|
+
export const META_AGENT_PREFIX = 'meta.agent.';
|
|
20
|
+
export const MAX_META_KEYS = 32;
|
|
21
|
+
export const MAX_META_KEY_CHARS = 64;
|
|
22
|
+
export const MAX_META_VALUE_CHARS = 256;
|
|
23
|
+
/** A metadata key: the same rule the desktop app's filters check before a key reaches SQL. */
|
|
24
|
+
export const META_KEY = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,63}$/;
|
|
25
|
+
/** The request header family: `X-Melch-Meta-<key>: <value>` (Node lowercases header names, so the key arrives lowercase). */
|
|
26
|
+
export const META_HEADER_PREFIX = 'x-melch-meta-';
|
|
27
|
+
/** True when the value looks like a credential (the redactor's `secret` pattern). */
|
|
28
|
+
export function isSecretShaped(value) {
|
|
29
|
+
// A fresh, non-global copy: the shared pattern carries the `g` flag, whose lastIndex would make .test() stateful.
|
|
30
|
+
return new RegExp(REDACTION_PATTERNS.secret.source).test(value);
|
|
31
|
+
}
|
|
32
|
+
/** Why one entry is not metadata, or undefined when it is. Never quotes the value. */
|
|
33
|
+
export function metadataEntryProblem(key, value) {
|
|
34
|
+
if (!META_KEY.test(key))
|
|
35
|
+
return `a metadata key is letters, digits and _ . : - (at most ${MAX_META_KEY_CHARS}, not starting with . : -)`;
|
|
36
|
+
if (typeof value === 'string') {
|
|
37
|
+
if (value.length > MAX_META_VALUE_CHARS)
|
|
38
|
+
return `metadata.${key} is longer than ${MAX_META_VALUE_CHARS} characters`;
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === 'number')
|
|
42
|
+
return Number.isFinite(value) ? undefined : `metadata.${key} is not a finite number`;
|
|
43
|
+
if (typeof value === 'boolean')
|
|
44
|
+
return undefined;
|
|
45
|
+
return `metadata.${key} must be a string, a number or a boolean`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The entries whose value looks like a credential, dropped: the rest, and
|
|
49
|
+
* one warning per dropped key (`where` names the place, e.g. a file and key path).
|
|
50
|
+
*/
|
|
51
|
+
export function dropSecretMetadata(meta, where, warn) {
|
|
52
|
+
if (!meta)
|
|
53
|
+
return meta;
|
|
54
|
+
let out;
|
|
55
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
56
|
+
if (typeof value === 'string' && isSecretShaped(value)) {
|
|
57
|
+
out ??= { ...meta };
|
|
58
|
+
delete out[key];
|
|
59
|
+
warn(`⚠ ${where}.${key} looks like a key or token and is dropped: metadata is a label, never a credential.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out ?? meta;
|
|
63
|
+
}
|
|
64
|
+
/** Metadata as span attributes under `prefix`, string values through the telemetry redactor in force. */
|
|
65
|
+
export function metaAttributes(meta, prefix = META_PREFIX, redactor = telemetryRedactor()) {
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const [key, value] of Object.entries(meta ?? {})) {
|
|
68
|
+
if (metadataEntryProblem(key, value) || (typeof value === 'string' && isSecretShaped(value)))
|
|
69
|
+
continue;
|
|
70
|
+
out[`${prefix}${key}`] = typeof value === 'string' && redactor ? redactor(value) : value;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The `X-Melch-Meta-*` headers as metadata, or why the request is refused.
|
|
76
|
+
* A header is an explicit act of this API, so a malformed one is refused
|
|
77
|
+
* rather than dropped (as X-Surface is): a key outside the rule, a value
|
|
78
|
+
* over the limit, more than 32 of them. A credential-shaped value is
|
|
79
|
+
* dropped, its key listed in `dropped`, for the caller to warn about once
|
|
80
|
+
* the request is authenticated (an anonymous request must not write logs).
|
|
81
|
+
*/
|
|
82
|
+
export function headerMetadata(headers) {
|
|
83
|
+
const meta = {};
|
|
84
|
+
const dropped = [];
|
|
85
|
+
let count = 0;
|
|
86
|
+
for (const [name, raw] of Object.entries(headers)) {
|
|
87
|
+
if (!name.toLowerCase().startsWith(META_HEADER_PREFIX) || raw === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
const key = name.slice(META_HEADER_PREFIX.length).toLowerCase();
|
|
90
|
+
const value = Array.isArray(raw) ? raw.join(', ') : raw;
|
|
91
|
+
const problem = metadataEntryProblem(key, value);
|
|
92
|
+
if (problem)
|
|
93
|
+
return { error: `Invalid X-Melch-Meta-* header: ${problem.replace(/^metadata\.[^ ]+/, 'its value')}` };
|
|
94
|
+
if (key.startsWith('agent.'))
|
|
95
|
+
return { error: 'Invalid X-Melch-Meta-* header: keys starting "agent." are reserved for agent spans' };
|
|
96
|
+
if (++count > MAX_META_KEYS)
|
|
97
|
+
return { error: `Too many X-Melch-Meta-* headers: at most ${MAX_META_KEYS}` };
|
|
98
|
+
if (isSecretShaped(value)) {
|
|
99
|
+
dropped.push(key);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
meta[key] = value;
|
|
103
|
+
}
|
|
104
|
+
return count ? { meta, ...(dropped.length ? { dropped } : {}) } : {};
|
|
105
|
+
}
|
|
106
|
+
/** The warning for a header `headerMetadata` dropped: the key, never the value. */
|
|
107
|
+
export const droppedHeaderWarning = (key) => `⚠ X-Melch-Meta-${key} looks like a key or token and is dropped: metadata is a label, never a credential.`;
|
|
108
|
+
/**
|
|
109
|
+
* A request's metadata from its sources, in precedence order (the first to
|
|
110
|
+
* name a key wins): only scalar entries that pass the rules, never a key in
|
|
111
|
+
* `reserved` (the YAML's own: a caller cannot relabel an operator's
|
|
112
|
+
* metadata), never one starting `agent.`, at most `room` keys. A2A's
|
|
113
|
+
* `Message.metadata` is shared by protocol extensions, so an entry that is
|
|
114
|
+
* not metadata (a nested object, a long value, a key outside the rule) is
|
|
115
|
+
* skipped, not refused; the warning counts what was skipped and names valid
|
|
116
|
+
* keys only.
|
|
117
|
+
*/
|
|
118
|
+
export function requestMetadata(sources, options = {}) {
|
|
119
|
+
const room = Math.max(0, Math.min(MAX_META_KEYS, options.room ?? MAX_META_KEYS));
|
|
120
|
+
const out = {};
|
|
121
|
+
let kept = 0;
|
|
122
|
+
let skipped = 0;
|
|
123
|
+
let overflow = 0;
|
|
124
|
+
for (const source of sources) {
|
|
125
|
+
if (!source || typeof source !== 'object' || Array.isArray(source))
|
|
126
|
+
continue;
|
|
127
|
+
for (const [key, value] of Object.entries(source)) {
|
|
128
|
+
if (Object.prototype.hasOwnProperty.call(out, key))
|
|
129
|
+
continue;
|
|
130
|
+
if (options.reserved?.has(key))
|
|
131
|
+
continue;
|
|
132
|
+
if (metadataEntryProblem(key, value) || key.startsWith('agent.')) {
|
|
133
|
+
skipped++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (typeof value === 'string' && isSecretShaped(value)) {
|
|
137
|
+
options.warn?.(`⚠ Request metadata ${key} looks like a key or token and is dropped: metadata is a label, never a credential.`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (kept >= room) {
|
|
141
|
+
overflow++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
out[key] = value;
|
|
145
|
+
kept++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (skipped)
|
|
149
|
+
options.warn?.(`Request metadata: ${skipped} entr${skipped === 1 ? 'y is' : 'ies are'} not metadata (a key outside [A-Za-z0-9_.:-]{1,64}, a value that is not a short string, number or boolean) and skipped.`);
|
|
150
|
+
if (overflow)
|
|
151
|
+
options.warn?.(`Request metadata: ${overflow} key(s) over the limit of ${MAX_META_KEYS} per turn skipped.`);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
* tool calls WITH their responses, the ids that join it to
|
|
14
14
|
* the session (session_id, invocation_id) and the task,
|
|
15
15
|
* and provenance (config_hash, engine_version).
|
|
16
|
-
* adk_telemetry one row per span —
|
|
16
|
+
* adk_telemetry one row per span the ledger keeps — the turn's root, each
|
|
17
|
+
* agent run, model step and provider call, tool call,
|
|
18
|
+
* script run and call, workflow walk and node run — each
|
|
19
|
+
* with its parent among them and its kind, so a turn's
|
|
20
|
+
* span tree is stored (ADR 0139).
|
|
17
21
|
* adk_payloads full request/response per model call, captured by
|
|
18
22
|
* POLICY and expiring. ADK's own call_llm spans carry the
|
|
19
23
|
* assembled prompt and raw response, and the native
|
|
@@ -37,6 +41,7 @@
|
|
|
37
41
|
* payloads or none, and a re-run of the same policy keeps the same set.
|
|
38
42
|
*/
|
|
39
43
|
import type { SpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
44
|
+
import type { LedgerSpanKind } from './lineage.ts';
|
|
40
45
|
/** Row shape for adk_telemetry (raw spans). */
|
|
41
46
|
export interface TelemetryRow {
|
|
42
47
|
ts: string;
|
|
@@ -50,11 +55,19 @@ export interface TelemetryRow {
|
|
|
50
55
|
input_tokens: number | null;
|
|
51
56
|
output_tokens: number | null;
|
|
52
57
|
thinking_tokens: number | null;
|
|
58
|
+
/** The parts of input_tokens read from and written to the provider's prompt cache; null when unreported. */
|
|
59
|
+
cache_read_tokens: number | null;
|
|
60
|
+
cache_write_tokens: number | null;
|
|
53
61
|
latency_ms: number;
|
|
54
62
|
session_id: string | null;
|
|
55
63
|
user_id: string | null;
|
|
56
64
|
task_id: string | null;
|
|
57
65
|
invocation_id: string | null;
|
|
66
|
+
/** The nearest ancestor span the ledger keeps (ADR 0139); null for a span with no parent. */
|
|
67
|
+
parent_span_id: string | null;
|
|
68
|
+
kind: LedgerSpanKind | null;
|
|
69
|
+
/** The span failed: an error status, an error code, a tool's error response. */
|
|
70
|
+
error: boolean;
|
|
58
71
|
schema_version: number;
|
|
59
72
|
span: Record<string, unknown>;
|
|
60
73
|
}
|
|
@@ -87,6 +100,9 @@ export interface TurnRow {
|
|
|
87
100
|
input_tokens: number | null;
|
|
88
101
|
output_tokens: number | null;
|
|
89
102
|
thinking_tokens: number | null;
|
|
103
|
+
/** Cache tokens summed over the turn's calls (a provider bills each call's). */
|
|
104
|
+
cache_read_tokens: number | null;
|
|
105
|
+
cache_write_tokens: number | null;
|
|
90
106
|
latency_ms: number;
|
|
91
107
|
model_ms: number | null;
|
|
92
108
|
tool_ms: number | null;
|
|
@@ -144,13 +160,26 @@ export interface TurnFacts {
|
|
|
144
160
|
}
|
|
145
161
|
/** Which payloads of a turn to keep, given the policy and the turn's outcome. */
|
|
146
162
|
export declare function payloadDecision(policy: PayloadPolicy, traceId: string, facts: TurnFacts): PayloadReason | null;
|
|
163
|
+
/**
|
|
164
|
+
* The spans that get an adk_telemetry row: every span ledgerSpanKind names
|
|
165
|
+
* (ADR 0139). A model step's row (`model.call`) leaves its payload to
|
|
166
|
+
* adk_payloads (spanJson drops `llm.payload.*`) and its tokens to its
|
|
167
|
+
* llm.request rows, so a trace's rows sum without counting a call twice. A
|
|
168
|
+
* script's own spans (ADR 0137 item 16) carry its run, its calls' kinds,
|
|
169
|
+
* names, sequence and status, never an argument or a result.
|
|
170
|
+
*/
|
|
171
|
+
export declare function isLedgerSpan(span: ReadableSpan): boolean;
|
|
147
172
|
export declare function isRootSpan(span: ReadableSpan): boolean;
|
|
148
173
|
export declare function isPayloadSpan(span: ReadableSpan): boolean;
|
|
149
174
|
/** `syndicate.input` is JSON.stringify of either a string or A2A parts. */
|
|
150
175
|
export declare function parseInputText(raw: unknown): string;
|
|
176
|
+
/** The span's parent in the ledger: the ancestor the tracer recorded at start, else its direct parent. */
|
|
177
|
+
export declare function ledgerParentOf(span: ReadableSpan): string | null;
|
|
178
|
+
/** Whether a span failed: its status, an error code it records, or a tool's error response. */
|
|
179
|
+
export declare function spanFailed(span: ReadableSpan): boolean;
|
|
151
180
|
/** Span attributes minus the payload capture (`llm.payload.*`). */
|
|
152
181
|
export declare function withoutPayloadAttributes(attrs: Record<string, unknown>): Record<string, unknown>;
|
|
153
|
-
/** Flatten
|
|
182
|
+
/** Flatten a span the ledger keeps (isLedgerSpan) into one adk_telemetry row. */
|
|
154
183
|
export declare function toTelemetryRow(span: ReadableSpan): TelemetryRow;
|
|
155
184
|
/** Project a root span into the adk_turns system-of-record row. */
|
|
156
185
|
export declare function toTurnRow(span: ReadableSpan): TurnRow;
|