mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Co-authored-by: bradgroux <brad@digitalmeld.io>
This commit is contained in:
parent
6f020263c0
commit
a42dc62ba4
30 changed files with 2153 additions and 175 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
race-safe version-skew cache invalidation, canonical immutable run snapshots
|
||||
and digests, complete capability posture, and attempt/history/trace/log
|
||||
persistence (#885).
|
||||
- Added validated runtime-manifest registration and heartbeat refresh, custom
|
||||
provider discovery, manifest-authoritative host posture, and provider-neutral
|
||||
required-capability routing with structured evidence for every attempted
|
||||
primary, fallback, or rejected candidate (#886).
|
||||
- Added an admin-governed SQLite journal maintenance workflow with non-mutating
|
||||
previews, restart-time exclusive conversion, verified backups, durable stage
|
||||
journals, forward-only crash recovery, integrity verification, in-place mode
|
||||
|
|
@ -25,6 +29,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Added Hermes to normalized provider profiles and Settings selectors, and made
|
||||
explicitly configured providers without an executable task adapter fail
|
||||
closed instead of silently dispatching through OpenClaw (#885).
|
||||
- Made host provider, model, tool, and sandbox capability decisions consume
|
||||
validated manifests. Legacy free-form registration posture remains visible
|
||||
but cannot satisfy required runtime capabilities. Runtime evidence writes are
|
||||
identity-bound, reject unredacted secrets and misspelled fields, expire with
|
||||
heartbeat liveness, and invalidate when provider identity changes. Unresolved
|
||||
sandbox presets fail closed instead of borrowing aggregate host signals
|
||||
(#886).
|
||||
- Classified the authoritative SQLite filesystem before database open, limited
|
||||
WAL to recognized durable local filesystems, refused known-unsafe and unknown
|
||||
storage before creating database sidecars, and exposed redacted filesystem,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,29 @@ They do not imply that adjacent roadmap work already exists. For example,
|
|||
provider-neutral approvals, reattachment, follow-up/fork/steer controls, and MCP
|
||||
governance remain unsupported or unknown until their dedicated issues land.
|
||||
|
||||
Agents and supervisors can register the same validated manifest with
|
||||
`POST /api/agents/register` and refresh it through the heartbeat endpoint. Host
|
||||
provider, model, `tool.*`, and sandbox posture is derived only from those
|
||||
manifests. Legacy free-form registration fields remain visible but are not
|
||||
trusted for runtime requirements. Route and host-preview requests can declare
|
||||
`requiredRuntimeCapabilities`; `supported` evidence qualifies, `advisory`
|
||||
evidence qualifies with a warning, and `unsupported`, `unknown`, missing, or
|
||||
failed-probe evidence rejects the candidate. All requirements must be satisfied
|
||||
by one manifest, so capabilities are never composed across providers.
|
||||
Self-registration requires an authenticated agent key/token whose identity
|
||||
matches the registry agent ID; operators with `agent:write` can register on an
|
||||
agent's behalf. Unknown request fields and unredacted secret-like evidence are
|
||||
rejected. Only registrations with a current five-minute heartbeat qualify for
|
||||
routing. Probe-evidence age enforcement is completed by #887; until then,
|
||||
operators must refresh the manifest whenever provider identity or evidence
|
||||
changes.
|
||||
|
||||
Host previews fail closed on a bare `sandboxPresetId` until #887 resolves each
|
||||
preset control through the same manifest evaluator. For a capability-only
|
||||
preview during this intermediate contract, omit the preset ID and provide the
|
||||
required filesystem, network, environment, and credential capability IDs
|
||||
directly.
|
||||
|
||||
## Sandbox Policy Presets
|
||||
|
||||
Use **Settings -> Agents -> Sandbox Policies** to manage reusable filesystem, network, environment, and credential controls for agent execution. Built-in presets are immutable; custom presets can be created, edited, disabled, or deleted.
|
||||
|
|
|
|||
|
|
@ -84,20 +84,36 @@ Registers a new agent or updates an existing one. Sets status to `online` automa
|
|||
],
|
||||
"version": "2.0.0",
|
||||
"metadata": { "role": "lead", "reference": "Interstellar" },
|
||||
"providerRuntimeManifest": { "schemaVersion": "provider-runtime-manifest/v1", "...": "..." },
|
||||
"sessionKey": "optional-openclaw-session-key"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| -------------- | ------ | -------- | ---------------------------------------- |
|
||||
| `id` | string | ✅ | Unique identifier (1-50 chars) |
|
||||
| `name` | string | ✅ | Display name (1-100 chars) |
|
||||
| `model` | string | | Model identifier |
|
||||
| `provider` | string | | Provider name |
|
||||
| `capabilities` | array | | List of `{ name, description? }` objects |
|
||||
| `version` | string | | Agent version or build info |
|
||||
| `metadata` | object | | Freeform key-value data |
|
||||
| `sessionKey` | string | | OpenClaw session key for routing |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------------- | ------ | -------- | -------------------------------------------------------------------------------------- |
|
||||
| `id` | string | ✅ | Unique identifier (1-50 chars) |
|
||||
| `name` | string | ✅ | Display name (1-100 chars) |
|
||||
| `model` | string | | Model identifier |
|
||||
| `provider` | string | | Provider name |
|
||||
| `capabilities` | array | | List of `{ name, description? }` objects |
|
||||
| `version` | string | | Agent version or build info |
|
||||
| `metadata` | object | | Freeform key-value data |
|
||||
| `providerRuntimeManifest` | object | | Validated runtime capability evidence; see the Provider Runtime Manifest API reference |
|
||||
| `sessionKey` | string | | OpenClaw session key for routing |
|
||||
|
||||
The manifest digest is recomputed on registration. Forged, incomplete, or
|
||||
forward-incompatible manifests, secret-like diagnostic fields, and unknown or
|
||||
misspelled request fields are rejected with `400`. Legacy `provider`,
|
||||
`model`, `capabilities`, and metadata fields remain available for compatibility
|
||||
and display, but they cannot satisfy required runtime capability routing.
|
||||
Authoritative manifest writes require `telemetry:write` plus either a key/token
|
||||
identity matching the target agent ID or `agent:write`. Use a distinct named API
|
||||
key for each self-registering agent; do not share a generic telemetry key for
|
||||
runtime evidence. Once a record contains authoritative evidence, re-registration,
|
||||
heartbeat, replacement, and deregistration remain identity-bound so another
|
||||
telemetry writer cannot refresh, invalidate, or delete it. Re-registering with a
|
||||
changed provider, model, or version and without replacement evidence invalidates
|
||||
the previous manifest.
|
||||
|
||||
**Response:** `201 Created`
|
||||
|
||||
|
|
@ -133,16 +149,21 @@ Updates the agent's last-seen timestamp and optionally changes status or task as
|
|||
"status": "busy",
|
||||
"currentTaskId": "task_20260206_abc123",
|
||||
"currentTaskTitle": "Implement authentication flow",
|
||||
"metadata": { "progress": 0.65 }
|
||||
"metadata": { "progress": 0.65 },
|
||||
"providerRuntimeManifest": { "schemaVersion": "provider-runtime-manifest/v1", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Values | Description |
|
||||
| ------------------ | -------------- | ------------------------ | --------------------------------------- |
|
||||
| `status` | string | `online`, `busy`, `idle` | Agent's current state |
|
||||
| `currentTaskId` | string \| null | | Task ID being worked on (null to clear) |
|
||||
| `currentTaskTitle` | string \| null | | Task title (null to clear) |
|
||||
| `metadata` | object | | Merge additional metadata |
|
||||
| Field | Type | Values | Description |
|
||||
| ------------------------- | -------------- | ------------------------ | ---------------------------------------------------------------------- |
|
||||
| `status` | string | `online`, `busy`, `idle` | Agent's current state |
|
||||
| `currentTaskId` | string \| null | | Task ID being worked on (null to clear) |
|
||||
| `currentTaskTitle` | string \| null | | Task title (null to clear) |
|
||||
| `metadata` | object | | Merge additional metadata |
|
||||
| `providerRuntimeManifest` | object | | Replace the validated runtime manifest after a provider/version change |
|
||||
|
||||
Heartbeat manifest replacement uses the same authenticated identity binding as
|
||||
registration. Ordinary telemetry-only heartbeats remain backward compatible.
|
||||
|
||||
**Response:** `200 OK` — Returns updated agent object.
|
||||
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ GET /api/tasks
|
|||
|
||||
Returns all active tasks. Supports query filters.
|
||||
|
||||
**Response** `200`:
|
||||
**Response** `200` (abridged manifest assessments):
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -1799,7 +1799,8 @@ Accepts either a task ID or ad-hoc metadata:
|
|||
|
||||
```json
|
||||
{
|
||||
"taskId": "TASK-001"
|
||||
"taskId": "TASK-001",
|
||||
"requiredRuntimeCapabilities": ["run.resume", "tool.mcp"]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1810,7 +1811,8 @@ Accepts either a task ID or ad-hoc metadata:
|
|||
"type": "bug",
|
||||
"priority": "high",
|
||||
"project": "rubicon",
|
||||
"subtaskCount": 3
|
||||
"subtaskCount": 3,
|
||||
"requiredRuntimeCapabilities": ["run.resume", "tool.mcp"]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1819,9 +1821,49 @@ Accepts either a task ID or ad-hoc metadata:
|
|||
```json
|
||||
{
|
||||
"agent": "codex-1",
|
||||
"model": "claude-sonnet-4.5",
|
||||
"model": "gpt-5.5",
|
||||
"rule": "high-priority-bugs",
|
||||
"confidence": 0.95,
|
||||
"reason": "Matched rule: High priority bugs. Selected manifest sha256:... with supported capability evidence.",
|
||||
"runtimeSelection": {
|
||||
"requiredCapabilities": ["run.resume", "tool.mcp"],
|
||||
"compatible": true,
|
||||
"selectedManifest": {
|
||||
"manifestDigest": "sha256:...",
|
||||
"provider": "codex-cli",
|
||||
"compatible": true
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"manifestDigest": "sha256:...",
|
||||
"provider": "codex-cli",
|
||||
"compatible": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"runtimeCandidates": [
|
||||
{
|
||||
"agent": "codex-1",
|
||||
"available": true,
|
||||
"selected": true,
|
||||
"reason": "Agent is healthy",
|
||||
"selection": {
|
||||
"requiredCapabilities": ["run.resume", "tool.mcp"],
|
||||
"compatible": true,
|
||||
"selectedManifest": {
|
||||
"manifestDigest": "sha256:...",
|
||||
"provider": "codex-cli",
|
||||
"compatible": true
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"manifestDigest": "sha256:...",
|
||||
"provider": "codex-cli",
|
||||
"compatible": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"traceId": "govtrace_1760000000000_ab12cd"
|
||||
}
|
||||
```
|
||||
|
|
@ -1829,6 +1871,10 @@ Accepts either a task ID or ad-hoc metadata:
|
|||
When an enabled team roster exists, `/api/agents/route` evaluates the roster
|
||||
before legacy routing rules. Roster-selected responses use a `team-roster:`
|
||||
rule prefix.
|
||||
When runtime requirements are present, `runtimeCandidates` preserves every
|
||||
agent manifest evaluation attempted by the rule/fallback chain. Exactly one
|
||||
entry is marked `selected` on success; terminal `409 Conflict` details preserve
|
||||
all rejected entries and their structured manifest assessments.
|
||||
|
||||
### Get/Update Routing Configuration
|
||||
|
||||
|
|
@ -1913,6 +1959,51 @@ evidence. Failed probes and unknown versions are not positively cached.
|
|||
Explicitly configured providers without a task execution adapter fail with
|
||||
`409 Conflict` instead of falling back to OpenClaw.
|
||||
|
||||
### Runtime Manifest Registration And Routing
|
||||
|
||||
`POST /api/agents/register` and
|
||||
`POST /api/agents/register/:id/heartbeat` accept an optional
|
||||
`providerRuntimeManifest` object using the contract above. The server validates
|
||||
the complete capability inventory and recomputes the canonical digest before
|
||||
storing it. Secret-like evidence and unknown request fields are rejected rather
|
||||
than silently stored or stripped. Invalid persisted manifests are ignored on
|
||||
restart so capability routing fails closed. An agent may write its own manifest
|
||||
when its authenticated key/token identity matches the registry ID; otherwise
|
||||
`agent:write` is required in addition to registry write access. Every later
|
||||
mutation of that authoritative record remains identity-bound. Changing the
|
||||
registered provider, model, or version without replacement evidence invalidates
|
||||
the prior manifest.
|
||||
|
||||
`POST /api/agents/route` and `POST /api/agents/hosts/preview` accept:
|
||||
|
||||
```json
|
||||
{
|
||||
"requiredRuntimeCapabilities": ["run.resume", "tool.mcp"]
|
||||
}
|
||||
```
|
||||
|
||||
Host provider, model, `tool.*`, and sandbox posture is aggregated from validated
|
||||
manifests. Legacy registry fields are returned as display-only posture and
|
||||
cannot satisfy these requirements. A single manifest must match the requested
|
||||
provider and model and satisfy every required capability. `supported` evidence
|
||||
qualifies; `advisory` qualifies with a warning; `unsupported`, `unknown`,
|
||||
missing, or failed-probe evidence rejects the candidate with structured reasons.
|
||||
Custom provider identifiers use the same schema and selection path without a
|
||||
central provider branch. Registration enables discovery and routing only; an
|
||||
execution adapter is still required before launch.
|
||||
|
||||
Only live registrations with a heartbeat no older than five minutes contribute
|
||||
runtime evidence. `requiredTools` values using `tool.*` are evaluated through
|
||||
the same single-manifest path; legacy named tools cannot qualify a host. Probe
|
||||
timestamp freshness and action-level enforcement are completed by #887, so
|
||||
agents must refresh evidence when their provider runtime changes. A bare
|
||||
`sandboxPresetId` does not qualify a host in this intermediate contract because
|
||||
the preset's individual controls have not yet been resolved into manifest
|
||||
requirements. For a capability-only preview, omit the preset ID and supply the
|
||||
relevant filesystem, network, environment, and credential IDs in
|
||||
`requiredRuntimeCapabilities`; #887 wires actual preset resolution into the
|
||||
same evaluator.
|
||||
|
||||
---
|
||||
|
||||
## Team Roster Manifests
|
||||
|
|
|
|||
|
|
@ -372,6 +372,9 @@ Service discovery and liveness tracking for AI agents.
|
|||
- **Heartbeat tracking** — Agents send periodic heartbeats; marked offline after configurable timeout (default 5 min)
|
||||
- **Status lifecycle** — Online → Busy → Idle → Offline with automatic transitions
|
||||
- **Capabilities declaration** — Agents declare what they can do (code-review, research, testing, etc.)
|
||||
- **Validated runtime registration** — Registration and heartbeat payloads can carry a digest-verified provider runtime manifest for provider-neutral capability decisions
|
||||
- **Capability-aware host routing** — Route and host-preview requests can require runtime capabilities; one matching manifest must satisfy the full set, with advisory warnings and fail-closed unsupported or unknown evidence
|
||||
- **Authenticated runtime evidence** — Manifest writes are bound to the registering agent identity (or `agent:write`), reject unredacted diagnostics and request typos, expire with registry heartbeat liveness, and preserve structured evidence for every attempted route candidate
|
||||
- **Stats endpoint** — `GET /api/agents/register/stats` returns total, online, busy, idle, offline counts
|
||||
- **File-based persistence** — Registry stored in `.veritas-kanban/agent-registry.json`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { AgentHostService } from '../services/agent-host-service';
|
||||
import type { RegisteredAgent } from '../services/agent-registry-service';
|
||||
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
|
||||
|
||||
function agent(
|
||||
id: string,
|
||||
|
|
@ -13,6 +14,7 @@ function agent(
|
|||
model: 'gpt-5',
|
||||
provider: 'codex-cli',
|
||||
capabilities: [{ name: 'code' }],
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture(),
|
||||
status: 'idle',
|
||||
registeredAt: '2026-06-01T12:00:00.000Z',
|
||||
lastHeartbeat: '2026-06-01T12:00:00.000Z',
|
||||
|
|
@ -73,7 +75,14 @@ describe('AgentHostService', () => {
|
|||
),
|
||||
agent(
|
||||
'other',
|
||||
{ provider: 'openclaw', model: 'other-model' },
|
||||
{
|
||||
provider: 'openclaw',
|
||||
model: 'other-model',
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
provider: 'openclaw',
|
||||
models: ['other-model'],
|
||||
}),
|
||||
},
|
||||
{ hostId: 'host-d', hostName: 'D Host', supportedAgents: ['other'] }
|
||||
),
|
||||
]);
|
||||
|
|
@ -84,7 +93,7 @@ describe('AgentHostService', () => {
|
|||
provider: 'codex-cli',
|
||||
model: 'gpt-5',
|
||||
workspacePath: '/Users/bradgroux/Projects/veritas-kanban/server',
|
||||
requiredTools: ['code'],
|
||||
requiredTools: ['tool.calls'],
|
||||
},
|
||||
now
|
||||
);
|
||||
|
|
@ -125,7 +134,7 @@ describe('AgentHostService', () => {
|
|||
const service = serviceFor([
|
||||
agent(
|
||||
'custom',
|
||||
{ provider: 'custom' },
|
||||
{ provider: 'custom', providerRuntimeManifest: undefined },
|
||||
{ hostId: 'host-custom', hostName: 'Custom Host', providers: ['custom'] }
|
||||
),
|
||||
]);
|
||||
|
|
@ -144,11 +153,270 @@ describe('AgentHostService', () => {
|
|||
expect(preview.previews[0].checks.find((check) => check.id === 'sandbox-policy')).toMatchObject(
|
||||
{
|
||||
passed: false,
|
||||
detail: 'Host does not report sandbox capability support for preset codex-repo-contained.',
|
||||
detail:
|
||||
'Sandbox preset codex-repo-contained cannot qualify a host until its required controls are resolved into requiredRuntimeCapabilities.',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let another manifest or a single sandbox signal qualify an unresolved preset', () => {
|
||||
const service = serviceFor([
|
||||
agent(
|
||||
'codex',
|
||||
{
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
capabilityStates: { 'filesystem.read': 'unsupported' },
|
||||
}),
|
||||
},
|
||||
{ hostId: 'shared-host' }
|
||||
),
|
||||
agent(
|
||||
'other',
|
||||
{
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
provider: 'other-provider',
|
||||
capabilityStates: { 'filesystem.read': 'supported' },
|
||||
}),
|
||||
},
|
||||
{ hostId: 'shared-host' }
|
||||
),
|
||||
]);
|
||||
|
||||
const preview = service.preview(
|
||||
{ agent: 'codex', sandboxPresetId: 'codex-repo-contained' },
|
||||
now
|
||||
);
|
||||
|
||||
expect(preview.previews[0]?.runtimeSelection?.selectedManifest?.provider).toBe('codex-cli');
|
||||
expect(preview.previews[0]?.checks.find((check) => check.id === 'sandbox-policy')?.passed).toBe(
|
||||
false
|
||||
);
|
||||
expect(preview.decision.selectedHostId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses a custom provider manifest without a central provider branch', () => {
|
||||
const service = serviceFor([
|
||||
agent('custom', {
|
||||
provider: 'custom-runtime',
|
||||
model: 'custom-model',
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
provider: 'custom-runtime',
|
||||
models: ['custom-model'],
|
||||
capabilityStates: { 'run.resume': 'supported' },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const preview = service.preview(
|
||||
{
|
||||
agent: 'custom',
|
||||
provider: 'custom-runtime',
|
||||
model: 'custom-model',
|
||||
requiredRuntimeCapabilities: ['run.resume'],
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
expect(preview.decision.selectedHostId).toBe('host-custom');
|
||||
expect(preview.previews[0]?.runtimeSelection?.selectedManifest?.provider).toBe(
|
||||
'custom-runtime'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not compose provider, model, or tool requirements across manifests', () => {
|
||||
const service = serviceFor([
|
||||
agent(
|
||||
'provider-a',
|
||||
{
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
provider: 'provider-a',
|
||||
models: ['model-a'],
|
||||
capabilityStates: { 'tool.mcp': 'unsupported' },
|
||||
}),
|
||||
},
|
||||
{ hostId: 'shared-host' }
|
||||
),
|
||||
agent(
|
||||
'provider-b',
|
||||
{
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
provider: 'provider-b',
|
||||
models: ['model-b'],
|
||||
capabilityStates: { 'tool.mcp': 'supported' },
|
||||
}),
|
||||
},
|
||||
{ hostId: 'shared-host' }
|
||||
),
|
||||
]);
|
||||
|
||||
const providerModelPreview = service.preview({ provider: 'provider-a', model: 'model-b' }, now);
|
||||
const providerToolPreview = service.preview(
|
||||
{ provider: 'provider-a', requiredTools: ['tool.mcp'] },
|
||||
now
|
||||
);
|
||||
|
||||
expect(providerModelPreview.decision.selectedHostId).toBeUndefined();
|
||||
expect(providerModelPreview.previews[0]?.runtimeSelection?.compatible).toBe(false);
|
||||
expect(providerToolPreview.decision.selectedHostId).toBeUndefined();
|
||||
expect(providerToolPreview.previews[0]?.runtimeSelection?.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts advisory tool capability evidence with a warning', () => {
|
||||
const service = serviceFor([
|
||||
agent('codex', {
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
capabilityStates: { 'tool.mcp': 'advisory' },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const preview = service.preview({ agent: 'codex', requiredTools: ['tool.mcp'] }, now);
|
||||
|
||||
expect(preview.decision.selectedHostId).toBe('host-codex');
|
||||
expect(preview.previews[0]?.warnings).toContain(
|
||||
'Required runtime capabilities have advisory evidence: tool.mcp.'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes tool capability requirements before manifest evaluation', () => {
|
||||
const service = serviceFor([
|
||||
agent('codex', {
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
capabilityStates: { 'tool.mcp': 'unsupported' },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const preview = service.preview({ agent: 'codex', requiredTools: [' tool.mcp '] }, now);
|
||||
|
||||
expect(preview.request.requiredTools).toEqual(['tool.mcp']);
|
||||
expect(preview.previews[0]?.runtimeSelection?.requiredCapabilities).toEqual(['tool.mcp']);
|
||||
expect(preview.previews[0]?.runtimeSelection?.compatible).toBe(false);
|
||||
expect(preview.decision.selectedHostId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not let legacy named tools qualify a host', () => {
|
||||
const service = serviceFor([
|
||||
agent(
|
||||
'legacy',
|
||||
{ providerRuntimeManifest: undefined },
|
||||
{ hostId: 'legacy-host', tools: ['code'] }
|
||||
),
|
||||
]);
|
||||
|
||||
const preview = service.preview({ agent: 'legacy', requiredTools: ['code'] }, now);
|
||||
|
||||
expect(preview.decision.selectedHostId).toBeUndefined();
|
||||
expect(preview.previews[0]?.reasons).toContain(
|
||||
'Legacy named tool requirements cannot qualify host runtime posture: code. Use requiredRuntimeCapabilities with a tool.* identifier.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let an offline sibling lend runtime evidence to a live host', () => {
|
||||
const service = serviceFor([
|
||||
agent(
|
||||
'live',
|
||||
{ providerRuntimeManifest: undefined },
|
||||
{ hostId: 'shared-host', hostName: 'Shared Host' }
|
||||
),
|
||||
agent('offline', { status: 'offline' }, { hostId: 'shared-host', hostName: 'Shared Host' }),
|
||||
]);
|
||||
|
||||
const preview = service.preview(
|
||||
{ provider: 'codex-cli', requiredRuntimeCapabilities: ['run.start'] },
|
||||
now
|
||||
);
|
||||
|
||||
expect(preview.previews[0]?.posture).toBe('connected');
|
||||
expect(preview.previews[0]?.runtimeSelection?.candidates).toHaveLength(0);
|
||||
expect(preview.previews[0]?.warnings).toContain(
|
||||
'Runtime manifest from agent offline was excluded because its registration is offline or outside the five-minute heartbeat window.'
|
||||
);
|
||||
expect(preview.decision.selectedHostId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows advisory runtime evidence with a concrete warning', () => {
|
||||
const service = serviceFor([
|
||||
agent('codex', {
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
capabilityStates: { 'run.resume': 'advisory' },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const preview = service.preview(
|
||||
{ agent: 'codex', requiredRuntimeCapabilities: ['run.resume'] },
|
||||
now
|
||||
);
|
||||
|
||||
expect(preview.decision.selectedHostId).toBe('host-codex');
|
||||
expect(preview.previews[0]?.warnings).toContain(
|
||||
'Required runtime capabilities have advisory evidence: run.resume.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['unsupported', 'unknown'] as const)(
|
||||
'rejects %s required runtime capability evidence',
|
||||
(state) => {
|
||||
const service = serviceFor([
|
||||
agent('codex', {
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
capabilityStates: { 'run.resume': state },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const preview = service.preview(
|
||||
{ agent: 'codex', requiredRuntimeCapabilities: ['run.resume'] },
|
||||
now
|
||||
);
|
||||
|
||||
expect(preview.decision.selectedHostId).toBeUndefined();
|
||||
expect(preview.previews[0]?.runtimeSelection?.candidates[0]?.capabilities[0]).toMatchObject({
|
||||
state,
|
||||
satisfied: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps legacy posture visible without letting it satisfy runtime requirements', () => {
|
||||
const service = serviceFor([
|
||||
agent(
|
||||
'legacy',
|
||||
{ provider: 'custom', model: 'legacy-model', providerRuntimeManifest: undefined },
|
||||
{
|
||||
providers: ['custom'],
|
||||
models: ['legacy-model'],
|
||||
tools: ['tool.calls'],
|
||||
sandboxCapabilities: ['filesystem.write'],
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
const health = service.getHealth(now);
|
||||
const preview = service.preview(
|
||||
{
|
||||
agent: 'legacy',
|
||||
provider: 'custom',
|
||||
model: 'legacy-model',
|
||||
requiredRuntimeCapabilities: ['tool.calls'],
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
expect(health.hosts[0]).toMatchObject({
|
||||
supportedProviders: [],
|
||||
supportedModels: [],
|
||||
supportedTools: [],
|
||||
legacyRuntimePosture: {
|
||||
providers: ['custom'],
|
||||
models: ['legacy-model'],
|
||||
tools: expect.arrayContaining(['code', 'tool.calls']),
|
||||
},
|
||||
});
|
||||
expect(preview.decision.selectedHostId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('disables auto-routing when no host is registered', () => {
|
||||
const service = serviceFor([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
type AppConfig,
|
||||
} from '@veritas-kanban/shared';
|
||||
import type { AgentHealthStatus } from '../services/agent-health-service';
|
||||
import type { RegisteredAgent } from '../services/agent-registry-service.js';
|
||||
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
|
||||
|
||||
// Mock ConfigService
|
||||
const mockGetConfig = vi.fn();
|
||||
|
|
@ -120,6 +122,21 @@ function requireFallbackResult(
|
|||
return result;
|
||||
}
|
||||
|
||||
function registeredAgent(
|
||||
id: string,
|
||||
providerRuntimeManifest = providerRuntimeManifestFixture()
|
||||
): RegisteredAgent {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
capabilities: [],
|
||||
providerRuntimeManifest,
|
||||
status: 'idle',
|
||||
registeredAt: '2026-07-15T12:00:00.000Z',
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentRoutingService', () => {
|
||||
let service: AgentRoutingService;
|
||||
|
||||
|
|
@ -167,6 +184,234 @@ describe('AgentRoutingService', () => {
|
|||
expect(result.reason).toContain('High-priority code');
|
||||
});
|
||||
|
||||
it('routes to a manifest that supports every required runtime capability', async () => {
|
||||
service = new AgentRoutingService(
|
||||
undefined,
|
||||
{ checkAgent: mockCheckAgent },
|
||||
{
|
||||
list: () => [
|
||||
registeredAgent(
|
||||
'claude-code',
|
||||
providerRuntimeManifestFixture({
|
||||
models: ['opus'],
|
||||
capabilityStates: { 'run.resume': 'supported' },
|
||||
})
|
||||
),
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const result = await service.resolveAgentWithTrace(
|
||||
{ type: 'code', priority: 'high' },
|
||||
{ requiredRuntimeCapabilities: ['run.start', 'run.resume'] }
|
||||
);
|
||||
|
||||
expect(result.result.agent).toBe('claude-code');
|
||||
expect(result.result.runtimeSelection).toMatchObject({
|
||||
compatible: true,
|
||||
selectedManifest: { advisory: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('routes with a warning when the only matching evidence is advisory', async () => {
|
||||
service = new AgentRoutingService(
|
||||
undefined,
|
||||
{ checkAgent: mockCheckAgent },
|
||||
{
|
||||
list: () => [
|
||||
registeredAgent(
|
||||
'claude-code',
|
||||
providerRuntimeManifestFixture({
|
||||
models: ['opus'],
|
||||
capabilityStates: { 'run.resume': 'advisory' },
|
||||
})
|
||||
),
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const result = await service.resolveAgentWithTrace(
|
||||
{ type: 'code', priority: 'high' },
|
||||
{ requiredRuntimeCapabilities: ['run.resume'] }
|
||||
);
|
||||
|
||||
expect(result.result.agent).toBe('claude-code');
|
||||
expect(result.result.runtimeSelection?.selectedManifest?.advisory).toBe(true);
|
||||
expect(result.result.reason).toContain('advisory capability evidence');
|
||||
});
|
||||
|
||||
it('rejects an unsupported primary and selects a capable fallback', async () => {
|
||||
service = new AgentRoutingService(
|
||||
undefined,
|
||||
{ checkAgent: mockCheckAgent },
|
||||
{
|
||||
list: () => [
|
||||
registeredAgent(
|
||||
'claude-code',
|
||||
providerRuntimeManifestFixture({
|
||||
models: ['opus'],
|
||||
capabilityStates: { 'run.resume': 'unsupported' },
|
||||
})
|
||||
),
|
||||
registeredAgent(
|
||||
'amp',
|
||||
providerRuntimeManifestFixture({
|
||||
provider: 'custom-amp',
|
||||
capabilityStates: { 'run.resume': 'supported' },
|
||||
})
|
||||
),
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const result = await service.resolveAgentWithTrace(
|
||||
{ type: 'code', priority: 'high' },
|
||||
{ requiredRuntimeCapabilities: ['run.resume'] }
|
||||
);
|
||||
|
||||
expect(result.result.agent).toBe('amp');
|
||||
expect(result.result.reason).toContain('unavailable');
|
||||
expect(result.result.runtimeSelection?.compatible).toBe(true);
|
||||
expect(result.result.runtimeCandidates).toHaveLength(2);
|
||||
expect(result.result.runtimeCandidates).toEqual([
|
||||
expect.objectContaining({ agent: 'claude-code', available: false, selected: false }),
|
||||
expect.objectContaining({ agent: 'amp', available: true, selected: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes a directly registered concrete custom manifest for a custom provider category', async () => {
|
||||
const config = structuredClone(BASE_CONFIG);
|
||||
const claude = requireAgent(config, 'claude-code');
|
||||
claude.provider = 'custom';
|
||||
mockGetConfig.mockResolvedValue(config);
|
||||
service = new AgentRoutingService(
|
||||
undefined,
|
||||
{ checkAgent: mockCheckAgent },
|
||||
{
|
||||
list: () => [
|
||||
registeredAgent(
|
||||
'claude-code',
|
||||
providerRuntimeManifestFixture({
|
||||
provider: 'custom-runtime',
|
||||
models: ['opus'],
|
||||
capabilityStates: { 'run.resume': 'supported' },
|
||||
})
|
||||
),
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const result = await service.resolveAgentWithTrace(
|
||||
{ type: 'code', priority: 'high' },
|
||||
{ requiredRuntimeCapabilities: ['run.resume'] }
|
||||
);
|
||||
|
||||
expect(result.result.agent).toBe('claude-code');
|
||||
expect(result.result.runtimeSelection?.selectedManifest?.provider).toBe('custom-runtime');
|
||||
});
|
||||
|
||||
it('does not borrow runtime evidence from another agent sharing a provider', async () => {
|
||||
const config = structuredClone(BASE_CONFIG);
|
||||
requireAgent(config, 'claude-code').provider = 'codex-cli';
|
||||
mockGetConfig.mockResolvedValue(config);
|
||||
service = new AgentRoutingService(
|
||||
undefined,
|
||||
{ checkAgent: mockCheckAgent },
|
||||
{
|
||||
list: () => [
|
||||
registeredAgent(
|
||||
'unrelated-agent',
|
||||
providerRuntimeManifestFixture({
|
||||
provider: 'codex-cli',
|
||||
models: ['opus'],
|
||||
capabilityStates: { 'run.resume': 'supported' },
|
||||
})
|
||||
),
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const error = (await service
|
||||
.resolveAgentWithTrace(
|
||||
{ type: 'code', priority: 'high' },
|
||||
{ requiredRuntimeCapabilities: ['run.resume'] }
|
||||
)
|
||||
.catch((caught: unknown) => caught)) as {
|
||||
statusCode: number;
|
||||
details: {
|
||||
runtimeCandidates: Array<{ selected: boolean; selection: { compatible: boolean } }>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(error.statusCode).toBe(409);
|
||||
expect(error.details.runtimeCandidates.length).toBeGreaterThan(0);
|
||||
expect(error.details.runtimeCandidates.every((candidate) => !candidate.selected)).toBe(true);
|
||||
expect(
|
||||
error.details.runtimeCandidates.every((candidate) => !candidate.selection.compatible)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed on ambiguous name matches and stale registrations', async () => {
|
||||
const duplicateName = 'Claude Code';
|
||||
const stale = registeredAgent('claude-code');
|
||||
stale.lastHeartbeat = '2026-01-01T00:00:00.000Z';
|
||||
service = new AgentRoutingService(
|
||||
undefined,
|
||||
{ checkAgent: mockCheckAgent },
|
||||
{
|
||||
list: () => [
|
||||
stale,
|
||||
{ ...registeredAgent('duplicate-a'), name: duplicateName },
|
||||
{ ...registeredAgent('duplicate-b'), name: duplicateName },
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.resolveAgentWithTrace(
|
||||
{ type: 'code', priority: 'high' },
|
||||
{ requiredRuntimeCapabilities: ['run.start'] }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
details: expect.objectContaining({
|
||||
runtimeCandidates: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
reason:
|
||||
'Registered agent "claude-code" is offline or outside the five-minute heartbeat window.',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when no candidate has a registered runtime manifest', async () => {
|
||||
service = new AgentRoutingService(
|
||||
undefined,
|
||||
{ checkAgent: mockCheckAgent },
|
||||
{ list: () => [] }
|
||||
);
|
||||
|
||||
const error = (await service
|
||||
.resolveAgentWithTrace(
|
||||
{ type: 'code', priority: 'high' },
|
||||
{ requiredRuntimeCapabilities: ['run.resume'] }
|
||||
)
|
||||
.catch((caught: unknown) => caught)) as {
|
||||
statusCode: number;
|
||||
details: {
|
||||
reason: string;
|
||||
runtimeCandidates: Array<{ selected: boolean }>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(error.statusCode).toBe(409);
|
||||
expect(error.details.reason).toBe(
|
||||
'No registry identity matches configured agent "claude-code".'
|
||||
);
|
||||
expect(error.details.runtimeCandidates.every((candidate) => !candidate.selected)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a governance trace with evaluated and matched routing rules', async () => {
|
||||
const result = await service.resolveAgentWithTrace(
|
||||
{
|
||||
|
|
|
|||
62
server/src/__tests__/fixtures/provider-runtime-manifest.ts
Normal file
62
server/src/__tests__/fixtures/provider-runtime-manifest.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
KNOWN_PROVIDER_RUNTIME_CAPABILITY_IDS,
|
||||
PROVIDER_RUNTIME_MANIFEST_SCHEMA_VERSION,
|
||||
PROVIDER_RUNTIME_PROBE_REVISION,
|
||||
type ProviderRuntimeCapabilityId,
|
||||
type ProviderRuntimeCapabilityState,
|
||||
type ProviderRuntimeManifest,
|
||||
type ProviderRuntimeProbeState,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { calculateProviderRuntimeManifestDigest } from '../../utils/provider-runtime-manifest-digest.js';
|
||||
|
||||
interface ProviderRuntimeManifestFixtureOptions {
|
||||
provider?: string;
|
||||
adapter?: string;
|
||||
providerVersion?: string;
|
||||
models?: string[];
|
||||
probeState?: ProviderRuntimeProbeState;
|
||||
capabilityStates?: Partial<Record<ProviderRuntimeCapabilityId, ProviderRuntimeCapabilityState>>;
|
||||
}
|
||||
|
||||
export function providerRuntimeManifestFixture(
|
||||
options: ProviderRuntimeManifestFixtureOptions = {}
|
||||
): ProviderRuntimeManifest {
|
||||
const provider = options.provider ?? 'codex-cli';
|
||||
const defaultSupported = new Set<ProviderRuntimeCapabilityId>([
|
||||
'run.start',
|
||||
'run.status',
|
||||
'tool.calls',
|
||||
'filesystem.read',
|
||||
'filesystem.write',
|
||||
'environment.allowlist',
|
||||
]);
|
||||
const payload: Omit<ProviderRuntimeManifest, 'digest'> = {
|
||||
schemaVersion: PROVIDER_RUNTIME_MANIFEST_SCHEMA_VERSION,
|
||||
probeRevision: PROVIDER_RUNTIME_PROBE_REVISION,
|
||||
provider,
|
||||
adapter: options.adapter ?? provider,
|
||||
protocolVersion: 'fixture-runtime/v1',
|
||||
providerVersion: options.providerVersion ?? `${provider} 1.0.0`,
|
||||
models: options.models ?? ['gpt-5'],
|
||||
capabilities: KNOWN_PROVIDER_RUNTIME_CAPABILITY_IDS.map((id) => {
|
||||
const state =
|
||||
options.capabilityStates?.[id] ?? (defaultSupported.has(id) ? 'supported' : 'unknown');
|
||||
return {
|
||||
id,
|
||||
state,
|
||||
source: 'contract-test' as const,
|
||||
reason: `Fixture reports ${id} as ${state}.`,
|
||||
};
|
||||
}),
|
||||
probe: {
|
||||
state: options.probeState ?? 'ready',
|
||||
probedAt: '2026-07-15T12:00:00.000Z',
|
||||
source: 'fixture',
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
return {
|
||||
...payload,
|
||||
digest: calculateProviderRuntimeManifestDigest(payload),
|
||||
};
|
||||
}
|
||||
115
server/src/__tests__/provider-runtime-capability-service.test.ts
Normal file
115
server/src/__tests__/provider-runtime-capability-service.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { selectProviderRuntimeManifest } from '../services/provider-runtime-capability-service.js';
|
||||
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
|
||||
|
||||
describe('selectProviderRuntimeManifest', () => {
|
||||
it('selects one manifest with supported required capabilities', () => {
|
||||
const selection = selectProviderRuntimeManifest({
|
||||
manifests: [providerRuntimeManifestFixture()],
|
||||
provider: 'codex-cli',
|
||||
model: 'gpt-5',
|
||||
requiredCapabilities: ['run.start', 'tool.calls'],
|
||||
});
|
||||
|
||||
expect(selection.compatible).toBe(true);
|
||||
expect(selection.selectedManifest).toMatchObject({
|
||||
provider: 'codex-cli',
|
||||
advisory: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows advisory evidence and surfaces a warning', () => {
|
||||
const selection = selectProviderRuntimeManifest({
|
||||
manifests: [
|
||||
providerRuntimeManifestFixture({
|
||||
capabilityStates: { 'run.resume': 'advisory' },
|
||||
}),
|
||||
],
|
||||
requiredCapabilities: ['run.resume'],
|
||||
});
|
||||
|
||||
expect(selection.compatible).toBe(true);
|
||||
expect(selection.selectedManifest?.advisory).toBe(true);
|
||||
expect(selection.selectedManifest?.warnings[0]).toContain('run.resume');
|
||||
});
|
||||
|
||||
it.each(['unsupported', 'unknown'] as const)('rejects %s capability evidence', (state) => {
|
||||
const selection = selectProviderRuntimeManifest({
|
||||
manifests: [
|
||||
providerRuntimeManifestFixture({
|
||||
capabilityStates: { 'run.resume': state },
|
||||
}),
|
||||
],
|
||||
requiredCapabilities: ['run.resume'],
|
||||
});
|
||||
|
||||
expect(selection.compatible).toBe(false);
|
||||
expect(selection.candidates[0]?.capabilities[0]).toMatchObject({ state, satisfied: false });
|
||||
});
|
||||
|
||||
it('does not compose required capabilities across manifests', () => {
|
||||
const selection = selectProviderRuntimeManifest({
|
||||
manifests: [
|
||||
providerRuntimeManifestFixture({
|
||||
provider: 'custom-a',
|
||||
capabilityStates: { 'run.start': 'supported', 'run.resume': 'unsupported' },
|
||||
}),
|
||||
providerRuntimeManifestFixture({
|
||||
provider: 'custom-b',
|
||||
capabilityStates: { 'run.start': 'unsupported', 'run.resume': 'supported' },
|
||||
}),
|
||||
],
|
||||
requiredCapabilities: ['run.start', 'run.resume'],
|
||||
});
|
||||
|
||||
expect(selection.compatible).toBe(false);
|
||||
expect(selection.candidates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects failed probes and provider or model mismatches', () => {
|
||||
const selection = selectProviderRuntimeManifest({
|
||||
manifests: [
|
||||
providerRuntimeManifestFixture({ probeState: 'failed' }),
|
||||
providerRuntimeManifestFixture({ provider: 'custom', models: ['other-model'] }),
|
||||
],
|
||||
provider: 'custom',
|
||||
model: 'gpt-5',
|
||||
requiredCapabilities: ['run.start'],
|
||||
});
|
||||
|
||||
expect(selection.compatible).toBe(false);
|
||||
expect(selection.candidates.flatMap((candidate) => candidate.reasons).join(' ')).toContain(
|
||||
'not reported'
|
||||
);
|
||||
});
|
||||
|
||||
it('orders mixed probe states deterministically', () => {
|
||||
const manifests = (['failed', 'degraded', 'ready'] as const).map((probeState) =>
|
||||
providerRuntimeManifestFixture({
|
||||
provider: `provider-${probeState}`,
|
||||
probeState,
|
||||
capabilityStates: { 'run.resume': 'advisory' },
|
||||
})
|
||||
);
|
||||
|
||||
const first = selectProviderRuntimeManifest({
|
||||
manifests,
|
||||
provider: 'non-matching-provider',
|
||||
requiredCapabilities: ['run.resume'],
|
||||
});
|
||||
const second = selectProviderRuntimeManifest({
|
||||
manifests: [...manifests].reverse(),
|
||||
provider: 'non-matching-provider',
|
||||
requiredCapabilities: ['run.resume'],
|
||||
});
|
||||
|
||||
expect(first.candidates.map((candidate) => candidate.probeState)).toEqual([
|
||||
'ready',
|
||||
'degraded',
|
||||
'failed',
|
||||
]);
|
||||
expect(second.candidates.map((candidate) => candidate.manifestDigest)).toEqual(
|
||||
first.candidates.map((candidate) => candidate.manifestDigest)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -226,12 +226,14 @@ describe('ProviderRuntimeManifestService', () => {
|
|||
});
|
||||
|
||||
it('redacts provider identity output before it enters the immutable snapshot', async () => {
|
||||
const manifest = await new ProviderRuntimeManifestService().probe(
|
||||
request('fixture 1.0.0 token=private-value sk-proj-abcdefghijklmnopqrstuvwxyz')
|
||||
);
|
||||
const unsafe = request('fixture 1.0.0 token=private-value sk-proj-abcdefghijklmnopqrstuvwxyz');
|
||||
unsafe.identity.source = 'fixture --version authorization=private-source-value';
|
||||
const manifest = await new ProviderRuntimeManifestService().probe(unsafe);
|
||||
|
||||
expect(manifest.providerVersion).toBe('fixture 1.0.0 token=[REDACTED] [REDACTED]');
|
||||
expect(manifest.probe.source).toBe('fixture --version authorization=[REDACTED]');
|
||||
expect(JSON.stringify(manifest)).not.toContain('private-value');
|
||||
expect(JSON.stringify(manifest)).not.toContain('private-source-value');
|
||||
expect(JSON.stringify(manifest)).not.toContain('sk-proj-');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ import policyRoutes from '../../routes/policies.js';
|
|||
import toolPolicyRoutes from '../../routes/tool-policies.js';
|
||||
import { agentPermissionRoutes } from '../../routes/agent-permissions.js';
|
||||
import { agentRoutingRoutes } from '../../routes/agent-routing.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
|
||||
const policyBody = {
|
||||
id: 'agent-created-policy',
|
||||
|
|
@ -124,6 +125,7 @@ function createApp() {
|
|||
app.use('/api/tool-policies', toolPolicyRoutes);
|
||||
app.use('/api/agents/permissions', agentPermissionRoutes);
|
||||
app.use('/api/agents', agentRoutingRoutes);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
|
|
@ -376,11 +378,72 @@ describe('admin-only governance routes', () => {
|
|||
.send({ type: 'feature', priority: 'medium' })
|
||||
.expect(200);
|
||||
|
||||
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith({
|
||||
type: 'feature',
|
||||
priority: 'medium',
|
||||
project: undefined,
|
||||
subtasks: undefined,
|
||||
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'feature',
|
||||
priority: 'medium',
|
||||
project: undefined,
|
||||
subtasks: undefined,
|
||||
},
|
||||
{ requiredRuntimeCapabilities: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes required runtime capabilities through ad-hoc routing requests', async () => {
|
||||
const app = createApp();
|
||||
|
||||
await request(app)
|
||||
.post('/api/agents/route')
|
||||
.set('X-API-Key', 'agent-key')
|
||||
.send({
|
||||
type: 'feature',
|
||||
priority: 'medium',
|
||||
requiredRuntimeCapabilities: ['run.resume', 'tool.mcp'],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'feature', priority: 'medium' }),
|
||||
{ requiredRuntimeCapabilities: ['run.resume', 'tool.mcp'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes required runtime capabilities through task routing requests', async () => {
|
||||
const app = createApp();
|
||||
const task = { id: 'task-1', type: 'feature', priority: 'medium' };
|
||||
mockTaskService.getTask.mockResolvedValue(task);
|
||||
|
||||
await request(app)
|
||||
.post('/api/agents/route')
|
||||
.set('X-API-Key', 'agent-key')
|
||||
.send({ taskId: 'task-1', requiredRuntimeCapabilities: ['run.resume'] })
|
||||
.expect(200);
|
||||
|
||||
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith(task, {
|
||||
taskId: 'task-1',
|
||||
requiredRuntimeCapabilities: ['run.resume'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects misspelled capability fields and malformed task routing bodies', async () => {
|
||||
const app = createApp();
|
||||
|
||||
await request(app)
|
||||
.post('/api/agents/route')
|
||||
.set('X-API-Key', 'agent-key')
|
||||
.send({ type: 'feature', requiredRuntimeCapabilites: ['run.resume'] })
|
||||
.expect(400);
|
||||
await request(app)
|
||||
.post('/api/agents/route')
|
||||
.set('X-API-Key', 'agent-key')
|
||||
.send({ taskId: '', type: 'feature' })
|
||||
.expect(400);
|
||||
await request(app)
|
||||
.post('/api/agents/hosts/preview')
|
||||
.set('X-API-Key', 'agent-key')
|
||||
.send({ requiredRuntimeCapabilites: ['run.resume'] })
|
||||
.expect(400);
|
||||
|
||||
expect(mockAgentRoutingService.resolveAgentWithTrace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
import { providerRuntimeManifestFixture } from '../fixtures/provider-runtime-manifest.js';
|
||||
import { calculateProviderRuntimeManifestDigest } from '../../utils/provider-runtime-manifest-digest.js';
|
||||
import type { AuthContext, AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
|
||||
const routeMocks = vi.hoisted(() => ({
|
||||
getAgentStatus: vi.fn(() => ({
|
||||
|
|
@ -24,6 +27,9 @@ vi.mock('../../storage/fs-helpers.js', () => ({
|
|||
readFileSync: vi.fn().mockReturnValue('{}'),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
rename: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/task-service.js', () => ({
|
||||
|
|
@ -43,6 +49,7 @@ const { agentRegistryRoutes } = await import('../../routes/agent-registry.js');
|
|||
|
||||
describe('Agent Registry Routes', () => {
|
||||
let app: express.Express;
|
||||
let authContext: AuthContext;
|
||||
|
||||
beforeEach(() => {
|
||||
disposeAgentRegistryService();
|
||||
|
|
@ -53,9 +60,18 @@ describe('Agent Registry Routes', () => {
|
|||
});
|
||||
routeMocks.getEvents.mockResolvedValue([]);
|
||||
routeMocks.listTasks.mockResolvedValue([]);
|
||||
authContext = {
|
||||
role: 'admin',
|
||||
isLocalhost: true,
|
||||
authMethod: 'disabled',
|
||||
};
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = authContext;
|
||||
next();
|
||||
});
|
||||
app.use('/api/agents/register', agentRegistryRoutes);
|
||||
app.use(errorHandler);
|
||||
});
|
||||
|
|
@ -124,6 +140,144 @@ describe('Agent Registry Routes', () => {
|
|||
expect(res.body.id).toBe('minimal-agent');
|
||||
expect(res.body.capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
it('registers a validated custom provider runtime manifest', async () => {
|
||||
const providerRuntimeManifest = providerRuntimeManifestFixture({
|
||||
provider: 'custom-runtime',
|
||||
adapter: 'custom-adapter',
|
||||
models: ['custom-model'],
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/agents/register').send({
|
||||
id: 'custom-agent',
|
||||
name: 'Custom Agent',
|
||||
providerRuntimeManifest,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.providerRuntimeManifest).toMatchObject({
|
||||
provider: 'custom-runtime',
|
||||
adapter: 'custom-adapter',
|
||||
digest: providerRuntimeManifest.digest,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects forged or incomplete provider runtime manifests', async () => {
|
||||
const valid = providerRuntimeManifestFixture();
|
||||
const forged = { ...valid, providerVersion: 'tampered' };
|
||||
const incompletePayload = {
|
||||
...valid,
|
||||
capabilities: valid.capabilities.slice(0, 1),
|
||||
};
|
||||
const incomplete = {
|
||||
...incompletePayload,
|
||||
digest: calculateProviderRuntimeManifestDigest(incompletePayload),
|
||||
};
|
||||
|
||||
const forgedResponse = await request(app).post('/api/agents/register').send({
|
||||
id: 'forged-agent',
|
||||
name: 'Forged Agent',
|
||||
providerRuntimeManifest: forged,
|
||||
});
|
||||
const incompleteResponse = await request(app).post('/api/agents/register').send({
|
||||
id: 'incomplete-agent',
|
||||
name: 'Incomplete Agent',
|
||||
providerRuntimeManifest: incomplete,
|
||||
});
|
||||
|
||||
expect(forgedResponse.status).toBe(400);
|
||||
expect(incompleteResponse.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects unredacted secrets in external runtime evidence', async () => {
|
||||
const valid = providerRuntimeManifestFixture();
|
||||
const unsafePayload = {
|
||||
...valid,
|
||||
capabilities: valid.capabilities.map((capability, index) =>
|
||||
index === 0 ? { ...capability, reason: 'token=secret-value' } : capability
|
||||
),
|
||||
};
|
||||
const unsafe = {
|
||||
...unsafePayload,
|
||||
digest: calculateProviderRuntimeManifestDigest(unsafePayload),
|
||||
};
|
||||
|
||||
const response = await request(app).post('/api/agents/register').send({
|
||||
id: 'unsafe-agent',
|
||||
name: 'Unsafe Agent',
|
||||
providerRuntimeManifest: unsafe,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(JSON.stringify(response.body)).not.toContain('secret-value');
|
||||
});
|
||||
|
||||
it('rejects misspelled manifest fields instead of silently dropping evidence', async () => {
|
||||
const response = await request(app).post('/api/agents/register').send({
|
||||
id: 'typo-agent',
|
||||
name: 'Typo Agent',
|
||||
providerRuntimeManfiest: providerRuntimeManifestFixture(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('binds manifest writes to the authenticated agent identity', async () => {
|
||||
authContext = {
|
||||
role: 'agent',
|
||||
keyName: 'other-agent',
|
||||
isLocalhost: false,
|
||||
authMethod: 'api-key',
|
||||
};
|
||||
const denied = await request(app).post('/api/agents/register').send({
|
||||
id: 'custom-agent',
|
||||
name: 'Custom Agent',
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture(),
|
||||
});
|
||||
expect(denied.status).toBe(403);
|
||||
|
||||
authContext.keyName = 'custom-agent';
|
||||
const allowed = await request(app).post('/api/agents/register').send({
|
||||
id: 'custom-agent',
|
||||
name: 'Custom Agent',
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture(),
|
||||
});
|
||||
expect(allowed.status).toBe(201);
|
||||
});
|
||||
|
||||
it('prevents another telemetry agent from replacing or clearing authoritative evidence', async () => {
|
||||
const manifest = providerRuntimeManifestFixture();
|
||||
await request(app).post('/api/agents/register').send({
|
||||
id: 'protected-agent',
|
||||
name: 'Protected Agent',
|
||||
provider: 'codex-cli',
|
||||
providerRuntimeManifest: manifest,
|
||||
});
|
||||
authContext = {
|
||||
role: 'agent',
|
||||
keyName: 'other-agent',
|
||||
isLocalhost: false,
|
||||
authMethod: 'api-key',
|
||||
};
|
||||
|
||||
await request(app)
|
||||
.post('/api/agents/register')
|
||||
.send({
|
||||
id: 'protected-agent',
|
||||
name: 'Protected Agent',
|
||||
provider: 'openclaw',
|
||||
})
|
||||
.expect(403);
|
||||
await request(app)
|
||||
.post('/api/agents/register/protected-agent/heartbeat')
|
||||
.send({ status: 'online' })
|
||||
.expect(403);
|
||||
await request(app).delete('/api/agents/register/protected-agent').expect(403);
|
||||
|
||||
const record = await request(app).get('/api/agents/register/protected-agent').expect(200);
|
||||
expect(record.body.provider).toBe('codex-cli');
|
||||
expect(record.body.providerRuntimeManifest.digest).toBe(manifest.digest);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Heartbeat ────────────────────────────────────────────────
|
||||
|
|
@ -165,6 +319,39 @@ describe('Agent Registry Routes', () => {
|
|||
expect(res.body.metadata).toEqual({ ping: 12345 });
|
||||
});
|
||||
|
||||
it('replaces the validated runtime manifest via heartbeat', async () => {
|
||||
const first = providerRuntimeManifestFixture({ providerVersion: 'fixture 1.0.0' });
|
||||
const upgraded = providerRuntimeManifestFixture({ providerVersion: 'fixture 2.0.0' });
|
||||
await request(app).post('/api/agents/register').send({
|
||||
id: 'manifest-agent',
|
||||
name: 'Manifest Agent',
|
||||
providerRuntimeManifest: first,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/agents/register/manifest-agent/heartbeat')
|
||||
.send({ providerRuntimeManifest: upgraded });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.providerRuntimeManifest).toMatchObject({
|
||||
providerVersion: 'fixture 2.0.0',
|
||||
digest: upgraded.digest,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a misspelled heartbeat manifest field', async () => {
|
||||
await request(app).post('/api/agents/register').send({
|
||||
id: 'manifest-agent',
|
||||
name: 'Manifest Agent',
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/register/manifest-agent/heartbeat')
|
||||
.send({ providerRuntimeManfiest: providerRuntimeManifestFixture() });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should clear task when status is idle', async () => {
|
||||
await request(app)
|
||||
.post('/api/agents/register')
|
||||
|
|
|
|||
|
|
@ -11,12 +11,17 @@ vi.mock('../../storage/fs-helpers.js', () => ({
|
|||
readFileSync: vi.fn().mockReturnValue('{}'),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
rename: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Must import after mock
|
||||
const { getAgentRegistryService, disposeAgentRegistryService, createTaskSyncToken } =
|
||||
await import('../../services/agent-registry-service.js');
|
||||
import type { RegisteredAgent, TaskSyncContext } from '../../services/agent-registry-service.js';
|
||||
import { providerRuntimeManifestFixture } from '../fixtures/provider-runtime-manifest.js';
|
||||
import { existsSync, readFileSync } from '../../storage/fs-helpers.js';
|
||||
|
||||
const TASK_SYNC_CONTEXT: TaskSyncContext = createTaskSyncToken('task-service');
|
||||
const TASK_RECONCILE_CONTEXT: TaskSyncContext = createTaskSyncToken('task-reconciler');
|
||||
|
|
@ -25,6 +30,8 @@ describe('AgentRegistryService', () => {
|
|||
beforeEach(() => {
|
||||
// Ensure fresh instance for each test
|
||||
disposeAgentRegistryService();
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
vi.mocked(readFileSync).mockReturnValue('{}');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -115,6 +122,77 @@ describe('AgentRegistryService', () => {
|
|||
newField: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates, freezes, and preserves the runtime manifest on re-register', () => {
|
||||
const service = getAgentRegistryService();
|
||||
const providerRuntimeManifest = providerRuntimeManifestFixture();
|
||||
const first = service.register({
|
||||
id: 'manifest-agent',
|
||||
name: 'Manifest Agent',
|
||||
providerRuntimeManifest,
|
||||
});
|
||||
const second = service.register({
|
||||
id: 'manifest-agent',
|
||||
name: 'Manifest Agent Updated',
|
||||
});
|
||||
|
||||
expect(first.providerRuntimeManifest?.digest).toBe(providerRuntimeManifest.digest);
|
||||
expect(Object.isFrozen(first.providerRuntimeManifest)).toBe(true);
|
||||
expect(Object.isFrozen(first.providerRuntimeManifest?.capabilities)).toBe(true);
|
||||
expect(second.providerRuntimeManifest?.digest).toBe(providerRuntimeManifest.digest);
|
||||
});
|
||||
|
||||
it.each([{ provider: 'openclaw' }, { model: 'different-model' }, { version: '2.0.0' }])(
|
||||
'invalidates preserved runtime evidence when identity changes: %o',
|
||||
(identityChange) => {
|
||||
const service = getAgentRegistryService();
|
||||
service.register({
|
||||
id: 'manifest-agent',
|
||||
name: 'Manifest Agent',
|
||||
provider: 'codex-cli',
|
||||
model: 'gpt-5',
|
||||
version: '1.0.0',
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture(),
|
||||
});
|
||||
|
||||
const updated = service.register({
|
||||
id: 'manifest-agent',
|
||||
name: 'Manifest Agent',
|
||||
...identityChange,
|
||||
});
|
||||
|
||||
expect(updated.providerRuntimeManifest).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('load()', () => {
|
||||
it('ignores invalid persisted runtime evidence', () => {
|
||||
const invalid = {
|
||||
...providerRuntimeManifestFixture(),
|
||||
providerVersion: 'tampered-after-digest',
|
||||
};
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFileSync).mockReturnValue(
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
persisted: {
|
||||
id: 'persisted',
|
||||
name: 'Persisted Agent',
|
||||
capabilities: [],
|
||||
status: 'idle',
|
||||
registeredAt: '2026-07-15T12:00:00.000Z',
|
||||
lastHeartbeat: '2026-07-15T12:00:00.000Z',
|
||||
providerRuntimeManifest: invalid,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const service = getAgentRegistryService();
|
||||
|
||||
expect(service.get('persisted')?.providerRuntimeManifest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Heartbeat ────────────────────────────────────────────────
|
||||
|
|
@ -233,6 +311,24 @@ describe('AgentRegistryService', () => {
|
|||
ping: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the runtime manifest on heartbeat', () => {
|
||||
const service = getAgentRegistryService();
|
||||
const first = providerRuntimeManifestFixture({ providerVersion: 'fixture 1.0.0' });
|
||||
const upgraded = providerRuntimeManifestFixture({ providerVersion: 'fixture 2.0.0' });
|
||||
service.register({
|
||||
id: 'manifest-agent',
|
||||
name: 'Manifest Agent',
|
||||
providerRuntimeManifest: first,
|
||||
});
|
||||
|
||||
const updated = service.heartbeat('manifest-agent', {
|
||||
providerRuntimeManifest: upgraded,
|
||||
});
|
||||
|
||||
expect(updated?.providerRuntimeManifest?.digest).toBe(upgraded.digest);
|
||||
expect(updated?.providerRuntimeManifest?.providerVersion).toBe('fixture 2.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Listing ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ import { getAgentHealthClassifierService } from '../services/agent-health-classi
|
|||
import { getTaskService } from '../services/task-service.js';
|
||||
import { getTelemetryService } from '../services/telemetry-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
import { getAgentStatus } from './agent-status.js';
|
||||
import { ProviderRuntimeManifestSchema } from '../schemas/provider-runtime-manifest-schemas.js';
|
||||
import { hasPermission, type AuthenticatedRequest } from '../middleware/auth.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
|
|
@ -30,23 +32,29 @@ const capabilitySchema = z.object({
|
|||
description: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
id: z.string().min(1).max(50),
|
||||
name: z.string().min(1).max(100),
|
||||
model: z.string().max(100).optional(),
|
||||
provider: z.string().max(50).optional(),
|
||||
capabilities: z.array(capabilitySchema).optional(),
|
||||
version: z.string().max(50).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
sessionKey: z.string().max(200).optional(),
|
||||
});
|
||||
const registerSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(50),
|
||||
name: z.string().min(1).max(100),
|
||||
model: z.string().max(100).optional(),
|
||||
provider: z.string().max(50).optional(),
|
||||
capabilities: z.array(capabilitySchema).optional(),
|
||||
version: z.string().max(50).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
providerRuntimeManifest: ProviderRuntimeManifestSchema.optional(),
|
||||
sessionKey: z.string().max(200).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const heartbeatSchema = z.object({
|
||||
status: z.enum(['online', 'busy', 'idle']).optional(),
|
||||
currentTaskId: z.string().max(100).optional().nullable(),
|
||||
currentTaskTitle: z.string().max(200).optional().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
const heartbeatSchema = z
|
||||
.object({
|
||||
status: z.enum(['online', 'busy', 'idle']).optional(),
|
||||
currentTaskId: z.string().max(100).optional().nullable(),
|
||||
currentTaskTitle: z.string().max(200).optional().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
providerRuntimeManifest: ProviderRuntimeManifestSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ─── Routes ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -116,8 +124,13 @@ router.post(
|
|||
if (!parsed.success) {
|
||||
throw new ValidationError('Invalid registration', parsed.error.issues);
|
||||
}
|
||||
|
||||
const registry = getAgentRegistryService();
|
||||
assertTrustedRuntimeEvidenceMutation(
|
||||
req as AuthenticatedRequest,
|
||||
parsed.data.providerRuntimeManifest !== undefined ||
|
||||
registry.get(parsed.data.id)?.providerRuntimeManifest !== undefined,
|
||||
parsed.data.id
|
||||
);
|
||||
const agent = registry.register(parsed.data);
|
||||
res.status(201).json(agent);
|
||||
})
|
||||
|
|
@ -166,8 +179,13 @@ router.post(
|
|||
if (!parsed.success) {
|
||||
throw new ValidationError('Invalid heartbeat', parsed.error.issues);
|
||||
}
|
||||
|
||||
const registry = getAgentRegistryService();
|
||||
assertTrustedRuntimeEvidenceMutation(
|
||||
req as AuthenticatedRequest,
|
||||
parsed.data.providerRuntimeManifest !== undefined ||
|
||||
registry.get(req.params.id as string)?.providerRuntimeManifest !== undefined,
|
||||
req.params.id as string
|
||||
);
|
||||
const agent = registry.heartbeat(req.params.id as string, {
|
||||
...parsed.data,
|
||||
currentTaskId: parsed.data.currentTaskId ?? undefined,
|
||||
|
|
@ -190,6 +208,11 @@ router.delete(
|
|||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const registry = getAgentRegistryService();
|
||||
assertTrustedRuntimeEvidenceMutation(
|
||||
req as AuthenticatedRequest,
|
||||
registry.get(req.params.id as string)?.providerRuntimeManifest !== undefined,
|
||||
req.params.id as string
|
||||
);
|
||||
const removed = registry.deregister(req.params.id as string);
|
||||
if (!removed) {
|
||||
throw new NotFoundError('Agent not found');
|
||||
|
|
@ -198,4 +221,25 @@ router.delete(
|
|||
})
|
||||
);
|
||||
|
||||
function assertTrustedRuntimeEvidenceMutation(
|
||||
req: AuthenticatedRequest,
|
||||
requiresIdentityBinding: boolean,
|
||||
agentId: string
|
||||
): void {
|
||||
if (!requiresIdentityBinding) return;
|
||||
if (hasPermission(req.auth, 'agent:write')) return;
|
||||
|
||||
const principalIds = [req.auth?.keyName, req.auth?.tokenName, req.auth?.clientId]
|
||||
.filter((value): value is string => Boolean(value?.trim()))
|
||||
.map((value) => value.trim().toLowerCase());
|
||||
const selfRegistration =
|
||||
req.auth?.role === 'agent' && principalIds.includes(agentId.trim().toLowerCase());
|
||||
if (selfRegistration) return;
|
||||
|
||||
throw new ForbiddenError(
|
||||
'Modifying authoritative provider runtime evidence requires a matching agent identity or agent:write permission',
|
||||
{ required: ['telemetry:write', 'matching-agent-identity-or-agent:write'] }
|
||||
);
|
||||
}
|
||||
|
||||
export { router as agentRegistryRoutes };
|
||||
|
|
|
|||
|
|
@ -20,16 +20,31 @@ const router: RouterType = Router();
|
|||
|
||||
// ─── Validation Schemas ──────────────────────────────────────────
|
||||
|
||||
const routeByTaskIdSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
});
|
||||
const runtimeCapabilityIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(80)
|
||||
.regex(/^[a-z][a-z0-9.-]*$/);
|
||||
|
||||
const routeByMetadataSchema = z.object({
|
||||
type: z.string().optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
subtaskCount: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
const requiredRuntimeCapabilitiesSchema = z.array(runtimeCapabilityIdSchema).max(64).optional();
|
||||
|
||||
const routeByTaskIdSchema = z
|
||||
.object({
|
||||
taskId: z.string().min(1),
|
||||
requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const routeByMetadataSchema = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
subtaskCount: z.number().int().nonnegative().optional(),
|
||||
requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const routingMatchSchema = z.object({
|
||||
type: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
|
|
@ -59,18 +74,21 @@ const routingConfigSchema = z.object({
|
|||
maxRetries: z.number().int().min(0).max(3),
|
||||
});
|
||||
|
||||
const hostPreviewSchema = z.object({
|
||||
agent: z.string().max(100).optional(),
|
||||
provider: z.string().max(80).optional(),
|
||||
model: z.string().max(100).optional(),
|
||||
workspacePath: z.string().max(1000).optional(),
|
||||
requiredTools: z.array(z.string().max(80)).max(50).optional(),
|
||||
verificationGates: z.array(z.string().max(200)).max(50).optional(),
|
||||
sandboxPresetId: z.string().max(80).optional(),
|
||||
manualHostId: z.string().max(120).optional(),
|
||||
projectDefaultHostId: z.string().max(120).optional(),
|
||||
autoRouting: z.boolean().optional(),
|
||||
});
|
||||
const hostPreviewSchema = z
|
||||
.object({
|
||||
agent: z.string().max(100).optional(),
|
||||
provider: z.string().max(80).optional(),
|
||||
model: z.string().max(100).optional(),
|
||||
workspacePath: z.string().max(1000).optional(),
|
||||
requiredTools: z.array(z.string().max(80)).max(50).optional(),
|
||||
requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema,
|
||||
verificationGates: z.array(z.string().max(200)).max(50).optional(),
|
||||
sandboxPresetId: z.string().max(80).optional(),
|
||||
manualHostId: z.string().max(120).optional(),
|
||||
projectDefaultHostId: z.string().max(120).optional(),
|
||||
autoRouting: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ─── Routes ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -94,7 +112,10 @@ router.post(
|
|||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
const result = await routing.resolveAgentWithTrace(task, { taskId: taskIdParse.data.taskId });
|
||||
const result = await routing.resolveAgentWithTrace(task, {
|
||||
taskId: taskIdParse.data.taskId,
|
||||
requiredRuntimeCapabilities: taskIdParse.data.requiredRuntimeCapabilities,
|
||||
});
|
||||
const trace = await getGovernanceTraceService().record(result.trace);
|
||||
return res.json({ ...result.result, traceId: trace.id });
|
||||
}
|
||||
|
|
@ -102,20 +123,23 @@ router.post(
|
|||
// Fall back to metadata
|
||||
const metaParse = routeByMetadataSchema.safeParse(req.body);
|
||||
if (metaParse.success) {
|
||||
const { type, priority, project, subtaskCount } = metaParse.data;
|
||||
const result = await routing.resolveAgentWithTrace({
|
||||
type: type || 'feature',
|
||||
priority: priority || 'medium',
|
||||
project,
|
||||
subtasks: subtaskCount
|
||||
? Array.from({ length: subtaskCount }, (_, i) => ({
|
||||
id: `stub_${i}`,
|
||||
title: '',
|
||||
completed: false,
|
||||
created: new Date().toISOString(),
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
const { type, priority, project, subtaskCount, requiredRuntimeCapabilities } = metaParse.data;
|
||||
const result = await routing.resolveAgentWithTrace(
|
||||
{
|
||||
type: type || 'feature',
|
||||
priority: priority || 'medium',
|
||||
project,
|
||||
subtasks: subtaskCount
|
||||
? Array.from({ length: subtaskCount }, (_, i) => ({
|
||||
id: `stub_${i}`,
|
||||
title: '',
|
||||
completed: false,
|
||||
created: new Date().toISOString(),
|
||||
}))
|
||||
: undefined,
|
||||
},
|
||||
{ requiredRuntimeCapabilities }
|
||||
);
|
||||
const trace = await getGovernanceTraceService().record(result.trace);
|
||||
return res.json({ ...result.result, traceId: trace.id });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type ProviderRuntimeManifest,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { calculateProviderRuntimeManifestDigest } from '../utils/provider-runtime-manifest-digest.js';
|
||||
import { containsUnredactedProviderRuntimeSecret } from '../utils/provider-runtime-manifest-sanitize.js';
|
||||
|
||||
const identifierSchema = z
|
||||
.string()
|
||||
|
|
@ -80,6 +81,30 @@ export const ProviderRuntimeManifestSchema = z
|
|||
message: 'Provider runtime manifest digest does not match its canonical payload',
|
||||
});
|
||||
}
|
||||
const sensitiveFields: Array<{ path: (string | number)[]; value: string }> = [
|
||||
{ path: ['providerVersion'], value: manifest.providerVersion },
|
||||
...(manifest.providerBuild
|
||||
? [{ path: ['providerBuild'], value: manifest.providerBuild }]
|
||||
: []),
|
||||
{ path: ['probe', 'source'], value: manifest.probe.source },
|
||||
...manifest.probe.diagnostics.map((value, index) => ({
|
||||
path: ['probe', 'diagnostics', index],
|
||||
value,
|
||||
})),
|
||||
...manifest.capabilities.map((capability, index) => ({
|
||||
path: ['capabilities', index, 'reason'],
|
||||
value: capability.reason,
|
||||
})),
|
||||
];
|
||||
for (const field of sensitiveFields) {
|
||||
if (containsUnredactedProviderRuntimeSecret(field.value)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: field.path,
|
||||
message: 'Provider runtime evidence must redact credentials and secrets before ingestion',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function parseProviderRuntimeManifest(input: unknown): ProviderRuntimeManifest {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,20 @@ import type {
|
|||
AgentHostPreviewRequest,
|
||||
AgentHostRecord,
|
||||
AgentHostRoutingDecision,
|
||||
ProviderRuntimeCapabilityId,
|
||||
ProviderRuntimeManifest,
|
||||
ProviderRuntimeSelection,
|
||||
SandboxProviderCapabilityId,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { getAgentRegistryService, type RegisteredAgent } from './agent-registry-service.js';
|
||||
import {
|
||||
AGENT_HEARTBEAT_TIMEOUT_MS,
|
||||
getAgentRegistryService,
|
||||
registeredAgentIsLive,
|
||||
type RegisteredAgent,
|
||||
} from './agent-registry-service.js';
|
||||
import { selectProviderRuntimeManifest } from './provider-runtime-capability-service.js';
|
||||
|
||||
const STALE_HEARTBEAT_MS = 10 * 60 * 1000;
|
||||
const STALE_HEARTBEAT_MS = AGENT_HEARTBEAT_TIMEOUT_MS;
|
||||
const DISCONNECTED_HEARTBEAT_MS = 30 * 60 * 1000;
|
||||
|
||||
interface AgentHostRegistryReader {
|
||||
|
|
@ -31,6 +40,11 @@ interface HostAccumulator {
|
|||
supportedModels: Set<string>;
|
||||
supportedTools: Set<string>;
|
||||
sandboxCapabilities: Set<SandboxProviderCapabilityId>;
|
||||
manifestRegistrations: HostManifestRegistration[];
|
||||
legacyProviders: Set<string>;
|
||||
legacyModels: Set<string>;
|
||||
legacyTools: Set<string>;
|
||||
legacySandboxCapabilities: Set<SandboxProviderCapabilityId>;
|
||||
workspaceLabels: Set<string>;
|
||||
workspaceRoots: string[];
|
||||
activeSessions: number;
|
||||
|
|
@ -43,8 +57,15 @@ interface HostAccumulator {
|
|||
diagnostics: Set<string>;
|
||||
}
|
||||
|
||||
interface HostManifestRegistration {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
manifest: ProviderRuntimeManifest;
|
||||
}
|
||||
|
||||
interface ResolvedAgentHost extends AgentHostRecord {
|
||||
workspaceRoots: string[];
|
||||
manifestRegistrations: HostManifestRegistration[];
|
||||
}
|
||||
|
||||
export class AgentHostService {
|
||||
|
|
@ -61,12 +82,13 @@ export class AgentHostService {
|
|||
|
||||
preview(request: AgentHostPreviewRequest, now = new Date()): AgentHostCompatibilityResponse {
|
||||
const hosts = this.buildHosts(now);
|
||||
const previews = hosts.map((host) => this.previewHost(host, request));
|
||||
const normalizedRequest = normalizeRequest(request);
|
||||
const previews = hosts.map((host) => this.previewHost(host, normalizedRequest));
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
request: normalizeRequest(request),
|
||||
request: normalizedRequest,
|
||||
previews,
|
||||
decision: this.resolveDecision(previews, request),
|
||||
decision: this.resolveDecision(previews, normalizedRequest),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -86,28 +108,53 @@ export class AgentHostService {
|
|||
host.supportedAgents.add(agent.name);
|
||||
for (const item of stringArray(metadata.supportedAgents)) host.supportedAgents.add(item);
|
||||
for (const capability of agent.capabilities) {
|
||||
host.supportedTools.add(capability.name);
|
||||
host.legacyTools.add(capability.name);
|
||||
}
|
||||
|
||||
if (agent.provider) host.supportedProviders.add(agent.provider);
|
||||
for (const item of stringArray(metadata.providers)) host.supportedProviders.add(item);
|
||||
if (agent.provider) host.legacyProviders.add(agent.provider);
|
||||
for (const item of stringArray(metadata.providers)) host.legacyProviders.add(item);
|
||||
for (const capability of sandboxCapabilityArray(metadata.sandboxCapabilities)) {
|
||||
host.sandboxCapabilities.add(capability);
|
||||
host.legacySandboxCapabilities.add(capability);
|
||||
}
|
||||
for (const capability of inferredSandboxCapabilities(agent.provider)) {
|
||||
host.sandboxCapabilities.add(capability);
|
||||
host.legacySandboxCapabilities.add(capability);
|
||||
}
|
||||
for (const provider of stringArray(metadata.providers)) {
|
||||
for (const capability of inferredSandboxCapabilities(provider)) {
|
||||
host.sandboxCapabilities.add(capability);
|
||||
host.legacySandboxCapabilities.add(capability);
|
||||
}
|
||||
}
|
||||
|
||||
if (agent.model) host.supportedModels.add(agent.model);
|
||||
for (const item of stringArray(metadata.models)) host.supportedModels.add(item);
|
||||
if (agent.model) host.legacyModels.add(agent.model);
|
||||
for (const item of stringArray(metadata.models)) host.legacyModels.add(item);
|
||||
|
||||
for (const item of stringArray(metadata.tools)) host.supportedTools.add(item);
|
||||
for (const item of stringArray(metadata.requiredTools)) host.supportedTools.add(item);
|
||||
for (const item of stringArray(metadata.tools)) host.legacyTools.add(item);
|
||||
for (const item of stringArray(metadata.requiredTools)) host.legacyTools.add(item);
|
||||
|
||||
const manifest = agent.providerRuntimeManifest;
|
||||
if (manifest && registrationManifestIsCurrent(agent, now)) {
|
||||
host.manifestRegistrations.push({ agentId: agent.id, agentName: agent.name, manifest });
|
||||
if (manifest.probe.state === 'failed') {
|
||||
host.diagnostics.add(
|
||||
`Provider manifest ${manifest.digest} failed its readiness probe and is display-only.`
|
||||
);
|
||||
} else {
|
||||
host.supportedProviders.add(manifest.provider);
|
||||
host.supportedProviders.add(manifest.adapter);
|
||||
for (const model of manifest.models) host.supportedModels.add(model);
|
||||
for (const capability of manifest.capabilities) {
|
||||
if (capability.state !== 'supported') continue;
|
||||
if (capability.id.startsWith('tool.')) host.supportedTools.add(capability.id);
|
||||
if (isSandboxCapability(capability.id)) {
|
||||
host.sandboxCapabilities.add(capability.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (manifest) {
|
||||
host.diagnostics.add(
|
||||
`Runtime manifest from agent ${agent.id} was excluded because its registration is offline or outside the five-minute heartbeat window.`
|
||||
);
|
||||
}
|
||||
|
||||
const rawRoots = stringArray(metadata.workspaceRoots);
|
||||
for (const root of rawRoots) {
|
||||
|
|
@ -148,6 +195,7 @@ export class AgentHostService {
|
|||
host: ResolvedAgentHost,
|
||||
request: AgentHostPreviewRequest
|
||||
): AgentHostCompatibilityPreview {
|
||||
const runtimeSelection = this.selectRuntimeManifest(host, request);
|
||||
const checks: AgentHostCompatibilityCheck[] = [
|
||||
{
|
||||
id: 'heartbeat',
|
||||
|
|
@ -181,8 +229,9 @@ export class AgentHostService {
|
|||
host.supportedModels,
|
||||
'No model requirement supplied.'
|
||||
),
|
||||
this.requiredToolsCheck(host, request.requiredTools),
|
||||
this.sandboxPolicyCheck(host, request.sandboxPresetId),
|
||||
this.runtimeCapabilitiesCheck(runtimeSelection, request),
|
||||
this.requiredToolsCheck(runtimeSelection, request.requiredTools),
|
||||
this.sandboxPolicyCheck(request.sandboxPresetId),
|
||||
{
|
||||
id: 'verification-gates',
|
||||
label: 'Verification gates',
|
||||
|
|
@ -210,7 +259,9 @@ export class AgentHostService {
|
|||
...host.diagnostics,
|
||||
...checks
|
||||
.filter(
|
||||
(check) => check.passed && /unknown|not supplied|will be recorded/i.test(check.detail)
|
||||
(check) =>
|
||||
check.passed &&
|
||||
/unknown|not supplied|will be recorded|advisory|legacy/i.test(check.detail)
|
||||
)
|
||||
.map((check) => check.detail),
|
||||
];
|
||||
|
|
@ -221,6 +272,7 @@ export class AgentHostService {
|
|||
posture: host.posture,
|
||||
compatible: reasons.length === 0,
|
||||
checks,
|
||||
runtimeSelection,
|
||||
reasons,
|
||||
warnings: uniqueSorted(warnings),
|
||||
};
|
||||
|
|
@ -274,8 +326,8 @@ export class AgentHostService {
|
|||
return {
|
||||
id,
|
||||
label,
|
||||
passed: true,
|
||||
detail: `${label} support is unknown for this host.`,
|
||||
passed: false,
|
||||
detail: `${label} support is unavailable because no validated runtime manifest reports it.`,
|
||||
};
|
||||
}
|
||||
const matched = supported.some((candidate) => normalize(candidate) === normalize(value));
|
||||
|
|
@ -289,8 +341,60 @@ export class AgentHostService {
|
|||
};
|
||||
}
|
||||
|
||||
private selectRuntimeManifest(
|
||||
host: ResolvedAgentHost,
|
||||
request: AgentHostPreviewRequest
|
||||
): ProviderRuntimeSelection {
|
||||
const registrations = host.manifestRegistrations.filter(
|
||||
(registration) =>
|
||||
!request.agent ||
|
||||
normalize(request.agent) === normalize(registration.agentId) ||
|
||||
normalize(request.agent) === normalize(registration.agentName)
|
||||
);
|
||||
return selectProviderRuntimeManifest({
|
||||
manifests: registrations.map((registration) => registration.manifest),
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
requiredCapabilities: [
|
||||
...(request.requiredRuntimeCapabilities ?? []),
|
||||
...(request.requiredTools ?? []).filter((tool) => tool.startsWith('tool.')),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private runtimeCapabilitiesCheck(
|
||||
selection: ProviderRuntimeSelection,
|
||||
request: AgentHostPreviewRequest
|
||||
): AgentHostCompatibilityCheck {
|
||||
const required = selection.requiredCapabilities;
|
||||
const hasManifestRequirement = Boolean(
|
||||
request.provider || request.model || required.length > 0
|
||||
);
|
||||
if (!hasManifestRequirement) {
|
||||
return {
|
||||
id: 'runtime-capabilities',
|
||||
label: 'Runtime manifest',
|
||||
passed: true,
|
||||
detail: 'No provider, model, or runtime capability requirement supplied.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'runtime-capabilities',
|
||||
label: 'Runtime manifest',
|
||||
passed: selection.compatible,
|
||||
detail: selection.compatible
|
||||
? required.length > 0
|
||||
? selection.selectedManifest?.advisory
|
||||
? `Required runtime capabilities have advisory evidence: ${required.join(', ')}.`
|
||||
: `Required runtime capabilities are supported: ${required.join(', ')}.`
|
||||
: 'Provider and model requirements are satisfied by one validated runtime manifest.'
|
||||
: selection.reason,
|
||||
};
|
||||
}
|
||||
|
||||
private requiredToolsCheck(
|
||||
host: AgentHostRecord,
|
||||
selection: ProviderRuntimeSelection,
|
||||
requiredTools: string[] | undefined
|
||||
): AgentHostCompatibilityCheck {
|
||||
const tools = (requiredTools ?? []).map((tool) => tool.trim()).filter(Boolean);
|
||||
|
|
@ -302,23 +406,23 @@ export class AgentHostService {
|
|||
detail: 'No required tools supplied.',
|
||||
};
|
||||
}
|
||||
const supported = new Set(host.supportedTools.map(normalize));
|
||||
const missing = tools.filter((tool) => !supported.has(normalize(tool)));
|
||||
const runtimeTools = tools.filter((tool) => tool.startsWith('tool.'));
|
||||
const legacyNamedTools = tools.filter((tool) => !tool.startsWith('tool.'));
|
||||
const runtimePassed = runtimeTools.length === 0 || selection.compatible;
|
||||
const legacyNamesPassed = legacyNamedTools.length === 0;
|
||||
return {
|
||||
id: 'required-tools',
|
||||
label: 'Required tools',
|
||||
passed: missing.length === 0,
|
||||
detail:
|
||||
missing.length === 0
|
||||
? 'Required tools are registered on this host.'
|
||||
: `Missing required tools: ${missing.join(', ')}.`,
|
||||
passed: runtimePassed && legacyNamesPassed,
|
||||
detail: !legacyNamesPassed
|
||||
? `Legacy named tool requirements cannot qualify host runtime posture: ${legacyNamedTools.join(', ')}. Use requiredRuntimeCapabilities with a tool.* identifier.`
|
||||
: runtimePassed
|
||||
? 'Required tool capabilities qualify through one validated runtime manifest.'
|
||||
: selection.reason,
|
||||
};
|
||||
}
|
||||
|
||||
private sandboxPolicyCheck(
|
||||
host: AgentHostRecord,
|
||||
sandboxPresetId: string | undefined
|
||||
): AgentHostCompatibilityCheck {
|
||||
private sandboxPolicyCheck(sandboxPresetId: string | undefined): AgentHostCompatibilityCheck {
|
||||
if (!sandboxPresetId) {
|
||||
return {
|
||||
id: 'sandbox-policy',
|
||||
|
|
@ -331,11 +435,8 @@ export class AgentHostService {
|
|||
return {
|
||||
id: 'sandbox-policy',
|
||||
label: 'Sandbox policy',
|
||||
passed: host.sandboxCapabilities.length > 0,
|
||||
detail:
|
||||
host.sandboxCapabilities.length > 0
|
||||
? `Host reports ${host.sandboxCapabilities.length} sandbox capability signal(s) for preset ${sandboxPresetId}.`
|
||||
: `Host does not report sandbox capability support for preset ${sandboxPresetId}.`,
|
||||
passed: false,
|
||||
detail: `Sandbox preset ${sandboxPresetId} cannot qualify a host until its required controls are resolved into requiredRuntimeCapabilities.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -444,6 +545,11 @@ function getOrCreateHost(
|
|||
supportedModels: new Set(),
|
||||
supportedTools: new Set(),
|
||||
sandboxCapabilities: new Set(),
|
||||
manifestRegistrations: [],
|
||||
legacyProviders: new Set(),
|
||||
legacyModels: new Set(),
|
||||
legacyTools: new Set(),
|
||||
legacySandboxCapabilities: new Set(),
|
||||
workspaceLabels: new Set(),
|
||||
workspaceRoots: [],
|
||||
activeSessions: 0,
|
||||
|
|
@ -475,6 +581,16 @@ function finalizeHost(host: HostAccumulator, now: Date): ResolvedAgentHost {
|
|||
if (host.lastFailure) {
|
||||
host.diagnostics.add('Supervisor reported a recent failure.');
|
||||
}
|
||||
if (
|
||||
host.legacyProviders.size > 0 ||
|
||||
host.legacyModels.size > 0 ||
|
||||
host.legacyTools.size > 0 ||
|
||||
host.legacySandboxCapabilities.size > 0
|
||||
) {
|
||||
host.diagnostics.add(
|
||||
'Legacy provider, model, tool, and sandbox metadata is display-only and cannot satisfy runtime capability requirements.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
|
|
@ -490,6 +606,17 @@ function finalizeHost(host: HostAccumulator, now: Date): ResolvedAgentHost {
|
|||
sandboxCapabilities: uniqueSorted(
|
||||
Array.from(host.sandboxCapabilities)
|
||||
) as SandboxProviderCapabilityId[],
|
||||
providerRuntimeManifests: uniqueManifests(
|
||||
host.manifestRegistrations.map((registration) => registration.manifest)
|
||||
),
|
||||
legacyRuntimePosture: {
|
||||
providers: uniqueSorted(Array.from(host.legacyProviders)),
|
||||
models: uniqueSorted(Array.from(host.legacyModels)),
|
||||
tools: uniqueSorted(Array.from(host.legacyTools)),
|
||||
sandboxCapabilities: uniqueSorted(
|
||||
Array.from(host.legacySandboxCapabilities)
|
||||
) as SandboxProviderCapabilityId[],
|
||||
},
|
||||
workspaceLabels: uniqueSorted(Array.from(host.workspaceLabels)),
|
||||
activeSessions: host.activeSessions,
|
||||
queueDepth: host.queueDepth,
|
||||
|
|
@ -500,6 +627,7 @@ function finalizeHost(host: HostAccumulator, now: Date): ResolvedAgentHost {
|
|||
diagnostics: uniqueSorted(Array.from(host.diagnostics)),
|
||||
registeredAgentIds: uniqueSorted(host.registeredAgentIds),
|
||||
workspaceRoots: uniqueSorted(host.workspaceRoots),
|
||||
manifestRegistrations: host.manifestRegistrations,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -516,6 +644,10 @@ function resolvePosture(host: HostAccumulator, overloaded: boolean, now: Date):
|
|||
return 'connected';
|
||||
}
|
||||
|
||||
function registrationManifestIsCurrent(agent: RegisteredAgent, now: Date): boolean {
|
||||
return registeredAgentIsLive(agent, now.getTime());
|
||||
}
|
||||
|
||||
function summarizeHosts(
|
||||
hosts: Pick<AgentHostRecord, 'posture' | 'overloaded'>[]
|
||||
): AgentHostHealthResponse['summary'] {
|
||||
|
|
@ -537,7 +669,11 @@ function summarizeHosts(
|
|||
}
|
||||
|
||||
function stripInternalHostFields(host: ResolvedAgentHost): AgentHostRecord {
|
||||
const { workspaceRoots: _workspaceRoots, ...publicHost } = host;
|
||||
const {
|
||||
workspaceRoots: _workspaceRoots,
|
||||
manifestRegistrations: _manifestRegistrations,
|
||||
...publicHost
|
||||
} = host;
|
||||
return publicHost;
|
||||
}
|
||||
|
||||
|
|
@ -548,6 +684,7 @@ function normalizeRequest(request: AgentHostPreviewRequest): AgentHostPreviewReq
|
|||
model: trimOptional(request.model),
|
||||
workspacePath: trimOptional(request.workspacePath),
|
||||
requiredTools: uniqueSorted(request.requiredTools ?? []),
|
||||
requiredRuntimeCapabilities: uniqueSorted(request.requiredRuntimeCapabilities ?? []),
|
||||
verificationGates: uniqueSorted(request.verificationGates ?? []),
|
||||
sandboxPresetId: trimOptional(request.sandboxPresetId),
|
||||
manualHostId: trimOptional(request.manualHostId),
|
||||
|
|
@ -601,6 +738,12 @@ const SANDBOX_CAPABILITY_IDS = new Set<SandboxProviderCapabilityId>([
|
|||
'credential.broker',
|
||||
]);
|
||||
|
||||
function isSandboxCapability(
|
||||
value: ProviderRuntimeCapabilityId
|
||||
): value is SandboxProviderCapabilityId {
|
||||
return SANDBOX_CAPABILITY_IDS.has(value as SandboxProviderCapabilityId);
|
||||
}
|
||||
|
||||
function sandboxCapabilityArray(value: unknown): SandboxProviderCapabilityId[] {
|
||||
return stringArray(value).filter((item): item is SandboxProviderCapabilityId =>
|
||||
SANDBOX_CAPABILITY_IDS.has(item as SandboxProviderCapabilityId)
|
||||
|
|
@ -683,6 +826,15 @@ function uniqueSorted(values: string[]): string[] {
|
|||
);
|
||||
}
|
||||
|
||||
function uniqueManifests(manifests: ProviderRuntimeManifest[]): ProviderRuntimeManifest[] {
|
||||
const byDigest = new Map<string, ProviderRuntimeManifest>();
|
||||
for (const manifest of manifests) byDigest.set(manifest.digest, manifest);
|
||||
return Array.from(byDigest.values()).sort(
|
||||
(left, right) =>
|
||||
left.provider.localeCompare(right.provider) || left.digest.localeCompare(right.digest)
|
||||
);
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import {
|
|||
} from '../storage/fs-helpers.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { getRuntimeDir } from '../utils/paths.js';
|
||||
import type { ProviderRuntimeManifest } from '@veritas-kanban/shared';
|
||||
import { parseProviderRuntimeManifest } from '../schemas/provider-runtime-manifest-schemas.js';
|
||||
|
||||
const log = createLogger('agent-registry');
|
||||
|
||||
|
|
@ -47,6 +49,8 @@ export interface RegisteredAgent {
|
|||
version?: string;
|
||||
/** Freeform metadata */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** Validated provider runtime evidence used for capability decisions. */
|
||||
providerRuntimeManifest?: ProviderRuntimeManifest;
|
||||
/** Current status */
|
||||
status: 'online' | 'busy' | 'idle' | 'offline';
|
||||
/** ISO timestamp of registration */
|
||||
|
|
@ -69,6 +73,7 @@ export interface AgentRegistration {
|
|||
capabilities?: AgentCapability[];
|
||||
version?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
providerRuntimeManifest?: ProviderRuntimeManifest;
|
||||
sessionKey?: string;
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +82,7 @@ export interface AgentHeartbeat {
|
|||
currentTaskId?: string;
|
||||
currentTaskTitle?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
providerRuntimeManifest?: ProviderRuntimeManifest;
|
||||
}
|
||||
|
||||
export interface AgentRegistryData {
|
||||
|
|
@ -128,7 +134,13 @@ export interface TaskSyncSnapshot {
|
|||
// ─── Configuration ───────────────────────────────────────────────
|
||||
|
||||
/** How long before an agent is considered offline (no heartbeat) */
|
||||
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
export const AGENT_HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export function registeredAgentIsLive(agent: RegisteredAgent, now = Date.now()): boolean {
|
||||
if (agent.status === 'offline') return false;
|
||||
const lastHeartbeat = Date.parse(agent.lastHeartbeat);
|
||||
return Number.isFinite(lastHeartbeat) && now - lastHeartbeat <= AGENT_HEARTBEAT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** How often to check for stale agents */
|
||||
const STALE_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute
|
||||
|
|
@ -200,6 +212,9 @@ class AgentRegistryService {
|
|||
register(registration: AgentRegistration): RegisteredAgent {
|
||||
const existing = this.agents.get(registration.id);
|
||||
const now = new Date().toISOString();
|
||||
const runtimeIdentityChanged = existing
|
||||
? registrationChangesRuntimeIdentity(registration, existing)
|
||||
: false;
|
||||
|
||||
const agent: RegisteredAgent = {
|
||||
id: registration.id,
|
||||
|
|
@ -209,6 +224,12 @@ class AgentRegistryService {
|
|||
capabilities: registration.capabilities ?? existing?.capabilities ?? [],
|
||||
version: registration.version ?? existing?.version,
|
||||
metadata: registration.metadata ?? existing?.metadata,
|
||||
providerRuntimeManifest:
|
||||
registration.providerRuntimeManifest !== undefined
|
||||
? immutableManifest(parseProviderRuntimeManifest(registration.providerRuntimeManifest))
|
||||
: runtimeIdentityChanged
|
||||
? undefined
|
||||
: existing?.providerRuntimeManifest,
|
||||
sessionKey: registration.sessionKey ?? existing?.sessionKey,
|
||||
status: 'online',
|
||||
registeredAt: existing?.registeredAt ?? now,
|
||||
|
|
@ -244,6 +265,11 @@ class AgentRegistryService {
|
|||
if (update?.currentTaskTitle !== undefined)
|
||||
agent.currentTaskTitle = update.currentTaskTitle || undefined;
|
||||
if (update?.metadata) agent.metadata = { ...agent.metadata, ...update.metadata };
|
||||
if (update?.providerRuntimeManifest !== undefined) {
|
||||
agent.providerRuntimeManifest = immutableManifest(
|
||||
parseProviderRuntimeManifest(update.providerRuntimeManifest)
|
||||
);
|
||||
}
|
||||
|
||||
this.agents.set(agentId, agent);
|
||||
this.persist();
|
||||
|
|
@ -527,8 +553,7 @@ class AgentRegistryService {
|
|||
for (const agent of this.agents.values()) {
|
||||
if (agent.status === 'offline') continue;
|
||||
|
||||
const lastBeat = new Date(agent.lastHeartbeat).getTime();
|
||||
if (now - lastBeat > HEARTBEAT_TIMEOUT_MS) {
|
||||
if (!registeredAgentIsLive(agent, now)) {
|
||||
agent.status = 'offline';
|
||||
changed = true;
|
||||
log.info(
|
||||
|
|
@ -579,7 +604,13 @@ class AgentRegistryService {
|
|||
const data = JSON.parse(raw) as AgentRegistryData;
|
||||
if (data.agents) {
|
||||
for (const [id, agent] of Object.entries(data.agents)) {
|
||||
this.agents.set(id, agent);
|
||||
const parsedManifest = agent.providerRuntimeManifest
|
||||
? safePersistedManifest(agent.providerRuntimeManifest, id)
|
||||
: undefined;
|
||||
this.agents.set(id, {
|
||||
...agent,
|
||||
providerRuntimeManifest: parsedManifest,
|
||||
});
|
||||
}
|
||||
log.info({ count: this.agents.size }, 'Agent registry loaded from disk');
|
||||
}
|
||||
|
|
@ -663,6 +694,56 @@ class AgentRegistryService {
|
|||
}
|
||||
}
|
||||
|
||||
function safePersistedManifest(
|
||||
manifest: unknown,
|
||||
agentId: string
|
||||
): ProviderRuntimeManifest | undefined {
|
||||
try {
|
||||
return immutableManifest(parseProviderRuntimeManifest(manifest));
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
{ agentId, error },
|
||||
'Ignored invalid persisted provider runtime manifest; capability routing will fail closed'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function registrationChangesRuntimeIdentity(
|
||||
registration: AgentRegistration,
|
||||
existing: RegisteredAgent
|
||||
): boolean {
|
||||
return (
|
||||
suppliedValueChanged(registration.provider, existing.provider, true) ||
|
||||
suppliedValueChanged(registration.model, existing.model, true) ||
|
||||
suppliedValueChanged(registration.version, existing.version, false)
|
||||
);
|
||||
}
|
||||
|
||||
function suppliedValueChanged(
|
||||
next: string | undefined,
|
||||
current: string | undefined,
|
||||
caseInsensitive: boolean
|
||||
): boolean {
|
||||
if (next === undefined) return false;
|
||||
const normalizedNext = next.trim();
|
||||
const normalizedCurrent = current?.trim() ?? '';
|
||||
return caseInsensitive
|
||||
? normalizedNext.toLowerCase() !== normalizedCurrent.toLowerCase()
|
||||
: normalizedNext !== normalizedCurrent;
|
||||
}
|
||||
|
||||
function immutableManifest(manifest: ProviderRuntimeManifest): ProviderRuntimeManifest {
|
||||
return deepFreeze(structuredClone(manifest));
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let instance: AgentRegistryService | null = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import {
|
|||
type RoutingRule,
|
||||
type RoutingResult,
|
||||
type RoutingMatchCriteria,
|
||||
type ProviderRuntimeCapabilityId,
|
||||
type ProviderRuntimeRouteCandidate,
|
||||
type ProviderRuntimeSelection,
|
||||
} from '@veritas-kanban/shared';
|
||||
import type { Task, AgentType } from '@veritas-kanban/shared';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
|
|
@ -29,6 +32,12 @@ import {
|
|||
type AgentHealthStatus,
|
||||
} from './agent-health-service.js';
|
||||
import { TeamRosterService } from './team-roster-service.js';
|
||||
import {
|
||||
getAgentRegistryService,
|
||||
registeredAgentIsLive,
|
||||
type RegisteredAgent,
|
||||
} from './agent-registry-service.js';
|
||||
import { selectProviderRuntimeManifest } from './provider-runtime-capability-service.js';
|
||||
|
||||
const log = createLogger('agent-routing');
|
||||
|
||||
|
|
@ -36,6 +45,16 @@ type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>;
|
|||
|
||||
interface RoutingTraceContext {
|
||||
taskId?: string;
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
}
|
||||
|
||||
interface RuntimeManifestRegistryReader {
|
||||
list(): RegisteredAgent[];
|
||||
}
|
||||
|
||||
interface RuntimeIdentityResolution {
|
||||
registrations: RegisteredAgent[];
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
interface AgentAvailability {
|
||||
|
|
@ -43,17 +62,24 @@ interface AgentAvailability {
|
|||
health?: AgentHealthStatus;
|
||||
available: boolean;
|
||||
reason: string;
|
||||
runtimeSelection?: ProviderRuntimeSelection;
|
||||
}
|
||||
|
||||
export class AgentRoutingService {
|
||||
private configService: ConfigService;
|
||||
private agentHealth: AgentHealthChecker;
|
||||
private teamRoster: TeamRosterService;
|
||||
private runtimeRegistry?: RuntimeManifestRegistryReader;
|
||||
|
||||
constructor(configService?: ConfigService, agentHealth?: AgentHealthChecker) {
|
||||
constructor(
|
||||
configService?: ConfigService,
|
||||
agentHealth?: AgentHealthChecker,
|
||||
runtimeRegistry?: RuntimeManifestRegistryReader
|
||||
) {
|
||||
this.configService = configService || new ConfigService();
|
||||
this.agentHealth = agentHealth || new AgentHealthService();
|
||||
this.teamRoster = new TeamRosterService(this.configService);
|
||||
this.runtimeRegistry = runtimeRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,6 +98,10 @@ export class AgentRoutingService {
|
|||
): Promise<{ result: RoutingResult; trace: CreateGovernanceTraceInput }> {
|
||||
const config = await this.configService.getConfig();
|
||||
const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG;
|
||||
const requiredRuntimeCapabilities = uniqueRuntimeCapabilities(
|
||||
context.requiredRuntimeCapabilities ?? []
|
||||
);
|
||||
const runtimeCandidates: ProviderRuntimeRouteCandidate[] = [];
|
||||
const evaluatedRules: GovernanceTraceRule[] = [];
|
||||
const steps: GovernanceTraceStep[] = [];
|
||||
const rosterPreview = this.teamRoster.resolveRoute(
|
||||
|
|
@ -85,7 +115,21 @@ export class AgentRoutingService {
|
|||
);
|
||||
|
||||
if (rosterPreview.matched && rosterPreview.agent) {
|
||||
const availability = await this.getAgentAvailability(config.agents, rosterPreview.agent);
|
||||
const profile = config.agentProfiles?.find(
|
||||
(candidate) => candidate.id === rosterPreview.profileId
|
||||
);
|
||||
const availability = await this.getAgentAvailability(
|
||||
config.agents,
|
||||
rosterPreview.agent,
|
||||
requiredRuntimeCapabilities,
|
||||
profile?.runtime.model
|
||||
);
|
||||
recordRuntimeCandidate(
|
||||
runtimeCandidates,
|
||||
rosterPreview.agent,
|
||||
profile?.runtime.model,
|
||||
availability
|
||||
);
|
||||
const ruleId = rosterPreview.ruleId ?? rosterPreview.member?.id ?? 'default';
|
||||
const traceRule: GovernanceTraceRule = {
|
||||
id: `team-roster:${ruleId}`,
|
||||
|
|
@ -111,14 +155,14 @@ export class AgentRoutingService {
|
|||
});
|
||||
|
||||
if (availability.available) {
|
||||
const profile = config.agentProfiles?.find(
|
||||
(candidate) => candidate.id === rosterPreview.profileId
|
||||
);
|
||||
markLastRuntimeCandidateSelected(runtimeCandidates);
|
||||
const result: RoutingResult = {
|
||||
agent: rosterPreview.agent,
|
||||
model: profile?.runtime.model ?? availability.agentConfig?.model,
|
||||
rule: traceRule.id,
|
||||
reason: rosterPreview.reason,
|
||||
reason: withRuntimeReason(rosterPreview.reason, availability.runtimeSelection),
|
||||
runtimeSelection: availability.runtimeSelection,
|
||||
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
|
||||
};
|
||||
return {
|
||||
result,
|
||||
|
|
@ -143,11 +187,24 @@ export class AgentRoutingService {
|
|||
// If routing is disabled, return the global default
|
||||
if (!routing.enabled) {
|
||||
const defaultAgent = routing.defaultAgent || config.defaultAgent;
|
||||
const defaultAvailability = await this.getAgentAvailability(config.agents, defaultAgent);
|
||||
const defaultAvailability = await this.getAgentAvailability(
|
||||
config.agents,
|
||||
defaultAgent,
|
||||
requiredRuntimeCapabilities,
|
||||
routing.defaultModel
|
||||
);
|
||||
recordRuntimeCandidate(
|
||||
runtimeCandidates,
|
||||
defaultAgent,
|
||||
routing.defaultModel,
|
||||
defaultAvailability
|
||||
);
|
||||
if (!defaultAvailability.available) {
|
||||
throw new ConflictError('No healthy agent available for routing', {
|
||||
agent: defaultAgent,
|
||||
reason: defaultAvailability.reason,
|
||||
runtimeSelection: defaultAvailability.runtimeSelection,
|
||||
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
|
||||
routingEnabled: routing.enabled,
|
||||
});
|
||||
}
|
||||
|
|
@ -155,8 +212,14 @@ export class AgentRoutingService {
|
|||
const result: RoutingResult = {
|
||||
agent: defaultAgent,
|
||||
model: routing.defaultModel,
|
||||
reason: 'Routing disabled, using default agent',
|
||||
reason: withRuntimeReason(
|
||||
'Routing disabled, using default agent',
|
||||
defaultAvailability.runtimeSelection
|
||||
),
|
||||
runtimeSelection: defaultAvailability.runtimeSelection,
|
||||
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
|
||||
};
|
||||
markLastRuntimeCandidateSelected(runtimeCandidates);
|
||||
return {
|
||||
result,
|
||||
trace: this.buildRoutingTrace(task, context, result, {
|
||||
|
|
@ -184,7 +247,13 @@ export class AgentRoutingService {
|
|||
}
|
||||
|
||||
if (this.matchesRule(task, rule.match)) {
|
||||
const availability = await this.getAgentAvailability(config.agents, rule.agent);
|
||||
const availability = await this.getAgentAvailability(
|
||||
config.agents,
|
||||
rule.agent,
|
||||
requiredRuntimeCapabilities,
|
||||
rule.model
|
||||
);
|
||||
recordRuntimeCandidate(runtimeCandidates, rule.agent, rule.model, availability);
|
||||
if (!availability.available) {
|
||||
const message = `Rule "${rule.name}" matched but agent "${rule.agent}" is unavailable: ${availability.reason}.`;
|
||||
log.warn(message);
|
||||
|
|
@ -200,14 +269,24 @@ export class AgentRoutingService {
|
|||
if (routing.fallbackOnFailure && rule.fallback) {
|
||||
const fallbackAvailability = await this.getAgentAvailability(
|
||||
config.agents,
|
||||
rule.fallback
|
||||
rule.fallback,
|
||||
requiredRuntimeCapabilities
|
||||
);
|
||||
recordRuntimeCandidate(
|
||||
runtimeCandidates,
|
||||
rule.fallback,
|
||||
fallbackAvailability.agentConfig?.model,
|
||||
fallbackAvailability
|
||||
);
|
||||
if (fallbackAvailability.available) {
|
||||
markLastRuntimeCandidateSelected(runtimeCandidates);
|
||||
const reason = `${message} Using fallback agent "${rule.fallback}".`;
|
||||
const result: RoutingResult = {
|
||||
agent: rule.fallback,
|
||||
rule: rule.id,
|
||||
reason,
|
||||
reason: withRuntimeReason(reason, fallbackAvailability.runtimeSelection),
|
||||
runtimeSelection: fallbackAvailability.runtimeSelection,
|
||||
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
|
||||
};
|
||||
return {
|
||||
result,
|
||||
|
|
@ -257,8 +336,11 @@ export class AgentRoutingService {
|
|||
model: rule.model,
|
||||
fallback: rule.fallback,
|
||||
rule: rule.id,
|
||||
reason: `Matched rule: ${rule.name}`,
|
||||
reason: withRuntimeReason(`Matched rule: ${rule.name}`, availability.runtimeSelection),
|
||||
runtimeSelection: availability.runtimeSelection,
|
||||
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
|
||||
};
|
||||
markLastRuntimeCandidateSelected(runtimeCandidates);
|
||||
return {
|
||||
result,
|
||||
trace: this.buildRoutingTrace(task, context, result, {
|
||||
|
|
@ -293,7 +375,13 @@ export class AgentRoutingService {
|
|||
model: routing.defaultModel,
|
||||
reason: 'No routing rules matched, using default agent',
|
||||
};
|
||||
const defaultAvailability = await this.getAgentAvailability(config.agents, result.agent);
|
||||
const defaultAvailability = await this.getAgentAvailability(
|
||||
config.agents,
|
||||
result.agent,
|
||||
requiredRuntimeCapabilities,
|
||||
result.model
|
||||
);
|
||||
recordRuntimeCandidate(runtimeCandidates, result.agent, result.model, defaultAvailability);
|
||||
if (!defaultAvailability.available) {
|
||||
const message = `Default agent "${result.agent}" is unavailable: ${defaultAvailability.reason}.`;
|
||||
steps.push({
|
||||
|
|
@ -305,9 +393,15 @@ export class AgentRoutingService {
|
|||
throw new ConflictError('No healthy agent available for routing', {
|
||||
agent: result.agent,
|
||||
reason: defaultAvailability.reason,
|
||||
runtimeSelection: defaultAvailability.runtimeSelection,
|
||||
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
|
||||
routingEnabled: routing.enabled,
|
||||
});
|
||||
}
|
||||
result.reason = withRuntimeReason(result.reason, defaultAvailability.runtimeSelection);
|
||||
result.runtimeSelection = defaultAvailability.runtimeSelection;
|
||||
markLastRuntimeCandidateSelected(runtimeCandidates);
|
||||
result.runtimeCandidates = optionalRuntimeCandidates(runtimeCandidates);
|
||||
|
||||
return {
|
||||
result,
|
||||
|
|
@ -418,7 +512,9 @@ export class AgentRoutingService {
|
|||
|
||||
private async getAgentAvailability(
|
||||
agents: AgentConfig[],
|
||||
agent: AgentType
|
||||
agent: AgentType,
|
||||
requiredRuntimeCapabilities: ProviderRuntimeCapabilityId[] = [],
|
||||
model?: string
|
||||
): Promise<AgentAvailability> {
|
||||
const agentConfig = agents.find((candidate) => candidate.type === agent);
|
||||
if (!agentConfig) {
|
||||
|
|
@ -436,6 +532,19 @@ export class AgentRoutingService {
|
|||
};
|
||||
}
|
||||
|
||||
const runtimeSelection =
|
||||
requiredRuntimeCapabilities.length > 0
|
||||
? this.selectAgentRuntimeManifest(agentConfig, requiredRuntimeCapabilities, model)
|
||||
: undefined;
|
||||
if (runtimeSelection && !runtimeSelection.compatible) {
|
||||
return {
|
||||
agentConfig,
|
||||
available: false,
|
||||
reason: runtimeSelection.reason,
|
||||
runtimeSelection,
|
||||
};
|
||||
}
|
||||
|
||||
const health = await this.agentHealth.checkAgent(agentConfig);
|
||||
if (!health.healthy) {
|
||||
return {
|
||||
|
|
@ -443,6 +552,7 @@ export class AgentRoutingService {
|
|||
health,
|
||||
available: false,
|
||||
reason: health.reason || 'Agent health check failed',
|
||||
runtimeSelection,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -450,10 +560,36 @@ export class AgentRoutingService {
|
|||
agentConfig,
|
||||
health,
|
||||
available: true,
|
||||
reason: 'Agent is healthy',
|
||||
reason: withRuntimeReason('Agent is healthy', runtimeSelection),
|
||||
runtimeSelection,
|
||||
};
|
||||
}
|
||||
|
||||
private selectAgentRuntimeManifest(
|
||||
agentConfig: AgentConfig,
|
||||
requiredCapabilities: ProviderRuntimeCapabilityId[],
|
||||
model?: string
|
||||
): ProviderRuntimeSelection {
|
||||
const registeredAgents = (this.runtimeRegistry ?? getAgentRegistryService()).list();
|
||||
const identity = resolveRegisteredRuntimeIdentity(registeredAgents, agentConfig);
|
||||
const selection = selectProviderRuntimeManifest({
|
||||
manifests: identity.registrations
|
||||
.map((registered) => registered.providerRuntimeManifest)
|
||||
.filter((manifest) => manifest !== undefined),
|
||||
provider: agentConfig.provider === 'custom' ? undefined : agentConfig.provider,
|
||||
model: model ?? agentConfig.model,
|
||||
requiredCapabilities,
|
||||
});
|
||||
return identity.rejectionReason
|
||||
? {
|
||||
...selection,
|
||||
compatible: false,
|
||||
selectedManifest: undefined,
|
||||
reason: identity.rejectionReason,
|
||||
}
|
||||
: selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task matches a rule's criteria.
|
||||
* All specified criteria must match (AND logic).
|
||||
|
|
@ -558,6 +694,92 @@ export class AgentRoutingService {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveRegisteredRuntimeIdentity(
|
||||
registeredAgents: RegisteredAgent[],
|
||||
agentConfig: AgentConfig
|
||||
): RuntimeIdentityResolution {
|
||||
const exactId = registeredAgents.find((registered) => registered.id === agentConfig.type);
|
||||
if (exactId) {
|
||||
return registeredAgentIsLive(exactId)
|
||||
? { registrations: [exactId] }
|
||||
: {
|
||||
registrations: [],
|
||||
rejectionReason: `Registered agent "${exactId.id}" is offline or outside the five-minute heartbeat window.`,
|
||||
};
|
||||
}
|
||||
|
||||
const nameMatches = registeredAgents.filter(
|
||||
(registered) =>
|
||||
normalizeRuntimeValue(registered.name) === normalizeRuntimeValue(agentConfig.name)
|
||||
);
|
||||
if (nameMatches.length === 0) {
|
||||
return {
|
||||
registrations: [],
|
||||
rejectionReason: `No registry identity matches configured agent "${agentConfig.type}".`,
|
||||
};
|
||||
}
|
||||
if (nameMatches.length > 1) {
|
||||
return {
|
||||
registrations: [],
|
||||
rejectionReason: `Multiple registry identities match configured agent name "${agentConfig.name}".`,
|
||||
};
|
||||
}
|
||||
const nameMatch = nameMatches[0] as RegisteredAgent;
|
||||
return registeredAgentIsLive(nameMatch)
|
||||
? { registrations: [nameMatch] }
|
||||
: {
|
||||
registrations: [],
|
||||
rejectionReason: `Registered agent "${nameMatch.id}" is offline or outside the five-minute heartbeat window.`,
|
||||
};
|
||||
}
|
||||
|
||||
function recordRuntimeCandidate(
|
||||
candidates: ProviderRuntimeRouteCandidate[],
|
||||
agent: AgentType,
|
||||
model: string | undefined,
|
||||
availability: AgentAvailability
|
||||
): void {
|
||||
if (!availability.runtimeSelection) return;
|
||||
candidates.push({
|
||||
agent,
|
||||
...(model ? { model } : {}),
|
||||
available: availability.available,
|
||||
selected: false,
|
||||
reason: availability.reason,
|
||||
selection: availability.runtimeSelection,
|
||||
});
|
||||
}
|
||||
|
||||
function markLastRuntimeCandidateSelected(candidates: ProviderRuntimeRouteCandidate[]): void {
|
||||
const candidate = candidates.at(-1);
|
||||
if (candidate) candidate.selected = true;
|
||||
}
|
||||
|
||||
function optionalRuntimeCandidates(
|
||||
candidates: ProviderRuntimeRouteCandidate[]
|
||||
): ProviderRuntimeRouteCandidate[] | undefined {
|
||||
return candidates.length > 0 ? candidates : undefined;
|
||||
}
|
||||
|
||||
function uniqueRuntimeCapabilities(
|
||||
capabilities: ProviderRuntimeCapabilityId[]
|
||||
): ProviderRuntimeCapabilityId[] {
|
||||
return Array.from(
|
||||
new Set(capabilities.map((capability) => capability.trim()).filter(Boolean))
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function withRuntimeReason(
|
||||
reason: string,
|
||||
runtimeSelection: ProviderRuntimeSelection | undefined
|
||||
): string {
|
||||
return runtimeSelection ? `${reason}. ${runtimeSelection.reason}` : reason;
|
||||
}
|
||||
|
||||
function normalizeRuntimeValue(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let _instance: AgentRoutingService | null = null;
|
||||
|
||||
|
|
|
|||
150
server/src/services/provider-runtime-capability-service.ts
Normal file
150
server/src/services/provider-runtime-capability-service.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import type {
|
||||
ProviderRuntimeCapabilityAssessment,
|
||||
ProviderRuntimeCapabilityId,
|
||||
ProviderRuntimeManifest,
|
||||
ProviderRuntimeManifestAssessment,
|
||||
ProviderRuntimeSelection,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
export interface ProviderRuntimeSelectionRequest {
|
||||
manifests: ProviderRuntimeManifest[];
|
||||
provider?: string;
|
||||
model?: string;
|
||||
requiredCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
}
|
||||
|
||||
export function selectProviderRuntimeManifest(
|
||||
request: ProviderRuntimeSelectionRequest
|
||||
): ProviderRuntimeSelection {
|
||||
const requiredCapabilities = uniqueSorted(request.requiredCapabilities ?? []);
|
||||
const candidates = request.manifests
|
||||
.map((manifest) => assessManifest(manifest, request, requiredCapabilities))
|
||||
.sort(compareAssessments);
|
||||
const selectedManifest = candidates.find((candidate) => candidate.compatible);
|
||||
|
||||
return {
|
||||
requiredCapabilities,
|
||||
compatible: selectedManifest !== undefined,
|
||||
selectedManifest,
|
||||
candidates,
|
||||
reason: selectedManifest
|
||||
? selectedManifest.advisory
|
||||
? `Selected manifest ${selectedManifest.manifestDigest} with advisory capability evidence.`
|
||||
: `Selected manifest ${selectedManifest.manifestDigest} with supported capability evidence.`
|
||||
: candidates.length === 0
|
||||
? 'No validated provider runtime manifest is registered.'
|
||||
: 'No single validated provider runtime manifest satisfies the provider, model, and capability requirements.',
|
||||
};
|
||||
}
|
||||
|
||||
function assessManifest(
|
||||
manifest: ProviderRuntimeManifest,
|
||||
request: ProviderRuntimeSelectionRequest,
|
||||
requiredCapabilities: ProviderRuntimeCapabilityId[]
|
||||
): ProviderRuntimeManifestAssessment {
|
||||
const reasons: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (manifest.probe.state === 'failed') {
|
||||
reasons.push('The manifest readiness probe failed.');
|
||||
} else if (manifest.probe.state === 'degraded') {
|
||||
warnings.push('The manifest readiness probe is degraded.');
|
||||
}
|
||||
|
||||
if (
|
||||
request.provider &&
|
||||
normalize(request.provider) !== normalize(manifest.provider) &&
|
||||
normalize(request.provider) !== normalize(manifest.adapter)
|
||||
) {
|
||||
reasons.push(`Provider ${request.provider} does not match this manifest.`);
|
||||
}
|
||||
|
||||
if (
|
||||
request.model &&
|
||||
!manifest.models.some(
|
||||
(candidate) => normalize(candidate) === normalize(request.model as string)
|
||||
)
|
||||
) {
|
||||
reasons.push(`Model ${request.model} is not reported by this manifest.`);
|
||||
}
|
||||
|
||||
const capabilities = requiredCapabilities.map((capabilityId) =>
|
||||
assessCapability(manifest, capabilityId)
|
||||
);
|
||||
for (const capability of capabilities) {
|
||||
if (!capability.satisfied) {
|
||||
reasons.push(`${capability.id}: ${capability.reason}`);
|
||||
} else if (capability.advisory) {
|
||||
warnings.push(`${capability.id}: ${capability.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
manifestDigest: manifest.digest,
|
||||
provider: manifest.provider,
|
||||
adapter: manifest.adapter,
|
||||
providerVersion: manifest.providerVersion,
|
||||
models: [...manifest.models],
|
||||
probeState: manifest.probe.state,
|
||||
compatible: reasons.length === 0,
|
||||
advisory: warnings.length > 0,
|
||||
capabilities,
|
||||
reasons,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function assessCapability(
|
||||
manifest: ProviderRuntimeManifest,
|
||||
capabilityId: ProviderRuntimeCapabilityId
|
||||
): ProviderRuntimeCapabilityAssessment {
|
||||
const evidence = manifest.capabilities.find((capability) => capability.id === capabilityId);
|
||||
if (!evidence) {
|
||||
return {
|
||||
id: capabilityId,
|
||||
state: 'unknown',
|
||||
satisfied: false,
|
||||
advisory: false,
|
||||
reason: 'No capability evidence is present.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: capabilityId,
|
||||
state: evidence.state,
|
||||
satisfied: evidence.state === 'supported' || evidence.state === 'advisory',
|
||||
advisory: evidence.state === 'advisory',
|
||||
reason: evidence.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function compareAssessments(
|
||||
left: ProviderRuntimeManifestAssessment,
|
||||
right: ProviderRuntimeManifestAssessment
|
||||
): number {
|
||||
if (left.compatible !== right.compatible) return left.compatible ? -1 : 1;
|
||||
if (left.advisory !== right.advisory) return left.advisory ? 1 : -1;
|
||||
if (left.probeState !== right.probeState) {
|
||||
return PROBE_STATE_RANK[left.probeState] - PROBE_STATE_RANK[right.probeState];
|
||||
}
|
||||
return (
|
||||
left.provider.localeCompare(right.provider) ||
|
||||
left.manifestDigest.localeCompare(right.manifestDigest)
|
||||
);
|
||||
}
|
||||
|
||||
const PROBE_STATE_RANK: Record<ProviderRuntimeManifestAssessment['probeState'], number> = {
|
||||
ready: 0,
|
||||
degraded: 1,
|
||||
failed: 2,
|
||||
};
|
||||
|
||||
function uniqueSorted(values: ProviderRuntimeCapabilityId[]): ProviderRuntimeCapabilityId[] {
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@veritas-kanban/shared';
|
||||
import { parseProviderRuntimeManifest } from '../schemas/provider-runtime-manifest-schemas.js';
|
||||
import { calculateProviderRuntimeManifestDigest } from '../utils/provider-runtime-manifest-digest.js';
|
||||
import { sanitizeProviderRuntimeDiagnostic } from '../utils/provider-runtime-manifest-sanitize.js';
|
||||
|
||||
export {
|
||||
calculateProviderRuntimeManifestDigest,
|
||||
|
|
@ -22,12 +23,6 @@ const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
|||
const DEFAULT_CONFORMANCE_PROBE_TIMEOUT_MS = 5_000;
|
||||
const MAX_DIAGNOSTIC_BYTES = 8 * 1024;
|
||||
|
||||
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'],
|
||||
[/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{12,}/gi, '[REDACTED]'],
|
||||
[/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*([^\s"'`,}]+)/gi, '$1=[REDACTED]'],
|
||||
];
|
||||
|
||||
export interface ProviderRuntimeIdentityEvidence {
|
||||
providerVersion?: string;
|
||||
providerBuild?: string;
|
||||
|
|
@ -239,7 +234,7 @@ export class ProviderRuntimeManifestService {
|
|||
probe: {
|
||||
state: probeState,
|
||||
probedAt,
|
||||
source: request.identity.source,
|
||||
source: sanitizeProviderRuntimeDiagnostic(request.identity.source),
|
||||
diagnostics: sanitizeDiagnostics(diagnostics),
|
||||
},
|
||||
};
|
||||
|
|
@ -305,11 +300,7 @@ function sanitizeDiagnostics(values: string[]): string[] {
|
|||
}
|
||||
|
||||
function sanitizeDiagnostic(value: string): string {
|
||||
let sanitized = value.trim().replace(/\s+/g, ' ');
|
||||
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, replacement);
|
||||
}
|
||||
return sanitized.slice(0, 1000);
|
||||
return sanitizeProviderRuntimeDiagnostic(value);
|
||||
}
|
||||
|
||||
function immutableClone<T>(value: T): T {
|
||||
|
|
|
|||
22
server/src/utils/provider-runtime-manifest-sanitize.ts
Normal file
22
server/src/utils/provider-runtime-manifest-sanitize.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'],
|
||||
[/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{12,}/gi, '[REDACTED]'],
|
||||
[/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*([^\s"'`,}]+)/gi, '$1=[REDACTED]'],
|
||||
];
|
||||
|
||||
export function sanitizeProviderRuntimeDiagnostic(value: string): string {
|
||||
let sanitized = normalizeProviderRuntimeDiagnostic(value);
|
||||
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, replacement);
|
||||
}
|
||||
return sanitized.slice(0, 1000);
|
||||
}
|
||||
|
||||
export function containsUnredactedProviderRuntimeSecret(value: string): boolean {
|
||||
const normalized = normalizeProviderRuntimeDiagnostic(value).slice(0, 1000);
|
||||
return sanitizeProviderRuntimeDiagnostic(value) !== normalized;
|
||||
}
|
||||
|
||||
function normalizeProviderRuntimeDiagnostic(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
|
@ -1,20 +1,24 @@
|
|||
import type { SandboxProviderCapabilityId } from './sandbox-policy.types.js';
|
||||
import type {
|
||||
ProviderRuntimeCapabilityId,
|
||||
ProviderRuntimeManifest,
|
||||
ProviderRuntimeSelection,
|
||||
} from './provider-runtime.types.js';
|
||||
|
||||
export type AgentHostPosture =
|
||||
| 'connected'
|
||||
| 'stale'
|
||||
| 'degraded'
|
||||
| 'disconnected'
|
||||
| 'risky'
|
||||
| 'unknown';
|
||||
'connected' | 'stale' | 'degraded' | 'disconnected' | 'risky' | 'unknown';
|
||||
|
||||
export type AgentHostAuthState = 'authenticated' | 'unauthenticated' | 'not-required' | 'unknown';
|
||||
|
||||
export type AgentHostRoutingPolicy =
|
||||
| 'manual'
|
||||
| 'project-default'
|
||||
| 'first-capable-healthy'
|
||||
| 'disabled';
|
||||
'manual' | 'project-default' | 'first-capable-healthy' | 'disabled';
|
||||
|
||||
export interface AgentHostLegacyRuntimePosture {
|
||||
providers: string[];
|
||||
models: string[];
|
||||
tools: string[];
|
||||
sandboxCapabilities: SandboxProviderCapabilityId[];
|
||||
}
|
||||
|
||||
export interface AgentHostRecord {
|
||||
id: string;
|
||||
|
|
@ -28,6 +32,8 @@ export interface AgentHostRecord {
|
|||
supportedModels: string[];
|
||||
supportedTools: string[];
|
||||
sandboxCapabilities: SandboxProviderCapabilityId[];
|
||||
providerRuntimeManifests: ProviderRuntimeManifest[];
|
||||
legacyRuntimePosture: AgentHostLegacyRuntimePosture;
|
||||
workspaceLabels: string[];
|
||||
activeSessions: number;
|
||||
queueDepth: number;
|
||||
|
|
@ -56,6 +62,7 @@ export type AgentHostCompatibilityCheckId =
|
|||
| 'model-supported'
|
||||
| 'agent-supported'
|
||||
| 'required-tools'
|
||||
| 'runtime-capabilities'
|
||||
| 'sandbox-policy'
|
||||
| 'verification-gates';
|
||||
|
||||
|
|
@ -72,6 +79,7 @@ export interface AgentHostCompatibilityPreview {
|
|||
posture: AgentHostPosture;
|
||||
compatible: boolean;
|
||||
checks: AgentHostCompatibilityCheck[];
|
||||
runtimeSelection?: ProviderRuntimeSelection;
|
||||
reasons: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
|
@ -82,6 +90,7 @@ export interface AgentHostPreviewRequest {
|
|||
model?: string;
|
||||
workspacePath?: string;
|
||||
requiredTools?: string[];
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
verificationGates?: string[];
|
||||
sandboxPresetId?: string;
|
||||
manualHostId?: string;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ export interface AgentRegistrationInput {
|
|||
version?: string;
|
||||
/** Optional metadata (environment, host, session label, etc.). */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** Validated provider runtime evidence used for routing and enforcement. */
|
||||
providerRuntimeManifest?: import('./provider-runtime.types.js').ProviderRuntimeManifest;
|
||||
}
|
||||
|
||||
/** A registered agent record (stored in the registry). */
|
||||
|
|
@ -82,6 +84,8 @@ export interface AgentHeartbeatInput {
|
|||
capabilities?: AgentCapability[];
|
||||
/** Arbitrary metadata update (merged with existing). */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** Refreshed provider runtime evidence, if the provider identity changed. */
|
||||
providerRuntimeManifest?: import('./provider-runtime.types.js').ProviderRuntimeManifest;
|
||||
}
|
||||
|
||||
// ─── Sessions ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ export interface RoutingResult {
|
|||
fallback?: AgentType;
|
||||
rule?: string; // ID of matched rule (undefined = default)
|
||||
reason: string; // Human-readable explanation
|
||||
runtimeSelection?: import('./provider-runtime.types.js').ProviderRuntimeSelection;
|
||||
runtimeCandidates?: import('./provider-runtime.types.js').ProviderRuntimeRouteCandidate[];
|
||||
}
|
||||
|
||||
/** Default routing config */
|
||||
|
|
|
|||
|
|
@ -87,6 +87,45 @@ export interface ProviderRuntimeManifest {
|
|||
digest: string;
|
||||
}
|
||||
|
||||
export interface ProviderRuntimeCapabilityAssessment {
|
||||
id: ProviderRuntimeCapabilityId;
|
||||
state: ProviderRuntimeCapabilityState;
|
||||
satisfied: boolean;
|
||||
advisory: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ProviderRuntimeManifestAssessment {
|
||||
manifestDigest: string;
|
||||
provider: string;
|
||||
adapter: string;
|
||||
providerVersion: string;
|
||||
models: string[];
|
||||
probeState: ProviderRuntimeProbeState;
|
||||
compatible: boolean;
|
||||
advisory: boolean;
|
||||
capabilities: ProviderRuntimeCapabilityAssessment[];
|
||||
reasons: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ProviderRuntimeSelection {
|
||||
requiredCapabilities: ProviderRuntimeCapabilityId[];
|
||||
compatible: boolean;
|
||||
selectedManifest?: ProviderRuntimeManifestAssessment;
|
||||
candidates: ProviderRuntimeManifestAssessment[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ProviderRuntimeRouteCandidate {
|
||||
agent: string;
|
||||
model?: string;
|
||||
available: boolean;
|
||||
selected: boolean;
|
||||
reason: string;
|
||||
selection: ProviderRuntimeSelection;
|
||||
}
|
||||
|
||||
export function findProviderRuntimeCapability(
|
||||
manifest: ProviderRuntimeManifest,
|
||||
capabilityId: ProviderRuntimeCapabilityId
|
||||
|
|
|
|||
|
|
@ -280,8 +280,15 @@ vi.mock('@/hooks/useAgent', () => ({
|
|||
supportedAgents: ['codex'],
|
||||
supportedProviders: ['codex-cli'],
|
||||
supportedModels: ['gpt-5'],
|
||||
supportedTools: ['code'],
|
||||
supportedTools: ['tool.calls'],
|
||||
sandboxCapabilities: ['filesystem.read', 'filesystem.write', 'environment.allowlist'],
|
||||
providerRuntimeManifests: [],
|
||||
legacyRuntimePosture: {
|
||||
providers: ['codex-cli'],
|
||||
models: ['gpt-5'],
|
||||
tools: ['code'],
|
||||
sandboxCapabilities: [],
|
||||
},
|
||||
workspaceLabels: ['workspace:veritas-kanban'],
|
||||
activeSessions: 0,
|
||||
queueDepth: 0,
|
||||
|
|
|
|||
|
|
@ -1017,6 +1017,10 @@ function AgentHostItem({ host }: { host: AgentHostRecord }) {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{host.providerRuntimeManifests.length} validated manifest
|
||||
{host.providerRuntimeManifests.length === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
{host.supportedAgents.slice(0, 4).map((agent) => (
|
||||
<Badge key={agent} size="xs" variant="outline" color="gray">
|
||||
{agent}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
*/
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { routingApi } from '@/lib/api/agent';
|
||||
import type { AgentRoutingConfig, RoutingResult } from '@veritas-kanban/shared';
|
||||
import type {
|
||||
AgentRoutingConfig,
|
||||
ProviderRuntimeCapabilityId,
|
||||
RoutingResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
/** Fetch the current routing config */
|
||||
export function useRoutingConfig() {
|
||||
|
|
@ -26,10 +30,13 @@ export function useUpdateRoutingConfig() {
|
|||
}
|
||||
|
||||
/** Resolve agent for an existing task */
|
||||
export function useResolveAgent(taskId: string | undefined) {
|
||||
export function useResolveAgent(
|
||||
taskId: string | undefined,
|
||||
requiredRuntimeCapabilities: ProviderRuntimeCapabilityId[] = []
|
||||
) {
|
||||
return useQuery<RoutingResult>({
|
||||
queryKey: ['routing-resolve', taskId],
|
||||
queryFn: () => routingApi.resolveForTask(taskId!),
|
||||
queryKey: ['routing-resolve', taskId, requiredRuntimeCapabilities],
|
||||
queryFn: () => routingApi.resolveForTask(taskId!, requiredRuntimeCapabilities),
|
||||
enabled: !!taskId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
|
@ -42,6 +49,7 @@ export function useResolveAgentForMetadata(
|
|||
priority?: string;
|
||||
project?: string;
|
||||
subtaskCount?: number;
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
} | null
|
||||
) {
|
||||
return useQuery<RoutingResult>({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import type {
|
|||
AgentRoutingConfig,
|
||||
RoutingResult,
|
||||
AgentBudgetPolicy,
|
||||
ProviderRuntimeManifest,
|
||||
ProviderRuntimeCapabilityId,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { API_BASE, apiFetch } from './helpers';
|
||||
|
||||
|
|
@ -116,6 +118,7 @@ export interface RegisteredAgent {
|
|||
provider?: string;
|
||||
capabilities?: Array<{ name: string; description?: string }>;
|
||||
version?: string;
|
||||
providerRuntimeManifest?: ProviderRuntimeManifest;
|
||||
status: 'online' | 'offline' | 'busy' | 'idle';
|
||||
currentTask?: string;
|
||||
currentTaskTitle?: string;
|
||||
|
|
@ -169,11 +172,14 @@ export const routingApi = {
|
|||
},
|
||||
|
||||
/** Resolve the best agent for a task */
|
||||
resolveForTask: async (taskId: string): Promise<RoutingResult> => {
|
||||
resolveForTask: async (
|
||||
taskId: string,
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[]
|
||||
): Promise<RoutingResult> => {
|
||||
return apiFetch<RoutingResult>(`${API_BASE}/agents/route`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ taskId }),
|
||||
body: JSON.stringify({ taskId, requiredRuntimeCapabilities }),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -183,6 +189,7 @@ export const routingApi = {
|
|||
priority?: string;
|
||||
project?: string;
|
||||
subtaskCount?: number;
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
}): Promise<RoutingResult> => {
|
||||
return apiFetch<RoutingResult>(`${API_BASE}/agents/route`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue