agent-relay-server 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/public/index.html +471 -46
- package/runner/src/adapter.ts +1 -1
- package/scripts/install-bin-shim.cjs +16 -3
- package/src/agent-ref.ts +217 -0
- package/src/automations.ts +4 -1
- package/src/cli.ts +8 -2
- package/src/config-store.ts +35 -1
- package/src/context-router.ts +7 -7
- package/src/db.ts +111 -29
- package/src/managed-policy.ts +4 -1
- package/src/mcp.ts +208 -67
- package/src/routes.ts +15 -3
- package/src/runtime-tokens.ts +26 -1
- package/src/security.ts +3 -1
package/runner/src/adapter.ts
CHANGED
|
@@ -159,7 +159,7 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
|
|
|
159
159
|
return config.agentProfile?.relay?.[feature] !== false;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_spawn_agent, and relay_shutdown_agent. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
|
|
162
|
+
export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present you can stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
|
|
163
163
|
|
|
164
164
|
const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
|
|
165
165
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, chmodSync } = require("node:fs");
|
|
2
|
+
const { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } = require("node:fs");
|
|
3
3
|
const { homedir } = require("node:os");
|
|
4
4
|
const { delimiter, join, relative } = require("node:path");
|
|
5
5
|
|
|
@@ -31,12 +31,24 @@ function pathContains(dir, env) {
|
|
|
31
31
|
return String(env.PATH || "").split(delimiter).some((entry) => entry === dir);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function pathEntries(env) {
|
|
35
|
+
return String(env.PATH || "").split(delimiter).filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
function chooseBinDir(env, home) {
|
|
35
39
|
if (env.AGENT_RELAY_USER_BIN_DIR) return env.AGENT_RELAY_USER_BIN_DIR;
|
|
36
40
|
const localBin = join(home, ".local", "bin");
|
|
37
41
|
const bunBin = join(home, ".bun", "bin");
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
const candidates = new Set([localBin, bunBin]);
|
|
43
|
+
const entries = pathEntries(env);
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (!candidates.has(entry)) continue;
|
|
46
|
+
const shimPath = join(entry, "agent-relay");
|
|
47
|
+
if (existsSync(shimPath) && canOverwriteShim(shimPath)) return entry;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (candidates.has(entry)) return entry;
|
|
51
|
+
}
|
|
40
52
|
return localBin;
|
|
41
53
|
}
|
|
42
54
|
|
|
@@ -82,6 +94,7 @@ function installShim(env = process.env) {
|
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
mkdirSync(binDir, { recursive: true });
|
|
97
|
+
if (existsSync(shimPath) && lstatSync(shimPath).isSymbolicLink()) unlinkSync(shimPath);
|
|
85
98
|
writeFileSync(shimPath, renderShim(cliPath), "utf8");
|
|
86
99
|
chmodSync(shimPath, 0o755);
|
|
87
100
|
|
package/src/agent-ref.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Shared agent-reference resolution. ONE home for "given a string an agent typed,
|
|
2
|
+
// which agent(s) does it mean?" — so messaging (send/reply) and pairing can never
|
|
3
|
+
// drift apart again (they used to: messaging matched exact id only, pairing had a
|
|
4
|
+
// rich matcher). See #222.
|
|
5
|
+
//
|
|
6
|
+
// A reference can be:
|
|
7
|
+
// - an explicit selector: id: label: name: tag: cap:/capability: machine: rig:
|
|
8
|
+
// - a bare exact match on id, label, name, a tag, a capability, or rig
|
|
9
|
+
// - a unique id *segment* (the "number part" of a session id), e.g. `c14bd37f`
|
|
10
|
+
//
|
|
11
|
+
// Resolution prefers ONLINE agents so a dead twin can't manufacture ambiguity, and
|
|
12
|
+
// never silently picks among several — it reports candidates instead.
|
|
13
|
+
|
|
14
|
+
import { STALE_TTL_MS } from "./config";
|
|
15
|
+
import type { AgentCard } from "./types";
|
|
16
|
+
|
|
17
|
+
export interface ResolveOptions {
|
|
18
|
+
/** Exclude this agent id from matches (e.g. the requester, when pairing). */
|
|
19
|
+
excludeId?: string;
|
|
20
|
+
/** Clock injection for tests. */
|
|
21
|
+
now?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ResolveResult =
|
|
25
|
+
| { status: "resolved"; agent: AgentCard }
|
|
26
|
+
| { status: "ambiguous"; candidates: AgentCard[] }
|
|
27
|
+
| { status: "not_found"; offlineMatches: AgentCard[] };
|
|
28
|
+
|
|
29
|
+
/** Canonical "is this agent reachable right now" predicate. Single source of truth. */
|
|
30
|
+
export function isAgentOnline(agent: AgentCard, now: number = Date.now()): boolean {
|
|
31
|
+
return agent.status !== "offline" && agent.ready && agent.lastSeen > now - STALE_TTL_MS;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Selector {
|
|
35
|
+
field: "id" | "label" | "name" | "tag" | "capability" | "machine" | "rig";
|
|
36
|
+
value: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SELECTOR_PREFIXES: Array<[string, Selector["field"]]> = [
|
|
40
|
+
["id:", "id"],
|
|
41
|
+
["label:", "label"],
|
|
42
|
+
["name:", "name"],
|
|
43
|
+
["tag:", "tag"],
|
|
44
|
+
["capability:", "capability"],
|
|
45
|
+
["cap:", "capability"],
|
|
46
|
+
["machine:", "machine"],
|
|
47
|
+
["rig:", "rig"],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function parseSelector(ref: string): Selector | null {
|
|
51
|
+
for (const [prefix, field] of SELECTOR_PREFIXES) {
|
|
52
|
+
if (ref.startsWith(prefix)) return { field, value: ref.slice(prefix.length).trim() };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function matchesSelector(agent: AgentCard, selector: Selector): boolean {
|
|
58
|
+
switch (selector.field) {
|
|
59
|
+
case "id": return agent.id === selector.value;
|
|
60
|
+
case "label": return agent.label === selector.value;
|
|
61
|
+
case "name": return agent.name === selector.value;
|
|
62
|
+
case "tag": return agent.tags.includes(selector.value);
|
|
63
|
+
case "capability": return agent.capabilities.includes(selector.value);
|
|
64
|
+
case "machine": return agent.machine === selector.value;
|
|
65
|
+
case "rig": return agent.rig === selector.value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matchesExact(agent: AgentCard, ref: string): boolean {
|
|
70
|
+
return (
|
|
71
|
+
agent.id === ref ||
|
|
72
|
+
agent.label === ref ||
|
|
73
|
+
agent.name === ref ||
|
|
74
|
+
agent.rig === ref ||
|
|
75
|
+
agent.tags.includes(ref) ||
|
|
76
|
+
agent.capabilities.includes(ref)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A reference matches an id "segment" when it equals — or is a prefix of — one of the
|
|
81
|
+
// id's delimiter-separated tokens. `session-1780922734580/c14bd37f-fe5d` tokenizes to
|
|
82
|
+
// [session, 1780922734580, c14bd37f, fe5d], so `c14bd37f`, `1780`, or `fe5d` all match.
|
|
83
|
+
function idMatchesSegment(id: string, ref: string): boolean {
|
|
84
|
+
if (!ref) return false;
|
|
85
|
+
const tokens = id.split(/[-/_:.]+/).filter(Boolean);
|
|
86
|
+
return tokens.some((token) => token === ref || token.startsWith(ref));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* All agents matching `ref`, using tiered logic: explicit selector wins; otherwise an
|
|
91
|
+
* exact match (id/label/name/tag/cap/rig) is preferred, and only if there are NO exact
|
|
92
|
+
* matches do we fall back to id-segment matching. No online filtering, no uniqueness —
|
|
93
|
+
* the caller decides what to do with multiplicity.
|
|
94
|
+
*/
|
|
95
|
+
export function matchAgents(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): AgentCard[] {
|
|
96
|
+
const trimmed = ref.trim();
|
|
97
|
+
if (!trimmed) return [];
|
|
98
|
+
const pool = opts.excludeId ? agents.filter((a) => a.id !== opts.excludeId) : agents;
|
|
99
|
+
|
|
100
|
+
const selector = parseSelector(trimmed);
|
|
101
|
+
if (selector) return selector.value ? pool.filter((a) => matchesSelector(a, selector)) : [];
|
|
102
|
+
|
|
103
|
+
const exact = pool.filter((a) => matchesExact(a, trimmed));
|
|
104
|
+
if (exact.length) return exact;
|
|
105
|
+
return pool.filter((a) => idMatchesSegment(a.id, trimmed));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve `ref` to a SINGLE agent. Prefers online matches; reports ambiguity rather
|
|
110
|
+
* than guessing. On not_found, returns any offline matches so callers can say "exists
|
|
111
|
+
* but offline" instead of "unknown".
|
|
112
|
+
*/
|
|
113
|
+
export function resolveAgentRef(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): ResolveResult {
|
|
114
|
+
const now = opts.now ?? Date.now();
|
|
115
|
+
const matches = matchAgents(ref, agents, opts);
|
|
116
|
+
if (matches.length === 0) return { status: "not_found", offlineMatches: [] };
|
|
117
|
+
|
|
118
|
+
const online = matches.filter((a) => isAgentOnline(a, now));
|
|
119
|
+
if (online.length === 1) return { status: "resolved", agent: online[0]! };
|
|
120
|
+
if (online.length > 1) return { status: "ambiguous", candidates: online };
|
|
121
|
+
return { status: "not_found", offlineMatches: matches };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- messaging send planning -------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export interface DeliveryReceipt {
|
|
127
|
+
/** A live recipient exists right now. */
|
|
128
|
+
delivered: boolean;
|
|
129
|
+
/** Whether the sender should expect a reply (false ⇒ don't block waiting). */
|
|
130
|
+
expectReply: boolean;
|
|
131
|
+
/** Online recipient agent ids this message will reach. */
|
|
132
|
+
recipients: string[];
|
|
133
|
+
/** Durable fan-out target with no online members — held until one appears. */
|
|
134
|
+
queued?: boolean;
|
|
135
|
+
/** Human reason when not delivered. */
|
|
136
|
+
reason?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type SendPlan =
|
|
140
|
+
// resolved/fan-out/passthrough carry the (possibly rewritten) canonical `to`
|
|
141
|
+
| { kind: "direct" | "fanout" | "passthrough"; to: string; receipt: DeliveryReceipt }
|
|
142
|
+
| { kind: "not_found"; message: string }
|
|
143
|
+
| { kind: "ambiguous"; message: string; candidates: AgentCard[] };
|
|
144
|
+
|
|
145
|
+
const FANOUT_PREFIXES = ["tag:", "cap:", "capability:", "label:"];
|
|
146
|
+
const PASSTHROUGH_PREFIXES = ["policy:", "orchestrator:", "pool:"];
|
|
147
|
+
const RESERVED_TARGETS = new Set(["user", "system"]);
|
|
148
|
+
|
|
149
|
+
function isPseudoAgent(agent: AgentCard): boolean {
|
|
150
|
+
return agent.id !== "user" && agent.id !== "system" && agent.kind !== "channel" && (agent.meta?.kind as unknown) !== "channel";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function describeAgent(agent: AgentCard): string {
|
|
154
|
+
return agent.label ? `${agent.id} (${agent.label})` : agent.id;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function ambiguousMessage(ref: string, candidates: AgentCard[]): string {
|
|
158
|
+
const list = candidates.slice(0, 8).map(describeAgent).join(", ");
|
|
159
|
+
const more = candidates.length > 8 ? `, +${candidates.length - 8} more` : "";
|
|
160
|
+
return `agent reference "${ref}" is ambiguous — matches ${candidates.length}: ${list}${more}. Use a full id, label, or a fan-out selector (tag:/cap:/label:).`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function notFoundMessage(ref: string, agents: AgentCard[]): string {
|
|
164
|
+
const needle = ref.toLowerCase();
|
|
165
|
+
const near = agents
|
|
166
|
+
.filter((a) => isPseudoAgent(a) && (a.id.toLowerCase().includes(needle) || a.label?.toLowerCase().includes(needle)))
|
|
167
|
+
.slice(0, 3)
|
|
168
|
+
.map(describeAgent);
|
|
169
|
+
const hint = near.length ? ` Did you mean: ${near.join(", ")}?` : "";
|
|
170
|
+
return `no agent matches "${ref}".${hint}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Classify a message target and produce a synchronous delivery receipt. Direct targets
|
|
175
|
+
* are resolved to a canonical agent id (so poll-time matching works) and report whether
|
|
176
|
+
* a live recipient exists; fan-out targets report how many online members they reach;
|
|
177
|
+
* reserved/policy targets pass through unchanged.
|
|
178
|
+
*/
|
|
179
|
+
export function planSend(to: string, agents: AgentCard[], now: number = Date.now()): SendPlan {
|
|
180
|
+
const target = to.trim();
|
|
181
|
+
|
|
182
|
+
if (target === "broadcast") {
|
|
183
|
+
const recipients = agents.filter((a) => isPseudoAgent(a) && isAgentOnline(a, now)).map((a) => a.id);
|
|
184
|
+
return { kind: "fanout", to: target, receipt: fanoutReceipt(recipients) };
|
|
185
|
+
}
|
|
186
|
+
if (FANOUT_PREFIXES.some((p) => target.startsWith(p))) {
|
|
187
|
+
const recipients = matchAgents(target.replace(/^cap:/, "capability:"), agents)
|
|
188
|
+
.filter((a) => isAgentOnline(a, now)).map((a) => a.id);
|
|
189
|
+
return { kind: "fanout", to: target, receipt: fanoutReceipt(recipients) };
|
|
190
|
+
}
|
|
191
|
+
if (RESERVED_TARGETS.has(target) || PASSTHROUGH_PREFIXES.some((p) => target.startsWith(p))) {
|
|
192
|
+
return { kind: "passthrough", to: target, receipt: { delivered: true, expectReply: true, recipients: [target] } };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Direct single-agent reference.
|
|
196
|
+
const resolved = resolveAgentRef(target, agents, { now });
|
|
197
|
+
if (resolved.status === "resolved") {
|
|
198
|
+
return { kind: "direct", to: resolved.agent.id, receipt: { delivered: true, expectReply: true, recipients: [resolved.agent.id] } };
|
|
199
|
+
}
|
|
200
|
+
if (resolved.status === "ambiguous") {
|
|
201
|
+
return { kind: "ambiguous", message: ambiguousMessage(target, resolved.candidates), candidates: resolved.candidates };
|
|
202
|
+
}
|
|
203
|
+
// not_found online — maybe it exists but is offline.
|
|
204
|
+
const offline = resolved.offlineMatches;
|
|
205
|
+
if (offline.length === 1) {
|
|
206
|
+
return { kind: "direct", to: offline[0]!.id, receipt: { delivered: false, expectReply: false, recipients: [], reason: "recipient offline" } };
|
|
207
|
+
}
|
|
208
|
+
if (offline.length > 1) {
|
|
209
|
+
return { kind: "ambiguous", message: ambiguousMessage(target, offline), candidates: offline };
|
|
210
|
+
}
|
|
211
|
+
return { kind: "not_found", message: notFoundMessage(target, agents) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function fanoutReceipt(recipients: string[]): DeliveryReceipt {
|
|
215
|
+
if (recipients.length === 0) return { delivered: false, expectReply: false, recipients: [], queued: true, reason: "no online members — queued" };
|
|
216
|
+
return { delivered: true, expectReply: true, recipients };
|
|
217
|
+
}
|
package/src/automations.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./db";
|
|
15
15
|
import { createCommand } from "./commands-db";
|
|
16
16
|
import { cleanEnum, cleanString, cleanStringArray, optionalEnum } from "./validation";
|
|
17
|
-
import { getAgentProfile, getSpawnPolicy } from "./config-store";
|
|
17
|
+
import { getAgentProfile, getSpawnPolicy, spawnGrantForProfile } from "./config-store";
|
|
18
18
|
import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
|
|
19
19
|
import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
|
|
20
20
|
import { errMessage, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
@@ -511,6 +511,7 @@ function dispatchOnDemandAutomation(
|
|
|
511
511
|
): AutomationDispatchResult {
|
|
512
512
|
if (!orchestrator.providers.includes(policy.provider)) throw new ValidationError(`orchestrator ${orchestrator.id} does not have provider available: ${policy.provider}`);
|
|
513
513
|
const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
|
|
514
|
+
const grant = spawnGrantForProfile(policy.profile);
|
|
514
515
|
const label = automationRunLabel(automation.id, run.id);
|
|
515
516
|
const command = createCommand({
|
|
516
517
|
type: "agent.spawn",
|
|
@@ -535,6 +536,8 @@ function dispatchOnDemandAutomation(
|
|
|
535
536
|
provider: policy.provider,
|
|
536
537
|
label,
|
|
537
538
|
createdBy: "automation",
|
|
539
|
+
canSpawn: grant.canSpawn,
|
|
540
|
+
maxSpawnedAgents: grant.maxSpawnedAgents,
|
|
538
541
|
}),
|
|
539
542
|
}),
|
|
540
543
|
});
|
package/src/cli.ts
CHANGED
|
@@ -49,6 +49,8 @@ import { shellQuote } from "agent-relay-sdk/shell-utils";
|
|
|
49
49
|
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
50
50
|
import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
|
|
51
51
|
|
|
52
|
+
export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]";
|
|
53
|
+
|
|
52
54
|
const HELP = `
|
|
53
55
|
agent-relay ${VERSION}
|
|
54
56
|
|
|
@@ -68,7 +70,7 @@ Usage:
|
|
|
68
70
|
agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
|
|
69
71
|
agent-relay token <create|list|revoke|verify> [options]
|
|
70
72
|
agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
|
|
71
|
-
agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy
|
|
73
|
+
agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]
|
|
72
74
|
agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
|
|
73
75
|
agent-relay message <target> <body> [options]
|
|
74
76
|
agent-relay get-message <messageId> [--json|--body]
|
|
@@ -1577,8 +1579,12 @@ function formatDepsRefresh(result: WorkspaceDepsRefreshResult, checkOnly: boolea
|
|
|
1577
1579
|
async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
1578
1580
|
const action = args[0];
|
|
1579
1581
|
const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale", "deps"]);
|
|
1582
|
+
if (action === "--help" || action === "-h" || action === "help") {
|
|
1583
|
+
console.log(WORKSPACE_USAGE);
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1580
1586
|
if (!action || !valid.has(action)) {
|
|
1581
|
-
throw new Error(
|
|
1587
|
+
throw new Error(WORKSPACE_USAGE);
|
|
1582
1588
|
}
|
|
1583
1589
|
|
|
1584
1590
|
let id = currentWorkspaceId();
|
package/src/config-store.ts
CHANGED
|
@@ -173,6 +173,20 @@ function cleanJsonRecord(value: unknown, field: string): Record<string, unknown>
|
|
|
173
173
|
return value;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
function cleanAgentProfileProviderOptions(value: unknown): Record<string, unknown> {
|
|
177
|
+
const options = cleanJsonRecord(value, "providerOptions");
|
|
178
|
+
if (options.codex === undefined || options.codex === null) return options;
|
|
179
|
+
if (!isRecord(options.codex)) throw new ValidationError("providerOptions.codex must be an object");
|
|
180
|
+
const codex = { ...options.codex };
|
|
181
|
+
if ("toolOutputTokenLimit" in codex && codex.toolOutputTokenLimit !== null) {
|
|
182
|
+
codex.toolOutputTokenLimit = cleanNumber(codex.toolOutputTokenLimit, "providerOptions.codex.toolOutputTokenLimit", {
|
|
183
|
+
min: 1_000,
|
|
184
|
+
max: 200_000,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return { ...options, codex };
|
|
188
|
+
}
|
|
189
|
+
|
|
176
190
|
function cleanBoolean(value: unknown, field: string): boolean {
|
|
177
191
|
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
178
192
|
return value;
|
|
@@ -221,6 +235,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
|
|
|
221
235
|
},
|
|
222
236
|
env: input.env ?? {},
|
|
223
237
|
providerOptions: input.providerOptions ?? {},
|
|
238
|
+
...(input.maxSpawnedAgents === undefined ? {} : { maxSpawnedAgents: input.maxSpawnedAgents }),
|
|
224
239
|
};
|
|
225
240
|
}
|
|
226
241
|
|
|
@@ -295,7 +310,10 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
|
|
|
295
310
|
: cleanEnum(permissions.filesystem, "permissions.filesystem", VALID_PROFILE_FILESYSTEM_SCOPES),
|
|
296
311
|
},
|
|
297
312
|
env: cleanStringRecord(value.env, "env"),
|
|
298
|
-
providerOptions:
|
|
313
|
+
providerOptions: cleanAgentProfileProviderOptions(value.providerOptions),
|
|
314
|
+
maxSpawnedAgents: value.maxSpawnedAgents === undefined || value.maxSpawnedAgents === null
|
|
315
|
+
? undefined
|
|
316
|
+
: cleanNumber(value.maxSpawnedAgents, "maxSpawnedAgents", { min: 0, max: 100 }),
|
|
299
317
|
});
|
|
300
318
|
}
|
|
301
319
|
|
|
@@ -657,6 +675,22 @@ export function getAgentProfile(name: string): ConfigEntry<AgentProfile> | null
|
|
|
657
675
|
return getConfig<AgentProfile>(AGENT_PROFILE_NAMESPACE, name);
|
|
658
676
|
}
|
|
659
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Resolve the spawn grant for a named profile (#221). `canSpawn` gates whether the agent's
|
|
680
|
+
* runtime token gets the `command:spawn`/`command:shutdown` scope; `maxSpawnedAgents` is the
|
|
681
|
+
* live-children quota baked into the token. Unknown/absent profile or `0` → no spawn (strict
|
|
682
|
+
* default). Callers pass `agentInitiated: true` for agent-requested spawns to force the child
|
|
683
|
+
* to NEVER be spawn-capable (no grandchildren), regardless of the child's profile.
|
|
684
|
+
*/
|
|
685
|
+
export function spawnGrantForProfile(
|
|
686
|
+
profileName: string | undefined,
|
|
687
|
+
agentInitiated = false,
|
|
688
|
+
): { canSpawn: boolean; maxSpawnedAgents: number } {
|
|
689
|
+
if (agentInitiated) return { canSpawn: false, maxSpawnedAgents: 0 };
|
|
690
|
+
const n = profileName ? (getAgentProfile(profileName)?.value.maxSpawnedAgents ?? 0) : 0;
|
|
691
|
+
return { canSpawn: n > 0, maxSpawnedAgents: n };
|
|
692
|
+
}
|
|
693
|
+
|
|
660
694
|
export function listAgentProfiles(): ConfigEntry<AgentProfile>[] {
|
|
661
695
|
const custom = listConfig<AgentProfile>(AGENT_PROFILE_NAMESPACE);
|
|
662
696
|
return [
|
package/src/context-router.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentCard } from "./types";
|
|
2
|
+
import { matchAgents } from "./agent-ref";
|
|
2
3
|
|
|
3
4
|
export interface RouteAdvisorInput {
|
|
4
5
|
text?: string;
|
|
@@ -39,7 +40,7 @@ function routeEligible(agent: AgentCard, input: RouteAdvisorInput, maxUtilizatio
|
|
|
39
40
|
if (agent.id === "user" || agent.id === "system" || agent.kind === "channel") return false;
|
|
40
41
|
if (agent.meta?.kind === "channel" || agent.tags.includes("channel")) return false;
|
|
41
42
|
if (agent.status === "offline" || agent.status === "stale") return false;
|
|
42
|
-
if (input.target && !
|
|
43
|
+
if (input.target && !matchesRouteTarget(agent, input.target)) return false;
|
|
43
44
|
if (input.capabilities?.length && !input.capabilities.every((cap) => agent.capabilities.includes(cap))) return false;
|
|
44
45
|
if (input.tags?.length && !input.tags.every((tag) => agent.tags.includes(tag))) return false;
|
|
45
46
|
if (maxUtilization !== undefined && (agent.context?.utilization ?? 0) > maxUtilization) return false;
|
|
@@ -67,11 +68,10 @@ function scoreAgent(agent: AgentCard, input: RouteAdvisorInput, terms: Set<strin
|
|
|
67
68
|
};
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return agent.id === target || agent.label === target;
|
|
71
|
+
// Broadcast matches every candidate; everything else delegates to the shared resolver
|
|
72
|
+
// (src/agent-ref.ts) so routing accepts the same ref forms as messaging and pairing.
|
|
73
|
+
function matchesRouteTarget(agent: AgentCard, target: string): boolean {
|
|
74
|
+
return target === "broadcast" || matchAgents(target, [agent]).length > 0;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function termsFromText(text: string | undefined): Set<string> {
|
|
@@ -100,7 +100,7 @@ function affinityScore(agent: AgentCard, input: RouteAdvisorInput): number {
|
|
|
100
100
|
}
|
|
101
101
|
if (input.target) {
|
|
102
102
|
possible++;
|
|
103
|
-
score +=
|
|
103
|
+
score += matchesRouteTarget(agent, input.target) ? 1 : 0;
|
|
104
104
|
}
|
|
105
105
|
return possible === 0 ? 0.5 : score / possible;
|
|
106
106
|
}
|
package/src/db.ts
CHANGED
|
@@ -74,6 +74,7 @@ import type {
|
|
|
74
74
|
WorkspaceStatus,
|
|
75
75
|
} from "./types";
|
|
76
76
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "./config";
|
|
77
|
+
import { matchAgents } from "./agent-ref";
|
|
77
78
|
|
|
78
79
|
let db: Database;
|
|
79
80
|
const CONTEXT_SNAPSHOT_DEBOUNCE_MS = 60_000;
|
|
@@ -210,6 +211,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
210
211
|
provider_capabilities TEXT,
|
|
211
212
|
context_state TEXT,
|
|
212
213
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
214
|
+
spawned_by TEXT,
|
|
213
215
|
last_seen INTEGER NOT NULL,
|
|
214
216
|
created_at INTEGER NOT NULL
|
|
215
217
|
);
|
|
@@ -1047,6 +1049,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
1047
1049
|
if (!agentColNames.includes("context_state")) {
|
|
1048
1050
|
db.run("ALTER TABLE agents ADD COLUMN context_state TEXT");
|
|
1049
1051
|
}
|
|
1052
|
+
if (!agentColNames.includes("spawned_by")) {
|
|
1053
|
+
db.run("ALTER TABLE agents ADD COLUMN spawned_by TEXT");
|
|
1054
|
+
}
|
|
1050
1055
|
db.run(`
|
|
1051
1056
|
CREATE TABLE IF NOT EXISTS context_snapshots (
|
|
1052
1057
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -1245,6 +1250,7 @@ function rowToAgent(row: any): AgentCard {
|
|
|
1245
1250
|
providerCapabilities: parseJson<ProviderCapabilities | undefined>(row.provider_capabilities, undefined),
|
|
1246
1251
|
context: parseJson<ContextState | undefined>(row.context_state, undefined),
|
|
1247
1252
|
meta: parseJson(row.meta, {}),
|
|
1253
|
+
spawnedBy: row.spawned_by ?? undefined,
|
|
1248
1254
|
lastSeen: row.last_seen,
|
|
1249
1255
|
createdAt: row.created_at,
|
|
1250
1256
|
};
|
|
@@ -1747,8 +1753,8 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
1747
1753
|
const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
|
|
1748
1754
|
const instanceProvided = Boolean(input.instanceId);
|
|
1749
1755
|
const stmt = db.query(`
|
|
1750
|
-
INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, last_seen, created_at)
|
|
1751
|
-
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $now, $now)
|
|
1756
|
+
INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, spawned_by, last_seen, created_at)
|
|
1757
|
+
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $spawnedBy, $now, $now)
|
|
1752
1758
|
ON CONFLICT(id) DO UPDATE SET
|
|
1753
1759
|
name = $name,
|
|
1754
1760
|
kind = $kind,
|
|
@@ -1767,6 +1773,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
1767
1773
|
provider_capabilities = coalesce($providerCapabilities, agents.provider_capabilities),
|
|
1768
1774
|
context_state = coalesce($contextState, agents.context_state),
|
|
1769
1775
|
meta = $meta,
|
|
1776
|
+
spawned_by = coalesce($spawnedBy, agents.spawned_by),
|
|
1770
1777
|
last_seen = $now
|
|
1771
1778
|
`);
|
|
1772
1779
|
|
|
@@ -1789,6 +1796,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
1789
1796
|
$providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
|
|
1790
1797
|
$contextState: input.context ? JSON.stringify(input.context) : null,
|
|
1791
1798
|
$meta: JSON.stringify(input.meta ?? {}),
|
|
1799
|
+
$spawnedBy: input.spawnedBy ?? null,
|
|
1792
1800
|
$now: now,
|
|
1793
1801
|
});
|
|
1794
1802
|
if (input.context) recordContextSnapshot(input.id, input.context, now);
|
|
@@ -1858,6 +1866,106 @@ export function listAgents(filter?: {
|
|
|
1858
1866
|
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
1859
1867
|
}
|
|
1860
1868
|
|
|
1869
|
+
export interface AgentSearchFilter {
|
|
1870
|
+
/** Substring match on label OR name (case-insensitive). */
|
|
1871
|
+
label?: string;
|
|
1872
|
+
/** One or more capabilities (OR within the field), via json_each(capabilities). */
|
|
1873
|
+
capability?: string | string[];
|
|
1874
|
+
/** One or more tags (OR within the field), via json_each(tags). */
|
|
1875
|
+
tag?: string | string[];
|
|
1876
|
+
/** Real model provider — providerCapabilities.model.provider (e.g. "claude", "codex"). */
|
|
1877
|
+
provider?: string | string[];
|
|
1878
|
+
machine?: string | string[];
|
|
1879
|
+
/** provider/orchestrator/channel/etc. */
|
|
1880
|
+
kind?: string | string[];
|
|
1881
|
+
/** Specific status(es), the "running" shorthand (live + ready + fresh), or "all". */
|
|
1882
|
+
status?: string | string[];
|
|
1883
|
+
/** Exact parent agent id — backs the `spawnedBy:` / `spawnedBy:me` fleet filter (#221). */
|
|
1884
|
+
spawnedBy?: string;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
export interface AgentSearchSort {
|
|
1888
|
+
sortBy?: "lastActive" | "created" | "label";
|
|
1889
|
+
order?: "asc" | "desc";
|
|
1890
|
+
limit?: number;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const AGENT_SEARCH_MAX_LIMIT = 200;
|
|
1894
|
+
|
|
1895
|
+
function searchValues(value: string | string[] | undefined): string[] {
|
|
1896
|
+
if (value == null) return [];
|
|
1897
|
+
return (Array.isArray(value) ? value : [value]).filter((v): v is string => typeof v === "string" && v.length > 0);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// Unified fleet search: all filters optional and AND-combined; array values OR within a
|
|
1901
|
+
// field. One parameterized query (no JS post-filter, no injection). Backs relay_find_agents
|
|
1902
|
+
// (#219) and the running-only relay_agent_status default (#218). "running" mirrors the
|
|
1903
|
+
// canonical liveness predicate (see db.ts delivery/steward checks + isAgentOnline).
|
|
1904
|
+
export function searchAgents(filter: AgentSearchFilter = {}, sort: AgentSearchSort = {}): AgentCard[] {
|
|
1905
|
+
const clauses: string[] = ["1=1"];
|
|
1906
|
+
const params: any[] = [];
|
|
1907
|
+
const inList = (column: string, values: string[]) => {
|
|
1908
|
+
clauses.push(`${column} IN (${values.map(() => "?").join(",")})`);
|
|
1909
|
+
params.push(...values);
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1912
|
+
if (filter.label) {
|
|
1913
|
+
clauses.push("(label LIKE ? OR name LIKE ?)");
|
|
1914
|
+
params.push(`%${filter.label}%`, `%${filter.label}%`);
|
|
1915
|
+
}
|
|
1916
|
+
const caps = searchValues(filter.capability);
|
|
1917
|
+
if (caps.length) {
|
|
1918
|
+
clauses.push(`EXISTS (SELECT 1 FROM json_each(capabilities) WHERE value IN (${caps.map(() => "?").join(",")}))`);
|
|
1919
|
+
params.push(...caps);
|
|
1920
|
+
}
|
|
1921
|
+
const tags = searchValues(filter.tag);
|
|
1922
|
+
if (tags.length) {
|
|
1923
|
+
clauses.push(`EXISTS (SELECT 1 FROM json_each(tags) WHERE value IN (${tags.map(() => "?").join(",")}))`);
|
|
1924
|
+
params.push(...tags);
|
|
1925
|
+
}
|
|
1926
|
+
const providers = searchValues(filter.provider);
|
|
1927
|
+
if (providers.length) {
|
|
1928
|
+
clauses.push(`json_extract(provider_capabilities, '$.model.provider') IN (${providers.map(() => "?").join(",")})`);
|
|
1929
|
+
params.push(...providers);
|
|
1930
|
+
}
|
|
1931
|
+
const machines = searchValues(filter.machine);
|
|
1932
|
+
if (machines.length) inList("machine", machines);
|
|
1933
|
+
const kinds = searchValues(filter.kind);
|
|
1934
|
+
if (kinds.length) inList("kind", kinds);
|
|
1935
|
+
if (filter.spawnedBy) {
|
|
1936
|
+
clauses.push("spawned_by = ?");
|
|
1937
|
+
params.push(filter.spawnedBy);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const statuses = searchValues(filter.status);
|
|
1941
|
+
if (!statuses.includes("all")) {
|
|
1942
|
+
if (statuses.includes("running")) {
|
|
1943
|
+
clauses.push("status NOT IN ('offline', 'stale') AND ready = 1 AND last_seen > ?");
|
|
1944
|
+
params.push(Date.now() - STALE_TTL_MS);
|
|
1945
|
+
}
|
|
1946
|
+
const explicit = statuses.filter((s) => s !== "running" && s !== "all");
|
|
1947
|
+
if (explicit.length) inList("status", explicit);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const sortColumn = sort.sortBy === "created" ? "created_at" : sort.sortBy === "label" ? "label COLLATE NOCASE" : "last_seen";
|
|
1951
|
+
const order = sort.order === "asc" ? "ASC" : "DESC";
|
|
1952
|
+
const limit = Math.max(1, Math.min(sort.limit ?? 50, AGENT_SEARCH_MAX_LIMIT));
|
|
1953
|
+
params.push(limit);
|
|
1954
|
+
|
|
1955
|
+
const sql = `SELECT * FROM agents WHERE ${clauses.join(" AND ")} ORDER BY ${sortColumn} ${order} LIMIT ?`;
|
|
1956
|
+
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// Count of a parent's currently-LIVE children — the spawn quota numerator (#221). "Live"
|
|
1960
|
+
// mirrors the running predicate (not offline/stale, ready, fresh); a child that exits frees
|
|
1961
|
+
// a slot. Uncapped COUNT (unlike searchAgents' LIMIT) so the quota is exact at any fleet size.
|
|
1962
|
+
export function countLiveSpawnedAgents(parentId: string, now: number = Date.now()): number {
|
|
1963
|
+
const row = db.query(
|
|
1964
|
+
"SELECT count(*) AS n FROM agents WHERE spawned_by = ? AND status NOT IN ('offline', 'stale') AND ready = 1 AND last_seen > ?",
|
|
1965
|
+
).get(parentId, now - STALE_TTL_MS) as { n: number };
|
|
1966
|
+
return row.n;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1861
1969
|
export function setStatus(id: string, status: AgentCard["status"], guard?: AgentSessionGuard): boolean {
|
|
1862
1970
|
if (!validateAgentSession(id, guard).ok) return false;
|
|
1863
1971
|
const now = Date.now();
|
|
@@ -2350,17 +2458,6 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2350
2458
|
})();
|
|
2351
2459
|
}
|
|
2352
2460
|
|
|
2353
|
-
export function findAgentsByCapability(capability: string, onlineOnly = true): AgentCard[] {
|
|
2354
|
-
let sql = `SELECT * FROM agents WHERE EXISTS (SELECT 1 FROM json_each(capabilities) WHERE value = ?)`;
|
|
2355
|
-
const params: any[] = [capability];
|
|
2356
|
-
if (onlineOnly) {
|
|
2357
|
-
sql += ` AND status != 'offline' AND last_seen > ?`;
|
|
2358
|
-
params.push(Date.now() - STALE_TTL_MS);
|
|
2359
|
-
}
|
|
2360
|
-
sql += " ORDER BY last_seen DESC";
|
|
2361
|
-
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
2362
|
-
}
|
|
2363
|
-
|
|
2364
2461
|
export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
2365
2462
|
if (id === "user" || id === "system") {
|
|
2366
2463
|
return { ok: false, error: "built-in agent cannot be deleted" };
|
|
@@ -3023,21 +3120,6 @@ function pairPeer(pair: PairSession, agentId: string): string {
|
|
|
3023
3120
|
return pair.requesterId === agentId ? pair.targetId : pair.requesterId;
|
|
3024
3121
|
}
|
|
3025
3122
|
|
|
3026
|
-
function agentMatchesPairTarget(agent: AgentCard, target: string): boolean {
|
|
3027
|
-
if (target.startsWith("id:")) return agent.id === target.slice(3);
|
|
3028
|
-
if (target.startsWith("label:")) return agent.label === target.slice(6);
|
|
3029
|
-
if (target.startsWith("tag:")) return agent.tags.includes(target.slice(4));
|
|
3030
|
-
if (target.startsWith("cap:")) return agent.capabilities.includes(target.slice(4));
|
|
3031
|
-
if (target.startsWith("rig:")) return agent.rig === target.slice(4);
|
|
3032
|
-
if (target.startsWith("machine:")) return agent.machine === target.slice(8);
|
|
3033
|
-
return agent.id === target ||
|
|
3034
|
-
agent.label === target ||
|
|
3035
|
-
agent.tags.includes(target) ||
|
|
3036
|
-
agent.capabilities.includes(target) ||
|
|
3037
|
-
agent.rig === target ||
|
|
3038
|
-
agent.name === target;
|
|
3039
|
-
}
|
|
3040
|
-
|
|
3041
3123
|
function resolvePairTarget(target: string, requesterId: string): {
|
|
3042
3124
|
ok: true;
|
|
3043
3125
|
agent: AgentCard;
|
|
@@ -3048,7 +3130,7 @@ function resolvePairTarget(target: string, requesterId: string): {
|
|
|
3048
3130
|
matches?: AgentCard[];
|
|
3049
3131
|
busy?: Array<{ agent: AgentCard; pair: PairSession }>;
|
|
3050
3132
|
} {
|
|
3051
|
-
const matches = listAgents()
|
|
3133
|
+
const matches = matchAgents(target, listAgents(), { excludeId: requesterId });
|
|
3052
3134
|
if (matches.length === 0) return { ok: false, code: "not_found", error: `no agent matches ${target}` };
|
|
3053
3135
|
|
|
3054
3136
|
const live = matches.filter((agent) => agent.status !== "offline" && agent.ready);
|