agent-relay-server 0.97.0 → 0.98.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 (79) hide show
  1. package/docs/openapi.json +55 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-CIDyX8f7.js → MessageBubble-C4AeEHdz.js} +2 -2
  4. package/public/assets/{MessageBubble-CIDyX8f7.js.map → MessageBubble-C4AeEHdz.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BmWB81w_.js → PlanGraphPanel-cwPUGFXA.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BmWB81w_.js.map → PlanGraphPanel-cwPUGFXA.js.map} +1 -1
  7. package/public/assets/{activity-QQikHZe4.js → activity-BWGwnSg5.js} +2 -2
  8. package/public/assets/{activity-QQikHZe4.js.map → activity-BWGwnSg5.js.map} +1 -1
  9. package/public/assets/{agent-profiles-WvAN5cR_.js → agent-profiles-C6N9cTJg.js} +2 -2
  10. package/public/assets/{agent-profiles-WvAN5cR_.js.map → agent-profiles-C6N9cTJg.js.map} +1 -1
  11. package/public/assets/{agents-BsFfumLn.js → agents-FUBxhCEU.js} +2 -2
  12. package/public/assets/{agents-BsFfumLn.js.map → agents-FUBxhCEU.js.map} +1 -1
  13. package/public/assets/{automation-BTbpiINE.js → automation-CMketzfB.js} +2 -2
  14. package/public/assets/{automation-BTbpiINE.js.map → automation-CMketzfB.js.map} +1 -1
  15. package/public/assets/{chat-BlFUmD3r.js → chat-BV_pnRhk.js} +2 -2
  16. package/public/assets/{chat-BlFUmD3r.js.map → chat-BV_pnRhk.js.map} +1 -1
  17. package/public/assets/{formatted-body-impl-Dbjjdcu9.js → formatted-body-impl-DzHQTBmn.js} +2 -2
  18. package/public/assets/{formatted-body-impl-Dbjjdcu9.js.map → formatted-body-impl-DzHQTBmn.js.map} +1 -1
  19. package/public/assets/index-CYLrIuJT.css +2 -0
  20. package/public/assets/{index-CPXz74W5.js → index-CdEAo41I.js} +7 -7
  21. package/public/assets/index-CdEAo41I.js.map +1 -0
  22. package/public/assets/{insights-D1Oq25BL.js → insights-C_pJNcFD.js} +2 -2
  23. package/public/assets/{insights-D1Oq25BL.js.map → insights-C_pJNcFD.js.map} +1 -1
  24. package/public/assets/{maintenance-CgXJwFTo.js → maintenance-Ckz5BAor.js} +2 -2
  25. package/public/assets/{maintenance-CgXJwFTo.js.map → maintenance-Ckz5BAor.js.map} +1 -1
  26. package/public/assets/{managed-agents-CbuFHwWE.js → managed-agents-BbZclnqP.js} +2 -2
  27. package/public/assets/{managed-agents-CbuFHwWE.js.map → managed-agents-BbZclnqP.js.map} +1 -1
  28. package/public/assets/{markdown-preview-impl-B79bSsKK.js → markdown-preview-impl-CtuHw6Mm.js} +2 -2
  29. package/public/assets/{markdown-preview-impl-B79bSsKK.js.map → markdown-preview-impl-CtuHw6Mm.js.map} +1 -1
  30. package/public/assets/{memory-Box_rMj_.js → memory-C5DK3rhW.js} +2 -2
  31. package/public/assets/{memory-Box_rMj_.js.map → memory-C5DK3rhW.js.map} +1 -1
  32. package/public/assets/{messages-BXIHBaN2.js → messages-CsjtiFEb.js} +2 -2
  33. package/public/assets/{messages-BXIHBaN2.js.map → messages-CsjtiFEb.js.map} +1 -1
  34. package/public/assets/{orchestrators-MNj_mp3U.js → orchestrators-D9fMiBRy.js} +2 -2
  35. package/public/assets/{orchestrators-MNj_mp3U.js.map → orchestrators-D9fMiBRy.js.map} +1 -1
  36. package/public/assets/{overview-KfwJgCUa.js → overview-BLWoCnNQ.js} +2 -2
  37. package/public/assets/{overview-KfwJgCUa.js.map → overview-BLWoCnNQ.js.map} +1 -1
  38. package/public/assets/{pairs-DfrGWlgL.js → pairs-HnvpLz4C.js} +2 -2
  39. package/public/assets/{pairs-DfrGWlgL.js.map → pairs-HnvpLz4C.js.map} +1 -1
  40. package/public/assets/{registry-CiBC97sL.js → registry-BqAS3ADV.js} +2 -2
  41. package/public/assets/{registry-CiBC97sL.js.map → registry-BqAS3ADV.js.map} +1 -1
  42. package/public/assets/{security-BbU_vIdb.js → security-CWgtcQ2X.js} +2 -2
  43. package/public/assets/{security-BbU_vIdb.js.map → security-CWgtcQ2X.js.map} +1 -1
  44. package/public/assets/{select-CeQwmVTm.js → select-UdIYiKFs.js} +2 -2
  45. package/public/assets/{select-CeQwmVTm.js.map → select-UdIYiKFs.js.map} +1 -1
  46. package/public/assets/settings-ruDxPuza.js +6 -0
  47. package/public/assets/settings-ruDxPuza.js.map +1 -0
  48. package/public/assets/{tasks-za0RxIQw.js → tasks-DomQFwZP.js} +2 -2
  49. package/public/assets/{tasks-za0RxIQw.js.map → tasks-DomQFwZP.js.map} +1 -1
  50. package/public/assets/{teams-ByAqwLG9.js → teams-CXNp0U3B.js} +2 -2
  51. package/public/assets/{teams-ByAqwLG9.js.map → teams-CXNp0U3B.js.map} +1 -1
  52. package/public/assets/{terminal-viewer-impl-CkoWD5D_.js → terminal-viewer-impl-DiWBtzvZ.js} +2 -2
  53. package/public/assets/{terminal-viewer-impl-CkoWD5D_.js.map → terminal-viewer-impl-DiWBtzvZ.js.map} +1 -1
  54. package/public/assets/{user-avatar-Q_N6iHEn.js → user-avatar-DBs9Koqp.js} +2 -2
  55. package/public/assets/{user-avatar-Q_N6iHEn.js.map → user-avatar-DBs9Koqp.js.map} +1 -1
  56. package/public/assets/{work-queue-DfRo1B2P.js → work-queue-BD29QuLd.js} +2 -2
  57. package/public/assets/{work-queue-DfRo1B2P.js.map → work-queue-BD29QuLd.js.map} +1 -1
  58. package/public/index.html +2 -2
  59. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  60. package/runner/src/adapter.ts +9 -1
  61. package/src/agent-busy-health.ts +62 -63
  62. package/src/codex-wham-quota.ts +5 -76
  63. package/src/compaction-watch.ts +7 -6
  64. package/src/db/provider-quota-burn.ts +6 -18
  65. package/src/db/provider-quotas.ts +5 -6
  66. package/src/lifecycle-manager.ts +4 -2
  67. package/src/maintenance/jobs.ts +1 -1
  68. package/src/notification-types.ts +3 -1
  69. package/src/routes/index.ts +3 -0
  70. package/src/routes/routing-policy.ts +25 -0
  71. package/src/routing-policy-push.ts +82 -0
  72. package/src/routing-policy-store.ts +148 -0
  73. package/src/routing-rule-resolver.ts +87 -0
  74. package/src/security.ts +6 -0
  75. package/src/spawn-targets.ts +44 -12
  76. package/public/assets/index-CPWbr4sq.css +0 -2
  77. package/public/assets/index-CPXz74W5.js.map +0 -1
  78. package/public/assets/settings-DBsL3jb7.js +0 -6
  79. package/public/assets/settings-DBsL3jb7.js.map +0 -1
@@ -8,6 +8,7 @@ import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomati
8
8
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
9
9
  import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaPublisherLeaseAcquireRoute, postProviderQuotaPublisherLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
10
10
  import { getProviderQuotaConfigRoute, getProviderQuotaConfigsRoute, putProviderQuotaConfigRoute } from "./provider-quota-config";
11
+ import { getRoutingPolicyRoute, putRoutingPolicyRoute } from "./routing-policy";
11
12
  import { deleteProvisioningCapabilityRoute, getProvisioningCapabilitiesRoute, getProvisioningCapabilityRoute, patchProvisioningCapabilityRoute, patchProvisioningVariantRoute, postProvisioningCapabilityRoute, postProvisioningVariantImportRoute, postProvisioningVariantMcpRoute, postProvisioningVariantUpdateRoute, postProvisioningVariantValidateRoute } from "./provisioning-registry";
12
13
  import { getTeamsConfigRoute, putTeamsConfigRoute } from "./teams-config";
13
14
  import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
@@ -281,6 +282,8 @@ const routes: Route[] = [
281
282
  route("GET", "/api/providers/quota-config", getProviderQuotaConfigsRoute),
282
283
  route("GET", "/api/providers/:provider/quota-config", getProviderQuotaConfigRoute),
283
284
  route("PUT", "/api/providers/:provider/quota-config", putProviderQuotaConfigRoute),
285
+ route("GET", "/api/routing-policy", getRoutingPolicyRoute),
286
+ route("PUT", "/api/routing-policy", putRoutingPolicyRoute),
284
287
  route("PUT", "/api/providers/:provider/models/:alias", putProviderModelRoute),
285
288
  route("PATCH", "/api/providers/:provider/models/:alias", patchProviderModelRoute),
286
289
  route("DELETE", "/api/providers/:provider/models/:alias", deleteProviderModelRoute),
@@ -0,0 +1,25 @@
1
+ // Routing-policy routes (#728). GET/PUT for the relay-owned routing-policy document.
2
+ // GET: agent:read (coordinators read the policy to apply rules).
3
+ // PUT: settings:write:routing (admin writes).
4
+ import { ValidationError } from "../db";
5
+ import { error, json, parseBody, type Handler } from "./_shared";
6
+ import { getRoutingPolicy, setRoutingPolicy } from "../routing-policy-store";
7
+ import { isRecord } from "agent-relay-sdk";
8
+ import { cleanString } from "../validation";
9
+
10
+ export const getRoutingPolicyRoute: Handler = () => {
11
+ return json(getRoutingPolicy());
12
+ };
13
+
14
+ export const putRoutingPolicyRoute: Handler = async (req) => {
15
+ const parsed = await parseBody<unknown>(req);
16
+ if (!parsed.ok) return error(parsed.error, parsed.status);
17
+ try {
18
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
19
+ const policy = setRoutingPolicy(parsed.body, { updatedBy });
20
+ return json(policy);
21
+ } catch (e) {
22
+ if (e instanceof ValidationError) return error(e.message, 400);
23
+ throw e;
24
+ }
25
+ };
@@ -0,0 +1,82 @@
1
+ // Live delta push for routing-policy saves (#732). When the policy is updated via
2
+ // PUT /api/routing-policy, all live spawn-capable agents (coordinators) receive an
3
+ // in-band routing.policy.changed notification so the change takes effect mid-session
4
+ // without respawn.
5
+ //
6
+ // Delivery mechanism: same emitRelayEvent + routeNotification path as agent.ready /
7
+ // agent.exited (#308/#239). emitRelayEvent writes to the durable bus outbox (so bus
8
+ // clients subscribed to routing.* receive it immediately); routeNotification sends a
9
+ // message.new push to each spawn-capable agent's inbox so connected coordinators
10
+ // that are subscribed only to message.* are also woken with the delta in-band.
11
+ //
12
+ // Spawn-capable = live token carries command:spawn (or command:* / admin:* / *).
13
+ // The same token-scope check used by quota-advisory.ts (isSpawnCapableAgent).
14
+ import { getDb } from "./db";
15
+ import { emitRelayEvent } from "./events";
16
+ import { routeNotification } from "./notification-router";
17
+ import { parseJson } from "./utils";
18
+ import { isRecord } from "agent-relay-sdk";
19
+ import type { RoutingPolicy } from "agent-relay-sdk";
20
+
21
+ const PUSH_TTL_MS = 15 * 60 * 1000;
22
+
23
+ /**
24
+ * Emit a durable `routing.policy.changed` event and push the delta to every
25
+ * live spawn-capable agent so coordinators ingest the new policy mid-session.
26
+ * Called by setRoutingPolicy on every successful save.
27
+ */
28
+ export function notifyRoutingPolicyChanged(policy: RoutingPolicy, opts: { updatedBy?: string } = {}): void {
29
+ emitRelayEvent({
30
+ type: "routing.policy.changed",
31
+ source: "server",
32
+ subject: "routing-policy:default",
33
+ data: { policy: policy as unknown as Record<string, unknown>, updatedBy: opts.updatedBy ?? null },
34
+ });
35
+
36
+ const recipients = getSpawnCapableAgentIds();
37
+ if (recipients.length === 0) return;
38
+
39
+ routeNotification({
40
+ type: "routing.policy.changed",
41
+ scope: {},
42
+ recipients,
43
+ ttlMs: PUSH_TTL_MS,
44
+ message: {
45
+ subject: "Routing policy updated",
46
+ body: `🗺 Routing policy updated${opts.updatedBy ? ` by ${opts.updatedBy}` : ""}. Ingest this delta now — spawn routing decisions take effect immediately without respawn. Re-read relay_spawn_targets for live provider/model options.`,
47
+ payload: { kind: "routing.policy.changed", policy: policy as unknown as Record<string, unknown>, updatedBy: opts.updatedBy ?? null },
48
+ replyExpected: false,
49
+ },
50
+ });
51
+ }
52
+
53
+ type AgentRow = { id: string; meta: string | null };
54
+
55
+ function getSpawnCapableAgentIds(): string[] {
56
+ const now = Date.now();
57
+ const candidates = getDb().query(`
58
+ SELECT id, meta FROM agents
59
+ WHERE ready = 1 AND status NOT IN ('offline', 'stale')
60
+ `).all() as AgentRow[];
61
+ return candidates.filter((agent) => isSpawnCapable(agent, now)).map((a) => a.id);
62
+ }
63
+
64
+ function isSpawnCapable(agent: AgentRow, now: number): boolean {
65
+ const meta = parseJson<Record<string, unknown>>(agent.meta ?? "{}", {});
66
+ const auth = isRecord(meta.auth) ? meta.auth : undefined;
67
+ const jti = typeof auth?.jti === "string" ? auth.jti : undefined;
68
+ if (!jti) return false;
69
+ const row = getDb().query(
70
+ "SELECT scope, revoked_at, expires_at FROM tokens WHERE jti = ?",
71
+ ).get(jti) as { scope: string; revoked_at?: number | null; expires_at?: number | null } | undefined;
72
+ if (!row || row.revoked_at) return false;
73
+ const nowSeconds = Math.floor(now / 1000);
74
+ if (row.expires_at !== null && row.expires_at !== undefined && row.expires_at <= nowSeconds) return false;
75
+ const scopes = parseJson<string[]>(row.scope, []);
76
+ return (
77
+ scopes.includes("command:spawn") ||
78
+ scopes.includes("command:*") ||
79
+ scopes.includes("admin:*") ||
80
+ scopes.includes("*")
81
+ );
82
+ }
@@ -0,0 +1,148 @@
1
+ // Routing-policy storage (#728). One relay-owned policy document per instance,
2
+ // backed by the existing `config` table (namespace "routing-policy", key "default")
3
+ // so history + config.changed SSE come for free at no schema cost.
4
+ //
5
+ // The policy covers routing rules (task-type/capability → provider/model/effort)
6
+ // and quota tuning (band thresholds + per-provider hardBackstopUtilization /
7
+ // burnPenaltyUtilizationFloor). #730 will wire quota fields into spawn-targets.ts;
8
+ // this module only stores and retrieves them.
9
+ import { ValidationError } from "./db";
10
+ import { getConfig, setConfig } from "./config-store";
11
+ import { notifyRoutingPolicyChanged } from "./routing-policy-push";
12
+ import { isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
13
+ import type { ProviderQuotaRoutingConfig, QuotaBandThresholds, RoutingPolicy, RoutingRule } from "agent-relay-sdk";
14
+
15
+ export const ROUTING_POLICY_NAMESPACE = "routing-policy";
16
+ export const ROUTING_POLICY_KEY = "default";
17
+
18
+ /** Default policy — preserves current spawn-targets.ts behaviour when unset. */
19
+ export const DEFAULT_ROUTING_POLICY: RoutingPolicy = {
20
+ schemaVersion: 1,
21
+ rules: [],
22
+ };
23
+
24
+ /** Retrieve the stored routing policy, falling back to the default when absent. */
25
+ export function getRoutingPolicy(): RoutingPolicy {
26
+ const entry = getConfig<RoutingPolicy>(ROUTING_POLICY_NAMESPACE, ROUTING_POLICY_KEY);
27
+ return entry ? normalizeRoutingPolicy(entry.value) : { ...DEFAULT_ROUTING_POLICY };
28
+ }
29
+
30
+ /**
31
+ * Persist the routing policy. The incoming value is normalized so partial bodies
32
+ * still produce a valid document. Emits `config.changed` via `setConfig` so the
33
+ * dashboard + any live listeners pick it up immediately.
34
+ */
35
+ export function setRoutingPolicy(value: unknown, opts: { updatedBy?: string } = {}): RoutingPolicy {
36
+ const normalized = normalizeRoutingPolicy(value);
37
+ setConfig(ROUTING_POLICY_NAMESPACE, ROUTING_POLICY_KEY, normalized, opts.updatedBy);
38
+ notifyRoutingPolicyChanged(normalized, opts);
39
+ return normalized;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Normalizer — coerces arbitrary input into a valid RoutingPolicy, defaulting
44
+ // missing fields so a partial PUT is safe and a missing policy is a no-op.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export function normalizeRoutingPolicy(value: unknown): RoutingPolicy {
48
+ const input = isRecord(value) ? value : {};
49
+ return {
50
+ schemaVersion: 1,
51
+ rules: normalizeRules(input.rules),
52
+ ...(input.quotaBands !== undefined ? { quotaBands: normalizeQuotaBands(input.quotaBands) } : {}),
53
+ ...(input.providerQuota !== undefined ? { providerQuota: normalizeProviderQuota(input.providerQuota) } : {}),
54
+ };
55
+ }
56
+
57
+ function normalizeRules(raw: unknown): RoutingRule[] {
58
+ if (!Array.isArray(raw)) return [];
59
+ const rules: RoutingRule[] = [];
60
+ for (let i = 0; i < raw.length; i++) {
61
+ rules.push(normalizeRule(raw[i], i));
62
+ }
63
+ return rules;
64
+ }
65
+
66
+ function normalizeRule(raw: unknown, idx: number): RoutingRule {
67
+ if (!isRecord(raw)) throw new ValidationError(`rules[${idx}] must be an object`);
68
+ const match = isRecord(raw.match) ? raw.match : {};
69
+ if (!isRecord(raw.match)) throw new ValidationError(`rules[${idx}].match must be an object`);
70
+ if (match.taskType === undefined && match.capability === undefined) {
71
+ throw new ValidationError(`rules[${idx}].match must set at least one of: taskType, capability`);
72
+ }
73
+ const target = isRecord(raw.target) ? raw.target : {};
74
+ if (!isRecord(raw.target)) throw new ValidationError(`rules[${idx}].target must be an object`);
75
+ if (typeof target.provider !== "string" || !(SPAWN_PROVIDERS as readonly string[]).includes(target.provider)) {
76
+ throw new ValidationError(`rules[${idx}].target.provider must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
77
+ }
78
+ const rule: RoutingRule = {
79
+ match: {
80
+ ...(typeof match.taskType === "string" && match.taskType ? { taskType: match.taskType } : {}),
81
+ ...(typeof match.capability === "string" && match.capability ? { capability: match.capability } : {}),
82
+ },
83
+ target: {
84
+ provider: target.provider as RoutingRule["target"]["provider"],
85
+ ...(typeof target.model === "string" && target.model ? { model: target.model } : {}),
86
+ ...(typeof target.effort === "string" && target.effort ? { effort: target.effort } : {}),
87
+ },
88
+ };
89
+ if (typeof raw.label === "string" && raw.label) rule.label = raw.label;
90
+ if (typeof raw.enabled === "boolean") rule.enabled = raw.enabled;
91
+ return rule;
92
+ }
93
+
94
+ function normalizeQuotaBands(raw: unknown): Partial<QuotaBandThresholds> {
95
+ if (!isRecord(raw)) throw new ValidationError("quotaBands must be an object");
96
+ const result: Partial<QuotaBandThresholds> = {};
97
+ if (raw.cautionAt !== undefined) result.cautionAt = cleanQuotaFraction(raw.cautionAt, "quotaBands.cautionAt");
98
+ if (raw.avoidLargeAt !== undefined) result.avoidLargeAt = cleanQuotaFraction(raw.avoidLargeAt, "quotaBands.avoidLargeAt");
99
+ if (raw.hardAvoidAt !== undefined) result.hardAvoidAt = cleanQuotaFraction(raw.hardAvoidAt, "quotaBands.hardAvoidAt");
100
+ const orderedPairs: Array<[string, number]> = [];
101
+ if (result.cautionAt !== undefined) orderedPairs.push(["cautionAt", result.cautionAt]);
102
+ if (result.avoidLargeAt !== undefined) orderedPairs.push(["avoidLargeAt", result.avoidLargeAt]);
103
+ if (result.hardAvoidAt !== undefined) orderedPairs.push(["hardAvoidAt", result.hardAvoidAt]);
104
+ for (let i = 0; i < orderedPairs.length - 1; i++) {
105
+ const cur = orderedPairs[i]!;
106
+ const next = orderedPairs[i + 1]!;
107
+ if (cur[1] >= next[1]) {
108
+ throw new ValidationError(`quotaBands.${cur[0]} must be less than quotaBands.${next[0]}`);
109
+ }
110
+ }
111
+ return result;
112
+ }
113
+
114
+ function normalizeProviderQuota(raw: unknown): Partial<Record<string, ProviderQuotaRoutingConfig>> {
115
+ if (!isRecord(raw)) throw new ValidationError("providerQuota must be an object");
116
+ const result: Partial<Record<string, ProviderQuotaRoutingConfig>> = {};
117
+ for (const provider of SPAWN_PROVIDERS) {
118
+ if (!(provider in raw)) continue;
119
+ result[provider] = normalizeProviderQuotaConfig(raw[provider], provider);
120
+ }
121
+ return result;
122
+ }
123
+
124
+ function normalizeProviderQuotaConfig(raw: unknown, provider: string): ProviderQuotaRoutingConfig {
125
+ if (!isRecord(raw)) throw new ValidationError(`providerQuota.${provider} must be an object`);
126
+ const cfg: ProviderQuotaRoutingConfig = {};
127
+ if (raw.hardBackstopUtilization !== undefined) {
128
+ cfg.hardBackstopUtilization = cleanFraction(raw.hardBackstopUtilization, `providerQuota.${provider}.hardBackstopUtilization`);
129
+ }
130
+ if (raw.burnPenaltyUtilizationFloor !== undefined) {
131
+ cfg.burnPenaltyUtilizationFloor = cleanFraction(raw.burnPenaltyUtilizationFloor, `providerQuota.${provider}.burnPenaltyUtilizationFloor`);
132
+ }
133
+ return cfg;
134
+ }
135
+
136
+ function cleanQuotaFraction(value: unknown, field: string): number {
137
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
138
+ throw new ValidationError(`${field} must be a non-negative finite number`);
139
+ }
140
+ return value;
141
+ }
142
+
143
+ function cleanFraction(value: unknown, field: string): number {
144
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
145
+ throw new ValidationError(`${field} must be a number between 0 and 1`);
146
+ }
147
+ return value;
148
+ }
@@ -0,0 +1,87 @@
1
+ // Declarative routing-rules engine (#729).
2
+ //
3
+ // Resolves the first matching enabled rule from RoutingPolicy.rules, given a task-type
4
+ // and/or capability. Rules are ADVISORY — they surface a recommended target to the
5
+ // coordinator; they never override an explicit per-spawn argument or block a spawn.
6
+ //
7
+ // Precedence (highest → lowest):
8
+ // 1. Explicit per-spawn args (provider/model/effort in relay_spawn_agent / SpawnAgentInput).
9
+ // 2. This resolver — the recommendation when no explicit arg is given.
10
+ // 3. Profile default — fallback when no rule matches.
11
+ //
12
+ // The quota band / gate is a parallel SAFETY layer that composes on top of the resolved
13
+ // target (it may advise a cheaper effort or healthier provider; a hard block still blocks).
14
+ // It is NOT part of rule resolution and is NOT consulted here.
15
+ import type { RoutingRule } from "agent-relay-sdk";
16
+
17
+ /** Input criteria for rule matching. At least one field should be set. */
18
+ export interface RoutingMatchInput {
19
+ /** Task-type to match against rule.match.taskType (prefix semantics). */
20
+ taskType?: string;
21
+ /** Capability tag to match against rule.match.capability (exact match). */
22
+ capability?: string;
23
+ }
24
+
25
+ /**
26
+ * The resolved target recommendation from a matching rule.
27
+ * Mirrors RoutingRule["target"] so callers never import the full rule.
28
+ */
29
+ export interface RoutingResolution {
30
+ /** Recommended provider. */
31
+ provider: RoutingRule["target"]["provider"];
32
+ /** Recommended model alias, if the rule specifies one. */
33
+ model?: string;
34
+ /** Recommended effort level, if the rule specifies one. */
35
+ effort?: string;
36
+ /** Human-readable label from the matched rule, for logging/display. */
37
+ matchedRuleLabel?: string;
38
+ }
39
+
40
+ /**
41
+ * Evaluate the rules array in order and return the first matching enabled rule's target.
42
+ *
43
+ * Match semantics:
44
+ * - `taskType`: the rule's taskType is a prefix of the input (e.g. rule "gui" matches
45
+ * input "gui" and "gui/ux", but not "gui2" or "xgui"). Case-sensitive.
46
+ * - `capability`: exact match. Case-sensitive.
47
+ * - When a rule sets both taskType AND capability, both must match (AND semantics).
48
+ * - A rule with `enabled: false` is skipped.
49
+ *
50
+ * Returns null when no rule matches or the rules array is empty.
51
+ */
52
+ export function resolveRoutingRule(
53
+ rules: RoutingRule[],
54
+ input: RoutingMatchInput,
55
+ ): RoutingResolution | null {
56
+ for (const rule of rules) {
57
+ if (rule.enabled === false) continue;
58
+ if (!matchesCriteria(rule.match, input)) continue;
59
+ return {
60
+ provider: rule.target.provider,
61
+ ...(rule.target.model ? { model: rule.target.model } : {}),
62
+ ...(rule.target.effort ? { effort: rule.target.effort } : {}),
63
+ ...(rule.label ? { matchedRuleLabel: rule.label } : {}),
64
+ };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function matchesCriteria(criteria: RoutingRule["match"], input: RoutingMatchInput): boolean {
70
+ if (!criteria.taskType && !criteria.capability) return false;
71
+ if (criteria.taskType !== undefined) {
72
+ if (!input.taskType) return false;
73
+ if (!isPrefixMatch(input.taskType, criteria.taskType)) return false;
74
+ }
75
+ if (criteria.capability !== undefined) {
76
+ if (input.capability !== criteria.capability) return false;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * True if `value` equals `prefix` or starts with `prefix + "/"`.
83
+ * Guards against substring-only matches (e.g. "gui" must not match "gui2").
84
+ */
85
+ function isPrefixMatch(value: string, prefix: string): boolean {
86
+ return value === prefix || value.startsWith(prefix + "/");
87
+ }
package/src/security.ts CHANGED
@@ -199,6 +199,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
199
199
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
200
200
  if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
201
201
  if (pathname === "/api/provider-quotas") return method === "GET" ? "agent:read" : "quota:write";
202
+ if (pathname === "/api/routing-policy") return method === "GET" ? "agent:read" : "settings:write:routing";
202
203
  if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
203
204
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
204
205
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
@@ -288,6 +289,11 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
288
289
  if (pathname === "/api/providers/quota-config" || pathname.match(/^\/api\/providers\/[^/]+\/quota-config$/)) {
289
290
  return method === "GET" ? "agent:read" : "settings:write:provider";
290
291
  }
292
+ // Routing policy (#728): reads are agent:read (coordinators read to apply rules);
293
+ // writes are settings:write:routing (admin-scoped).
294
+ if (pathname === "/api/routing-policy") {
295
+ return method === "GET" ? "agent:read" : "settings:write:routing";
296
+ }
291
297
  if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
292
298
  if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
293
299
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
@@ -1,8 +1,10 @@
1
1
  import { countLiveSpawnedAgents, getAgent, getOrchestrator, listOrchestrators, listProviderQuotas, ValidationError } from "./db";
2
2
  import { DEFAULT_SPAWN_PROFILE_NAME, listAgentProfiles } from "./config-store";
3
3
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
4
+ import { getRoutingPolicy } from "./routing-policy-store";
4
5
  import { isPathWithinBase } from "./utils";
5
6
  import { McpNotFoundError } from "./mcp-errors";
7
+ import type { ProviderQuotaRoutingConfig } from "agent-relay-sdk";
6
8
  import type { Orchestrator, ProviderCatalogSummary, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaRecord, QuotaState, SpawnProvider } from "./types";
7
9
 
8
10
  // #308 — spawn discovery + target selection. Home for the live capability matrix that
@@ -22,6 +24,8 @@ export function buildSpawnTargets(input: { callerId?: string; quota: number }):
22
24
  const self = preferHost ? orchestrators.find((o) => o.hostname === preferHost || o.id === preferHost) : undefined;
23
25
  const liveChildren = input.callerId ? countLiveSpawnedAgents(input.callerId) : 0;
24
26
  const quotaAdvisories = providerQuotaAdvisories();
27
+ const policy = getRoutingPolicy();
28
+ const enabledRules = policy.rules.filter((r) => r.enabled !== false);
25
29
 
26
30
  return {
27
31
  self: {
@@ -39,6 +43,14 @@ export function buildSpawnTargets(input: { callerId?: string; quota: number }):
39
43
  providers: providerMatrixFor(o, quotaAdvisories),
40
44
  })),
41
45
  profiles: spawnProfilesSummary(),
46
+ // #729 — declarative routing rules advisory. Rules are evaluated in array order;
47
+ // first matching enabled rule wins. Precedence: explicit per-spawn args > matching rule >
48
+ // profile default. Quota gate (blockedNow / quotaPenaltyBand above) is a parallel safety
49
+ // layer applied AFTER target resolution — it advises on the resolved target but does not
50
+ // pick it. Resolve programmatically with resolveRoutingRule() from routing-rule-resolver.ts.
51
+ routing: {
52
+ rules: enabledRules,
53
+ },
42
54
  guidance:
43
55
  `Default to your own host (isSelf:true); only set orchestratorId to spawn onto another host that runs a provider/model yours doesn't. Omitting profile applies ${DEFAULT_SPAWN_PROFILE_NAME}; omitting model/effort uses the provider default (marked below). Prefer a named profile over assembling raw knobs; set spawnRequestId for idempotency. After spawning you'll be pushed agent.ready / agent.exited / agent.spawn_failed — don't block-poll.`,
44
56
  };
@@ -132,19 +144,28 @@ function providerMatrixFor(o: Orchestrator, quotaAdvisories: Map<SpawnProvider,
132
144
  }
133
145
 
134
146
  function providerQuotaAdvisories(now = Date.now()): Map<SpawnProvider, SpawnQuotaAdvisory> {
147
+ const policy = getRoutingPolicy();
135
148
  const byProvider = new Map<SpawnProvider, ProviderQuotaRecord[]>();
136
149
  for (const record of listProviderQuotas({ historyLimit: 0, now }).quotas) {
137
150
  if (record.provider !== "claude" && record.provider !== "codex") continue;
138
151
  const provider = record.provider;
139
152
  byProvider.set(provider, [...(byProvider.get(provider) ?? []), record]);
140
153
  }
141
- return new Map([...byProvider.entries()].map(([provider, records]) => [provider, quotaAdvisoryFor(records, now)]));
154
+ return new Map([...byProvider.entries()].map(([provider, records]) => [
155
+ provider,
156
+ quotaAdvisoryFor(records, now, policy.quotaBands, policy.providerQuota?.[provider]),
157
+ ]));
142
158
  }
143
159
 
144
- function quotaAdvisoryFor(records: ProviderQuotaRecord[], now: number): SpawnQuotaAdvisory {
160
+ function quotaAdvisoryFor(
161
+ records: ProviderQuotaRecord[],
162
+ now: number,
163
+ quotaBands?: Partial<{ cautionAt: number; avoidLargeAt: number; hardAvoidAt: number }>,
164
+ providerCfg?: ProviderQuotaRoutingConfig,
165
+ ): SpawnQuotaAdvisory {
145
166
  const selected = new Map<ProviderQuotaBurnRole, SpawnQuotaBurnWindow>();
146
167
  for (const record of records) {
147
- for (const window of quotaWindowsFor(record, now)) {
168
+ for (const window of quotaWindowsFor(record, now, providerCfg)) {
148
169
  const existing = selected.get(window.role);
149
170
  if (!existing || compareQuotaWindows(window, existing) > 0) selected.set(window.role, window);
150
171
  }
@@ -152,7 +173,7 @@ function quotaAdvisoryFor(records: ProviderQuotaRecord[], now: number): SpawnQuo
152
173
  const windows = [...selected.values()].sort((a, b) => roleSort(a.role) - roleSort(b.role));
153
174
  const quotaPenalty = Math.max(0, ...windows.map((window) => window.penalty));
154
175
  const blockedNow = windows.some((window) => window.blockedNow);
155
- const quotaPenaltyBand = quotaPenaltyBandFor(quotaPenalty);
176
+ const quotaPenaltyBand = quotaPenaltyBandFor(quotaPenalty, quotaBands);
156
177
  return {
157
178
  quotaBurn: { windows },
158
179
  quotaPenalty,
@@ -162,10 +183,10 @@ function quotaAdvisoryFor(records: ProviderQuotaRecord[], now: number): SpawnQuo
162
183
  };
163
184
  }
164
185
 
165
- function quotaWindowsFor(record: ProviderQuotaRecord, now: number): SpawnQuotaBurnWindow[] {
186
+ function quotaWindowsFor(record: ProviderQuotaRecord, now: number, providerCfg?: ProviderQuotaRoutingConfig): SpawnQuotaBurnWindow[] {
166
187
  const quotaByName = new Map((record.quota?.windows ?? []).map((window) => [window.name, window]));
167
188
  return (record.burn?.windows ?? [])
168
- .map((burnWindow) => spawnQuotaWindow(record, burnWindow, quotaByName.get(burnWindow.name), now))
189
+ .map((burnWindow) => spawnQuotaWindow(record, burnWindow, quotaByName.get(burnWindow.name), now, providerCfg))
169
190
  .filter((window): window is SpawnQuotaBurnWindow => Boolean(window));
170
191
  }
171
192
 
@@ -174,6 +195,7 @@ function spawnQuotaWindow(
174
195
  burnWindow: ProviderQuotaBurnWindow,
175
196
  quotaWindow: QuotaState["windows"][number] | undefined,
176
197
  now: number,
198
+ providerCfg?: ProviderQuotaRoutingConfig,
177
199
  ): SpawnQuotaBurnWindow | undefined {
178
200
  if (!quotaWindow) return undefined;
179
201
  const timeToResetMs = typeof quotaWindow.resetsAt === "number" ? Math.max(0, quotaWindow.resetsAt - now) : null;
@@ -181,6 +203,10 @@ function spawnQuotaWindow(
181
203
  ? Math.max(0, burnWindow.earlyWallMs) / timeToResetMs
182
204
  : null;
183
205
  const weight = burnWindow.role === "7d" ? 3 : 1;
206
+ const hardBackstop = providerCfg?.hardBackstopUtilization ?? 0.99;
207
+ const utilizationFloor = providerCfg?.burnPenaltyUtilizationFloor ?? 0.5;
208
+ const rawPenalty = (earlyFraction ?? 0) * weight;
209
+ const penalty = quotaWindow.utilization < utilizationFloor ? 0 : rawPenalty;
184
210
  return {
185
211
  role: burnWindow.role,
186
212
  name: burnWindow.name,
@@ -195,8 +221,8 @@ function spawnQuotaWindow(
195
221
  earlyWallMs: burnWindow.earlyWallMs,
196
222
  earlyFraction,
197
223
  weight,
198
- penalty: (earlyFraction ?? 0) * weight,
199
- blockedNow: quotaWindow.utilization >= 0.99,
224
+ penalty,
225
+ blockedNow: quotaWindow.utilization >= hardBackstop,
200
226
  };
201
227
  }
202
228
 
@@ -206,10 +232,16 @@ function compareQuotaWindows(a: SpawnQuotaBurnWindow, b: SpawnQuotaBurnWindow):
206
232
  return a.utilization - b.utilization;
207
233
  }
208
234
 
209
- function quotaPenaltyBandFor(quotaPenalty: number): SpawnQuotaBurnBand {
210
- if (quotaPenalty >= 1) return "hard-avoid";
211
- if (quotaPenalty >= 0.5) return "avoid-large";
212
- if (quotaPenalty >= 0.15) return "caution";
235
+ function quotaPenaltyBandFor(
236
+ quotaPenalty: number,
237
+ quotaBands?: Partial<{ cautionAt: number; avoidLargeAt: number; hardAvoidAt: number }>,
238
+ ): SpawnQuotaBurnBand {
239
+ const hardAvoidAt = quotaBands?.hardAvoidAt ?? 1.0;
240
+ const avoidLargeAt = quotaBands?.avoidLargeAt ?? 0.5;
241
+ const cautionAt = quotaBands?.cautionAt ?? 0.15;
242
+ if (quotaPenalty >= hardAvoidAt) return "hard-avoid";
243
+ if (quotaPenalty >= avoidLargeAt) return "avoid-large";
244
+ if (quotaPenalty >= cautionAt) return "caution";
213
245
  return "normal";
214
246
  }
215
247