agent-relay-server 0.33.0 → 0.34.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -174,7 +174,23 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
174
174
 
175
175
  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_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then 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`;
176
176
 
177
- export const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
177
+ // #306 deliver the FULL message body by default. Only a pathological body beyond this
178
+ // high cap truncates (with a get-message hint) so it can't nuke an agent's context; the 99%
179
+ // case (incl. multi-thousand-char handoffs/reports) arrives whole in one shot, no extra
180
+ // round-trip. The mirror flow (user typing directly) already injects full text — this removes
181
+ // the asymmetry where agent↔agent + attachment-bearing messages were second-class.
182
+ export const DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS = 24_000;
183
+
184
+ // Resolve the delivered-body cap. Deployment-dependent (the right ceiling tracks the host's
185
+ // context budget), so it's overridable via env — set on the orchestrator/host that spawns runners.
186
+ export function providerMessageBodyMaxChars(): number {
187
+ const raw = process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
188
+ if (raw !== undefined) {
189
+ const parsed = Number.parseInt(raw.trim(), 10);
190
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
191
+ }
192
+ return DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS;
193
+ }
178
194
 
179
195
  function attachmentRefs(message: Message): Record<string, unknown>[] {
180
196
  const payloadRefs = message.payload?.attachments;
@@ -280,15 +296,16 @@ export function providerAttachmentText(message: Message): string | undefined {
280
296
 
281
297
  export function providerMessageText(messages: Message[]): string {
282
298
  const replyable = latestReplyableMessage(messages);
299
+ const maxChars = providerMessageBodyMaxChars();
283
300
  const sections = messages
284
301
  .map((message) => {
285
302
  const subject = message.subject ? `Subject: ${message.subject}\n` : "";
286
303
  const isMemoryContext = isMemoryInjection(message);
287
304
  const canReferenceMessage = isPersistedRelayMessage(message) && !isMemoryContext && !isReactionNotification(message);
288
- const shouldPreview = canReferenceMessage && message.body.length > PROVIDER_MESSAGE_BODY_PREVIEW_CHARS;
305
+ const shouldPreview = canReferenceMessage && message.body.length > maxChars;
289
306
  const preview = shouldPreview
290
307
  ? {
291
- body: message.body.slice(0, PROVIDER_MESSAGE_BODY_PREVIEW_CHARS),
308
+ body: message.body.slice(0, maxChars),
292
309
  truncated: true,
293
310
  }
294
311
  : {
@@ -297,7 +314,7 @@ export function providerMessageText(messages: Message[]): string {
297
314
  };
298
315
  const truncationGuidance = preview.truncated
299
316
  ? [
300
- `[truncated: showing first ${PROVIDER_MESSAGE_BODY_PREVIEW_CHARS} of ${message.body.length} chars]`,
317
+ `[truncated: showing first ${maxChars} of ${message.body.length} chars]`,
301
318
  `Read full: agent-relay get-message ${message.id}`,
302
319
  `Body only: agent-relay get-message ${message.id} --body`,
303
320
  `Long reply: agent-relay /reply ${message.id} --stdin < response.md`,
@@ -0,0 +1,24 @@
1
+ import type { ChannelRouteTarget } from "./types";
2
+
3
+ // Canonical parser for a channel route-target address — the `pool:` / `policy:` /
4
+ // `label:` / `tag:` / `cap:` / `orchestrator:` / `broadcast` wire form. Single
5
+ // source of truth: both the routes POST path (routeTargetFromAddress) and the db
6
+ // legacy-target path (routeTargetFromLegacyTarget) call this, so they can't drift.
7
+ //
8
+ // They DID drift — the db copy silently dropped `policy:`, so a `policy:`
9
+ // configured agent target parsed to { type: "agent", id: "policy:foo" } and never
10
+ // routed (surfaced in #299, fixed with the db split #298). Keep this the only
11
+ // place that maps an address string to a ChannelRouteTarget.
12
+ //
13
+ // Pure and non-throwing: it always returns a typed target. Callers apply their
14
+ // own policy on top (e.g. the routes path rejects `orchestrator:` as unsupported).
15
+ export function parseChannelRouteTarget(target: string): ChannelRouteTarget {
16
+ if (target.startsWith("pool:")) return { type: "pool", id: target.slice("pool:".length) };
17
+ if (target.startsWith("policy:")) return { type: "policy", id: target.slice("policy:".length) };
18
+ if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
19
+ if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
20
+ if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
21
+ if (target === "broadcast") return { type: "broadcast" };
22
+ if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
23
+ return { type: "agent", id: target };
24
+ }
@@ -0,0 +1,194 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
4
+ import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
5
+ import { parseJson } from "../utils";
6
+ import { isLiveIsolatedWorkspace } from "../workspace-phase";
7
+ import {
8
+ CONTRACT_REQUIREMENTS,
9
+ contractCompatibility,
10
+ parseRuntimeCapabilities,
11
+ parseRuntimeContracts,
12
+ parseRuntimePackage,
13
+ type RuntimeContracts,
14
+ } from "../contracts";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
+ import { matchAgents } from "../agent-ref";
17
+ import { getDb } from "./connection.ts";
18
+ import { rowToActivityEvent } from "./mappers.ts";
19
+ import type {
20
+ AgentCard,
21
+ ActivityEvent,
22
+ ActivityEventInput,
23
+ AgentKind,
24
+ AgentSessionGuard,
25
+ Artifact,
26
+ ArtifactBlob,
27
+ ArtifactKind,
28
+ ArtifactLink,
29
+ ArtifactSensitivity,
30
+ ArtifactVisibility,
31
+ AttachmentRef,
32
+ ChannelBinding,
33
+ ChannelBindingMode,
34
+ ChannelRouteTarget,
35
+ ChatHistoryImport,
36
+ ChatHistoryImportEntry,
37
+ ChannelSummary,
38
+ ChannelTargetHealth,
39
+ CreatePairInput,
40
+ HealthCheck,
41
+ HealthReport,
42
+ ManagedAgent,
43
+ ManagedSessionExitDiagnostics,
44
+ Message,
45
+ MessageDeliveryAttempt,
46
+ MessageDeliveryStatus,
47
+ Orchestrator,
48
+ OrchestratorHealth,
49
+ OrchestratorRuntimeInput,
50
+ OrchestratorStatus,
51
+ OrchestratorUpgradeState,
52
+ PairActionInput,
53
+ PairMessageInput,
54
+ PairSession,
55
+ PairStatus,
56
+ RegisterAgentInput,
57
+ ReplyObligation,
58
+ RegisterOrchestratorInput,
59
+ SendMessageInput,
60
+ PollQuery,
61
+ SpawnApprovalMode,
62
+ SpawnProvider,
63
+ Task,
64
+ TaskEvent,
65
+ TaskSeverity,
66
+ TaskStatus,
67
+ IntegrationEventInput,
68
+ IntegrationSummary,
69
+ IntegrationTaskStats,
70
+ InboxDraft,
71
+ InboxState,
72
+ InboxThreadState,
73
+ ContextSnapshot,
74
+ ContextState,
75
+ ProviderCapabilities,
76
+ TaskStatusInput,
77
+ WorkspaceMetadata,
78
+ WorkspaceRecord,
79
+ WorkspaceStatus,
80
+ } from "../types";
81
+
82
+ export function listActivityEvents(input: {
83
+ operatorId?: string;
84
+ agentId?: string;
85
+ limit?: number;
86
+ since?: number;
87
+ } = {}): ActivityEvent[] {
88
+ const conditions: string[] = [];
89
+ const params: any[] = [];
90
+ if (input.operatorId) {
91
+ conditions.push("operator_id = ?");
92
+ params.push(input.operatorId);
93
+ }
94
+ if (input.agentId) {
95
+ conditions.push("agent_id = ?");
96
+ params.push(input.agentId);
97
+ }
98
+ if (input.since !== undefined) {
99
+ conditions.push("created_at >= ?");
100
+ params.push(input.since);
101
+ }
102
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
103
+ params.push(input.limit ?? 200);
104
+ return (getDb().query(
105
+ `SELECT * FROM activity_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
106
+ ).all(...params) as any[]).map(rowToActivityEvent);
107
+ }
108
+
109
+ export interface AgentTimelineEntry {
110
+ ts: number;
111
+ source: "activity" | "command" | "delivery";
112
+ kind: string;
113
+ title: string;
114
+ detail?: string;
115
+ metadata?: Record<string, unknown>;
116
+ }
117
+
118
+ // One unified, newest-first stream of everything that happened to an agent —
119
+ // lifecycle transitions, liveness mismatches, commands + outcomes, and delivery
120
+ // attempts — joined by agentId and (for managed agents) policyName. Backs the
121
+ // agent-drawer timeline without new write paths.
122
+ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?: number } = {}): AgentTimelineEntry[] {
123
+ const limit = opts.limit ?? 200;
124
+ const since = opts.since;
125
+ const entries: AgentTimelineEntry[] = [];
126
+
127
+ for (const ev of listActivityEvents({ agentId, limit, since })) {
128
+ entries.push({ ts: ev.createdAt, source: "activity", kind: ev.kind, title: ev.title, detail: ev.body, metadata: ev.metadata });
129
+ }
130
+
131
+ // Managed agents: include policy-scoped lifecycle transitions recorded before
132
+ // the agent registered (agent_id null), correlated via metadata.policyName.
133
+ const managed = getDb().query("SELECT policy_name FROM managed_agent_state WHERE agent_id = ? LIMIT 1").get(agentId) as { policy_name?: string } | undefined;
134
+ if (managed?.policy_name) {
135
+ const rows = (since !== undefined
136
+ ? getDb().query("SELECT * FROM activity_events WHERE agent_id IS NULL AND json_extract(metadata, '$.policyName') = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(managed.policy_name, since, limit)
137
+ : getDb().query("SELECT * FROM activity_events WHERE agent_id IS NULL AND json_extract(metadata, '$.policyName') = ? ORDER BY created_at DESC LIMIT ?").all(managed.policy_name, limit)) as any[];
138
+ for (const ev of rows.map(rowToActivityEvent)) {
139
+ entries.push({ ts: ev.createdAt, source: "activity", kind: ev.kind, title: ev.title, detail: ev.body, metadata: ev.metadata });
140
+ }
141
+ }
142
+
143
+ // Commands targeting this agent (spawn/shutdown/restart/compact) with outcome.
144
+ const cmds = (since !== undefined
145
+ ? getDb().query("SELECT id, type, status, result, correlation_id, created_at, updated_at FROM commands WHERE target = ? AND updated_at >= ? ORDER BY updated_at DESC LIMIT ?").all(agentId, since, limit)
146
+ : getDb().query("SELECT id, type, status, result, correlation_id, created_at, updated_at FROM commands WHERE target = ? ORDER BY updated_at DESC LIMIT ?").all(agentId, limit)) as any[];
147
+ for (const c of cmds) {
148
+ entries.push({ ts: c.updated_at ?? c.created_at, source: "command", kind: c.type, title: `${c.type} · ${c.status}`, detail: c.result ?? undefined, metadata: { id: c.id, status: c.status, correlationId: c.correlation_id ?? undefined } });
149
+ }
150
+
151
+ // Delivery attempts involving this agent (failures, retries, poison).
152
+ const attempts = (since !== undefined
153
+ ? getDb().query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(agentId, since, limit)
154
+ : getDb().query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?").all(agentId, limit)) as any[];
155
+ for (const row of attempts) {
156
+ entries.push({ ts: row.created_at, source: "delivery", kind: `delivery.${row.action}`, title: `delivery ${row.status}`, detail: row.error ?? undefined, metadata: { messageId: row.message_id, status: row.status, poisonReason: row.poison_reason ?? undefined } });
157
+ }
158
+
159
+ entries.sort((a, b) => b.ts - a.ts);
160
+ return entries.slice(0, limit);
161
+ }
162
+
163
+ export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
164
+ if (input.clientId) {
165
+ const existing = getDb().query("SELECT * FROM activity_events WHERE client_id = ?").get(input.clientId) as any | undefined;
166
+ if (existing) return rowToActivityEvent(existing);
167
+ }
168
+ const now = Date.now();
169
+ const result = getDb().query(`
170
+ INSERT INTO activity_events (
171
+ operator_id, client_id, kind, title, body, meta_text, icon, view, peer_id,
172
+ message_id, pair_id, task_id, agent_id, metadata, created_at
173
+ )
174
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
175
+ `).run(
176
+ input.operatorId ?? null,
177
+ input.clientId ?? null,
178
+ input.kind,
179
+ input.title,
180
+ input.body ?? null,
181
+ input.meta ?? null,
182
+ input.icon ?? null,
183
+ input.view ?? null,
184
+ input.peer ?? null,
185
+ input.messageId ?? null,
186
+ input.pairId ?? null,
187
+ input.taskId ?? null,
188
+ input.agentId ?? null,
189
+ JSON.stringify(input.metadata ?? {}),
190
+ now,
191
+ );
192
+ return rowToActivityEvent(getDb().query("SELECT * FROM activity_events WHERE id = ?").get(Number(result.lastInsertRowid)));
193
+ }
194
+
@@ -0,0 +1,174 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
4
+ import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
5
+ import { parseJson } from "../utils";
6
+ import { isLiveIsolatedWorkspace } from "../workspace-phase";
7
+ import {
8
+ CONTRACT_REQUIREMENTS,
9
+ contractCompatibility,
10
+ parseRuntimeCapabilities,
11
+ parseRuntimeContracts,
12
+ parseRuntimePackage,
13
+ type RuntimeContracts,
14
+ } from "../contracts";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
+ import { matchAgents } from "../agent-ref";
17
+ import { getDb } from "./connection.ts";
18
+ import { rowToAgent } from "./mappers.ts";
19
+ import type {
20
+ AgentCard,
21
+ ActivityEvent,
22
+ ActivityEventInput,
23
+ AgentKind,
24
+ AgentSessionGuard,
25
+ Artifact,
26
+ ArtifactBlob,
27
+ ArtifactKind,
28
+ ArtifactLink,
29
+ ArtifactSensitivity,
30
+ ArtifactVisibility,
31
+ AttachmentRef,
32
+ ChannelBinding,
33
+ ChannelBindingMode,
34
+ ChannelRouteTarget,
35
+ ChatHistoryImport,
36
+ ChatHistoryImportEntry,
37
+ ChannelSummary,
38
+ ChannelTargetHealth,
39
+ CreatePairInput,
40
+ HealthCheck,
41
+ HealthReport,
42
+ ManagedAgent,
43
+ ManagedSessionExitDiagnostics,
44
+ Message,
45
+ MessageDeliveryAttempt,
46
+ MessageDeliveryStatus,
47
+ Orchestrator,
48
+ OrchestratorHealth,
49
+ OrchestratorRuntimeInput,
50
+ OrchestratorStatus,
51
+ OrchestratorUpgradeState,
52
+ PairActionInput,
53
+ PairMessageInput,
54
+ PairSession,
55
+ PairStatus,
56
+ RegisterAgentInput,
57
+ ReplyObligation,
58
+ RegisterOrchestratorInput,
59
+ SendMessageInput,
60
+ PollQuery,
61
+ SpawnApprovalMode,
62
+ SpawnProvider,
63
+ Task,
64
+ TaskEvent,
65
+ TaskSeverity,
66
+ TaskStatus,
67
+ IntegrationEventInput,
68
+ IntegrationSummary,
69
+ IntegrationTaskStats,
70
+ InboxDraft,
71
+ InboxState,
72
+ InboxThreadState,
73
+ ContextSnapshot,
74
+ ContextState,
75
+ ProviderCapabilities,
76
+ TaskStatusInput,
77
+ WorkspaceMetadata,
78
+ WorkspaceRecord,
79
+ WorkspaceStatus,
80
+ } from "../types";
81
+
82
+ export interface AgentSearchFilter {
83
+ /** Substring match on label OR name (case-insensitive). */
84
+ label?: string;
85
+ /** One or more capabilities (OR within the field), via json_each(capabilities). */
86
+ capability?: string | string[];
87
+ /** One or more tags (OR within the field), via json_each(tags). */
88
+ tag?: string | string[];
89
+ /** Real model provider — providerCapabilities.model.provider (e.g. "claude", "codex"). */
90
+ provider?: string | string[];
91
+ machine?: string | string[];
92
+ /** provider/orchestrator/channel/etc. */
93
+ kind?: string | string[];
94
+ /** Specific status(es), the "running" shorthand (live + ready + fresh), or "all". */
95
+ status?: string | string[];
96
+ /** Exact parent agent id — backs the `spawnedBy:` / `spawnedBy:me` fleet filter (#221). */
97
+ spawnedBy?: string;
98
+ }
99
+
100
+ export interface AgentSearchSort {
101
+ sortBy?: "lastActive" | "created" | "label";
102
+ order?: "asc" | "desc";
103
+ limit?: number;
104
+ }
105
+
106
+ export const AGENT_SEARCH_MAX_LIMIT = 200;
107
+
108
+ export function searchValues(value: string | string[] | undefined): string[] {
109
+ if (value == null) return [];
110
+ return (Array.isArray(value) ? value : [value]).filter((v): v is string => typeof v === "string" && v.length > 0);
111
+ }
112
+
113
+ // Unified fleet search: all filters optional and AND-combined; array values OR within a
114
+ // field. One parameterized query (no JS post-filter, no injection). Backs relay_find_agents
115
+ // (#219) and the running-only relay_agent_status default (#218). "running" mirrors the
116
+ // canonical liveness predicate (see getDb().ts delivery/steward checks + isAgentOnline).
117
+ export function searchAgents(filter: AgentSearchFilter = {}, sort: AgentSearchSort = {}): AgentCard[] {
118
+ const clauses: string[] = ["1=1"];
119
+ const params: any[] = [];
120
+ const inList = (column: string, values: string[]) => {
121
+ clauses.push(`${column} IN (${values.map(() => "?").join(",")})`);
122
+ params.push(...values);
123
+ };
124
+
125
+ if (filter.label) {
126
+ clauses.push("(label LIKE ? OR name LIKE ?)");
127
+ params.push(`%${filter.label}%`, `%${filter.label}%`);
128
+ }
129
+ const caps = searchValues(filter.capability);
130
+ if (caps.length) {
131
+ clauses.push(`EXISTS (SELECT 1 FROM json_each(capabilities) WHERE value IN (${caps.map(() => "?").join(",")}))`);
132
+ params.push(...caps);
133
+ }
134
+ const tags = searchValues(filter.tag);
135
+ if (tags.length) {
136
+ clauses.push(`EXISTS (SELECT 1 FROM json_each(tags) WHERE value IN (${tags.map(() => "?").join(",")}))`);
137
+ params.push(...tags);
138
+ }
139
+ const providers = searchValues(filter.provider);
140
+ if (providers.length) {
141
+ clauses.push(`json_extract(provider_capabilities, '$.model.provider') IN (${providers.map(() => "?").join(",")})`);
142
+ params.push(...providers);
143
+ }
144
+ const machines = searchValues(filter.machine);
145
+ if (machines.length) inList("machine", machines);
146
+ const kinds = searchValues(filter.kind);
147
+ if (kinds.length) inList("kind", kinds);
148
+ if (filter.spawnedBy) {
149
+ clauses.push("spawned_by = ?");
150
+ params.push(filter.spawnedBy);
151
+ }
152
+
153
+ const statuses = searchValues(filter.status);
154
+ if (!statuses.includes("all")) {
155
+ if (statuses.includes("running")) {
156
+ clauses.push("status NOT IN ('offline', 'stale') AND ready = 1 AND last_seen > ?");
157
+ params.push(Date.now() - STALE_TTL_MS);
158
+ }
159
+ const explicit = statuses.filter((s) => s !== "running" && s !== "all");
160
+ if (explicit.length) inList("status", explicit);
161
+ }
162
+
163
+ const sortColumn = sort.sortBy === "created" ? "created_at" : sort.sortBy === "label" ? "label COLLATE NOCASE" : "last_seen";
164
+ const order = sort.order === "asc" ? "ASC" : "DESC";
165
+ const limit = Math.max(1, Math.min(sort.limit ?? 50, AGENT_SEARCH_MAX_LIMIT));
166
+ params.push(limit);
167
+
168
+ const sql = `SELECT * FROM agents WHERE ${clauses.join(" AND ")} ORDER BY ${sortColumn} ${order} LIMIT ?`;
169
+ return (getDb().query(sql).all(...params) as any[]).map(rowToAgent);
170
+ }
171
+
172
+ // Count of a parent's currently-LIVE children — the spawn quota numerator (#221). "Live"
173
+ // mirrors the running predicate (not offline/stale, ready, fresh); a child that exits frees
174
+ // a slot. Uncapped COUNT (unlike searchAgents' LIMIT) so the quota is exact at any fleet size.