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.
- package/CHANGELOG.md +161 -1
- package/DOCUMENTATION.md +77 -10
- package/README.md +2 -0
- package/config/agents/syndicate.schema.json +69 -0
- package/config/agents/syndicateSchema.yaml +17 -0
- package/db/hardening.sql +3 -1
- package/db/migrations/0016_span_tree.sql +52 -0
- package/db/migrations/0017_cache_pricing.sql +146 -0
- package/db/migrations/0018_queue_view.sql +82 -0
- package/db/telemetry.sql +36 -8
- package/dist/lib/a2a/app.d.ts +26 -4
- package/dist/lib/a2a/app.js +41 -6
- package/dist/lib/a2a/executor.d.ts +28 -1
- package/dist/lib/a2a/executor.js +65 -2
- package/dist/lib/a2a/feedback.d.ts +121 -0
- package/dist/lib/a2a/feedback.js +219 -0
- package/dist/lib/a2a/policy.d.ts +55 -5
- package/dist/lib/a2a/policy.js +56 -12
- package/dist/lib/compile.d.ts +2 -0
- package/dist/lib/compile.js +2 -0
- package/dist/lib/compileNative.js +2 -0
- package/dist/lib/doctor.d.ts +10 -0
- package/dist/lib/doctor.js +33 -4
- package/dist/lib/index.d.ts +6 -2
- package/dist/lib/index.js +3 -0
- package/dist/lib/loadSyndicate.d.ts +14 -0
- package/dist/lib/loadSyndicate.js +22 -5
- package/dist/lib/memory/providers.js +7 -3
- package/dist/lib/observability/lineage.d.ts +19 -0
- package/dist/lib/observability/lineage.js +31 -0
- package/dist/lib/observability/metadata.d.ts +69 -0
- package/dist/lib/observability/metadata.js +153 -0
- package/dist/lib/observability/otlpFilter.js +1 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +31 -2
- package/dist/lib/observability/supabaseSpanExporter.js +45 -6
- package/dist/lib/observability/tracer.d.ts +17 -1
- package/dist/lib/observability/tracer.js +61 -3
- package/dist/lib/project.d.ts +116 -0
- package/dist/lib/project.js +274 -0
- package/dist/lib/runtime/native/compaction.js +7 -3
- package/dist/lib/runtime/native/request.d.ts +2 -0
- package/dist/lib/runtime/native/step.js +1 -1
- package/dist/lib/runtime/native/telemetry.d.ts +10 -3
- package/dist/lib/runtime/native/telemetry.js +49 -4
- package/dist/lib/runtime/syndicateTurn.js +5 -1
- package/dist/lib/storage/postgres/index.d.ts +10 -0
- package/dist/lib/storage/postgres/index.js +6 -1
- package/dist/lib/syndicateSchema.d.ts +5 -0
- package/dist/lib/syndicateSchema.js +13 -0
- package/dist/lib/tools/taskTools.d.ts +6 -0
- package/dist/lib/tools/taskTools.js +24 -5
- package/dist/lib/workflow/toolNode.d.ts +1 -0
- package/dist/lib/workflow/toolNode.js +1 -1
- package/dist/scripts/a2a_server.js +3 -3
- package/dist/scripts/doctor.js +2 -1
- package/dist/scripts/mcp_server.js +2 -1
- package/package.json +6 -4
package/dist/lib/a2a/executor.js
CHANGED
|
@@ -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,
|
|
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
|
+
}
|
package/dist/lib/a2a/policy.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/lib/a2a/policy.js
CHANGED
|
@@ -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
|
|
155
|
-
|
|
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
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
}
|
package/dist/lib/compile.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/compile.js
CHANGED
|
@@ -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
|
/**
|
package/dist/lib/doctor.d.ts
CHANGED
|
@@ -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.
|