agent-relay-server 0.97.0 → 0.98.1
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 +27 -12
- package/src/lifecycle-manager.ts +4 -2
- package/src/maintenance/jobs.ts +1 -1
- package/src/notification-types.ts +3 -1
- package/src/quota.ts +1 -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
|
@@ -10,6 +10,7 @@ const PROVIDER_QUOTA_STALE_AFTER_MS = 10 * 60_000;
|
|
|
10
10
|
const QUOTA_UTILIZATION_EPSILON = 1e-9;
|
|
11
11
|
const QUOTA_RESET_BOUNDARY_ADVANCE_MS = 5_000;
|
|
12
12
|
const QUOTA_RESET_UTILIZATION_MAX = 0.01;
|
|
13
|
+
const FIVE_HOUR_MS = 5 * 60 * 60_000;
|
|
13
14
|
|
|
14
15
|
export interface ProviderQuotaAdvisoryInput {
|
|
15
16
|
provider: string;
|
|
@@ -258,11 +259,20 @@ function quotaWindowUtilizationRegressed(existing: QuotaState["windows"][number]
|
|
|
258
259
|
&& !quotaWindowLooksReset(existing, incoming);
|
|
259
260
|
}
|
|
260
261
|
|
|
261
|
-
function publicQuotaState(quota: StoredQuotaState): QuotaState {
|
|
262
|
+
function publicQuotaState(quota: StoredQuotaState, now?: number): QuotaState {
|
|
263
|
+
let emulated = false;
|
|
264
|
+
const windows = quota.windows.map((window) => {
|
|
265
|
+
const period = quotaWindowPeriodMs(window.name);
|
|
266
|
+
if (now === undefined || period === undefined || typeof window.resetsAt !== "number" || !Number.isFinite(window.resetsAt) || now < window.resetsAt) return { ...window };
|
|
267
|
+
let resetsAt = window.resetsAt;
|
|
268
|
+
while (now >= resetsAt) resetsAt += period;
|
|
269
|
+
emulated = true;
|
|
270
|
+
return { ...window, utilization: 0, resetsAt };
|
|
271
|
+
});
|
|
262
272
|
return {
|
|
263
|
-
windows
|
|
264
|
-
source: quota.source,
|
|
265
|
-
updatedAt: quota.updatedAt,
|
|
273
|
+
windows,
|
|
274
|
+
source: emulated ? "emulated" : quota.source,
|
|
275
|
+
updatedAt: emulated && now !== undefined ? Math.max(quota.updatedAt, now) : quota.updatedAt,
|
|
266
276
|
};
|
|
267
277
|
}
|
|
268
278
|
|
|
@@ -287,7 +297,7 @@ function storedQuotaWindowSampleTimes(quota: StoredQuotaState): Map<string, numb
|
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
// Source priority for per-window precedence: "ingest" (WHAM/API) beats "probe" (agent self-report).
|
|
290
|
-
const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0 };
|
|
300
|
+
const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0, emulated: -1 };
|
|
291
301
|
function quotaSourcePriority(source: string): number {
|
|
292
302
|
return QUOTA_SOURCE_PRIORITY[source] ?? 1;
|
|
293
303
|
}
|
|
@@ -345,6 +355,12 @@ function quotaWindowSampleTimes(provider: string, accountKey: string, fallback?:
|
|
|
345
355
|
return times;
|
|
346
356
|
}
|
|
347
357
|
|
|
358
|
+
function quotaWindowPeriodMs(name: string): number | undefined {
|
|
359
|
+
if (name === "five_hour" || name === "primary") return FIVE_HOUR_MS;
|
|
360
|
+
if (name === "seven_day" || name === "seven_day_sonnet" || name === "seven_day_opus" || name === "secondary") return 7 * DAY_MS;
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
348
364
|
function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: QuotaState, provider: string, accountKey: string): {
|
|
349
365
|
quota: StoredQuotaState;
|
|
350
366
|
acceptedIncomingWindow: boolean;
|
|
@@ -370,12 +386,11 @@ function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: Quo
|
|
|
370
386
|
if (!incomingWindow) {
|
|
371
387
|
mergedTimes.set(key, existingUpdatedAt); mergedSources.set(key, existingSource); return window;
|
|
372
388
|
}
|
|
373
|
-
// Per-window source precedence
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
// must NOT block authoritative data correcting a stale probe that pinned the window high.
|
|
389
|
+
// Per-window source precedence: #712/#716 are handled by the symmetric three-way split below.
|
|
390
|
+
// Higher existing → keep (no regression check needed; lower-priority input simply loses).
|
|
391
|
+
// Higher incoming → accept (no regression check needed; higher-priority source is authoritative).
|
|
392
|
+
// Same priority → apply the regression guard (rejects improbable utilization drops from noisy
|
|
393
|
+
// same-quality sources, e.g. a wedged probe self-report; #725 §4.3).
|
|
379
394
|
const existingPriority = quotaSourcePriority(existingSource);
|
|
380
395
|
const incomingPriority = quotaSourcePriority(incoming.source);
|
|
381
396
|
if (existingPriority > incomingPriority) {
|
|
@@ -435,7 +450,7 @@ function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
|
|
|
435
450
|
function rowToRecord(row: ProviderQuotaRow, history: ProviderQuotaSnapshot[], now: number): ProviderQuotaRecord {
|
|
436
451
|
const lastError = parseJson<ProviderQuotaError | undefined>(row.last_error, undefined);
|
|
437
452
|
const lastUpdatedAt = row.last_updated_at ?? undefined;
|
|
438
|
-
const quota = row.quota_state ? publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at })) : undefined;
|
|
453
|
+
const quota = row.quota_state ? publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at }), now) : undefined;
|
|
439
454
|
const burn = quota ? deriveProviderQuotaBurn({
|
|
440
455
|
provider: row.provider,
|
|
441
456
|
quota,
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -622,8 +622,10 @@ export function __resetLifecycleManager(): void {
|
|
|
622
622
|
singleton = null;
|
|
623
623
|
}
|
|
624
624
|
|
|
625
|
-
function managedModelUnavailableMessage(
|
|
626
|
-
|
|
625
|
+
function managedModelUnavailableMessage(_policy: SpawnPolicy, error: string, exited?: ManagedSessionExitDiagnostics): string | null {
|
|
626
|
+
// extractClaudeModelUnavailableMessage returns null for text that doesn't match
|
|
627
|
+
// the Claude-specific error pattern, so this is safe to call unconditionally —
|
|
628
|
+
// no provider=== branch needed here; the parser is the gate (#743).
|
|
627
629
|
return extractClaudeModelUnavailableMessage([
|
|
628
630
|
error,
|
|
629
631
|
exited?.lastError,
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -83,7 +83,7 @@ function isManagedBusyWatchCandidate(agent: ReturnType<typeof listAgents>[number
|
|
|
83
83
|
|
|
84
84
|
function stuckBusyBody(agentId: string, health: NonNullable<ReturnType<typeof deriveAgentBusyHealth>>): string {
|
|
85
85
|
const staleMinutes = Math.max(1, Math.floor(health.staleForMs / 60_000));
|
|
86
|
-
const work = health.activeWorkLabel || health.
|
|
86
|
+
const work = health.activeWorkLabel || health.activeWorkId || health.kind;
|
|
87
87
|
return `Agent \`${agentId}\` appears stuck busy in ${work} for ${staleMinutes} min with no provider progress. Use interrupt first if available; restart or shutdown if the turn is wedged.`;
|
|
88
88
|
}
|
|
89
89
|
|
|
@@ -25,7 +25,8 @@ export type NotificationType =
|
|
|
25
25
|
| "workspace.stranded_escalation"
|
|
26
26
|
| "workspace.auto_abandoned"
|
|
27
27
|
| "workspace.review_request"
|
|
28
|
-
| "workspace.review_changes_requested"
|
|
28
|
+
| "workspace.review_changes_requested"
|
|
29
|
+
| "routing.policy.changed";
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* The entities a notification is ABOUT: routing inputs (#442) and explainability subjects (#446).
|
|
@@ -94,6 +95,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
94
95
|
"workspace.auto_abandoned": null,
|
|
95
96
|
"workspace.review_request": null,
|
|
96
97
|
"workspace.review_changes_requested": null,
|
|
98
|
+
"routing.policy.changed": 15 * MINUTES,
|
|
97
99
|
};
|
|
98
100
|
|
|
99
101
|
export const NOTIFICATION_TYPES = Object.keys(DEFAULT_TTL_MS) as NotificationType[];
|
package/src/quota.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
2
|
import type { ProviderQuotaError, QuotaState, QuotaWindowName, QuotaSource, QuotaUnit } from "./types";
|
|
3
3
|
|
|
4
|
-
const QUOTA_SOURCES = new Set<QuotaSource>(["header", "probe", "ingest", "statusline"]);
|
|
4
|
+
const QUOTA_SOURCES = new Set<QuotaSource>(["header", "probe", "ingest", "statusline", "emulated"]);
|
|
5
5
|
const QUOTA_UNITS = new Set<QuotaUnit>(["tokens", "requests", "%"]);
|
|
6
6
|
const QUOTA_WINDOWS = new Set<QuotaWindowName>(["five_hour", "seven_day", "seven_day_sonnet", "seven_day_opus", "primary", "secondary"]);
|
|
7
7
|
|
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";
|