agent-relay-server 0.121.3 → 0.121.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/bus.ts +6 -4
- package/src/db/agents.ts +29 -4
- package/src/db/messages.ts +20 -4
- package/src/managed-policy.ts +43 -6
- package/src/services/register-agent.ts +12 -1
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.121.
|
|
5
|
+
"version": "0.121.5",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.121.
|
|
3
|
+
"version": "0.121.5",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"CONTRIBUTING.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"agent-relay-channels-host": "0.121.
|
|
40
|
+
"agent-relay-channels-host": "0.121.5",
|
|
41
41
|
"agent-relay-providers": "0.104.3",
|
|
42
42
|
"agent-relay-sdk": "0.2.113",
|
|
43
43
|
"ajv": "^8.20.0"
|
package/src/bus.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
-
import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent,
|
|
2
|
+
import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, setStatus, validateAgentSession } from "./db";
|
|
3
3
|
import { getOutboxCursor, hasReplayGap, replayEventsSlice, type BusEvent } from "./bus-outbox";
|
|
4
4
|
import { projectAgentStatusData } from "./agent-card-projection";
|
|
5
5
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
@@ -127,9 +127,11 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs(), lagGraceMs = 0
|
|
|
127
127
|
const rows = getDb().query("SELECT id FROM agents WHERE status = 'stale' AND last_seen < ? AND id NOT IN ('user', 'system')").all(cutoff) as Array<{ id: string }>;
|
|
128
128
|
const orphanedTasks: Task[] = [];
|
|
129
129
|
for (const row of rows) {
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
130
|
+
// #1051 — bus-grace staleness is a liveness observation, not a credential fact: the
|
|
131
|
+
// process may still be alive (relay stall / network blip). Mark offline for routing,
|
|
132
|
+
// but do NOT revoke the token — a reconnect accepts the EXISTING token and re-marks
|
|
133
|
+
// live. The #484 restart-strand revoke now fires on instance takeover in registerAgent
|
|
134
|
+
// (a fresh instanceId → epoch bump), never on a heartbeat lapse.
|
|
133
135
|
setStatus(row.id, "offline");
|
|
134
136
|
emitAgentStatusEvent(row.id);
|
|
135
137
|
// #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
|
package/src/db/agents.ts
CHANGED
|
@@ -505,10 +505,13 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS, lagGraceMs = 0): s
|
|
|
505
505
|
)
|
|
506
506
|
.all(cutoff) as any[];
|
|
507
507
|
for (const row of rows) {
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
508
|
+
// #1051 — liveness ≠ credential validity. A lapsed heartbeat is a transient,
|
|
509
|
+
// recoverable observation (the relay may have stalled, or the network blipped);
|
|
510
|
+
// it marks status only and must NOT void a still-unexpired token. On reconnect the
|
|
511
|
+
// relay accepts the agent's EXISTING token and re-marks it live. The #484 restart-
|
|
512
|
+
// strand invariant (an orphaned prior-instance token must not stay usable) is now
|
|
513
|
+
// enforced on instance takeover instead — see revokeSupersededRuntimeTokens, fired
|
|
514
|
+
// from registerAgent when a fresh process re-registers under a new instanceId.
|
|
512
515
|
closeOpenPairsForAgent(row.id, now);
|
|
513
516
|
}
|
|
514
517
|
return rows.map((r: any) => r.id);
|
|
@@ -589,6 +592,28 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
|
589
592
|
export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now(), reason: TokenRevokeReason = "agent-removed"): string[] {
|
|
590
593
|
const row = getDb().query("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
|
|
591
594
|
const jtis = runtimeTokenJtisFromMeta(parseJson(row?.meta ?? "{}", {}));
|
|
595
|
+
return revokeProviderRuntimeTokensByJti(jtis, reason, now);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// #1051 — the #484 restart-strand invariant, re-homed from heartbeat-lapse to instance
|
|
599
|
+
// takeover. Staleness (a missed heartbeat) no longer revokes anything; instead, when a
|
|
600
|
+
// FRESH runner process re-registers under an existing agent id — a new instanceId, which
|
|
601
|
+
// bumps the agent epoch (see upsertAgent) — the PRIOR instance's runtime tokens are
|
|
602
|
+
// revoked "stale" here. "stale" (not a hard kill) keeps them re-mintable via the
|
|
603
|
+
// orchestrator (#484), so a live process that merely rotated instances still self-heals,
|
|
604
|
+
// while an orphaned prior-instance token can no longer authenticate a request directly.
|
|
605
|
+
// The incoming instance's own token (already stamped into the NEW meta) is preserved: a
|
|
606
|
+
// same-instance reconnect (relay-restart / lag recovery) keeps its instanceId, does not
|
|
607
|
+
// bump the epoch, and so never reaches this path — its token stays valid untouched.
|
|
608
|
+
export function revokeSupersededRuntimeTokens(prior: AgentCard, current: AgentCard, now = Date.now()): string[] {
|
|
609
|
+
const priorJtis = runtimeTokenJtisFromMeta((prior.meta ?? {}) as Record<string, unknown>);
|
|
610
|
+
if (priorJtis.length === 0) return [];
|
|
611
|
+
const keep = new Set(runtimeTokenJtisFromMeta((current.meta ?? {}) as Record<string, unknown>));
|
|
612
|
+
const superseded = priorJtis.filter((jti) => !keep.has(jti));
|
|
613
|
+
return revokeProviderRuntimeTokensByJti(superseded, "stale", now);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function revokeProviderRuntimeTokensByJti(jtis: string[], reason: TokenRevokeReason, now: number): string[] {
|
|
592
617
|
if (jtis.length === 0) return [];
|
|
593
618
|
const revokedAt = Math.floor(now / 1000);
|
|
594
619
|
const placeholders = jtis.map(() => "?").join(",");
|
package/src/db/messages.ts
CHANGED
|
@@ -354,17 +354,33 @@ function childIsBusy(child: AgentCard): boolean {
|
|
|
354
354
|
// verbatim floods the coordinator with a wall of mid-work text (the exact bug #1037 keeps
|
|
355
355
|
// re-shipping). Idle-gating alone can't help: this is a genuine turn-final at a genuine idle, so
|
|
356
356
|
// the payload itself must be distilled. Short bodies pass through untouched (concise finals are
|
|
357
|
-
// unaffected)
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
357
|
+
// unaffected). Pure function of the body → deterministic, so re-promotion (the idle flush) yields
|
|
358
|
+
// the identical distilled body + idempotency key and dedups cleanly.
|
|
359
|
+
//
|
|
360
|
+
// #1056 — cut on a SEMANTIC result boundary, not a block count. The old heuristic kept the last N
|
|
361
|
+
// `\n\n`-separated blocks by a size budget; that is fragile to how a model CHUNKS its answer. A
|
|
362
|
+
// chatty Claude emitted its `## SUMMARY` result as one block (kept whole), but a chatty Codex
|
|
363
|
+
// paragraph-broke the heading away from its bullets, so the tail-block budget clipped the header +
|
|
364
|
+
// the first bullet — distillation ate the TOP of the result, delivering it mid-sentence. Fix: keep
|
|
365
|
+
// everything from the FIRST markdown heading onward. Workers conclude under a heading (`## SUMMARY`,
|
|
366
|
+
// `## FINAL REPORT`), narration is prose chatter emitted before it — so the result is the trailing
|
|
367
|
+
// region a heading marks the start of, and cutting at the heading drops the leading narration while
|
|
368
|
+
// keeping the WHOLE result, however the model split it. We cut at the FIRST heading (not the last):
|
|
369
|
+
// the result may itself contain sub-headings (`### Details`) or multiple `##` sections, and "last
|
|
370
|
+
// heading" would slice into it — the very clip #1056 is about; "first heading" never can, since it
|
|
371
|
+
// sits at or before the result's start. Fallback (no heading anywhere — rare; workers are told to
|
|
372
|
+
// emit one): there is no reliable boundary, so keep the trailing answer and trim only the leading
|
|
373
|
+
// narration flood by the legacy tail budget, never slicing the block we keep.
|
|
361
374
|
const REPORT_UP_DISTILL_THRESHOLD = 800;
|
|
362
375
|
const REPORT_UP_TAIL_MAX = 800;
|
|
363
376
|
const REPORT_UP_ELISION = "[…mid-work narration trimmed by report-up…]";
|
|
377
|
+
const REPORT_UP_HEADING = /^#{1,6}\s/m;
|
|
364
378
|
|
|
365
379
|
function distillLineageReportBody(body: string): string {
|
|
366
380
|
const trimmed = body.trim();
|
|
367
381
|
if (trimmed.length <= REPORT_UP_DISTILL_THRESHOLD) return body;
|
|
382
|
+
const heading = REPORT_UP_HEADING.exec(trimmed);
|
|
383
|
+
if (heading) return heading.index === 0 ? body : `${REPORT_UP_ELISION}\n\n${trimmed.slice(heading.index).trim()}`;
|
|
368
384
|
const blocks = trimmed.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
|
|
369
385
|
const kept: string[] = [];
|
|
370
386
|
let size = 0;
|
package/src/managed-policy.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { getManifest } from "agent-relay-providers";
|
|
2
|
+
import { DEFAULT_USER_ID } from "agent-relay-sdk";
|
|
2
3
|
import { getAgentProfile } from "./config-store";
|
|
3
|
-
import { withResolvedProvisioning } from "./db";
|
|
4
|
+
import { resolveProjectForCwd, resolveScopedAnchor, withResolvedProvisioning } from "./db";
|
|
5
|
+
import { composeSpawnInstructionsWithEvents } from "./project-instructions";
|
|
4
6
|
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
5
7
|
import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
|
|
6
8
|
import { applyDefaultSharedMcp } from "./shared-mcp";
|
|
@@ -10,11 +12,19 @@ function managedPolicyPromptMode(policy: SpawnPolicy): "prompt" | "append-system
|
|
|
10
12
|
return getManifest(policy.provider)?.launch?.managedPolicyPrompt?.alwaysOn ?? "prompt";
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The always-on keepalive prompt an `append-system-prompt`-mode provider (Claude) carries as a
|
|
17
|
+
* system-prompt append (Codex uses the launch `prompt` channel instead — see
|
|
18
|
+
* `managedPolicyLaunchPrompt`). Returns undefined when it does not apply. #1052 folds this into
|
|
19
|
+
* `composeSpawnInstructionsWithEvents` as the caller append so there is a SINGLE ordered
|
|
20
|
+
* `--append-system-prompt` block (profile role charter + project knowledge + this keepalive),
|
|
21
|
+
* instead of a second stray `--append-system-prompt` arg that could stack on the composed one.
|
|
22
|
+
*/
|
|
23
|
+
export function managedPolicyAppendSystemPrompt(policy: SpawnPolicy): string | undefined {
|
|
14
24
|
if (policy.mode === "always-on" && policy.prompt?.trim() && managedPolicyPromptMode(policy) === "append-system-prompt") {
|
|
15
|
-
return
|
|
25
|
+
return policy.prompt.trim();
|
|
16
26
|
}
|
|
17
|
-
return
|
|
27
|
+
return undefined;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
export function managedPolicyLaunchPrompt(policy: SpawnPolicy): string | undefined {
|
|
@@ -33,10 +43,35 @@ interface ManagedSpawnContext {
|
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string, ctx: ManagedSpawnContext): Record<string, unknown> {
|
|
36
|
-
const providerArgs = managedPolicyProviderArgs(policy);
|
|
37
46
|
const prompt = managedPolicyLaunchPrompt(policy);
|
|
38
47
|
const resolvedProfile = withResolvedProvisioning(policy.profile ? getAgentProfile(policy.profile)?.value : undefined, policy.provider);
|
|
39
48
|
const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
|
|
49
|
+
|
|
50
|
+
// #1052 — run the SAME instruction projection a fresh interactive spawn gets
|
|
51
|
+
// (composeSpawnInstructionsWithEvents, the single home in spawn-agent.ts), so a MANAGED agent
|
|
52
|
+
// (e.g. the always-on coordinator) is onboarded IDENTICALLY on its first launch AND on every
|
|
53
|
+
// restart / deploy-bounce relaunch — this builder feeds both `spawnAgent` and `restartAgent`.
|
|
54
|
+
// Previously the managed path forwarded only `agentProfile` (for provisioning) + providerArgs
|
|
55
|
+
// and silently dropped the profile ROLE CHARTER (`instructions.system`/`append`), project-
|
|
56
|
+
// knowledge preamble, and scoped {profile|user} memory, so a restarted coordinator reverted to
|
|
57
|
+
// provider-default IC behavior until a human re-injected the role file (#1052). The always-on
|
|
58
|
+
// keepalive prompt folds in as the caller append → one ordered `--append-system-prompt` block,
|
|
59
|
+
// no double-inject. The projection is deterministic (sorted + deduped), so re-running it on a
|
|
60
|
+
// native-resume relaunch reproduces a byte-identical block rather than stacking a second copy.
|
|
61
|
+
const callerProject = resolveProjectForCwd(policy.cwd) ?? undefined;
|
|
62
|
+
const projectInstructions = agentProfile?.projectInstructions;
|
|
63
|
+
const profileAnchor = policy.profile ? resolveScopedAnchor("profile", policy.profile) ?? undefined : undefined;
|
|
64
|
+
const userAnchor = resolveScopedAnchor("user", DEFAULT_USER_ID) ?? undefined;
|
|
65
|
+
const alwaysOnAppend = managedPolicyAppendSystemPrompt(policy);
|
|
66
|
+
const composed = composeSpawnInstructionsWithEvents({
|
|
67
|
+
...(callerProject ? { callerProject } : {}),
|
|
68
|
+
...(projectInstructions ? { projectInstructions } : {}),
|
|
69
|
+
...(profileAnchor ? { profileAnchor } : {}),
|
|
70
|
+
...(userAnchor ? { userAnchor } : {}),
|
|
71
|
+
...(agentProfile ? { profile: agentProfile } : {}),
|
|
72
|
+
...(alwaysOnAppend ? { callerSystemPromptAppend: alwaysOnAppend } : {}),
|
|
73
|
+
});
|
|
74
|
+
|
|
40
75
|
return buildSpawnCommand({
|
|
41
76
|
provider: policy.provider,
|
|
42
77
|
orchestratorId: policy.orchestratorId,
|
|
@@ -51,8 +86,10 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
|
|
|
51
86
|
capabilities: policy.capabilities,
|
|
52
87
|
approvalMode: policy.permissionMode,
|
|
53
88
|
permissionMode: policy.permissionMode,
|
|
54
|
-
providerArgs,
|
|
89
|
+
providerArgs: policy.providerArgs,
|
|
55
90
|
prompt: prompt || undefined,
|
|
91
|
+
...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
|
|
92
|
+
...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
|
|
56
93
|
headless: true,
|
|
57
94
|
policyName: policy.name,
|
|
58
95
|
spawnRequestId: requestId,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// status broadcast, the single timeline note, the spawned-child parent-wake, and
|
|
11
11
|
// the first-registration audit row.
|
|
12
12
|
import { errMessage, isReservedAgentId } from "agent-relay-sdk";
|
|
13
|
-
import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
|
|
13
|
+
import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, revokeSupersededRuntimeTokens, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
|
|
14
14
|
import { emitAgentStatus, emitNewMessage } from "../sse";
|
|
15
15
|
import { noteAgentTimelineEvent } from "../compaction-watch";
|
|
16
16
|
import { agentProviderTimelineEvent } from "../provider-timeline-event";
|
|
@@ -93,6 +93,17 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
|
|
|
93
93
|
if (existing) delete registrationInput.label;
|
|
94
94
|
const agent = upsertAgent(registrationInput);
|
|
95
95
|
|
|
96
|
+
// #1051/#484 — instance takeover is where the restart-strand invariant fires now that a
|
|
97
|
+
// heartbeat lapse no longer revokes anything. When a FRESH runner process re-registers
|
|
98
|
+
// under this agent id (new instanceId → epoch bump), revoke the PRIOR instance's runtime
|
|
99
|
+
// tokens "stale": they can no longer authenticate a request directly, yet stay re-mintable
|
|
100
|
+
// via the orchestrator (#484). A same-instance reconnect keeps its instanceId, so the epoch
|
|
101
|
+
// is unchanged and the live token is left valid — the whole point of #1051 (in-band recovery
|
|
102
|
+
// after lag/relay-restart without a re-mint).
|
|
103
|
+
if (existing?.instanceId && agent.instanceId && existing.instanceId !== agent.instanceId) {
|
|
104
|
+
revokeSupersededRuntimeTokens(existing, agent);
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
// Managed came-up-running reconcile — no-op for non-managed agents (the guard lives
|
|
97
108
|
// in markManagedAgentRunning). Clears backoff, flushes the policy queue, transitions
|
|
98
109
|
// managed state to running.
|