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
@@ -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;
@@ -0,0 +1,219 @@
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 { createHash } from 'node:crypto';
27
+ import express from 'express';
28
+ import { z } from 'zod';
29
+ import { REDACTION_PATTERNS, telemetryRedactor } from '../observability/redact.js';
30
+ /** The labeler every end-user feedback row carries. */
31
+ export const END_USER_LABELER = 'end-user';
32
+ /** The route's own JSON body limit. */
33
+ export const FEEDBACK_BODY_LIMIT = '32kb';
34
+ /** The longest comment accepted, in characters. */
35
+ export const FEEDBACK_COMMENT_MAX = 4_000;
36
+ /** One label per (turn, user) in this window. */
37
+ export const FEEDBACK_WINDOW_MS = 60_000;
38
+ const ID = /^[A-Za-z0-9._:-]{1,128}$/;
39
+ const USER_ID = /^[A-Za-z0-9._@:+/-]{1,128}$/;
40
+ const bodySchema = z.strictObject({
41
+ taskId: z.string().regex(ID).optional(),
42
+ traceId: z.string().regex(ID).optional(),
43
+ score: z.union([z.enum(['up', 'down']), z.number().int().min(1).max(5)]),
44
+ comment: z.string().max(FEEDBACK_COMMENT_MAX).optional(),
45
+ userId: z.string().regex(USER_ID).optional(),
46
+ });
47
+ /** Field-level messages that name the field and the rule, never the value sent. */
48
+ const MESSAGES = {
49
+ taskId: 'taskId must be 1–128 characters of [A-Za-z0-9._:-].',
50
+ traceId: 'traceId must be 1–128 characters of [A-Za-z0-9._:-].',
51
+ score: "score must be 'up', 'down' or an integer from 1 to 5.",
52
+ comment: `comment must be a string of at most ${FEEDBACK_COMMENT_MAX.toLocaleString('en-US')} characters.`,
53
+ userId: 'userId must be 1–128 characters of [A-Za-z0-9._@:+/-].',
54
+ };
55
+ /** Validates a feedback body. The error names the rule broken, never the value. */
56
+ export function parseFeedback(body) {
57
+ if (!body || typeof body !== 'object' || Array.isArray(body))
58
+ return { ok: false, error: 'Send a JSON object body (Content-Type: application/json).' };
59
+ const r = bodySchema.safeParse(body);
60
+ if (!r.success) {
61
+ const issue = r.error.issues[0];
62
+ if (issue.code === 'unrecognized_keys')
63
+ return { ok: false, error: 'Unknown field; the body takes taskId or traceId, score, comment and userId.' };
64
+ const field = String(issue.path[0] ?? '');
65
+ return { ok: false, error: MESSAGES[field] ?? 'Invalid feedback body.' };
66
+ }
67
+ const { taskId, traceId, score, comment, userId } = r.data;
68
+ if ((taskId === undefined) === (traceId === undefined))
69
+ return { ok: false, error: 'Name the turn with exactly one of taskId or traceId.' };
70
+ if (userId && new RegExp(REDACTION_PATTERNS.secret.source).test(userId))
71
+ return { ok: false, error: 'userId looks like a credential; send the product’s own user id.' };
72
+ return {
73
+ ok: true,
74
+ value: {
75
+ ref: taskId !== undefined ? { taskId } : { traceId: traceId },
76
+ score: score,
77
+ comment: comment && comment.trim() ? comment : null,
78
+ userId: userId ?? null,
79
+ },
80
+ };
81
+ }
82
+ /** The row a parsed request writes for a turn, the comment through `redactor` (none: kept as sent). */
83
+ export function feedbackRow(traceId, f, redactor) {
84
+ return {
85
+ ref: traceId,
86
+ trace_id: traceId,
87
+ labeler: END_USER_LABELER,
88
+ fields: { score: f.score, userId: f.userId },
89
+ note: f.comment === null ? null : redactor ? redactor(f.comment) : f.comment,
90
+ };
91
+ }
92
+ /** May this caller rate a turn that ran under `turnUserId`? */
93
+ export function mayRate(ctx, turnUserId) {
94
+ if (ctx.operator)
95
+ return true;
96
+ if (!turnUserId)
97
+ return false;
98
+ return turnUserId === ctx.scopeKey || (!!ctx.ownsNested && turnUserId.startsWith(`${ctx.scopeKey}/`));
99
+ }
100
+ /** One label per key per window, in this process. Keys are hashes; the map is swept as it grows. */
101
+ export function feedbackLimiter(windowMs = FEEDBACK_WINDOW_MS, now = Date.now) {
102
+ const until = new Map();
103
+ const sweep = () => {
104
+ const t = now();
105
+ for (const [k, v] of until)
106
+ if (v <= t)
107
+ until.delete(k);
108
+ };
109
+ return {
110
+ /** Reserves the key; the ms to wait when it is taken. */
111
+ take(key) {
112
+ const t = now();
113
+ const held = until.get(key);
114
+ if (held !== undefined && held > t)
115
+ return { ok: false, retryAfterMs: held - t };
116
+ if (until.size >= 10_000)
117
+ sweep();
118
+ until.set(key, t + windowMs);
119
+ return { ok: true };
120
+ },
121
+ /** Gives a reservation back (the write failed). */
122
+ release(key) {
123
+ until.delete(key);
124
+ },
125
+ };
126
+ }
127
+ /** The JSON parser for the feedback routes: 32 KB, and its errors answered as JSON without the body. */
128
+ export function feedbackBodyParser() {
129
+ const parse = express.json({ limit: FEEDBACK_BODY_LIMIT });
130
+ return (req, res, next) => {
131
+ parse(req, res, (err) => {
132
+ if (!err)
133
+ return next();
134
+ const e = err;
135
+ if (e.type === 'entity.too.large' || e.status === 413) {
136
+ res.status(413).json({ error: `The feedback body is over ${FEEDBACK_BODY_LIMIT.toUpperCase()}.` });
137
+ return;
138
+ }
139
+ res.status(400).json({ error: 'The body is not valid JSON.' });
140
+ });
141
+ };
142
+ }
143
+ /** The route handler; mount it after authentication, with its body parsed by feedbackBodyParser(). */
144
+ export function feedbackHandler(opts) {
145
+ const limiter = opts.limiter ?? feedbackLimiter();
146
+ return async (req, res) => {
147
+ const agentId = typeof req.params?.agentId === 'string' ? req.params.agentId : undefined;
148
+ if (agentId !== undefined && opts.agentServed && !opts.agentServed(agentId)) {
149
+ res.status(404).json({ error: 'Unknown agent.' });
150
+ return;
151
+ }
152
+ if (!opts.store) {
153
+ res.status(501).json({ error: 'Feedback needs the telemetry ledger; this server has none configured.' });
154
+ return;
155
+ }
156
+ const ctx = opts.context();
157
+ if (!ctx) {
158
+ res.status(401).json({ error: 'No authentication context.' });
159
+ return;
160
+ }
161
+ const parsed = parseFeedback(req.body);
162
+ if (!parsed.ok) {
163
+ res.status(400).json({ error: parsed.error });
164
+ return;
165
+ }
166
+ const f = parsed.value;
167
+ let turn;
168
+ try {
169
+ turn = await opts.store.turn(f.ref);
170
+ }
171
+ catch (err) {
172
+ opts.warn?.(`Feedback: the ledger could not be read (${err instanceof Error ? err.message : String(err)}).`);
173
+ res.status(503).json({ error: 'The ledger is unavailable; retry shortly.' });
174
+ return;
175
+ }
176
+ // Not found and not yours answer alike: a caller cannot probe for other people's turns.
177
+ if (!turn || !mayRate(ctx, turn.userId)) {
178
+ res.status(404).json({ error: 'No turn with that id (a turn reaches the ledger a few seconds after it ends).' });
179
+ return;
180
+ }
181
+ const who = f.userId !== null ? `user:${f.userId}` : `scope:${ctx.scopeKey}`;
182
+ const key = createHash('sha256').update(`${turn.traceId}\u0000${who}`).digest('hex');
183
+ const slot = limiter.take(key);
184
+ if (!slot.ok) {
185
+ res.setHeader('Retry-After', String(Math.ceil(slot.retryAfterMs / 1000)));
186
+ res.status(429).json({ error: 'Feedback on this turn from this user was just recorded; one per minute.' });
187
+ return;
188
+ }
189
+ try {
190
+ await opts.store.add(feedbackRow(turn.traceId, f, telemetryRedactor()));
191
+ }
192
+ catch (err) {
193
+ limiter.release(key);
194
+ opts.warn?.(`Feedback: the label was not written (${err instanceof Error ? err.message : String(err)}).`);
195
+ res.status(503).json({ error: 'The feedback could not be stored; retry shortly.' });
196
+ return;
197
+ }
198
+ opts.log?.(`Feedback on trace ${turn.traceId.slice(0, 8)}: ${typeof f.score === 'number' ? `${f.score}/5` : f.score}${f.comment !== null ? ', with a comment' : ''}`);
199
+ res.status(201).json({ ok: true, traceId: turn.traceId });
200
+ };
201
+ }
202
+ /** Matches the feedback routes' paths, so the server-wide JSON parser leaves their bodies to feedbackBodyParser. */
203
+ export const isFeedbackPath = (path) => path === '/feedback' || /^\/[^/]+\/feedback$/.test(path);
204
+ /** A FeedbackStore over a Postgres pool holding the ledger (db/telemetry.sql). */
205
+ export function postgresFeedbackStore(pool) {
206
+ return {
207
+ async turn(ref) {
208
+ const byTask = 'taskId' in ref;
209
+ const { rows } = await pool.query(
210
+ // A dispatch task has a classifier turn beside its answer: the answer is the one rated.
211
+ `SELECT trace_id, user_id FROM adk_turns WHERE ${byTask ? "task_id = $1 AND stage IS DISTINCT FROM 'classify'" : 'trace_id = $1'} ORDER BY ts DESC LIMIT 1`, [byTask ? ref.taskId : ref.traceId]);
212
+ const r = rows[0];
213
+ return r ? { traceId: String(r.trace_id), userId: r.user_id == null ? null : String(r.user_id) } : null;
214
+ },
215
+ async add(row) {
216
+ await pool.query('INSERT INTO adk_labels (ref, trace_id, labeler, fields, note) VALUES ($1, $2, $3, $4::jsonb, $5)', [row.ref, row.trace_id, row.labeler, JSON.stringify(row.fields), row.note]);
217
+ },
218
+ };
219
+ }
@@ -95,16 +95,66 @@ export type BudgetConfig = z.infer<typeof budgetsSchema>;
95
95
  export declare function parseBudgets(json: string): BudgetConfig;
96
96
  /** A scope as a counter subject: hashed, so the usage table holds no user identifier. */
97
97
  export declare const scopeSubject: (scopeKey: string) => string;
98
+ /** Where a subject stands against its limits today. */
99
+ export type BudgetLevel = 'ok' | 'warn' | 'over';
100
+ /**
101
+ * One subject's standing, as `onBudgetState` reports it: after every task
102
+ * it recorded (cause 'record'), and when a task arrives over a limit
103
+ * (cause 'admit'; `stopped` says whether it was refused). Counts only,
104
+ * never user text; the scope is its hash.
105
+ */
106
+ export interface BudgetState {
107
+ /** `caller:<name>` or `scope:<SHA-256 prefix>` (scopeSubject). */
108
+ subject: string;
109
+ /** The UTC day counted (YYYY-MM-DD). */
110
+ day: string;
111
+ /** The subject's totals for the day, this task included. */
112
+ usage: DailyUsage;
113
+ /** The limits that apply to the subject; undefined when none do. */
114
+ limits?: BudgetLimits;
115
+ /** 'over' at or past any limit, 'warn' at or past `warnAt` of one, else 'ok'. */
116
+ level: BudgetLevel;
117
+ /** The limit reached, as the refusal names it ('2,000,000 tokens'), when over. */
118
+ reached?: string;
119
+ cause: 'record' | 'admit';
120
+ /** The agent id the task was sent to ('' or 'default' for the boot syndicate). */
121
+ agentId: string;
122
+ /** With cause 'admit': true when the task was refused, false when it went ahead over the limit (hardStop: false). */
123
+ stopped?: boolean;
124
+ }
125
+ /**
126
+ * A stop decided outside the daily limits, at admission: return a reason to
127
+ * refuse the task with it, or undefined to admit it. Throwing refuses (fail
128
+ * closed). A desktop app serving locally passes its own month's spend check.
129
+ */
130
+ export type HardStop = (subject: PolicySubject) => string | undefined | Promise<string | undefined>;
131
+ export interface BudgetOptions {
132
+ store?: UsageStore;
133
+ now?: () => Date;
134
+ /**
135
+ * Called with each subject's standing after a task is recorded, and when
136
+ * a task arrives over a limit, so a consumer can mirror a deployment's spend. Errors are
137
+ * swallowed; it never delays or fails a task beyond its own runtime.
138
+ */
139
+ onBudgetState?: (state: BudgetState) => void | Promise<void>;
140
+ /** The share of a limit at which the level is 'warn'. Default 0.8. */
141
+ warnAt?: number;
142
+ /**
143
+ * true (default): a subject at a limit is refused. false: the limits only
144
+ * report ('over' through onBudgetState) and tasks go ahead. A function:
145
+ * the limits refuse, and the function is asked as well (HardStop).
146
+ */
147
+ hardStop?: boolean | HardStop;
148
+ }
98
149
  /**
99
150
  * Daily budgets per caller and per scope. A request is refused when the
100
151
  * caller or its scope has reached any limit today (UTC); the message says
101
152
  * which and when it resets. A store failure refuses (fail closed) — a
102
- * budget that cannot be read is not a budget.
153
+ * budget that cannot be read is not a budget. `onBudgetState` mirrors each
154
+ * subject's standing; `hardStop` makes the limits advisory, or adds an
155
+ * outside stop (BudgetOptions).
103
156
  */
104
- export declare function budgets(config: BudgetConfig, opts?: {
105
- store?: UsageStore;
106
- now?: () => Date;
107
- }): Policy & {
157
+ export declare function budgets(config: BudgetConfig, opts?: BudgetOptions): Policy & {
108
158
  store: UsageStore;
109
159
  };
110
160
  export {};
@@ -137,42 +137,86 @@ function over(limits, u) {
137
137
  return `${limits.tokens.toLocaleString('en-US')} tokens`;
138
138
  return undefined;
139
139
  }
140
+ function levelOf(limits, u, warnAt) {
141
+ if (!limits)
142
+ return 'ok';
143
+ if (over(limits, u))
144
+ return 'over';
145
+ const near = (used, limit) => limit !== undefined && used >= limit * warnAt;
146
+ return near(u.tasks, limits.tasks) || near(u.llmCalls, limits.llmCalls) || near(tokensOf(u), limits.tokens) ? 'warn' : 'ok';
147
+ }
140
148
  /**
141
149
  * Daily budgets per caller and per scope. A request is refused when the
142
150
  * caller or its scope has reached any limit today (UTC); the message says
143
151
  * which and when it resets. A store failure refuses (fail closed) — a
144
- * budget that cannot be read is not a budget.
152
+ * budget that cannot be read is not a budget. `onBudgetState` mirrors each
153
+ * subject's standing; `hardStop` makes the limits advisory, or adds an
154
+ * outside stop (BudgetOptions).
145
155
  */
146
156
  export function budgets(config, opts = {}) {
147
157
  const store = opts.store ?? memoryUsageStore();
148
158
  const now = opts.now ?? (() => new Date());
159
+ const warnAt = opts.warnAt !== undefined && opts.warnAt > 0 && opts.warnAt <= 1 ? opts.warnAt : 0.8;
160
+ const enforce = opts.hardStop !== false;
161
+ const outsideStop = typeof opts.hardStop === 'function' ? opts.hardStop : undefined;
149
162
  const callerLimits = (caller) => (caller && config.callers?.[caller]) || config.perCaller;
163
+ const subjectsOf = (s) => [
164
+ ...(s.caller ? [[`caller:${s.caller}`, callerLimits(s.caller)]] : []),
165
+ [scopeSubject(s.scopeKey), config.perScope],
166
+ ];
167
+ const report = async (state) => {
168
+ if (!opts.onBudgetState)
169
+ return;
170
+ try {
171
+ await opts.onBudgetState(state);
172
+ }
173
+ catch {
174
+ /* a broken mirror must not touch the task */
175
+ }
176
+ };
150
177
  return {
151
178
  store,
152
179
  async admit(s) {
153
180
  const day = today(now);
154
- const checks = [
155
- [s.caller ? `caller:${s.caller}` : '', s.caller ? callerLimits(s.caller) : undefined],
156
- [scopeSubject(s.scopeKey), config.perScope],
157
- ];
158
- for (const [subject, limits] of checks) {
159
- if (!subject || !limits)
181
+ for (const [subject, limits] of subjectsOf(s)) {
182
+ if (!limits)
160
183
  continue;
161
- const hit = over(limits, await store.get(day, subject));
184
+ const usage = await store.get(day, subject);
185
+ const hit = over(limits, usage);
162
186
  if (hit) {
187
+ await report({ subject, day, usage, limits, level: 'over', reached: hit, cause: 'admit', agentId: s.agentId, stopped: enforce });
188
+ if (!enforce)
189
+ continue;
163
190
  const who = subject.startsWith('caller:') ? `Caller '${s.caller}'` : 'This user';
164
191
  return { ok: false, reason: `${who} has used today's budget of ${hit}. It resets at 00:00 UTC.` };
165
192
  }
166
193
  }
194
+ if (outsideStop) {
195
+ const reason = await outsideStop(s);
196
+ if (reason)
197
+ return { ok: false, reason };
198
+ }
167
199
  return { ok: true };
168
200
  },
169
201
  async record(s, usage) {
170
202
  const day = today(now);
171
203
  const delta = { tasks: 1, ...usage };
172
- const writes = [store.add(day, scopeSubject(s.scopeKey), delta)];
173
- if (s.caller)
174
- writes.push(store.add(day, `caller:${s.caller}`, delta));
175
- await Promise.all(writes);
204
+ const subjects = subjectsOf(s);
205
+ await Promise.all(subjects.map(([subject]) => store.add(day, subject, delta)));
206
+ if (!opts.onBudgetState)
207
+ return;
208
+ for (const [subject, limits] of subjects) {
209
+ let totals;
210
+ try {
211
+ totals = await store.get(day, subject);
212
+ }
213
+ catch {
214
+ continue; // the spend is recorded; only the mirror misses this one
215
+ }
216
+ const level = levelOf(limits, totals, warnAt);
217
+ const reached = level === 'over' ? over(limits, totals) : undefined;
218
+ await report({ subject, day, usage: totals, ...(limits ? { limits } : {}), level, ...(reached ? { reached } : {}), cause: 'record', agentId: s.agentId });
219
+ }
176
220
  },
177
221
  };
178
222
  }
@@ -208,6 +208,8 @@ export interface AgentSpec {
208
208
  mode?: 'task';
209
209
  /** The syndicate's `max_concurrency`, on its orchestrator (ADR 0116): delegated calls one step runs at once. */
210
210
  maxConcurrency?: number;
211
+ /** `metadata:` (ADR 0141): `meta.agent.<key>` on the agent's span. */
212
+ metadata?: Record<string, string | number | boolean>;
211
213
  }
212
214
  /**
213
215
  * The spec of a workflow syndicate: every agent through compileSubagentSpec
@@ -270,6 +270,8 @@ function specOf(yaml, name, description, instruction, tools, { resolvedModel, mo
270
270
  spec.context = yaml.context;
271
271
  if (yaml.mode)
272
272
  spec.mode = yaml.mode;
273
+ if (yaml.metadata && Object.keys(yaml.metadata).length)
274
+ spec.metadata = yaml.metadata;
273
275
  return spec;
274
276
  }
275
277
  const asTools = (tools) => tools.map((tool) => ({ kind: 'tool', tool }));
@@ -149,6 +149,8 @@ export function compileNative(spec) {
149
149
  agent.context = spec.context;
150
150
  if (spec.maxConcurrency !== undefined)
151
151
  agent.maxConcurrency = spec.maxConcurrency;
152
+ if (spec.metadata)
153
+ agent.metadata = spec.metadata;
152
154
  return agent;
153
155
  }
154
156
  /**
@@ -27,6 +27,7 @@ import type { Platform } from './models/endpoints.ts';
27
27
  import type { CapabilityGap, CapabilityReport } from './models/capabilities.ts';
28
28
  import type { ProviderId } from './models/providerMap.ts';
29
29
  import type { RuntimeName, RuntimeSource } from './runtime/runtimeFlag.ts';
30
+ import { type ProjectCheck } from './project.ts';
30
31
  /**
31
32
  * What a file costs to run: `keyless` (all Ollama), a single provider's id
32
33
  * when every agent shares one (`gemini`, `anthropic`, `openai`, `xai` — one
@@ -169,7 +170,16 @@ export interface DoctorResult {
169
170
  serving?: DoctorServing;
170
171
  /** A stored Sign in with ChatGPT credential (ADR 0126), when one is present or its variables are set. */
171
172
  chatgpt?: DoctorChatGpt;
173
+ /** The project manifest (ADR 0138) found from the working directory, when there is one. */
174
+ project?: DoctorProject;
172
175
  }
176
+ /** A `melchizedek.json` at or above the working directory: its checks, or why it does not load. */
177
+ export type DoctorProject = ProjectCheck | {
178
+ root: string;
179
+ error: string;
180
+ problems: string[];
181
+ warnings: string[];
182
+ };
173
183
  /**
174
184
  * A Sign in with ChatGPT credential (ADR 0126): where it is, whether it is
175
185
  * signed in and carries OpenAI ids here, and what stops it. Never a token.