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.
Files changed (57) hide show
  1. package/CHANGELOG.md +161 -1
  2. package/DOCUMENTATION.md +77 -10
  3. package/README.md +2 -0
  4. package/config/agents/syndicate.schema.json +69 -0
  5. package/config/agents/syndicateSchema.yaml +17 -0
  6. package/db/hardening.sql +3 -1
  7. package/db/migrations/0016_span_tree.sql +52 -0
  8. package/db/migrations/0017_cache_pricing.sql +146 -0
  9. package/db/migrations/0018_queue_view.sql +82 -0
  10. package/db/telemetry.sql +36 -8
  11. package/dist/lib/a2a/app.d.ts +26 -4
  12. package/dist/lib/a2a/app.js +41 -6
  13. package/dist/lib/a2a/executor.d.ts +28 -1
  14. package/dist/lib/a2a/executor.js +65 -2
  15. package/dist/lib/a2a/feedback.d.ts +121 -0
  16. package/dist/lib/a2a/feedback.js +219 -0
  17. package/dist/lib/a2a/policy.d.ts +55 -5
  18. package/dist/lib/a2a/policy.js +56 -12
  19. package/dist/lib/compile.d.ts +2 -0
  20. package/dist/lib/compile.js +2 -0
  21. package/dist/lib/compileNative.js +2 -0
  22. package/dist/lib/doctor.d.ts +10 -0
  23. package/dist/lib/doctor.js +33 -4
  24. package/dist/lib/index.d.ts +6 -2
  25. package/dist/lib/index.js +3 -0
  26. package/dist/lib/loadSyndicate.d.ts +14 -0
  27. package/dist/lib/loadSyndicate.js +22 -5
  28. package/dist/lib/memory/providers.js +7 -3
  29. package/dist/lib/observability/lineage.d.ts +19 -0
  30. package/dist/lib/observability/lineage.js +31 -0
  31. package/dist/lib/observability/metadata.d.ts +69 -0
  32. package/dist/lib/observability/metadata.js +153 -0
  33. package/dist/lib/observability/otlpFilter.js +1 -0
  34. package/dist/lib/observability/supabaseSpanExporter.d.ts +31 -2
  35. package/dist/lib/observability/supabaseSpanExporter.js +45 -6
  36. package/dist/lib/observability/tracer.d.ts +17 -1
  37. package/dist/lib/observability/tracer.js +61 -3
  38. package/dist/lib/project.d.ts +116 -0
  39. package/dist/lib/project.js +274 -0
  40. package/dist/lib/runtime/native/compaction.js +7 -3
  41. package/dist/lib/runtime/native/request.d.ts +2 -0
  42. package/dist/lib/runtime/native/step.js +1 -1
  43. package/dist/lib/runtime/native/telemetry.d.ts +10 -3
  44. package/dist/lib/runtime/native/telemetry.js +49 -4
  45. package/dist/lib/runtime/syndicateTurn.js +5 -1
  46. package/dist/lib/storage/postgres/index.d.ts +10 -0
  47. package/dist/lib/storage/postgres/index.js +6 -1
  48. package/dist/lib/syndicateSchema.d.ts +5 -0
  49. package/dist/lib/syndicateSchema.js +13 -0
  50. package/dist/lib/tools/taskTools.d.ts +6 -0
  51. package/dist/lib/tools/taskTools.js +24 -5
  52. package/dist/lib/workflow/toolNode.d.ts +1 -0
  53. package/dist/lib/workflow/toolNode.js +1 -1
  54. package/dist/scripts/a2a_server.js +3 -3
  55. package/dist/scripts/doctor.js +2 -1
  56. package/dist/scripts/mcp_server.js +2 -1
  57. package/package.json +6 -4
@@ -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 = path.resolve(options.agentsDir ??
461
- process.env.MELCHIZEDEK_AGENTS_DIR ??
462
- path.join(process.cwd(), 'config', 'agents'));
463
- const syndicates = listSyndicateFiles(agentsDir).map((f) => diagnoseSyndicate(f, agentsDir, options.scripts));
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;
@@ -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 = path.resolve(options.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
- yield modelResponseToLlmResponse(response);
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
+ }
@@ -27,6 +27,7 @@ export const CONTENT_ATTRIBUTES = new Set([
27
27
  'llm.thinking',
28
28
  'llm.error_message',
29
29
  'tool.args',
30
+ 'tool.result',
30
31
  'tool.data_gathered',
31
32
  ]);
32
33
  const CONTENT_PREFIXES = ['llm.payload.'];
@@ -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 — llm.request and root spans, raw.
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 an llm.request or root span into one adk_telemetry row. */
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;