melchizedek-agents 1.8.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 (51) hide show
  1. package/CHANGELOG.md +131 -1
  2. package/DOCUMENTATION.md +66 -4
  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/index.d.ts +5 -2
  23. package/dist/lib/index.js +1 -0
  24. package/dist/lib/loadSyndicate.d.ts +14 -0
  25. package/dist/lib/loadSyndicate.js +19 -1
  26. package/dist/lib/memory/providers.js +7 -3
  27. package/dist/lib/observability/lineage.d.ts +19 -0
  28. package/dist/lib/observability/lineage.js +31 -0
  29. package/dist/lib/observability/metadata.d.ts +69 -0
  30. package/dist/lib/observability/metadata.js +153 -0
  31. package/dist/lib/observability/otlpFilter.js +1 -0
  32. package/dist/lib/observability/supabaseSpanExporter.d.ts +31 -2
  33. package/dist/lib/observability/supabaseSpanExporter.js +45 -6
  34. package/dist/lib/observability/tracer.d.ts +17 -1
  35. package/dist/lib/observability/tracer.js +61 -3
  36. package/dist/lib/project.js +1 -1
  37. package/dist/lib/runtime/native/compaction.js +7 -3
  38. package/dist/lib/runtime/native/request.d.ts +2 -0
  39. package/dist/lib/runtime/native/step.js +1 -1
  40. package/dist/lib/runtime/native/telemetry.d.ts +10 -3
  41. package/dist/lib/runtime/native/telemetry.js +49 -4
  42. package/dist/lib/runtime/syndicateTurn.js +5 -1
  43. package/dist/lib/storage/postgres/index.d.ts +10 -0
  44. package/dist/lib/storage/postgres/index.js +6 -1
  45. package/dist/lib/syndicateSchema.d.ts +5 -0
  46. package/dist/lib/syndicateSchema.js +13 -0
  47. package/dist/lib/tools/taskTools.d.ts +6 -0
  48. package/dist/lib/tools/taskTools.js +24 -5
  49. package/dist/lib/workflow/toolNode.d.ts +1 -0
  50. package/dist/lib/workflow/toolNode.js +1 -1
  51. package/package.json +5 -4
@@ -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
@@ -26,6 +26,7 @@ import { deepestPause, delegatedPauses, routePause } from '../runtime/native/int
26
26
  import { isWorkflowSyndicate } from '../workflow.js';
27
27
  import { describeInput } from '../runtime/syndicateTurn.js';
28
28
  import { describeConsent } from '../runtime/credentials.js';
29
+ import { MAX_META_KEYS, metaAttributes, requestMetadata } from '../observability/metadata.js';
29
30
  export const requestContextStorage = new AsyncLocalStorage();
30
31
  /** The app name every A2A session and memory silo is stored under. */
31
32
  export const A2A_APP_NAME = 'melchizedek-a2a';
@@ -52,6 +53,64 @@ export function surfaceAttributes(ctx) {
52
53
  attrs['surface.user'] = s.user;
53
54
  return attrs;
54
55
  }
56
+ /**
57
+ * A request's metadata as root-span attributes (ADR 0141): the
58
+ * `X-Melch-Meta-*` headers, then the A2A message's `metadata` object (a
59
+ * header wins a key both name), never a key the YAML's own `metadata:`
60
+ * declares, and at most as many keys as the YAML leaves of the 32.
61
+ */
62
+ export function requestMetaAttributes(ctx, messageMetadata, config, warn) {
63
+ const declared = Object.keys(config.metadata ?? {});
64
+ const meta = requestMetadata([ctx?.meta, messageMetadata], {
65
+ reserved: new Set(declared),
66
+ room: MAX_META_KEYS - declared.length,
67
+ ...(warn ? { warn } : {}),
68
+ });
69
+ return metaAttributes(meta);
70
+ }
71
+ const STATE_NAMES = {
72
+ [TaskState.TASK_STATE_SUBMITTED]: 'submitted',
73
+ [TaskState.TASK_STATE_WORKING]: 'working',
74
+ [TaskState.TASK_STATE_INPUT_REQUIRED]: 'input-required',
75
+ [TaskState.TASK_STATE_COMPLETED]: 'completed',
76
+ [TaskState.TASK_STATE_FAILED]: 'failed',
77
+ [TaskState.TASK_STATE_CANCELED]: 'canceled',
78
+ [TaskState.TASK_STATE_REJECTED]: 'rejected',
79
+ [TaskState.TASK_STATE_AUTH_REQUIRED]: 'auth-required',
80
+ };
81
+ /**
82
+ * The bus, with `onState` called after each published event that moves the
83
+ * task to a new state (the task itself, then each status update whose state
84
+ * differs from the last: the `[STATUS]` lines of a working task are one
85
+ * state). A throwing observer never reaches the task.
86
+ */
87
+ function observeStates(bus, onState) {
88
+ let last;
89
+ const publish = (event) => {
90
+ bus.publish(event);
91
+ const raw = event.kind === 'task' ? event.data.status?.state : event.kind === 'statusUpdate' ? event.data.status?.state : undefined;
92
+ if (raw === undefined)
93
+ return;
94
+ const state = STATE_NAMES[raw] ?? 'unknown';
95
+ if (state === last)
96
+ return;
97
+ last = state;
98
+ try {
99
+ onState(state);
100
+ }
101
+ catch {
102
+ /* an observer must not fail the task */
103
+ }
104
+ };
105
+ return new Proxy(bus, {
106
+ get(target, prop) {
107
+ if (prop === 'publish')
108
+ return publish;
109
+ const value = Reflect.get(target, prop, target);
110
+ return typeof value === 'function' ? value.bind(target) : value;
111
+ },
112
+ });
113
+ }
55
114
  /** One human-readable line naming WHY a turn failed, for the failed task's
56
115
  * status message — the text a client renders verbatim. Provider errors
57
116
  * sometimes arrive as a raw JSON blob (Gemini ApiError), so the inner
@@ -465,11 +524,15 @@ export class SyndicateExecutor {
465
524
  }
466
525
  return this.configHash;
467
526
  }
468
- async execute(requestContext, eventBus) {
527
+ async execute(requestContext, published) {
469
528
  const { config, log, warn } = this.opts;
470
529
  const message = requestContext.userMessage;
471
530
  const contextId = requestContext.contextId;
472
531
  const taskId = requestContext.taskId;
532
+ const onTaskState = this.opts.onTaskState;
533
+ const eventBus = onTaskState
534
+ ? observeStates(published, (state) => onTaskState({ taskId, contextId, state, syndicate: config.syndicate_name, agentId: this.opts.agentId ?? '', ...(traceId ? { traceId } : {}), at: Date.now() }))
535
+ : published;
473
536
  const short = taskId.slice(0, 8);
474
537
  let slot;
475
538
  let releaseTurn = null;
@@ -655,7 +718,7 @@ export class SyndicateExecutor {
655
718
  trace: {
656
719
  taskId,
657
720
  configHash: this.configHashFor(),
658
- attributes: surfaceAttributes(ctx),
721
+ attributes: { ...surfaceAttributes(ctx), ...requestMetaAttributes(ctx, message?.metadata, config, warn) },
659
722
  traceparent: ctx.traceparent,
660
723
  onSpanStart: (ids) => {
661
724
  traceId = ids.traceId;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * lib/a2a/feedback.ts — end-user feedback on a served turn (ADR 0140):
3
+ * `POST /feedback` and `POST /<agentId>/feedback`.
4
+ *
5
+ * POST /feedback
6
+ * { "traceId": "4bf9…", "score": "down", "comment": "Wrong refund policy", "userId": "u-123" }
7
+ *
8
+ * The body names the turn by exactly one of `taskId` (the A2A task) or
9
+ * `traceId` (the ledger's), a `score` of 'up' | 'down' or an integer 1–5,
10
+ * and optionally a `comment` (≤ 4,000 characters) and the product's own
11
+ * `userId`. It lands in the ledger as one `adk_labels` row:
12
+ *
13
+ * ref = trace_id = the turn's trace id, labeler = 'end-user',
14
+ * fields = { score, userId } (userId null when none was sent),
15
+ * note = the comment through the telemetry redactor (null when none)
16
+ *
17
+ * The route sits behind the same bearer check, authenticator, failed-auth
18
+ * limiter and task rate limit as the A2A routes, with its own body limit
19
+ * (32 KB). A caller rates only turns it could have run: the turn's
20
+ * `user_id` must be the caller's scope (or one beneath a scope that owns
21
+ * nested ones), unless the caller holds an operator credential; anything
22
+ * else answers the same 404 as a turn that does not exist. One label per
23
+ * (turn, userId) per minute, per process; without a userId the caller's
24
+ * scope stands in. Answers never repeat what was sent.
25
+ */
26
+ import type { RequestHandler } from 'express';
27
+ import type { Redactor } from '../observability/redact.ts';
28
+ import type { A2AContext } from './executor.ts';
29
+ /** The labeler every end-user feedback row carries. */
30
+ export declare const END_USER_LABELER = "end-user";
31
+ /** The route's own JSON body limit. */
32
+ export declare const FEEDBACK_BODY_LIMIT = "32kb";
33
+ /** The longest comment accepted, in characters. */
34
+ export declare const FEEDBACK_COMMENT_MAX = 4000;
35
+ /** One label per (turn, user) in this window. */
36
+ export declare const FEEDBACK_WINDOW_MS = 60000;
37
+ export type FeedbackScore = 'up' | 'down' | 1 | 2 | 3 | 4 | 5;
38
+ /** The adk_labels row a feedback request writes (the columns it names; the rest keep their defaults). */
39
+ export interface FeedbackRow {
40
+ ref: string;
41
+ trace_id: string;
42
+ labeler: typeof END_USER_LABELER;
43
+ fields: {
44
+ score: FeedbackScore;
45
+ userId: string | null;
46
+ };
47
+ note: string | null;
48
+ }
49
+ /** The turn a feedback request names, as the ledger holds it. */
50
+ export interface FeedbackTurn {
51
+ traceId: string;
52
+ /** adk_turns.user_id: the scope key the turn ran under. */
53
+ userId: string | null;
54
+ }
55
+ /** Where feedback is read from and written to: the telemetry ledger. */
56
+ export interface FeedbackStore {
57
+ /** The turn by its A2A task id or its trace id; null when the ledger has no such turn. */
58
+ turn(ref: {
59
+ taskId: string;
60
+ } | {
61
+ traceId: string;
62
+ }): Promise<FeedbackTurn | null>;
63
+ /** Appends one label. */
64
+ add(row: FeedbackRow): Promise<void>;
65
+ }
66
+ export interface ParsedFeedback {
67
+ ref: {
68
+ taskId: string;
69
+ } | {
70
+ traceId: string;
71
+ };
72
+ score: FeedbackScore;
73
+ comment: string | null;
74
+ userId: string | null;
75
+ }
76
+ /** Validates a feedback body. The error names the rule broken, never the value. */
77
+ export declare function parseFeedback(body: unknown): {
78
+ ok: true;
79
+ value: ParsedFeedback;
80
+ } | {
81
+ ok: false;
82
+ error: string;
83
+ };
84
+ /** The row a parsed request writes for a turn, the comment through `redactor` (none: kept as sent). */
85
+ export declare function feedbackRow(traceId: string, f: Pick<ParsedFeedback, 'score' | 'comment' | 'userId'>, redactor: Redactor | undefined): FeedbackRow;
86
+ /** May this caller rate a turn that ran under `turnUserId`? */
87
+ export declare function mayRate(ctx: Pick<A2AContext, 'scopeKey' | 'ownsNested' | 'operator'>, turnUserId: string | null): boolean;
88
+ /** One label per key per window, in this process. Keys are hashes; the map is swept as it grows. */
89
+ export declare function feedbackLimiter(windowMs?: number, now?: () => number): {
90
+ /** Reserves the key; the ms to wait when it is taken. */
91
+ take(key: string): {
92
+ ok: true;
93
+ } | {
94
+ ok: false;
95
+ retryAfterMs: number;
96
+ };
97
+ /** Gives a reservation back (the write failed). */
98
+ release(key: string): void;
99
+ };
100
+ /** The JSON parser for the feedback routes: 32 KB, and its errors answered as JSON without the body. */
101
+ export declare function feedbackBodyParser(): RequestHandler;
102
+ export interface FeedbackHandlerOptions {
103
+ store: FeedbackStore | undefined;
104
+ /** The caller context the identity middleware set. */
105
+ context: () => A2AContext | undefined;
106
+ /** Validates `:agentId` on the per-agent route; false answers 404. */
107
+ agentServed?: (agentId: string) => boolean;
108
+ limiter?: ReturnType<typeof feedbackLimiter>;
109
+ log?: (m: string) => void;
110
+ warn?: (m: string) => void;
111
+ }
112
+ /** The route handler; mount it after authentication, with its body parsed by feedbackBodyParser(). */
113
+ export declare function feedbackHandler(opts: FeedbackHandlerOptions): RequestHandler;
114
+ /** Matches the feedback routes' paths, so the server-wide JSON parser leaves their bodies to feedbackBodyParser. */
115
+ export declare const isFeedbackPath: (path: string) => boolean;
116
+ /** A FeedbackStore over a Postgres pool holding the ledger (db/telemetry.sql). */
117
+ export declare function postgresFeedbackStore(pool: {
118
+ query: (sql: string, params?: unknown[]) => Promise<{
119
+ rows: any[];
120
+ }>;
121
+ }): FeedbackStore;