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,146 @@
1
+ -- ============================================================================
2
+ -- 0017_cache_pricing — prompt-cache reads and writes at their own rates
3
+ -- (ADR 0139, extends ADR 0136)
4
+ -- ============================================================================
5
+ -- model_pricing gains two rates, USD per million tokens:
6
+ -- cache_read_per_mtok a token read from the provider's prompt cache
7
+ -- cache_write_per_mtok a token written to it (Anthropic's 5-minute cache,
8
+ -- the TTL the engine's requests get; OpenAI's 30-minute
9
+ -- write where its price list names one)
10
+ -- NULL prices that token as input, which is what every row meant before:
11
+ -- a cache token is an input token (lib/models/contract.ts Usage counts both
12
+ -- inside inputTokens), so a row without cache rates prices exactly as 0015.
13
+ --
14
+ -- The cost functions take the two counts:
15
+ -- melchizedek_cost_usd(model, input, output, thinking, cache_read, cache_write)
16
+ -- input less both cache counts at the input rate, each cache count at
17
+ -- its rate, output, thinking at the thinking rate (else output's)
18
+ -- melchizedek_cost_usd(model, input, output, thinking) the same with no
19
+ -- cache tokens, kept for a caller written against 0015
20
+ -- melchizedek_usage_cost(usage) reads each model's cache_read and
21
+ -- cache_write beside input, output and thinking in syndicate.usage;
22
+ -- melchizedek_usage_text_cost (db/telemetry.sql) calls it, so the
23
+ -- ledger trigger prices a turn's cache tokens too.
24
+ --
25
+ -- The seed: rates checked against the vendors' own price lists on
26
+ -- 2026-10-20 (platform.claude.com/docs/en/about-claude/pricing and
27
+ -- developers.openai.com/api/docs/pricing, standard tier), with verified_at
28
+ -- set to that day. An Anthropic row 0015 seeded gains its cache rates only
29
+ -- while it still holds 0015's input and output rates and no cache rate: an
30
+ -- operator's own rates are never overwritten. New rows never overwrite a
31
+ -- row that exists. Models the engine names but the lists price by prompt
32
+ -- length beyond a flat row (claude-haiku-5-5, gpt-5.5 and gpt-5.4 over
33
+ -- 272K) are left as noted below. Gemini, Grok and Kimi rows get no cache
34
+ -- rates here (none was checked): their cache tokens price as input.
35
+ --
36
+ -- Nothing secret, nothing personal. Idempotent; safe to re-run.
37
+ -- ============================================================================
38
+
39
+ ALTER TABLE model_pricing ADD COLUMN IF NOT EXISTS cache_read_per_mtok NUMERIC CHECK (cache_read_per_mtok >= 0);
40
+ ALTER TABLE model_pricing ADD COLUMN IF NOT EXISTS cache_write_per_mtok NUMERIC CHECK (cache_write_per_mtok >= 0);
41
+
42
+ -- Anthropic: the rows 0015 seeded, at the rates it seeded them.
43
+ UPDATE model_pricing p
44
+ SET cache_read_per_mtok = s.cache_read,
45
+ cache_write_per_mtok = s.cache_write,
46
+ verified_at = '2026-10-20',
47
+ source = 'platform.claude.com/docs/en/about-claude/pricing',
48
+ note = 'Anthropic first-party, checked 2026-10-20; cache write is the 5-minute rate',
49
+ updated_at = now()
50
+ FROM (VALUES
51
+ ('claude-fable-5-1', 10.00, 50.00, 0.25, 12.50),
52
+ ('claude-fable-5', 10.00, 50.00, 1.00, 12.50),
53
+ ('claude-opus-5', 5.00, 25.00, 0.50, 6.25),
54
+ ('claude-opus-4-8', 5.00, 25.00, 0.50, 6.25),
55
+ ('claude-opus-4-7', 5.00, 25.00, 0.50, 6.25),
56
+ ('claude-opus-4-6', 5.00, 25.00, 0.50, 6.25),
57
+ ('claude-sonnet-5', 2.00, 10.00, 0.20, 2.50),
58
+ ('claude-sonnet-4-6', 3.00, 15.00, 0.30, 3.75),
59
+ ('claude-haiku-4-5', 1.00, 5.00, 0.10, 1.25)
60
+ ) AS s(model, input, output, cache_read, cache_write)
61
+ WHERE p.model = s.model
62
+ AND p.input_per_mtok = s.input
63
+ AND p.output_per_mtok = s.output
64
+ AND p.cache_read_per_mtok IS NULL
65
+ AND p.cache_write_per_mtok IS NULL;
66
+
67
+ INSERT INTO model_pricing (model, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, verified_at, source, note) VALUES
68
+ -- Anthropic models 0015 did not seed. claude-haiku-5-5 is left out: its
69
+ -- price doubles past a 100K-token prompt, which a flat row cannot say.
70
+ ('claude-opus-5-5', 4.00, 20.00, 0.20, 5.00, '2026-10-20', 'platform.claude.com/docs/en/about-claude/pricing', 'Anthropic first-party, checked 2026-10-20; cache write is the 5-minute rate'),
71
+ ('claude-sonnet-5-5', 2.00, 10.00, 0.10, 2.50, '2026-10-20', 'platform.claude.com/docs/en/about-claude/pricing', 'Anthropic first-party, checked 2026-10-20; cache write is the 5-minute rate'),
72
+ -- OpenAI, standard tier. Reasoning tokens bill as output (thinking NULL).
73
+ -- A NULL cache write: the list names no write rate, so a write prices as input.
74
+ ('gpt-6-astra', 10.00, 50.00, 1.00, 12.50, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20; cache write is the 30-minute rate'),
75
+ ('gpt-6.1-sol', 2.00, 10.00, 0.10, 2.50, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20; cache write is the 30-minute rate'),
76
+ ('gpt-6-sol', 2.00, 10.00, 0.20, 2.50, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20; cache write is the 30-minute rate'),
77
+ ('gpt-6-luna', 0.10, 0.50, 0.01, 0.125, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20; cache write is the 30-minute rate'),
78
+ ('gpt-5.6-sol', 4.00, 20.00, 0.40, 5.00, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20; cache write is the 30-minute rate'),
79
+ ('gpt-5.6-terra', 2.00, 12.00, 0.20, 2.50, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20; cache write is the 30-minute rate'),
80
+ ('gpt-5.6-luna', 0.20, 1.20, 0.02, 0.25, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20; cache write is the 30-minute rate'),
81
+ ('gpt-5.5', 5.00, 30.00, 0.50, NULL, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20; the rate for prompts under 272K tokens'),
82
+ ('gpt-5.4', 2.50, 15.00, 0.25, NULL, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20; the rate for prompts under 272K tokens'),
83
+ ('gpt-5.2', 1.75, 14.00, 0.175, NULL, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20'),
84
+ ('gpt-5.1', 1.25, 10.00, 0.125, NULL, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20'),
85
+ ('gpt-5', 1.25, 10.00, 0.125, NULL, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20'),
86
+ ('gpt-5-mini', 0.25, 2.00, 0.025, NULL, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20'),
87
+ ('gpt-5-nano', 0.05, 0.40, 0.005, NULL, '2026-10-20', 'developers.openai.com/api/docs/pricing', 'OpenAI standard tier, checked 2026-10-20')
88
+ ON CONFLICT (model) DO NOTHING;
89
+
90
+ -- The row that prices `model`: its own, else its prefix's `<prefix>/*`.
91
+ CREATE OR REPLACE FUNCTION melchizedek_cost_usd(model TEXT, input BIGINT, output BIGINT, thinking BIGINT, cache_read BIGINT, cache_write BIGINT)
92
+ RETURNS NUMERIC
93
+ LANGUAGE sql
94
+ STABLE
95
+ SET search_path = public
96
+ AS $$
97
+ SELECT (greatest(coalesce(input, 0) - coalesce(cache_read, 0) - coalesce(cache_write, 0), 0) * p.input_per_mtok
98
+ + coalesce(cache_read, 0) * coalesce(p.cache_read_per_mtok, p.input_per_mtok)
99
+ + coalesce(cache_write, 0) * coalesce(p.cache_write_per_mtok, p.input_per_mtok)
100
+ + coalesce(output, 0) * p.output_per_mtok
101
+ + coalesce(thinking, 0) * coalesce(p.thinking_per_mtok, p.output_per_mtok)) / 1000000
102
+ FROM model_pricing p
103
+ WHERE p.model = melchizedek_cost_usd.model
104
+ OR (position('/' IN melchizedek_cost_usd.model) > 0
105
+ AND p.model = split_part(melchizedek_cost_usd.model, '/', 1) || '/*')
106
+ ORDER BY (p.model = melchizedek_cost_usd.model) DESC
107
+ LIMIT 1
108
+ $$;
109
+
110
+ CREATE OR REPLACE FUNCTION melchizedek_cost_usd(model TEXT, input BIGINT, output BIGINT, thinking BIGINT)
111
+ RETURNS NUMERIC
112
+ LANGUAGE sql
113
+ STABLE
114
+ SET search_path = public
115
+ AS $$
116
+ SELECT melchizedek_cost_usd(model, input, output, thinking, 0::bigint, 0::bigint)
117
+ $$;
118
+
119
+ CREATE OR REPLACE FUNCTION melchizedek_usage_cost(usage JSONB)
120
+ RETURNS NUMERIC
121
+ LANGUAGE sql
122
+ STABLE
123
+ SET search_path = public
124
+ AS $$
125
+ SELECT CASE
126
+ WHEN usage IS NULL OR jsonb_typeof(usage) <> 'object' THEN NULL
127
+ -- bool_and over no rows is NULL: a turn that made no model call costs nothing.
128
+ WHEN coalesce(bool_and(c.cost IS NOT NULL), true) THEN coalesce(sum(c.cost), 0)
129
+ ELSE NULL
130
+ END
131
+ FROM (
132
+ SELECT melchizedek_cost_usd(
133
+ e.key,
134
+ CASE WHEN jsonb_typeof(e.value->'input') = 'number' THEN (e.value->>'input')::numeric::bigint END,
135
+ CASE WHEN jsonb_typeof(e.value->'output') = 'number' THEN (e.value->>'output')::numeric::bigint END,
136
+ CASE WHEN jsonb_typeof(e.value->'thinking') = 'number' THEN (e.value->>'thinking')::numeric::bigint END,
137
+ CASE WHEN jsonb_typeof(e.value->'cache_read') = 'number' THEN (e.value->>'cache_read')::numeric::bigint END,
138
+ CASE WHEN jsonb_typeof(e.value->'cache_write') = 'number' THEN (e.value->>'cache_write')::numeric::bigint END
139
+ ) AS cost
140
+ FROM jsonb_each(CASE WHEN jsonb_typeof(usage) = 'object' THEN usage ELSE '{}'::jsonb END) e
141
+ ) c
142
+ $$;
143
+
144
+ INSERT INTO melchizedek_schema_version (version, name)
145
+ VALUES (17, '0017_cache_pricing')
146
+ ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,82 @@
1
+ -- ============================================================================
2
+ -- 0018_queue_view — the job queue as an operator reads it (ADR 0021, 0113)
3
+ -- ============================================================================
4
+ -- A read model over melchizedek_tasks (migration 0009, checkpoint columns
5
+ -- from 0014): one row per background job, with what an operator asks of a
6
+ -- queue — is it stuck, how often was it tried, why did it fail. Todo items
7
+ -- are not jobs and are left out. Nothing is stored; the view reads the
8
+ -- table as it stands.
9
+ --
10
+ -- owner the list the job belongs to (the caller's scope key;
11
+ -- '' for a shared list)
12
+ -- id the job's id within that list (t1, t2, …)
13
+ -- title the job's title as queued (user text)
14
+ -- status queued | running | done | failed | cancelled
15
+ -- attempts claims so far (a claim counts one, a retry resets it)
16
+ -- retries attempts beyond the first: GREATEST(attempts - 1, 0)
17
+ -- max_attempts the claims a job gets before an interruption fails it
18
+ -- (MAX_ATTEMPTS in lib/tools/taskTools.ts)
19
+ -- dead_lettered true when the job failed and will not run again unless
20
+ -- someone retries it (status 'failed')
21
+ -- last_error the error the job failed with, or why it was not retried
22
+ -- (cut at 1,000 characters when written)
23
+ -- lease_owner the worker holding a running job, and lease_until when
24
+ -- its claim runs out: a running job whose lease is past is
25
+ -- stuck until the next recover()
26
+ -- has_checkpoint whether a resumable checkpoint is kept (running or
27
+ -- queued only), and checkpoint_at when it was saved
28
+ -- created_at, started_at, finished_at, updated_at
29
+ --
30
+ -- Retry a dead-lettered or cancelled job with postgresStorage().retryTask
31
+ -- (owner, id), the same rule task_update's `queued` applies. The view has
32
+ -- the table's owner's privileges where security_invoker is not available
33
+ -- (Postgres < 15), so it is revoked from the API roles here and in
34
+ -- db/hardening.sql, like the table. Idempotent; safe to re-run.
35
+ -- ============================================================================
36
+
37
+ CREATE OR REPLACE VIEW melchizedek_task_queue AS
38
+ SELECT
39
+ t.owner,
40
+ t.id,
41
+ t.record->>'title' AS title,
42
+ t.status,
43
+ COALESCE((t.record->>'attempts')::int, 0) AS attempts,
44
+ GREATEST(COALESCE((t.record->>'attempts')::int, 0) - 1, 0) AS retries,
45
+ 2 AS max_attempts,
46
+ (t.status = 'failed') AS dead_lettered,
47
+ t.record->>'error' AS last_error,
48
+ t.lease_owner,
49
+ t.lease_until,
50
+ (t.checkpoint IS NOT NULL) AS has_checkpoint,
51
+ t.checkpoint_at,
52
+ (t.record->>'created_at')::timestamptz AS created_at,
53
+ (t.record->>'started_at')::timestamptz AS started_at,
54
+ (t.record->>'finished_at')::timestamptz AS finished_at,
55
+ t.updated_at
56
+ FROM melchizedek_tasks t
57
+ WHERE t.kind = 'background';
58
+
59
+ COMMENT ON VIEW melchizedek_task_queue IS
60
+ 'Background jobs as an operator reads them: status, attempts, retries, dead_lettered, last_error, lease and checkpoint. Retry with postgresStorage().retryTask(owner, id). Migration 0018.';
61
+
62
+ -- Postgres 15+: the view reads with the querying role's rights.
63
+ DO $$
64
+ BEGIN
65
+ IF current_setting('server_version_num')::int >= 150000 THEN
66
+ EXECUTE 'ALTER VIEW melchizedek_task_queue SET (security_invoker = true)';
67
+ END IF;
68
+ END $$;
69
+
70
+ DO $$
71
+ BEGIN
72
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
73
+ EXECUTE 'REVOKE ALL ON melchizedek_task_queue FROM anon';
74
+ END IF;
75
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
76
+ EXECUTE 'REVOKE ALL ON melchizedek_task_queue FROM authenticated';
77
+ END IF;
78
+ END $$;
79
+
80
+ INSERT INTO melchizedek_schema_version (version, name)
81
+ VALUES (18, '0018_queue_view')
82
+ ON CONFLICT (version) DO NOTHING;
package/db/telemetry.sql CHANGED
@@ -22,10 +22,14 @@
22
22
  -- resolved syndicate that ran) and engine_version. Kept
23
23
  -- indefinitely; full-text searchable. Eval runs land here
24
24
  -- too, tagged eval_* so production views exclude them.
25
- -- adk_telemetry ONE ROW PER SPAN — the raw llm.request and root spans as
26
- -- before (v1 shape, now with the identity columns). Per-call
27
- -- tokens, latency and the agent that made the call. Small;
28
- -- kept indefinitely.
25
+ -- adk_telemetry ONE ROW PER SPAN the ledger keeps — the turn's root, each
26
+ -- agent run, model step (model.call) and provider call
27
+ -- (llm.request), tool call, script run and call, workflow
28
+ -- walk and node run — with its parent among them and its
29
+ -- kind, so the turn's span tree is stored (ADR 0139). Per-call
30
+ -- tokens, latency and the agent that made the call; a tool
31
+ -- call's arguments and the start of its result. Small; kept
32
+ -- indefinitely.
29
33
  -- adk_payloads FULL REQUEST/RESPONSE PER MODEL CALL — the assembled
30
34
  -- prompt (system instruction, history, tool schemas) and the
31
35
  -- raw response. 10-100x the size of a turn row and repeats
@@ -47,7 +51,7 @@ CREATE TABLE IF NOT EXISTS adk_telemetry (
47
51
  ts TIMESTAMPTZ NOT NULL DEFAULT now(),
48
52
  trace_id TEXT NOT NULL,
49
53
  span_id TEXT NOT NULL,
50
- span_name TEXT NOT NULL, -- 'llm.request' | 'Syndicate Execution: <name>'
54
+ span_name TEXT NOT NULL, -- 'llm.request' | 'Syndicate Execution: <name>' | 'agent.invoke <name>' | ...
51
55
  syndicate TEXT, -- syndicate name, when known
52
56
  agent TEXT, -- the agent that made the call / answered the turn
53
57
  provider TEXT, -- 'gemini' | 'anthropic' | 'openai' | 'xai' | 'ollama'
@@ -68,6 +72,23 @@ CREATE INDEX IF NOT EXISTS idx_adk_telemetry_ts ON adk_telemetry (ts DESC);
68
72
  CREATE INDEX IF NOT EXISTS idx_adk_telemetry_trace ON adk_telemetry (trace_id);
69
73
  CREATE INDEX IF NOT EXISTS idx_adk_telemetry_session ON adk_telemetry (session_id);
70
74
 
75
+ -- The span tree (migration 0016_span_tree, ADR 0139), for a fresh install;
76
+ -- 0016 adds the same columns to a ledger that already exists:
77
+ -- parent_span_id the nearest ancestor span the ledger keeps; NULL for a root
78
+ -- kind turn | agent | model | tool | script | node
79
+ -- error the span failed (status, error code, a tool's error response)
80
+ -- cache_read_tokens parts of input_tokens read from / written to the
81
+ -- cache_write_tokens provider's prompt cache: one call's, or a root's sum
82
+ -- All NULL on rows written before 0016. Tokens and cost sit on llm.request
83
+ -- rows (and a root's on the root): a model.call row carries neither, so a
84
+ -- trace's rows sum without counting a call twice.
85
+ ALTER TABLE adk_telemetry ADD COLUMN IF NOT EXISTS parent_span_id TEXT;
86
+ ALTER TABLE adk_telemetry ADD COLUMN IF NOT EXISTS kind TEXT;
87
+ ALTER TABLE adk_telemetry ADD COLUMN IF NOT EXISTS error BOOLEAN;
88
+ ALTER TABLE adk_telemetry ADD COLUMN IF NOT EXISTS cache_read_tokens INTEGER;
89
+ ALTER TABLE adk_telemetry ADD COLUMN IF NOT EXISTS cache_write_tokens INTEGER;
90
+ CREATE INDEX IF NOT EXISTS idx_adk_telemetry_parent ON adk_telemetry (trace_id, parent_span_id);
91
+
71
92
  -- ── adk_turns: one row per turn — the system of record ───────────────────
72
93
  CREATE TABLE IF NOT EXISTS adk_turns (
73
94
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
@@ -123,6 +144,10 @@ CREATE TABLE IF NOT EXISTS adk_turns (
123
144
  UNIQUE (trace_id, span_id)
124
145
  );
125
146
 
147
+ -- Cache tokens summed over the turn's calls (migration 0016, ADR 0139).
148
+ ALTER TABLE adk_turns ADD COLUMN IF NOT EXISTS cache_read_tokens INTEGER;
149
+ ALTER TABLE adk_turns ADD COLUMN IF NOT EXISTS cache_write_tokens INTEGER;
150
+
126
151
  CREATE INDEX IF NOT EXISTS idx_adk_turns_ts ON adk_turns (ts DESC);
127
152
  CREATE INDEX IF NOT EXISTS idx_adk_turns_trace ON adk_turns (trace_id);
128
153
  CREATE INDEX IF NOT EXISTS idx_adk_turns_session ON adk_turns (session_id, ts);
@@ -167,8 +192,10 @@ CREATE INDEX IF NOT EXISTS idx_adk_turns_surface_user
167
192
  -- cost_usd is filled at write time by the trigger below from model_pricing
168
193
  -- (db/migrations/0015_model_pricing.sql), so both sinks and a replayed
169
194
  -- dead-letter spool get it, and the price table is the one source:
170
- -- adk_telemetry an llm.request row: its model and tokens. A root row: the
171
- -- turn's per-model usage on the span (syndicate.usage).
195
+ -- adk_telemetry an llm.request row: its model and tokens, cache reads and
196
+ -- writes at their own rates (migration 0017). A root row: the
197
+ -- turn's per-model usage on the span (syndicate.usage). Any
198
+ -- other row (an agent run, a model step, a tool call): NULL.
172
199
  -- adk_turns the turn's per-model usage (attributes->'syndicate.usage',
173
200
  -- the tracer sums each model's calls), so a turn that used
174
201
  -- two models is priced per model, input summed per call.
@@ -225,7 +252,8 @@ BEGIN
225
252
  RETURN NEW;
226
253
  END IF;
227
254
  IF NEW.model IS NOT NULL THEN
228
- NEW.cost_usd := melchizedek_cost_usd(NEW.model, NEW.input_tokens, NEW.output_tokens, NEW.thinking_tokens);
255
+ NEW.cost_usd := melchizedek_cost_usd(NEW.model, NEW.input_tokens, NEW.output_tokens, NEW.thinking_tokens,
256
+ NEW.cache_read_tokens::bigint, NEW.cache_write_tokens::bigint);
229
257
  ELSE
230
258
  NEW.cost_usd := melchizedek_usage_text_cost(NEW.span->'attributes'->>'syndicate.usage');
231
259
  END IF;
@@ -19,8 +19,10 @@
19
19
  * 5. BYOK headers → per-request AsyncLocalStorage context. Agent-card GETs
20
20
  * are exempt from X-API-Key: discovery must not require a model key.
21
21
  * 6. Task rate limiter (POSTs only; polling GETs are exempt).
22
- * 7. Routes: the adopter's own (`routes`), DELETE /memory, the default
23
- * syndicate at /a2a/*, and every other syndicate at /:agentId/a2a/*.
22
+ * 7. Routes: the adopter's own (`routes`), DELETE /memory, POST /feedback
23
+ * and /:agentId/feedback (ADR 0140; their own 32 KB body limit), the
24
+ * default syndicate at /a2a/*, and every other syndicate at
25
+ * /:agentId/a2a/*.
24
26
  *
25
27
  * State that is per-process (documented, not hidden): the A2A task store,
26
28
  * the handler cache, the rate-limiter counters. Sessions and memory are
@@ -53,9 +55,10 @@ import type { AuditSink } from '../observability/audit.ts';
53
55
  export declare const DEFAULT_MAX_CONCURRENT_PER_SCOPE = 4;
54
56
  import type { EraseCounts } from '../memory/erase.ts';
55
57
  import type { Embedder, MemoryExtractor } from '../memory/providers.ts';
56
- import type { A2AContext } from './executor.ts';
58
+ import type { A2AContext, TaskStateRecord } from './executor.ts';
57
59
  import type { IdentityScheme } from './identity.ts';
58
60
  import type { Policy } from './policy.ts';
61
+ import type { FeedbackStore } from './feedback.ts';
59
62
  import type { TaskRecord } from '../observability/metrics.ts';
60
63
  import type { OAuthConsent, ToolCredentials } from '../tools/oauthConsent.ts';
61
64
  /** Agent ids address a config (file or "registry:<id>"): a safe charset only. */
@@ -117,6 +120,14 @@ export interface A2AAppOptions {
117
120
  rlsHardening?: () => Promise<RlsHardeningStatus>;
118
121
  /** Appends audit events (postgresStorage supplies one; ADR 0042). */
119
122
  audit?: AuditSink;
123
+ /**
124
+ * Where `POST /feedback` reads turns and writes end-user labels: the
125
+ * telemetry ledger (ADR 0140). postgresStorage supplies one when its
126
+ * `ledger` is on; `postgresFeedbackStore(pool)` (lib/a2a/feedback.ts)
127
+ * builds one on any pool holding db/telemetry.sql. Without it the
128
+ * route answers 501.
129
+ */
130
+ feedback?: FeedbackStore;
120
131
  /**
121
132
  * One turn at a time per conversation across instances (postgresStorage
122
133
  * supplies an advisory lock). Default: a lock in this process.
@@ -174,6 +185,15 @@ export interface A2AAppOptions {
174
185
  metricsToken?: string;
175
186
  /** One record per task, however it ended (structured logs, your own metrics). */
176
187
  onTaskEnd?: (record: TaskRecord) => void;
188
+ /**
189
+ * Every state a task enters, as the executor publishes it (ADR 0141):
190
+ * `submitted` first, `working` with its first progress line, then the
191
+ * state it ends in (`completed`, `failed`, `canceled`, `rejected`,
192
+ * `input-required`). A repeat of the same state is not reported again. For
193
+ * a live view of in-flight work; `tasks/get` answers the same state to a
194
+ * polling caller. A throwing hook is ignored.
195
+ */
196
+ onTaskState?: (record: TaskStateRecord) => void;
177
197
  /**
178
198
  * Credentials plug point (ADR 0017/0023): how a provider is reached for
179
199
  * this request — from a secret manager, per tenant, anywhere. Return an
@@ -194,7 +214,9 @@ export interface A2AAppOptions {
194
214
  extractor?: MemoryExtractor;
195
215
  embedder?: Embedder;
196
216
  };
197
- /** Adopter routes, mounted after authentication and before the A2A routes. */
217
+ /** Adopter routes, mounted after authentication and before the A2A routes.
218
+ * A POST to a path ending in /feedback (one segment deep) parses at the
219
+ * feedback routes' 32 KB limit, not `bodyLimit`. */
198
220
  routes?: (app: Express) => void;
199
221
  /**
200
222
  * Tool credentials for every turn (ADR 0072): the sealed per-user store,
@@ -19,8 +19,10 @@
19
19
  * 5. BYOK headers → per-request AsyncLocalStorage context. Agent-card GETs
20
20
  * are exempt from X-API-Key: discovery must not require a model key.
21
21
  * 6. Task rate limiter (POSTs only; polling GETs are exempt).
22
- * 7. Routes: the adopter's own (`routes`), DELETE /memory, the default
23
- * syndicate at /a2a/*, and every other syndicate at /:agentId/a2a/*.
22
+ * 7. Routes: the adopter's own (`routes`), DELETE /memory, POST /feedback
23
+ * and /:agentId/feedback (ADR 0140; their own 32 KB body limit), the
24
+ * default syndicate at /a2a/*, and every other syndicate at
25
+ * /:agentId/a2a/*.
24
26
  *
25
27
  * State that is per-process (documented, not hidden): the A2A task store,
26
28
  * the handler cache, the rate-limiter counters. Sessions and memory are
@@ -61,7 +63,9 @@ import { eraseScope } from '../memory/erase.js';
61
63
  import { namespacedMemoryService } from '../memory/namespace.js';
62
64
  import { memoryCrossesProviders, memoryDestinations } from '../memory/providers.js';
63
65
  import { A2A_APP_NAME, SyndicateExecutor, TaskLimiter, deriveUserId, requestContextStorage, } from './executor.js';
66
+ import { droppedHeaderWarning, headerMetadata } from '../observability/metadata.js';
64
67
  import { HEADER_VALUE_PATTERN, SCOPE_KEY_PATTERN } from './identity.js';
68
+ import { feedbackBodyParser, feedbackHandler, feedbackLimiter, isFeedbackPath } from './feedback.js';
65
69
  import { createMetrics } from '../observability/metrics.js';
66
70
  import { ConsentError } from '../tools/oauthConsent.js';
67
71
  import { setOAuthHosts } from '../tools/oauthHosts.js';
@@ -545,6 +549,7 @@ export async function createA2AApp(options) {
545
549
  agentId,
546
550
  policy: options.policy,
547
551
  onTaskEnd,
552
+ ...(options.onTaskState ? { onTaskState: options.onTaskState } : {}),
548
553
  onAudit: audit,
549
554
  log,
550
555
  warn,
@@ -716,7 +721,10 @@ export async function createA2AApp(options) {
716
721
  });
717
722
  }
718
723
  // ── 4. Body ────────────────────────────────────────────────────────────────
719
- app.use(express.json({ limit: options.bodyLimit ?? '1mb' }));
724
+ // A POST to /feedback or /<id>/feedback parses at 32 KB, its errors as JSON (ADR 0140).
725
+ const jsonBody = express.json({ limit: options.bodyLimit ?? '1mb' });
726
+ const feedbackBody = feedbackBodyParser();
727
+ app.use((req, res, next) => (req.method === 'POST' && isFeedbackPath(req.path) ? feedbackBody(req, res, next) : jsonBody(req, res, next)));
720
728
  // ── 5. Caller context: who is calling, and whose data this is ──────────
721
729
  // Agent-card GETs need no caller context: discovery must not require a
722
730
  // model key or an identity.
@@ -764,6 +772,18 @@ export async function createA2AApp(options) {
764
772
  }
765
773
  surface[field] = raw;
766
774
  }
775
+ // X-Melch-Meta-*: per-request metadata, telemetry only (ADR 0141). Refused when malformed, as X-Surface is.
776
+ const metaHeaders = headerMetadata(req.headers);
777
+ if (metaHeaders.error) {
778
+ res.status(400).json({ error: metaHeaders.error });
779
+ return;
780
+ }
781
+ const meta = metaHeaders.meta;
782
+ // Warned about only once the caller is known: an anonymous request writes no log line.
783
+ const warnDroppedMeta = () => {
784
+ for (const key of metaHeaders.dropped ?? [])
785
+ warn(droppedHeaderWarning(key));
786
+ };
767
787
  // The adopter's identity system decides, when one is plugged in.
768
788
  if (options.resolveRequest) {
769
789
  let identity;
@@ -796,6 +816,7 @@ export async function createA2AApp(options) {
796
816
  }
797
817
  if (identity.apiKey && !identity.provider)
798
818
  provider = 'google';
819
+ warnDroppedMeta();
799
820
  requestContextStorage.run({
800
821
  apiKey,
801
822
  provider,
@@ -804,7 +825,7 @@ export async function createA2AApp(options) {
804
825
  caller: identity.caller,
805
826
  ownsNested: identity.ownsNested ?? false,
806
827
  operator: identity.operator ?? false,
807
- sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent),
828
+ sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent), ...(meta ? { meta } : {}),
808
829
  }, () => next());
809
830
  return;
810
831
  }
@@ -829,7 +850,8 @@ export async function createA2AApp(options) {
829
850
  // The caller's key funds its provider, and its hash prefixes the scope
830
851
  // so no key holder can reach another's data.
831
852
  const scopeKey = deriveUserId({ apiKey, siteUserId });
832
- requestContextStorage.run({ apiKey, provider, siteUserId, scopeKey, surface, caller: 'shared-secret', ownsNested: !siteUserId, operator: !!options.serverSecret, sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent) }, () => next());
853
+ warnDroppedMeta();
854
+ requestContextStorage.run({ apiKey, provider, siteUserId, scopeKey, surface, caller: 'shared-secret', ownsNested: !siteUserId, operator: !!options.serverSecret, sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent), ...(meta ? { meta } : {}) }, () => next());
833
855
  return;
834
856
  }
835
857
  // Server mode: the server's credentials pay; X-API-Key is not used.
@@ -837,6 +859,7 @@ export async function createA2AApp(options) {
837
859
  warnedIgnoredKey = true;
838
860
  warn('A caller sent X-API-Key, which server key mode ignores. Set A2A_KEY_MODE=byok (keyMode: \'byok\') if callers fund their own inference — and to keep reaching sessions and memory stored under key-hash silos.');
839
861
  }
862
+ warnDroppedMeta();
840
863
  requestContextStorage.run({
841
864
  apiKey: '',
842
865
  provider,
@@ -846,7 +869,7 @@ export async function createA2AApp(options) {
846
869
  // Everyone holding the secret is one caller; without a secret (loopback) the caller is local.
847
870
  caller: options.serverSecret ? 'shared-secret' : 'local',
848
871
  operator: !!options.serverSecret,
849
- sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent),
872
+ sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent), ...(meta ? { meta } : {}),
850
873
  }, () => next());
851
874
  });
852
875
  // ── 6. Task rate limit ────────────────────────────────────────────────────
@@ -933,6 +956,18 @@ export async function createA2AApp(options) {
933
956
  const served = options.servedAgents ? new Set(options.servedAgents) : undefined;
934
957
  class AgentNotFound extends Error {
935
958
  }
959
+ // End-user feedback (ADR 0140): behind the same authentication and task
960
+ // rate limit as a task, with its own body limit and per-turn limit.
961
+ const onFeedback = feedbackHandler({
962
+ store: options.storage?.feedback,
963
+ context: () => requestContextStorage.getStore(),
964
+ agentServed: (agentId) => isValidAgentId(agentId) && (!served || served.has(agentId)),
965
+ limiter: feedbackLimiter(),
966
+ log,
967
+ warn,
968
+ });
969
+ app.post('/feedback', onFeedback);
970
+ app.post('/:agentId/feedback', onFeedback);
936
971
  const loadAgentConfig = async (agentId) => {
937
972
  const registryId = agentId.startsWith('registry:') ? agentId.slice('registry:'.length) : registryIds.has(agentId) ? agentId : undefined;
938
973
  if (registryId) {
@@ -25,6 +25,7 @@ import type { ToolCredentials } from '../tools/oauthConsent.ts';
25
25
  import type { TaskRecord } from '../observability/metrics.ts';
26
26
  import type { AuditSink } from '../observability/audit.ts';
27
27
  import type { Policy } from './policy.ts';
28
+ import type { Metadata } from '../observability/metadata.ts';
28
29
  /** Per-request caller context, set by the server's identity middleware. */
29
30
  export interface A2AContext {
30
31
  /** The caller's provider key in BYOK mode (or from resolveRequest); '' otherwise. */
@@ -54,6 +55,8 @@ export interface A2AContext {
54
55
  sourceIp?: string;
55
56
  /** The caller's W3C traceparent, when well formed: the turn's root span links to it. */
56
57
  traceparent?: string;
58
+ /** The request's `X-Melch-Meta-*` headers, checked (ADR 0141): `meta.<key>` on the turn's root span. Telemetry only. */
59
+ meta?: Metadata;
57
60
  }
58
61
  export interface SurfaceContext {
59
62
  name: string;
@@ -73,6 +76,28 @@ export declare const A2A_APP_NAME = "melchizedek-a2a";
73
76
  export declare function deriveUserId(ctx: Pick<A2AContext, 'apiKey' | 'siteUserId'>): string;
74
77
  /** Root-span attributes for the caller's surface (absent headers add none). */
75
78
  export declare function surfaceAttributes(ctx: A2AContext | undefined): Record<string, string>;
79
+ /**
80
+ * A request's metadata as root-span attributes (ADR 0141): the
81
+ * `X-Melch-Meta-*` headers, then the A2A message's `metadata` object (a
82
+ * header wins a key both name), never a key the YAML's own `metadata:`
83
+ * declares, and at most as many keys as the YAML leaves of the 32.
84
+ */
85
+ export declare function requestMetaAttributes(ctx: Pick<A2AContext, 'meta'> | undefined, messageMetadata: unknown, config: Pick<SyndicateYamlConfig, 'metadata'>, warn?: (message: string) => void): Record<string, string | number | boolean>;
86
+ /** The A2A task states, as the protocol spells them. */
87
+ export type TaskStateName = 'submitted' | 'working' | 'input-required' | 'completed' | 'failed' | 'canceled' | 'rejected' | 'auth-required' | 'unknown';
88
+ /** One state a task entered, as the executor published it (A2AAppOptions.onTaskState). */
89
+ export interface TaskStateRecord {
90
+ taskId: string;
91
+ contextId: string;
92
+ state: TaskStateName;
93
+ /** The syndicate's name, and the agent id the task was sent to (`default` for the one served at /a2a/*). */
94
+ syndicate: string;
95
+ agentId: string;
96
+ /** The turn's trace id, once its root span started (absent on `submitted`, and on a task refused before it ran). */
97
+ traceId?: string;
98
+ /** When the state was published, ms since the epoch. */
99
+ at: number;
100
+ }
76
101
  /** One human-readable line naming WHY a turn failed, for the failed task's
77
102
  * status message — the text a client renders verbatim. Provider errors
78
103
  * sometimes arrive as a raw JSON blob (Gemini ApiError), so the inner
@@ -115,6 +140,8 @@ export interface ExecutorOptions {
115
140
  policy?: Policy;
116
141
  /** One record per task, however it ended: the task log and metrics. */
117
142
  onTaskEnd?: (record: TaskRecord) => void;
143
+ /** Every state the task enters, as it is published (ADR 0141). */
144
+ onTaskState?: (record: TaskStateRecord) => void;
118
145
  /** Receives one `task.end` audit event per task (ADR 0042). */
119
146
  onAudit?: AuditSink;
120
147
  /**
@@ -204,7 +231,7 @@ export declare class SyndicateExecutor implements AgentExecutor {
204
231
  constructor(opts: ExecutorOptions);
205
232
  /** Provenance stamp for every turn this executor serves (lineage.ts). */
206
233
  configHashFor(): string;
207
- execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void>;
234
+ execute(requestContext: RequestContext, published: ExecutionEventBus): Promise<void>;
208
235
  /**
209
236
  * Aborts the task's run; `execute` then publishes the `canceled` status.
210
237
  * The SDK calls this only on the instance whose event bus carries the