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.
- package/docs/openapi.json +55 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-CIDyX8f7.js → MessageBubble-C4AeEHdz.js} +2 -2
- package/public/assets/{MessageBubble-CIDyX8f7.js.map → MessageBubble-C4AeEHdz.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BmWB81w_.js → PlanGraphPanel-cwPUGFXA.js} +2 -2
- package/public/assets/{PlanGraphPanel-BmWB81w_.js.map → PlanGraphPanel-cwPUGFXA.js.map} +1 -1
- package/public/assets/{activity-QQikHZe4.js → activity-BWGwnSg5.js} +2 -2
- package/public/assets/{activity-QQikHZe4.js.map → activity-BWGwnSg5.js.map} +1 -1
- package/public/assets/{agent-profiles-WvAN5cR_.js → agent-profiles-C6N9cTJg.js} +2 -2
- package/public/assets/{agent-profiles-WvAN5cR_.js.map → agent-profiles-C6N9cTJg.js.map} +1 -1
- package/public/assets/{agents-BsFfumLn.js → agents-FUBxhCEU.js} +2 -2
- package/public/assets/{agents-BsFfumLn.js.map → agents-FUBxhCEU.js.map} +1 -1
- package/public/assets/{automation-BTbpiINE.js → automation-CMketzfB.js} +2 -2
- package/public/assets/{automation-BTbpiINE.js.map → automation-CMketzfB.js.map} +1 -1
- package/public/assets/{chat-BlFUmD3r.js → chat-BV_pnRhk.js} +2 -2
- package/public/assets/{chat-BlFUmD3r.js.map → chat-BV_pnRhk.js.map} +1 -1
- package/public/assets/{formatted-body-impl-Dbjjdcu9.js → formatted-body-impl-DzHQTBmn.js} +2 -2
- package/public/assets/{formatted-body-impl-Dbjjdcu9.js.map → formatted-body-impl-DzHQTBmn.js.map} +1 -1
- package/public/assets/index-CYLrIuJT.css +2 -0
- package/public/assets/{index-CPXz74W5.js → index-CdEAo41I.js} +7 -7
- package/public/assets/index-CdEAo41I.js.map +1 -0
- package/public/assets/{insights-D1Oq25BL.js → insights-C_pJNcFD.js} +2 -2
- package/public/assets/{insights-D1Oq25BL.js.map → insights-C_pJNcFD.js.map} +1 -1
- package/public/assets/{maintenance-CgXJwFTo.js → maintenance-Ckz5BAor.js} +2 -2
- package/public/assets/{maintenance-CgXJwFTo.js.map → maintenance-Ckz5BAor.js.map} +1 -1
- package/public/assets/{managed-agents-CbuFHwWE.js → managed-agents-BbZclnqP.js} +2 -2
- package/public/assets/{managed-agents-CbuFHwWE.js.map → managed-agents-BbZclnqP.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-B79bSsKK.js → markdown-preview-impl-CtuHw6Mm.js} +2 -2
- package/public/assets/{markdown-preview-impl-B79bSsKK.js.map → markdown-preview-impl-CtuHw6Mm.js.map} +1 -1
- package/public/assets/{memory-Box_rMj_.js → memory-C5DK3rhW.js} +2 -2
- package/public/assets/{memory-Box_rMj_.js.map → memory-C5DK3rhW.js.map} +1 -1
- package/public/assets/{messages-BXIHBaN2.js → messages-CsjtiFEb.js} +2 -2
- package/public/assets/{messages-BXIHBaN2.js.map → messages-CsjtiFEb.js.map} +1 -1
- package/public/assets/{orchestrators-MNj_mp3U.js → orchestrators-D9fMiBRy.js} +2 -2
- package/public/assets/{orchestrators-MNj_mp3U.js.map → orchestrators-D9fMiBRy.js.map} +1 -1
- package/public/assets/{overview-KfwJgCUa.js → overview-BLWoCnNQ.js} +2 -2
- package/public/assets/{overview-KfwJgCUa.js.map → overview-BLWoCnNQ.js.map} +1 -1
- package/public/assets/{pairs-DfrGWlgL.js → pairs-HnvpLz4C.js} +2 -2
- package/public/assets/{pairs-DfrGWlgL.js.map → pairs-HnvpLz4C.js.map} +1 -1
- package/public/assets/{registry-CiBC97sL.js → registry-BqAS3ADV.js} +2 -2
- package/public/assets/{registry-CiBC97sL.js.map → registry-BqAS3ADV.js.map} +1 -1
- package/public/assets/{security-BbU_vIdb.js → security-CWgtcQ2X.js} +2 -2
- package/public/assets/{security-BbU_vIdb.js.map → security-CWgtcQ2X.js.map} +1 -1
- package/public/assets/{select-CeQwmVTm.js → select-UdIYiKFs.js} +2 -2
- package/public/assets/{select-CeQwmVTm.js.map → select-UdIYiKFs.js.map} +1 -1
- package/public/assets/settings-ruDxPuza.js +6 -0
- package/public/assets/settings-ruDxPuza.js.map +1 -0
- package/public/assets/{tasks-za0RxIQw.js → tasks-DomQFwZP.js} +2 -2
- package/public/assets/{tasks-za0RxIQw.js.map → tasks-DomQFwZP.js.map} +1 -1
- package/public/assets/{teams-ByAqwLG9.js → teams-CXNp0U3B.js} +2 -2
- package/public/assets/{teams-ByAqwLG9.js.map → teams-CXNp0U3B.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CkoWD5D_.js → terminal-viewer-impl-DiWBtzvZ.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CkoWD5D_.js.map → terminal-viewer-impl-DiWBtzvZ.js.map} +1 -1
- package/public/assets/{user-avatar-Q_N6iHEn.js → user-avatar-DBs9Koqp.js} +2 -2
- package/public/assets/{user-avatar-Q_N6iHEn.js.map → user-avatar-DBs9Koqp.js.map} +1 -1
- package/public/assets/{work-queue-DfRo1B2P.js → work-queue-BD29QuLd.js} +2 -2
- package/public/assets/{work-queue-DfRo1B2P.js.map → work-queue-BD29QuLd.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/src/adapter.ts +9 -1
- package/src/agent-busy-health.ts +62 -63
- package/src/codex-wham-quota.ts +5 -76
- package/src/compaction-watch.ts +7 -6
- package/src/db/provider-quota-burn.ts +6 -18
- package/src/db/provider-quotas.ts +5 -6
- package/src/lifecycle-manager.ts +4 -2
- package/src/maintenance/jobs.ts +1 -1
- package/src/notification-types.ts +3 -1
- package/src/routes/index.ts +3 -0
- package/src/routes/routing-policy.ts +25 -0
- package/src/routing-policy-push.ts +82 -0
- package/src/routing-policy-store.ts +148 -0
- package/src/routing-rule-resolver.ts +87 -0
- package/src/security.ts +6 -0
- package/src/spawn-targets.ts +44 -12
- package/public/assets/index-CPWbr4sq.css +0 -2
- package/public/assets/index-CPXz74W5.js.map +0 -1
- package/public/assets/settings-DBsL3jb7.js +0 -6
- package/public/assets/settings-DBsL3jb7.js.map +0 -1
package/src/routes/index.ts
CHANGED
|
@@ -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";
|
package/src/spawn-targets.ts
CHANGED
|
@@ -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]) => [
|
|
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(
|
|
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
|
|
199
|
-
blockedNow: quotaWindow.utilization >=
|
|
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(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|