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
@@ -0,0 +1,274 @@
1
+ /**
2
+ * lib/project.ts — the project manifest, `melchizedek.json` (ADR 0138).
3
+ *
4
+ * A project is a folder, usually a git repository, with a `melchizedek.json`
5
+ * at its root. The manifest records what the engine cannot infer from the
6
+ * files: which folder holds the syndicates, which engine version the project
7
+ * is written for, and how each file ships. It holds names, never values: an
8
+ * `env` entry is a variable name, and no field may carry a secret.
9
+ *
10
+ * `findProject()` walks up from a directory to the nearest manifest and stops
11
+ * at the repository root (the first directory holding `.git`) or the home
12
+ * directory, so a manifest outside the repository never steers it.
13
+ * `resolveAgentsDir()` is the one agents-directory chain every loader and bin
14
+ * uses: explicit option → MELCHIZEDEK_AGENTS_DIR → the manifest's `agents` →
15
+ * <cwd>/config/agents. A deployed server sets the variable, so the manifest
16
+ * only changes what a bare command run inside a project finds.
17
+ *
18
+ * Imports only node built-ins, zod and lib/paths.ts.
19
+ */
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join, relative, resolve, sep } from 'node:path';
23
+ import { z } from 'zod';
24
+ import { enginePackageRoot } from './paths.js';
25
+ export const PROJECT_MANIFEST = 'melchizedek.json';
26
+ export const MANIFEST_VERSION = 1;
27
+ /** A path relative to the project root: no absolute paths, no `..` segments. */
28
+ const relPath = z
29
+ .string()
30
+ .min(1)
31
+ .refine((p) => !/^([a-zA-Z]:)?[\\/]/.test(p) && !p.split(/[\\/]/).includes('..'), 'must be relative to the project root and stay inside it');
32
+ const envName = z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/, 'must be an environment variable name');
33
+ const syndicateRecord = z
34
+ .object({
35
+ deliverable: z.enum(['service', 'library', 'mcp']).optional(),
36
+ publish: z.object({ kind: z.literal('registry'), id: z.string().min(1) }).passthrough().optional(),
37
+ })
38
+ .passthrough();
39
+ export const projectManifestSchema = z
40
+ .object({
41
+ manifest: z.number().int().min(1),
42
+ engine: z.string().regex(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, 'must be an exact version, e.g. 1.10.0'),
43
+ agents: relPath,
44
+ ignore: z.array(z.string().min(1)).optional(),
45
+ tests: relPath.optional(),
46
+ env: z.array(envName).optional(),
47
+ syndicates: z.record(z.string(), syndicateRecord).optional(),
48
+ registry: z
49
+ .object({ kind: z.literal('supabase'), url_env: envName.optional(), restart: z.string().optional() })
50
+ .passthrough()
51
+ .optional(),
52
+ production: z
53
+ .object({ ledger: z.object({ kind: z.literal('postgres'), url_env: envName }).passthrough().optional() })
54
+ .passthrough()
55
+ .optional(),
56
+ tools: z
57
+ .object({ remote: z.record(z.string(), z.object({ mcp_env: envName }).passthrough()).optional() })
58
+ .passthrough()
59
+ .optional(),
60
+ })
61
+ // Unknown keys are kept: a newer writer's fields survive an older reader.
62
+ .passthrough();
63
+ /** Thrown for a manifest that does not parse or validate; names the file and fields, never a value. */
64
+ export class ProjectManifestError extends Error {
65
+ file;
66
+ issues;
67
+ constructor(file, issues) {
68
+ super(`${file}: ${issues.join('; ')}`);
69
+ this.file = file;
70
+ this.issues = issues;
71
+ this.name = 'ProjectManifestError';
72
+ }
73
+ }
74
+ /**
75
+ * The nearest folder at or above `from` holding a manifest, without crossing
76
+ * the repository root or the home directory. Undefined when there is none.
77
+ */
78
+ export function findProjectRoot(from = process.cwd()) {
79
+ const home = resolve(homedir());
80
+ let dir = resolve(from);
81
+ for (;;) {
82
+ if (existsSync(join(dir, PROJECT_MANIFEST)))
83
+ return dir;
84
+ if (existsSync(join(dir, '.git')) || dir === home)
85
+ return undefined;
86
+ const parent = dirname(dir);
87
+ if (parent === dir)
88
+ return undefined;
89
+ dir = parent;
90
+ }
91
+ }
92
+ /** Parse and validate a manifest's text. Throws `ProjectManifestError`. */
93
+ export function parseProjectManifest(text, file = PROJECT_MANIFEST) {
94
+ let raw;
95
+ try {
96
+ raw = JSON.parse(text);
97
+ }
98
+ catch {
99
+ throw new ProjectManifestError(file, ['not valid JSON']);
100
+ }
101
+ const parsed = projectManifestSchema.safeParse(raw);
102
+ if (!parsed.success) {
103
+ throw new ProjectManifestError(file, parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`));
104
+ }
105
+ if (parsed.data.manifest > MANIFEST_VERSION) {
106
+ throw new ProjectManifestError(file, [`manifest: version ${parsed.data.manifest} is newer than this engine reads (${MANIFEST_VERSION}); upgrade melchizedek-agents`]);
107
+ }
108
+ return parsed.data;
109
+ }
110
+ /** A path under the root, or an error when it would leave it. */
111
+ function under(root, rel, field, file) {
112
+ const abs = resolve(root, rel);
113
+ if (abs !== root && !abs.startsWith(root + sep))
114
+ throw new ProjectManifestError(file, [`${field}: resolves outside the project root`]);
115
+ return abs;
116
+ }
117
+ /** Load the project rooted at `root` (the folder holding the manifest). Throws `ProjectManifestError`. */
118
+ export function loadProject(root) {
119
+ const r = resolve(root);
120
+ const file = join(r, PROJECT_MANIFEST);
121
+ const manifest = parseProjectManifest(readFileSync(file, 'utf-8'), file);
122
+ return {
123
+ root: r,
124
+ file,
125
+ manifest,
126
+ agentsDir: under(r, manifest.agents, 'agents', file),
127
+ testsDir: under(r, manifest.tests ?? join('tests', 'agents'), 'tests', file),
128
+ };
129
+ }
130
+ /** The project at or above `from`, or undefined when there is none. Throws on a broken manifest. */
131
+ export function findProject(from = process.cwd()) {
132
+ const root = findProjectRoot(from);
133
+ return root ? loadProject(root) : undefined;
134
+ }
135
+ // The agents-directory chain runs on every syndicate read (an A2A route
136
+ // reads per request), so the manifest lookup is cached per working
137
+ // directory. A broken manifest is cached as "none" and reported by
138
+ // `melchizedek-doctor`, never by a request path.
139
+ const agentsDirCache = new Map();
140
+ /** Forget cached manifest lookups (tests, and an embedder that edits the manifest in-process). */
141
+ export function clearProjectCache() {
142
+ agentsDirCache.clear();
143
+ }
144
+ function manifestAgentsDir(cwd) {
145
+ if (!agentsDirCache.has(cwd)) {
146
+ let dir = null;
147
+ try {
148
+ dir = findProject(cwd)?.agentsDir ?? null;
149
+ }
150
+ catch {
151
+ dir = null;
152
+ }
153
+ agentsDirCache.set(cwd, dir);
154
+ }
155
+ return agentsDirCache.get(cwd) ?? undefined;
156
+ }
157
+ /**
158
+ * The agents directory: `explicit` → MELCHIZEDEK_AGENTS_DIR → the nearest
159
+ * manifest's `agents` → <cwd>/config/agents. Always absolute.
160
+ */
161
+ export function resolveAgentsDir(explicit, env = process.env) {
162
+ const fromEnv = env.MELCHIZEDEK_AGENTS_DIR?.trim();
163
+ const cwd = process.cwd();
164
+ return resolve(explicit ?? (fromEnv || undefined) ?? manifestAgentsDir(cwd) ?? join(cwd, 'config', 'agents'));
165
+ }
166
+ /** Glob to RegExp for `*` (one segment), `**` (any depth) and `?`, on `/`-separated paths. */
167
+ function globToRegExp(glob) {
168
+ let re = '';
169
+ for (let i = 0; i < glob.length; i++) {
170
+ const c = glob[i];
171
+ if (c === '*') {
172
+ if (glob[i + 1] === '*') {
173
+ i++;
174
+ if (glob[i + 1] === '/') {
175
+ i++;
176
+ re += '(?:.*/)?';
177
+ }
178
+ else
179
+ re += '.*';
180
+ }
181
+ else
182
+ re += '[^/]*';
183
+ }
184
+ else if (c === '?')
185
+ re += '[^/]';
186
+ else
187
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
188
+ }
189
+ return new RegExp(`^${re}$`);
190
+ }
191
+ /** True when `relFromRoot` (a `/`-separated path relative to the project root) matches an `ignore` pattern. */
192
+ export function isIgnored(project, relFromRoot) {
193
+ const p = relFromRoot.split(sep).join('/');
194
+ return (project.manifest.ignore ?? []).some((g) => globToRegExp(g).test(p));
195
+ }
196
+ /** The syndicate files directly in the agents folder, as names relative to it, minus `ignore`. */
197
+ export function listProjectSyndicates(project) {
198
+ if (!existsSync(project.agentsDir))
199
+ return [];
200
+ const rel = relative(project.root, project.agentsDir);
201
+ return readdirSync(project.agentsDir)
202
+ .filter((f) => (f.endsWith('.yaml') || f.endsWith('.yml')) && f !== 'syndicateSchema.yaml')
203
+ .filter((f) => statSync(join(project.agentsDir, f)).isFile())
204
+ .filter((f) => !isIgnored(project, rel ? `${rel}/${f}` : f))
205
+ .sort();
206
+ }
207
+ /** The installed engine's package version (no commit suffix). */
208
+ export function enginePackageVersion() {
209
+ try {
210
+ const root = enginePackageRoot();
211
+ if (root)
212
+ return String(JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version);
213
+ }
214
+ catch {
215
+ /* fall through */
216
+ }
217
+ return '0.0.0';
218
+ }
219
+ /** What is wrong with a project: engine pin, missing folders, stale ignores, unset env names. Never reads a value. */
220
+ export function checkProject(project, running = enginePackageVersion(), env = process.env) {
221
+ const problems = [];
222
+ const warnings = [];
223
+ const pinned = project.manifest.engine;
224
+ const matches = pinned === running;
225
+ if (!matches)
226
+ problems.push(`engine: the project pins melchizedek-agents ${pinned}; ${running} is running`);
227
+ if (!existsSync(project.agentsDir))
228
+ problems.push(`agents: ${project.manifest.agents} does not exist`);
229
+ if (project.manifest.tests && !existsSync(project.testsDir))
230
+ problems.push(`tests: ${project.manifest.tests} does not exist`);
231
+ for (const g of project.manifest.ignore ?? []) {
232
+ if (!anyPathMatches(project.root, globToRegExp(g)))
233
+ warnings.push(`ignore: "${g}" matches nothing`);
234
+ }
235
+ for (const name of project.manifest.env ?? [])
236
+ if (!env[name]?.trim())
237
+ warnings.push(`env: ${name} is not set`);
238
+ const syndicates = listProjectSyndicates(project);
239
+ for (const f of Object.keys(project.manifest.syndicates ?? {})) {
240
+ if (!syndicates.includes(f))
241
+ warnings.push(`syndicates: ${f} is not in ${project.manifest.agents}`);
242
+ }
243
+ return { root: project.root, agentsDir: project.agentsDir, engine: { pinned, running, matches }, syndicates, problems, warnings };
244
+ }
245
+ const SKIP_DIRS = new Set(['.git', 'node_modules', 'venv', '.venv', '__pycache__', 'dist']);
246
+ /** Whether any path under root (depth-limited, skipping vendor folders) matches. */
247
+ function anyPathMatches(root, re, rel = '', depth = 0) {
248
+ if (depth > 6)
249
+ return false;
250
+ let entries;
251
+ try {
252
+ entries = readdirSync(join(root, rel));
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ for (const e of entries) {
258
+ if (SKIP_DIRS.has(e))
259
+ continue;
260
+ const p = rel ? `${rel}/${e}` : e;
261
+ if (re.test(p))
262
+ return true;
263
+ let dir = false;
264
+ try {
265
+ dir = statSync(join(root, p)).isDirectory();
266
+ }
267
+ catch {
268
+ /* unreadable entry */
269
+ }
270
+ if (dir && anyPathMatches(root, re, p, depth + 1))
271
+ return true;
272
+ }
273
+ return false;
274
+ }
@@ -144,11 +144,15 @@ export async function summarize(events, adapter, model, options = {}) {
144
144
  const startTime = events[0].timestamp;
145
145
  const endTime = events[events.length - 1].timestamp;
146
146
  const request = summaryRequest(events, model, options.signal);
147
+ const sources = new WeakMap();
147
148
  async function* inner() {
148
- for await (const response of adapter.generate(request))
149
- yield modelResponseToLlmResponse(response);
149
+ for await (const response of adapter.generate(request)) {
150
+ const mapped = modelResponseToLlmResponse(response);
151
+ sources.set(mapped, response);
152
+ yield mapped;
153
+ }
150
154
  }
151
- const responses = traceLlmGeneration({ provider: adapter.provider, model, request }, inner());
155
+ const responses = traceLlmGeneration({ provider: adapter.provider, model, request, source: (r) => sources.get(r) }, inner());
152
156
  // As ADK: the first response must carry text; nothing after a failure is read.
153
157
  const first = await responses.next();
154
158
  const head = first.done ? undefined : first.value.content?.parts?.[0]?.text;
@@ -112,6 +112,8 @@ export interface NativeAgent {
112
112
  mode?: 'task';
113
113
  /** The session-state key the agent's final answer is saved under (the loop writes it, lib/runtime/native/agentLoop.ts). */
114
114
  outputKey?: string;
115
+ /** The YAML's `metadata:` (ADR 0141): `meta.agent.<key>` attributes on the agent's `agent.invoke` span. */
116
+ metadata?: Record<string, string | number | boolean>;
115
117
  /** `fallback_model:`: answers a provider-side failure of the agent's model (ADR 0044). The loop calls it as its own leaf adapter. */
116
118
  fallbackModel?: string;
117
119
  /** `context:` (ADR 0033): compact the history into a summary past a token threshold. The loop runs it before each step (compaction.ts). */
@@ -218,7 +218,7 @@ export async function runModelStep(options) {
218
218
  return { ...result, event: stored, error: { code, message, retryable: false } };
219
219
  }
220
220
  async function readResponses() {
221
- for await (const traced of traceLlmGeneration({ provider: adapter.provider, model: request.model, request }, inner())) {
221
+ for await (const traced of traceLlmGeneration({ provider: adapter.provider, model: request.model, request, source: (r) => sources.get(r) }, inner())) {
222
222
  if (signal?.aborted)
223
223
  return { ...result, stopped: stopOf() };
224
224
  const source = sources.get(traced);
@@ -30,8 +30,12 @@
30
30
  * adapter's final response). A failed step carries none: its llm.request
31
31
  * span carries the failed call's payload, as under ADK, where a failed
32
32
  * call_llm never ended. With TELEMETRY_PAYLOADS=off no payload is
33
- * recorded at all. Tool spans carry no arguments and no results: the root
34
- * span's ToolCall/ToolResponse events are where the ledger keeps those.
33
+ * recorded at all. A tool span carries its call's arguments (`tool.args`)
34
+ * and the start of its result (`tool.result`), each as JSON, redacted by
35
+ * the telemetry redactor and cut at TOOL_ATTRIBUTE_MAX_CHARS
36
+ * (`tool.args_truncated` / `tool.result_truncated` when cut), so the
37
+ * ledger's span tree shows a call without the payload tier (ADR 0139). The
38
+ * root span's ToolCall/ToolResponse events still hold the whole of both.
35
39
  *
36
40
  * The loop calls three hooks (agentLoop.ts): traceAgentInvocation around the
37
41
  * whole run, traceModelCall around each step, traceToolCall around each call.
@@ -41,8 +45,10 @@ import type { AgentLoopContext, AgentLoopEnd } from './agentLoop.ts';
41
45
  import type { NativeAgent } from './request.ts';
42
46
  import type { ModelStepResult } from './step.ts';
43
47
  import type { NodeResult, TracedNode } from '../../workflow/scheduler.ts';
48
+ /** The most a tool span's `tool.args` or `tool.result` holds: the first 8 KB of its redacted JSON (ADR 0139). */
49
+ export declare const TOOL_ATTRIBUTE_MAX_CHARS = 8192;
44
50
  /** The agent's run as an `agent.invoke <name>` span, opened when the run starts. */
45
- export declare function traceAgentInvocation(agent: Pick<NativeAgent, 'name' | 'description'>, ctx: Pick<AgentLoopContext, 'session' | 'invocationId'>, run: () => AsyncGenerator<TurnEvent, AgentLoopEnd>): AsyncGenerator<TurnEvent, AgentLoopEnd>;
51
+ export declare function traceAgentInvocation(agent: Pick<NativeAgent, 'name' | 'description' | 'metadata'>, ctx: Pick<AgentLoopContext, 'session' | 'invocationId'>, run: () => AsyncGenerator<TurnEvent, AgentLoopEnd>): AsyncGenerator<TurnEvent, AgentLoopEnd>;
46
52
  /**
47
53
  * One model step as a `model.call` span. `step` gets the loop's context with
48
54
  * an `adapterFor` that notes the adapter it hands out, so the span names the
@@ -72,4 +78,5 @@ export declare function traceNodeExecution(node: TracedNode, run: () => Promise<
72
78
  export declare function traceToolNodeCall<T>(call: {
73
79
  id: string;
74
80
  name: string;
81
+ args?: unknown;
75
82
  }, tool: unknown, run: () => Promise<T>): Promise<T>;
@@ -30,8 +30,12 @@
30
30
  * adapter's final response). A failed step carries none: its llm.request
31
31
  * span carries the failed call's payload, as under ADK, where a failed
32
32
  * call_llm never ended. With TELEMETRY_PAYLOADS=off no payload is
33
- * recorded at all. Tool spans carry no arguments and no results: the root
34
- * span's ToolCall/ToolResponse events are where the ledger keeps those.
33
+ * recorded at all. A tool span carries its call's arguments (`tool.args`)
34
+ * and the start of its result (`tool.result`), each as JSON, redacted by
35
+ * the telemetry redactor and cut at TOOL_ATTRIBUTE_MAX_CHARS
36
+ * (`tool.args_truncated` / `tool.result_truncated` when cut), so the
37
+ * ledger's span tree shows a call without the payload tier (ADR 0139). The
38
+ * root span's ToolCall/ToolResponse events still hold the whole of both.
35
39
  *
36
40
  * The loop calls three hooks (agentLoop.ts): traceAgentInvocation around the
37
41
  * whole run, traceModelCall around each step, traceToolCall around each call.
@@ -40,9 +44,39 @@ import { SpanStatusCode, context, trace } from '@opentelemetry/api';
40
44
  import { resolveAdapter } from '../../models/registry.js';
41
45
  import { RUNTIME_SPAN_SCOPE } from '../../observability/lineage.js';
42
46
  import { payloadPolicyFromEnv } from '../../observability/supabaseSpanExporter.js';
47
+ import { telemetryRedactor } from '../../observability/redact.js';
48
+ import { META_AGENT_PREFIX, metaAttributes } from '../../observability/metadata.js';
43
49
  import { startEngineSpan } from '../../observability/tracer.js';
44
50
  /** The most a payload attribute holds, as for a failed call's (lib/observability/tracer.ts). */
45
51
  const PAYLOAD_MAX_CHARS = 200_000;
52
+ /** The most a tool span's `tool.args` or `tool.result` holds: the first 8 KB of its redacted JSON (ADR 0139). */
53
+ export const TOOL_ATTRIBUTE_MAX_CHARS = 8_192;
54
+ /**
55
+ * Sets `key` to `value` as JSON, redacted first (so a cut never splits a
56
+ * secret the redactor would have caught) and then cut to the cap, with
57
+ * `<key>_truncated` when it was cut. Nothing on a span that records nothing.
58
+ */
59
+ function setToolContent(span, key, value) {
60
+ if (!span.isRecording() || value === undefined)
61
+ return;
62
+ let json;
63
+ try {
64
+ json = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
65
+ }
66
+ catch {
67
+ json = String(value);
68
+ }
69
+ const redactor = telemetryRedactor();
70
+ if (redactor)
71
+ json = redactor(json);
72
+ if (json.length > TOOL_ATTRIBUTE_MAX_CHARS) {
73
+ span.setAttribute(key, json.slice(0, TOOL_ATTRIBUTE_MAX_CHARS));
74
+ span.setAttribute(`${key}_truncated`, true);
75
+ }
76
+ else {
77
+ span.setAttribute(key, json);
78
+ }
79
+ }
46
80
  /** A loop span in scope melchizedek.runtime; a non-recording one in a turn that opted out of tracing. */
47
81
  const startRuntimeSpan = (name, options) => startEngineSpan(RUNTIME_SPAN_SCOPE, name, options);
48
82
  /** JSON that never throws and never exceeds the cap. */
@@ -93,6 +127,8 @@ export async function* traceAgentInvocation(agent, ctx, run) {
93
127
  'gen_ai.agent.description': agent.description ?? '',
94
128
  'gen_ai.conversation.id': ctx.session.id,
95
129
  'adk.invocation_id': ctx.invocationId,
130
+ // The YAML's agent metadata (ADR 0141), redacted as every ledger value is.
131
+ ...metaAttributes(agent.metadata, META_AGENT_PREFIX),
96
132
  },
97
133
  });
98
134
  try {
@@ -158,8 +194,10 @@ function recordModelCall(span, result, provider) {
158
194
  span.setAttribute('gen_ai.usage.input_tokens', response.usage.inputTokens);
159
195
  span.setAttribute('gen_ai.usage.output_tokens', response.usage.outputTokens);
160
196
  }
161
- if (response?.finishReason)
197
+ if (response?.finishReason) {
162
198
  span.setAttribute('gen_ai.response.finish_reasons', [response.finishReason]);
199
+ span.setAttribute('llm.finish_reason', response.finishReason);
200
+ }
163
201
  if (result.stopped) {
164
202
  span.setAttribute('model.call.stop_code', result.stopped.code);
165
203
  return;
@@ -190,9 +228,12 @@ export async function traceToolCall(call, tool, run) {
190
228
  ...(call.id ? { 'gen_ai.tool.call.id': call.id } : {}),
191
229
  },
192
230
  });
231
+ setToolContent(span, 'tool.args', call.args ?? {});
193
232
  try {
194
233
  const outcome = await context.with(trace.setSpan(context.active(), span), run);
195
234
  const response = outcome?.part?.functionResponse?.response;
235
+ if (response !== undefined)
236
+ setToolContent(span, 'tool.result', response);
196
237
  if (!outcome?.part)
197
238
  span.setAttribute('tool.pending', true);
198
239
  else if (response && Object.hasOwn(response, 'error')) {
@@ -279,8 +320,12 @@ export async function traceToolNodeCall(call, tool, run) {
279
320
  'gen_ai.tool.call.id': call.id,
280
321
  },
281
322
  });
323
+ if (call.args !== undefined)
324
+ setToolContent(span, 'tool.args', call.args);
282
325
  try {
283
- return await context.with(trace.setSpan(context.active(), span), run);
326
+ const result = await context.with(trace.setSpan(context.active(), span), run);
327
+ setToolContent(span, 'tool.result', result ?? null);
328
+ return result;
284
329
  }
285
330
  catch (error) {
286
331
  // The tool node turns the throw into its { error } response; the span notes the failure, not the message.
@@ -85,6 +85,7 @@ import { journalsEveryCall, openRun } from '../script/journal.js';
85
85
  import { toEngineAgent } from '../agentDialect.js';
86
86
  import { ScriptFatal, runScript } from '../script/run.js';
87
87
  import { createTurnControl, runWithTurnControl, stopCode, stopMessage } from './turnControl.js';
88
+ import { metaAttributes } from '../observability/metadata.js';
88
89
  const THOUGHTS_PREVIEW_CHARS = 600;
89
90
  const CLASSIFIER_PREAMBLE = `RECENT CONVERSATION, oldest first — context for your classification only. `
90
91
  + `Never answer it, and never route on it alone; it is here so you can tell a `
@@ -397,7 +398,10 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
397
398
  // (a registry version, ADR 0018 item 6), else from files.
398
399
  const compileOpts = { ...opts.compile, loadNested: opts.compile?.loadNested ?? nestedLoader(config) };
399
400
  const subagentNames = new Set((config.subagents ?? []).map((s) => s.name));
400
- const trace = opts.trace === false ? undefined : opts.trace ?? {};
401
+ const traced = opts.trace === false ? undefined : opts.trace ?? {};
402
+ // The YAML's metadata (ADR 0141) as `meta.<key>` on every root span; the caller's own attributes win a clash.
403
+ const yamlMeta = traced && config.metadata ? metaAttributes(config.metadata) : undefined;
404
+ const trace = traced && yamlMeta && Object.keys(yamlMeta).length ? { ...traced, attributes: { ...yamlMeta, ...traced.attributes } } : traced;
401
405
  let parts = opts.parts.map((p) => (typeof p === 'string' ? { text: p } : p));
402
406
  const messageText = parts.map((p) => (typeof p.text === 'string' ? p.text : '')).join('\n');
403
407
  // What the runtime does not run fails here, before the session is touched.
@@ -19,6 +19,7 @@ import { PostgresSessionService } from './sessionService.ts';
19
19
  import { PostgresTaskStore } from './taskStore.ts';
20
20
  import type { CredentialStoreOptions } from '../../tools/credentialStore.ts';
21
21
  import type { CredentialStore } from '../../tools/auth.ts';
22
+ import type { FeedbackStore } from '../../a2a/feedback.ts';
22
23
  import type { PayloadPolicy } from '../../observability/supabaseSpanExporter.ts';
23
24
  import type { AuditSink } from '../../observability/audit.ts';
24
25
  import type { RlsHardeningStatus } from '../rlsStatus.ts';
@@ -119,6 +120,8 @@ export interface PostgresStorage {
119
120
  rlsHardening: () => Promise<RlsHardeningStatus>;
120
121
  /** Appends to melchizedek_audit (migration 0012, ADR 0042). */
121
122
  audit: AuditSink;
123
+ /** End-user feedback into the ledger's adk_labels (POST /feedback, ADR 0140); present when `ledger` is on. */
124
+ feedback?: FeedbackStore;
122
125
  /** Tool credentials, sealed (migration 0013, ADR 0072); present when `credentials` was given. */
123
126
  credentials?: CredentialStore;
124
127
  /**
@@ -129,6 +132,13 @@ export interface PostgresStorage {
129
132
  turnLock: TurnLock;
130
133
  /** The task tools' lists and job queue (lib/tools/taskTools.ts setTaskBackend). */
131
134
  taskQueue: TaskBackend;
135
+ /**
136
+ * Puts a failed or cancelled background job back on the queue, attempts
137
+ * reset (the Retry of an operator's queue view, melchizedek_task_queue,
138
+ * migration 0018). Ids are per owner, so the owner names the list.
139
+ * 'not-found' and 'not-retryable' leave the queue as it was.
140
+ */
141
+ retryTask: (owner: string, id: string) => Promise<'queued' | 'not-found' | 'not-retryable'>;
132
142
  /**
133
143
  * Task leases: renew this instance's, fail other instances' expired ones,
134
144
  * and list this instance's running tasks a caller asked another instance
@@ -24,6 +24,8 @@ import { POSTGRES_RLS_QUERY, evaluatePostgresRls } from '../rlsStatus.js';
24
24
  import { postgresAuditSink } from '../../observability/audit.js';
25
25
  import { attachLedger } from '../../observability/tracer.js';
26
26
  import { postgresLedgerClient } from '../../observability/supabaseSpanExporter.js';
27
+ import { postgresFeedbackStore } from '../../a2a/feedback.js';
28
+ import { applyRetry } from '../../tools/taskTools.js';
27
29
  import { createHash, randomUUID } from 'node:crypto';
28
30
  import { hostname } from 'node:os';
29
31
  export { PostgresSessionService } from './sessionService.js';
@@ -114,6 +116,7 @@ export function postgresStorage(options) {
114
116
  ? attachLedger(postgresLedgerClient(pool), options.ledger === true ? {} : options.ledger)
115
117
  : undefined;
116
118
  const audit = postgresAuditSink(pool);
119
+ const taskQueue = postgresTaskBackend(pool, options.taskQueue);
117
120
  const credentials = options.credentials ? postgresCredentialStore(pool, { ...options.credentials, audit }) : undefined;
118
121
  return {
119
122
  pool,
@@ -121,6 +124,7 @@ export function postgresStorage(options) {
121
124
  turnLock,
122
125
  ...(credentials ? { credentials } : {}),
123
126
  ...(memoryService ? { memoryService } : {}),
127
+ ...(options.ledger ? { feedback: postgresFeedbackStore(pool) } : {}),
124
128
  taskStore: (agentId) => new PostgresTaskStore(pool, agentId, { ttlDays: options.ttlDays, ownerResolver: options.taskOwner, lease }),
125
129
  leases: {
126
130
  ...lease,
@@ -128,7 +132,8 @@ export function postgresStorage(options) {
128
132
  reap: () => reapExpiredTasks(pool),
129
133
  cancelRequested: () => cancelRequestedTasks(pool, lease),
130
134
  },
131
- taskQueue: postgresTaskBackend(pool, options.taskQueue),
135
+ taskQueue,
136
+ retryTask: (owner, id) => taskQueue.mutate(owner, (store) => applyRetry(store, id)),
132
137
  async schemaVersion() {
133
138
  try {
134
139
  const r = await pool.query('SELECT max(version) AS v FROM melchizedek_schema_version');
@@ -245,6 +245,7 @@ export declare const agentSchema: z.ZodObject<{
245
245
  }>>;
246
246
  delegates: z.ZodOptional<z.ZodArray<z.ZodString>>;
247
247
  }, z.core.$strict>>;
248
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
248
249
  }, z.core.$strict>;
249
250
  /**
250
251
  * Model and instruction are optional at the type level because a nested
@@ -462,6 +463,7 @@ export declare const subagentSchema: z.ZodObject<{
462
463
  }>>;
463
464
  delegates: z.ZodOptional<z.ZodArray<z.ZodString>>;
464
465
  }, z.core.$strict>>;
466
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
465
467
  description: z.ZodString;
466
468
  model: z.ZodOptional<z.ZodString>;
467
469
  instruction: z.ZodOptional<z.ZodString>;
@@ -685,6 +687,7 @@ export declare const syndicateSchema: z.ZodObject<{
685
687
  }>>;
686
688
  delegates: z.ZodOptional<z.ZodArray<z.ZodString>>;
687
689
  }, z.core.$strict>>;
690
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
688
691
  }, z.core.$strict>;
689
692
  subagents: z.ZodOptional<z.ZodArray<z.ZodObject<{
690
693
  name: z.ZodString;
@@ -896,6 +899,7 @@ export declare const syndicateSchema: z.ZodObject<{
896
899
  }>>;
897
900
  delegates: z.ZodOptional<z.ZodArray<z.ZodString>>;
898
901
  }, z.core.$strict>>;
902
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
899
903
  description: z.ZodString;
900
904
  model: z.ZodOptional<z.ZodString>;
901
905
  instruction: z.ZodOptional<z.ZodString>;
@@ -945,6 +949,7 @@ export declare const syndicateSchema: z.ZodObject<{
945
949
  model_errors: z.ZodOptional<z.ZodNumber>;
946
950
  tool_errors: z.ZodOptional<z.ZodNumber>;
947
951
  }, z.core.$strict>>;
952
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
948
953
  variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
949
954
  memory_system: z.ZodOptional<z.ZodEnum<{
950
955
  "internal-only": "internal-only";
@@ -27,6 +27,7 @@ import { isSecretShapedEnvName } from './tools/skills/env.js';
27
27
  import { OAUTH_SCOPE, PROVIDER_NAME } from './tools/auth.js';
28
28
  import { V2_AGENT_KEYS, conflictMessage, conflictsOf, toEngineAgent } from './agentDialect.js';
29
29
  import { MCP_TRANSPORTS } from './tools/mcpTransports.js';
30
+ import { MAX_META_KEYS, MAX_META_VALUE_CHARS, META_KEY } from './observability/metadata.js';
30
31
  // ── Leaf rules ───────────────────────────────────────────────────────────────
31
32
  /**
32
33
  * ADK's agent-name rule (validateAgentName in ADK's base_agent), kept: checked here
@@ -337,6 +338,12 @@ const openapiEntry = z
337
338
  .describe('Credentials, always from the environment, never written in YAML.'),
338
339
  })
339
340
  .describe('An HTTP API as tools, from its OpenAPI spec (lib/tools/openapiTools.ts).');
341
+ // ── Metadata (ADR 0141) ──────────────────────────────────────────────────────
342
+ /** Static labels copied onto the turn's spans as `meta.*` attributes (lib/observability/metadata.ts). */
343
+ const metadataSchema = (where) => z
344
+ .record(z.string().regex(META_KEY, 'a metadata key is letters, digits and _ . : - (at most 64, not starting with . : -)'), z.union([z.string().max(MAX_META_VALUE_CHARS), z.number(), z.boolean()]))
345
+ .refine((m) => Object.keys(m).length <= MAX_META_KEYS, { message: `at most ${MAX_META_KEYS} metadata keys` })
346
+ .describe(`Labels for the ledger: strings (at most ${MAX_META_VALUE_CHARS} characters), numbers or booleans, at most ${MAX_META_KEYS} keys. ${where} A value shaped like a key or token is dropped at load with a warning; string values pass through the telemetry redactor (ADR 0141).`);
340
347
  // ── Agents ───────────────────────────────────────────────────────────────────
341
348
  const agentFields = {
342
349
  name: agentName,
@@ -440,6 +447,7 @@ const agentFields = {
440
447
  delegates: z.array(z.string()).optional(),
441
448
  })
442
449
  .optional(),
450
+ metadata: metadataSchema('Each becomes `meta.agent.<key>` on this agent\'s agent.invoke span.').optional(),
443
451
  };
444
452
  export const agentSchema = z
445
453
  .strictObject(agentFields)
@@ -583,6 +591,7 @@ export const syndicateSchema = z
583
591
  })
584
592
  .optional()
585
593
  .describe('Self-correction on model and tool errors: the engine\'s reflect-and-retry. On by default.'),
594
+ metadata: metadataSchema('Each becomes `meta.<key>` on every turn\'s root span, so on the ledger row (adk_turns.attributes); a key the YAML names cannot be set by an A2A caller.').optional(),
586
595
  variables: z
587
596
  .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
588
597
  .optional()
@@ -1391,6 +1400,10 @@ export function syndicateJsonSchema() {
1391
1400
  if (ctx.zodSchema === samplingSchema || ctx.zodSchema === outputSchemaV2) {
1392
1401
  ctx.jsonSchema.minProperties = 1;
1393
1402
  }
1403
+ // metadata: (ADR 0141): the key-count refinement, which JSON Schema says as maxProperties.
1404
+ if (ctx.jsonSchema.propertyNames?.pattern === META_KEY.source) {
1405
+ ctx.jsonSchema.maxProperties = MAX_META_KEYS;
1406
+ }
1394
1407
  if (ctx.zodSchema === modelOverrideEntry) {
1395
1408
  ctx.jsonSchema.oneOf = [{ required: ['instruction'] }, { required: ['instruction_append'] }];
1396
1409
  }
@@ -180,6 +180,12 @@ export declare function claimNextJob(): TaskRecord | null;
180
180
  export declare function finishJob(id: string, outcome: JobOutcome): void;
181
181
  /** The outcome rule, on a snapshot (every backend). */
182
182
  export declare function applyFinish(store: TaskStore, id: string, outcome: JobOutcome): void;
183
+ /**
184
+ * The retry rule (every backend; an operator's Retry and task_update's
185
+ * `queued` alike): a failed or cancelled background job goes back to the
186
+ * queue with its attempts reset. Anything else is left alone.
187
+ */
188
+ export declare function applyRetry(store: TaskStore, id: string): 'queued' | 'not-found' | 'not-retryable';
183
189
  /** The interrupted-job rule for one record: back to the queue, or failed. */
184
190
  export declare function applyInterrupted(t: TaskRecord): 'requeued' | 'failed';
185
191
  /** The single-user JSON file, its checkpoints capped by `options` (the default backend is fileTaskBackend). */