agent-relay-server 0.127.1 → 0.127.3
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 +6 -4
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -1
- package/runner/src/adapter.ts +5 -1
- package/runner/src/adapters/claude-delivery.ts +5 -1
- package/runner/src/mcp-outbox.ts +13 -1
- package/runner/src/runner-core.ts +10 -3
- package/scripts/orchestrator-spawn-smoke.ts +81 -15
- package/src/bus.ts +6 -41
- package/src/prompt-resolver.ts +12 -12
- package/src/self-resume.ts +50 -8
- package/src/services/bus-command-update.ts +59 -0
- package/src/services/task-delivery.ts +30 -3
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.127.
|
|
5
|
+
"version": "0.127.3",
|
|
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.127.
|
|
3
|
+
"version": "0.127.3",
|
|
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.127.
|
|
40
|
+
"agent-relay-channels-host": "0.127.3",
|
|
41
41
|
"agent-relay-providers": "0.104.4",
|
|
42
42
|
"agent-relay-sdk": "0.2.118",
|
|
43
43
|
"ajv": "^8.20.0"
|
|
@@ -54,9 +54,11 @@
|
|
|
54
54
|
"build:dashboard": "bun run build:dashboard:bundle && bun run deploy:dashboard",
|
|
55
55
|
"build:dashboard:bundle": "bun run build:sdk && cd dashboard && bun run build",
|
|
56
56
|
"deploy:dashboard": "bun run scripts/deploy-dashboard.ts",
|
|
57
|
-
"ci:land": "bun run build:dashboard:bundle && bun run build:claude-monitor:check && bun run lint:import-boundaries && bun run lint:provider-contract && bun run typecheck && bun run docs:api:check && bun
|
|
57
|
+
"ci:land": "bun run build:dashboard:bundle && bun run build:claude-monitor:check && bun run lint:import-boundaries && bun run lint:provider-contract && bun run typecheck && bun run docs:api:check && bun test",
|
|
58
|
+
"ci:release": "bun run build:dashboard:bundle && bun run build:claude-monitor:check && bun run lint:import-boundaries && bun run lint:provider-contract && bun run typecheck && bun run docs:api:check && bun test --bail",
|
|
58
59
|
"ci": "bun run ci:land",
|
|
59
|
-
"test": "bun test",
|
|
60
|
+
"test": "bun run test:guards && bun test --bail",
|
|
61
|
+
"test:guards": "bun test --bail=1 src/file-size-ratchet.test.ts src/duplication-ratchet.test.ts src/env-access-ratchet.test.ts src/no-raw-control-bytes.test.ts src/transport-thinness-ratchet.test.ts src/commands-db-ttl-guard.test.ts scripts/release-deploy-guard.test.ts scripts/release-tree-guard.test.ts",
|
|
60
62
|
"smoke:spawn": "bun run scripts/orchestrator-spawn-smoke.ts",
|
|
61
63
|
"publish:smoke": "bun run scripts/publish-smoke.ts",
|
|
62
64
|
"lint:provider-contract": "bun run scripts/check-provider-contract.ts",
|
|
@@ -320,6 +320,9 @@ function shouldShowReplyReminder(deliveryCount) {
|
|
|
320
320
|
function latestReplyableMessage(messages) {
|
|
321
321
|
return messages.filter((message) => isPersistedRelayMessage2(message) && !isMemoryInjection(message) && !isReactionNotification(message) && !isSpawnObligationMessage(message) && message.replyExpected !== false).at(-1);
|
|
322
322
|
}
|
|
323
|
+
function isSelfResumeInjection(message) {
|
|
324
|
+
return isRecord(message.payload) && message.payload.resumeInjection === true;
|
|
325
|
+
}
|
|
323
326
|
function formatMessage(message, relaySurface) {
|
|
324
327
|
const subject = message.subject ? `Subject: ${message.subject}
|
|
325
328
|
` : "";
|
|
@@ -331,7 +334,7 @@ function formatMessage(message, relaySurface) {
|
|
|
331
334
|
`) : undefined;
|
|
332
335
|
if (isMemoryContext) {
|
|
333
336
|
return [
|
|
334
|
-
`[agent-relay context from ${message.from}]`,
|
|
337
|
+
`[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
|
|
335
338
|
providerAttachmentText(message),
|
|
336
339
|
`${subject}${preview.body}`,
|
|
337
340
|
truncationGuidance
|
package/runner/src/adapter.ts
CHANGED
|
@@ -327,6 +327,10 @@ function isMemoryInjection(message: Message): boolean {
|
|
|
327
327
|
return isRecord(message.payload) && message.payload.memoryInjection === true;
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
function isSelfResumeInjection(message: Message): boolean {
|
|
331
|
+
return isRecord(message.payload) && message.payload.resumeInjection === true;
|
|
332
|
+
}
|
|
333
|
+
|
|
330
334
|
function isReactionNotification(message: Message): boolean {
|
|
331
335
|
const event = isRecord(message.payload?.event) ? message.payload.event : undefined;
|
|
332
336
|
return message.payload?.reactionNotification === true || event?.type === "message.reaction";
|
|
@@ -434,7 +438,7 @@ export function providerMessageText(messages: Message[]): string {
|
|
|
434
438
|
: undefined;
|
|
435
439
|
if (isMemoryContext) {
|
|
436
440
|
return [
|
|
437
|
-
`[agent-relay context from ${message.from}]`,
|
|
441
|
+
`[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
|
|
438
442
|
"Guide: agent-relay /guide",
|
|
439
443
|
providerAttachmentText(message),
|
|
440
444
|
`${subject}${preview.body}`,
|
|
@@ -69,6 +69,10 @@ function latestReplyableMessage(messages: Message[]): Message | undefined {
|
|
|
69
69
|
.at(-1);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function isSelfResumeInjection(message: Message): boolean {
|
|
73
|
+
return isRecord(message.payload) && message.payload.resumeInjection === true;
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
function formatMessage(message: Message, relaySurface: boolean): string {
|
|
73
77
|
const subject = message.subject ? `Subject: ${message.subject}\n` : "";
|
|
74
78
|
const isMemoryContext = isMemoryInjection(message);
|
|
@@ -81,7 +85,7 @@ function formatMessage(message: Message, relaySurface: boolean): string {
|
|
|
81
85
|
|
|
82
86
|
if (isMemoryContext) {
|
|
83
87
|
return [
|
|
84
|
-
`[agent-relay context from ${message.from}]`,
|
|
88
|
+
`[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
|
|
85
89
|
providerAttachmentText(message),
|
|
86
90
|
`${subject}${preview.body}`,
|
|
87
91
|
truncationGuidance,
|
package/runner/src/mcp-outbox.ts
CHANGED
|
@@ -67,7 +67,12 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
|
|
|
67
67
|
}
|
|
68
68
|
logger.warn("outbox", `dropping event with unknown kind: ${record.kind}`);
|
|
69
69
|
} catch (error) {
|
|
70
|
-
if (isHttpStatusError(error, 409))
|
|
70
|
+
if (isHttpStatusError(error, 409)) {
|
|
71
|
+
if (record.kind === "session-message-batch") {
|
|
72
|
+
logger.error("outbox", `relay reported duplicate session batch; acking row seq=${record.seq} batchKey=${record.idempotencyKey} itemKeys=${sessionBatchItemKeys(record).join(",")} error=${errMessage(error)}`);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
71
76
|
if (isHttpAuthError(error)) input.recoverRuntimeTokenAfterAuthFailure("outbox");
|
|
72
77
|
throw error;
|
|
73
78
|
}
|
|
@@ -89,6 +94,13 @@ function isDeterministicValidationError(error: unknown): boolean {
|
|
|
89
94
|
return status === 400 || status === 413 || status === 422;
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
function sessionBatchItemKeys(record: OutboxRecord): string[] {
|
|
98
|
+
const messages = Array.isArray((record.payload as { messages?: unknown }).messages)
|
|
99
|
+
? (record.payload as { messages: SendMessageInput[] }).messages
|
|
100
|
+
: [];
|
|
101
|
+
return messages.map((message, index) => message.idempotencyKey ?? `${record.idempotencyKey}:${index}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
export async function deliverBufferedMcpCall(input: {
|
|
93
105
|
record: OutboxRecord;
|
|
94
106
|
relayUrl: string;
|
|
@@ -842,17 +842,24 @@ export class AgentRunner {
|
|
|
842
842
|
const content = typeof params.content === "string" ? params.content.trim() : "";
|
|
843
843
|
if (!content) throw new Error("content required");
|
|
844
844
|
const memoryIds = Array.isArray(params.memoryIds) ? params.memoryIds.filter((id): id is string => typeof id === "string") : [];
|
|
845
|
+
const reason = typeof params.reason === "string" ? params.reason : undefined;
|
|
846
|
+
const selfResume = params.selfResume !== null && typeof params.selfResume === "object" && !Array.isArray(params.selfResume)
|
|
847
|
+
? params.selfResume as Record<string, unknown>
|
|
848
|
+
: undefined;
|
|
849
|
+
const isSelfResumeContinuation = reason === "self-resume-continuation" || Boolean(selfResume);
|
|
845
850
|
await this.options.adapter.deliver(this.process!, [{
|
|
846
851
|
id: 0,
|
|
847
852
|
from: "system",
|
|
848
853
|
to: this.agentId,
|
|
849
854
|
kind: "system",
|
|
850
|
-
subject: "Agent Relay memory context",
|
|
855
|
+
subject: isSelfResumeContinuation ? "Agent Relay self-resume continuation" : "Agent Relay memory context",
|
|
851
856
|
body: content,
|
|
852
857
|
payload: {
|
|
853
858
|
memoryInjection: true,
|
|
854
|
-
reason
|
|
859
|
+
reason,
|
|
855
860
|
memoryIds,
|
|
861
|
+
...(isSelfResumeContinuation ? { resumeInjection: true } : {}),
|
|
862
|
+
...(selfResume ? { selfResume } : {}),
|
|
856
863
|
},
|
|
857
864
|
readBy: [],
|
|
858
865
|
createdAt: Date.now(),
|
|
@@ -1598,7 +1605,7 @@ export class AgentRunner {
|
|
|
1598
1605
|
});
|
|
1599
1606
|
this.sessionOutbox.enqueue({
|
|
1600
1607
|
kind: "session-message-batch",
|
|
1601
|
-
idempotencyKey: `session-batch:${this.agentId}:${++this.sessionBatchSeq}:${messages.length}`,
|
|
1608
|
+
idempotencyKey: `session-batch:${this.agentId}:${this.options.instanceId}:${this.options.startedAt}:${++this.sessionBatchSeq}:${messages.length}`,
|
|
1602
1609
|
payload: { messages },
|
|
1603
1610
|
});
|
|
1604
1611
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
5
|
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
5
6
|
|
|
6
7
|
type Orchestrator = {
|
|
@@ -33,14 +34,16 @@ let cwd = process.env.AGENT_RELAY_SMOKE_CWD || "";
|
|
|
33
34
|
let timeoutMs = Number(process.env.AGENT_RELAY_SMOKE_TIMEOUT_MS || 90_000);
|
|
34
35
|
let providers = (process.env.AGENT_RELAY_SMOKE_PROVIDERS || "codex,claude").split(",").filter(Boolean);
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
function parseArgs(): void {
|
|
38
|
+
for (let i = 0; i < args.length; i++) {
|
|
39
|
+
const arg = args[i]!;
|
|
40
|
+
if (arg === "--relay-url" && args[i + 1]) relayUrl = args[++i]!;
|
|
41
|
+
else if (arg === "--orchestrator" && args[i + 1]) orchestratorId = args[++i]!;
|
|
42
|
+
else if (arg === "--cwd" && args[i + 1]) cwd = args[++i]!;
|
|
43
|
+
else if (arg === "--timeout" && args[i + 1]) timeoutMs = Number(args[++i]);
|
|
44
|
+
else if (arg === "--providers" && args[i + 1]) providers = args[++i]!.split(",").map((p) => p.trim()).filter(Boolean);
|
|
45
|
+
else throw new Error(`Unknown option: ${arg}`);
|
|
46
|
+
}
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
relayUrl = relayUrl.replace(/\/+$/, "");
|
|
@@ -92,6 +95,68 @@ function localRepoRoot(): string | null {
|
|
|
92
95
|
return result.stdout.trim() || null;
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
function localRepoName(): string | null {
|
|
99
|
+
const result = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
|
100
|
+
cwd: process.cwd(),
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
});
|
|
103
|
+
if (result.status !== 0) return null;
|
|
104
|
+
const remote = result.stdout.trim();
|
|
105
|
+
if (!remote) return null;
|
|
106
|
+
const name = basename(remote.replace(/\.git$/, ""));
|
|
107
|
+
return name || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pathContainedBy(path: string, parent: string): boolean {
|
|
111
|
+
const rel = relative(resolve(parent), resolve(path));
|
|
112
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isGitRepo(path: string): boolean {
|
|
116
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
117
|
+
cwd: path,
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
});
|
|
120
|
+
return result.status === 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveDefaultSpawnCwd(
|
|
124
|
+
orchestrator: Pick<Orchestrator, "id" | "baseDir">,
|
|
125
|
+
deps: {
|
|
126
|
+
hostname?: () => string;
|
|
127
|
+
localRepoRoot?: () => string | null;
|
|
128
|
+
localRepoName?: () => string | null;
|
|
129
|
+
isGitRepo?: (path: string) => boolean;
|
|
130
|
+
} = {},
|
|
131
|
+
): string {
|
|
132
|
+
const host = deps.hostname ?? hostname;
|
|
133
|
+
if (orchestrator.id !== host()) {
|
|
134
|
+
throw new Error(`orchestrator ${orchestrator.id} is remote; pass --cwd or set AGENT_RELAY_SMOKE_CWD to a git repo on that host`);
|
|
135
|
+
}
|
|
136
|
+
if (!orchestrator.baseDir) {
|
|
137
|
+
throw new Error(`orchestrator ${orchestrator.id} did not report a baseDir; pass --cwd or set AGENT_RELAY_SMOKE_CWD`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const gitRoot = (deps.localRepoRoot ?? localRepoRoot)();
|
|
141
|
+
if (gitRoot && pathContainedBy(gitRoot, orchestrator.baseDir)) return gitRoot;
|
|
142
|
+
|
|
143
|
+
const repoName = (deps.localRepoName ?? localRepoName)();
|
|
144
|
+
const gitCheck = deps.isGitRepo ?? isGitRepo;
|
|
145
|
+
const candidates = [
|
|
146
|
+
repoName ? join(orchestrator.baseDir, "oss", repoName) : "",
|
|
147
|
+
repoName ? join(orchestrator.baseDir, repoName) : "",
|
|
148
|
+
orchestrator.baseDir,
|
|
149
|
+
].filter(Boolean);
|
|
150
|
+
for (const candidate of candidates) {
|
|
151
|
+
if (pathContainedBy(candidate, orchestrator.baseDir) && gitCheck(candidate)) return candidate;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
throw new Error(
|
|
155
|
+
`could not resolve a git repo inside orchestrator ${orchestrator.id} base dir ${orchestrator.baseDir}; ` +
|
|
156
|
+
"pass --cwd or set AGENT_RELAY_SMOKE_CWD",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
95
160
|
function findSpawnedAgent(agents: Agent[], provider: string, label: string, startedAt: number): Agent | null {
|
|
96
161
|
return agents
|
|
97
162
|
.filter((agent) => agent.ready && agent.status !== "offline")
|
|
@@ -127,6 +192,8 @@ function promptForCase(provider: string, approvalMode: ApprovalMode, label: stri
|
|
|
127
192
|
};
|
|
128
193
|
}
|
|
129
194
|
|
|
195
|
+
async function main(): Promise<void> {
|
|
196
|
+
parseArgs();
|
|
130
197
|
const orchestrators = await api<Orchestrator[]>("GET", "/orchestrators");
|
|
131
198
|
// Default selection prefers the orchestrator on the same host as the relay (the
|
|
132
199
|
// gate runs there): a local spawn is faster and avoids cross-host teardown
|
|
@@ -146,13 +213,7 @@ if (orchestrator.status !== "online") throw new Error(`orchestrator ${orchestrat
|
|
|
146
213
|
// An explicit --cwd / env override still wins (and is subject to the
|
|
147
214
|
// orchestrator's containment check).
|
|
148
215
|
if (!cwd) {
|
|
149
|
-
|
|
150
|
-
throw new Error(`orchestrator ${orchestrator.id} is remote; pass --cwd or set AGENT_RELAY_SMOKE_CWD to a git repo on that host`);
|
|
151
|
-
}
|
|
152
|
-
cwd = localRepoRoot() || "";
|
|
153
|
-
if (!cwd) {
|
|
154
|
-
throw new Error("could not resolve the local git repo root; pass --cwd or set AGENT_RELAY_SMOKE_CWD");
|
|
155
|
-
}
|
|
216
|
+
cwd = resolveDefaultSpawnCwd(orchestrator);
|
|
156
217
|
}
|
|
157
218
|
console.log(`using cwd ${cwd} on ${orchestrator.id} (base ${orchestrator.baseDir})`);
|
|
158
219
|
|
|
@@ -239,3 +300,8 @@ for (const provider of providers) {
|
|
|
239
300
|
}
|
|
240
301
|
|
|
241
302
|
console.log("spawn smoke passed");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (import.meta.main) {
|
|
306
|
+
await main();
|
|
307
|
+
}
|
package/src/bus.ts
CHANGED
|
@@ -3,9 +3,7 @@ import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta,
|
|
|
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";
|
|
6
|
-
import {
|
|
7
|
-
import { emitCommandEvent } from "./command-events";
|
|
8
|
-
import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
|
|
6
|
+
import { noteAgentTimelineEvent } from "./compaction-watch";
|
|
9
7
|
import { reconcileTerminalProviderExit } from "./services/terminal-provider-exit";
|
|
10
8
|
import { bindSpawnedTaskToReadyAgent, registerAgent } from "./services/register-agent";
|
|
11
9
|
import { authContextFromBus } from "./services/auth-context";
|
|
@@ -15,9 +13,8 @@ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider
|
|
|
15
13
|
import { ServiceAuthError } from "./services/errors";
|
|
16
14
|
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
|
|
17
15
|
import { flushQueuedDirectMessages } from "./services/managed-running";
|
|
16
|
+
import { updateCommandFromBus } from "./services/bus-command-update";
|
|
18
17
|
import { isAgentUnavailable } from "./db/agent-predicates";
|
|
19
|
-
import { applyCommandToRecipe } from "./recipe-runner";
|
|
20
|
-
import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
|
|
21
18
|
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
22
19
|
import { probeWorkspaceOnTurnEnd } from "./workspace-probe";
|
|
23
20
|
import { busStaleGraceMs } from "./config";
|
|
@@ -31,7 +28,7 @@ import {
|
|
|
31
28
|
} from "agent-relay-sdk/protocol";
|
|
32
29
|
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
33
30
|
import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
|
|
34
|
-
import type { AgentCard,
|
|
31
|
+
import type { AgentCard, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
|
|
35
32
|
|
|
36
33
|
interface BusSocketData {
|
|
37
34
|
kind: "bus";
|
|
@@ -238,30 +235,9 @@ function handleCommandFrame(
|
|
|
238
235
|
params: Record<string, unknown>,
|
|
239
236
|
): void {
|
|
240
237
|
if (commandType === "command.update") {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
if (!busCommandAuthorized(conn, command)) {
|
|
247
|
-
sendCommandResult(ws, frameId, "rejected", undefined, "component token lacks command scope");
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
const status = typeof params.status === "string" ? params.status : undefined;
|
|
251
|
-
const updated = updateCommand(command.id, {
|
|
252
|
-
status: status as any,
|
|
253
|
-
result: isRecord(params.result) ? params.result : undefined,
|
|
254
|
-
error: typeof params.error === "string" ? params.error : undefined,
|
|
255
|
-
});
|
|
256
|
-
if (updated) {
|
|
257
|
-
applyCommandToRecipe(updated);
|
|
258
|
-
emitCommandEvent(updated, `command.${updated.status}`);
|
|
259
|
-
// Production command completions arrive here over the bus, not the HTTP
|
|
260
|
-
// PATCH route — arm the stall watch for finished compact/clear commands.
|
|
261
|
-
noteCompactionCommandCompleted(updated.type, updated.status, updated.id, updated.target);
|
|
262
|
-
enqueueContinuationInjectionAfterSelfResume(updated);
|
|
263
|
-
}
|
|
264
|
-
sendCommandResult(ws, frameId, "succeeded", updated ? { command: updated } : undefined);
|
|
238
|
+
void updateCommandFromBus({ target, params, componentAuth: conn.componentAuth })
|
|
239
|
+
.then((result) => sendCommandResult(ws, frameId, result.status, result.result, result.error))
|
|
240
|
+
.catch((error) => sendError(ws, frameId, "FRAME_FAILED", errMessage(error)));
|
|
265
241
|
return;
|
|
266
242
|
}
|
|
267
243
|
|
|
@@ -303,17 +279,6 @@ function handleCommandFrame(
|
|
|
303
279
|
}
|
|
304
280
|
}
|
|
305
281
|
|
|
306
|
-
function busCommandAuthorized(
|
|
307
|
-
conn: BusConnection,
|
|
308
|
-
command: Pick<Command, "target" | "params">,
|
|
309
|
-
): boolean {
|
|
310
|
-
if (!conn.componentAuth) return true;
|
|
311
|
-
return isComponentAuthorizedFor(conn.componentAuth, {
|
|
312
|
-
scope: "command:write",
|
|
313
|
-
resource: commandAuthorizationResource(command),
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
282
|
function handleRegister(ws: BusWebSocket, frame: RegisterFrame): void {
|
|
318
283
|
const payload = frame.payload;
|
|
319
284
|
const runnerManaged = payload.meta?.runnerManaged === true || typeof payload.meta?.runnerId === "string";
|
package/src/prompt-resolver.ts
CHANGED
|
@@ -147,35 +147,35 @@ const DEFAULT_PROMPT_TEMPLATES = [
|
|
|
147
147
|
"Objective (verbatim, relay-owned):",
|
|
148
148
|
"{{objective}}",
|
|
149
149
|
"",
|
|
150
|
-
"Ruled-out ledger (append-only):",
|
|
151
|
-
"{{ruledOut}}",
|
|
152
|
-
"",
|
|
153
|
-
"Decision log (append-only):",
|
|
154
|
-
"{{decisions}}",
|
|
155
|
-
"",
|
|
156
150
|
"Working state:",
|
|
157
151
|
"{{workingState}}",
|
|
158
152
|
"",
|
|
159
153
|
"Archive refs:",
|
|
160
154
|
"{{archiveRefs}}",
|
|
161
|
-
),
|
|
162
|
-
defaultTemplate: lines(
|
|
163
|
-
"{{intro}}",
|
|
164
|
-
"",
|
|
165
|
-
"Objective (verbatim, relay-owned):",
|
|
166
|
-
"{{objective}}",
|
|
167
155
|
"",
|
|
168
156
|
"Ruled-out ledger (append-only):",
|
|
169
157
|
"{{ruledOut}}",
|
|
170
158
|
"",
|
|
171
159
|
"Decision log (append-only):",
|
|
172
160
|
"{{decisions}}",
|
|
161
|
+
),
|
|
162
|
+
defaultTemplate: lines(
|
|
163
|
+
"{{intro}}",
|
|
164
|
+
"",
|
|
165
|
+
"Objective (verbatim, relay-owned):",
|
|
166
|
+
"{{objective}}",
|
|
173
167
|
"",
|
|
174
168
|
"Working state:",
|
|
175
169
|
"{{workingState}}",
|
|
176
170
|
"",
|
|
177
171
|
"Archive refs:",
|
|
178
172
|
"{{archiveRefs}}",
|
|
173
|
+
"",
|
|
174
|
+
"Ruled-out ledger (append-only):",
|
|
175
|
+
"{{ruledOut}}",
|
|
176
|
+
"",
|
|
177
|
+
"Decision log (append-only):",
|
|
178
|
+
"{{decisions}}",
|
|
179
179
|
),
|
|
180
180
|
variables: ["intro", "generation", "objective", "ruledOut", "decisions", "workingState", "archiveRefs"],
|
|
181
181
|
},
|
package/src/self-resume.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
findMessageByIdempotencyKey,
|
|
10
10
|
getAgent,
|
|
11
11
|
getContinuationEnvelope,
|
|
12
|
+
sendMessage,
|
|
12
13
|
updateContinuationAfterCompact,
|
|
13
14
|
ValidationError,
|
|
14
15
|
} from "./db";
|
|
@@ -31,6 +32,7 @@ interface CompactAndResumeResult {
|
|
|
31
32
|
generation: number;
|
|
32
33
|
commandId: string;
|
|
33
34
|
continuationChars?: number;
|
|
35
|
+
continuationMessageId?: number;
|
|
34
36
|
ledgerEntries: number;
|
|
35
37
|
exhausted?: boolean;
|
|
36
38
|
reason?: string;
|
|
@@ -91,10 +93,12 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
|
|
|
91
93
|
now,
|
|
92
94
|
});
|
|
93
95
|
if (updated.droppedLedgerEntries.length) archiveDroppedLedgerEntries(agent.id, generation, updated.droppedLedgerEntries, now);
|
|
94
|
-
const
|
|
96
|
+
const fullContinuation = renderContinuation(updated.envelope);
|
|
97
|
+
const continuationMessage = createContinuationRecoveryMessage(agent.id, generation, fullContinuation, now);
|
|
98
|
+
const continuation = insertRecoveryPointerAfterIntro(fullContinuation, continuationMessage.id);
|
|
95
99
|
const command = mode === "native"
|
|
96
|
-
? createNativeCompact(agent.id, generation, continuation)
|
|
97
|
-
: createClearThenInject(agent.id, generation, continuation);
|
|
100
|
+
? createNativeCompact(agent.id, generation, continuation, continuationMessage.id)
|
|
101
|
+
: createClearThenInject(agent.id, generation, continuation, continuationMessage.id);
|
|
98
102
|
emitCommandEvent(command, "command.requested");
|
|
99
103
|
return {
|
|
100
104
|
ok: true,
|
|
@@ -102,10 +106,45 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
|
|
|
102
106
|
generation,
|
|
103
107
|
commandId: command.id,
|
|
104
108
|
continuationChars: continuation.length,
|
|
109
|
+
continuationMessageId: continuationMessage.id,
|
|
105
110
|
ledgerEntries: updated.envelope.ledger.length,
|
|
106
111
|
};
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
function continuationRecoveryPointer(messageId: number): string {
|
|
115
|
+
return `Recovery pointer: use relay_get_message with message id ${messageId} to read your full prior context.`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function insertRecoveryPointerAfterIntro(continuation: string, messageId: number): string {
|
|
119
|
+
const pointer = continuationRecoveryPointer(messageId);
|
|
120
|
+
const firstNewline = continuation.indexOf("\n");
|
|
121
|
+
if (firstNewline === -1) return `${continuation.trimEnd()}\n\n${pointer}`;
|
|
122
|
+
return `${continuation.slice(0, firstNewline).trimEnd()}\n${pointer}${continuation.slice(firstNewline)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createContinuationRecoveryMessage(agentId: string, generation: number, continuation: string, now: number) {
|
|
126
|
+
const idempotencyKey = `self-resume-continuation:${agentId}:${generation}`;
|
|
127
|
+
const existing = findMessageByIdempotencyKey("system", idempotencyKey);
|
|
128
|
+
if (existing) return existing;
|
|
129
|
+
return sendMessage({
|
|
130
|
+
from: "system",
|
|
131
|
+
to: agentId,
|
|
132
|
+
kind: "system",
|
|
133
|
+
subject: `Self-resume continuation generation ${generation}`,
|
|
134
|
+
body: continuation,
|
|
135
|
+
replyExpected: false,
|
|
136
|
+
idempotencyKey,
|
|
137
|
+
maxAgeSeconds: null,
|
|
138
|
+
occurredAt: now,
|
|
139
|
+
payload: {
|
|
140
|
+
kind: "agent.self_resume_continuation",
|
|
141
|
+
agentId,
|
|
142
|
+
generation,
|
|
143
|
+
fullEnvelope: true,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
109
148
|
// #1023 — the ruledOut ledger bound (see updateContinuationAfterCompact) drops the
|
|
110
149
|
// oldest entries once the persisted ledger grows past its cap instead of letting a
|
|
111
150
|
// long-running agent render a multi-megabyte continuation. Dropped entries are not
|
|
@@ -142,6 +181,7 @@ interface PendingInjection {
|
|
|
142
181
|
continuation: string;
|
|
143
182
|
mode: string;
|
|
144
183
|
generation?: number;
|
|
184
|
+
fullContextMessageId?: number;
|
|
145
185
|
}
|
|
146
186
|
|
|
147
187
|
function extractPendingInjection(command: Command): PendingInjection | null {
|
|
@@ -158,7 +198,8 @@ function extractPendingInjection(command: Command): PendingInjection | null {
|
|
|
158
198
|
: undefined;
|
|
159
199
|
if (!continuation) return null;
|
|
160
200
|
const generation = typeof resume?.generation === "number" ? resume.generation : undefined;
|
|
161
|
-
|
|
201
|
+
const fullContextMessageId = typeof resume?.fullContextMessageId === "number" ? resume.fullContextMessageId : undefined;
|
|
202
|
+
return { continuation, mode, generation, fullContextMessageId };
|
|
162
203
|
}
|
|
163
204
|
|
|
164
205
|
function buildInjectionParams(command: Command, injection: PendingInjection): Record<string, unknown> {
|
|
@@ -168,6 +209,7 @@ function buildInjectionParams(command: Command, injection: PendingInjection): Re
|
|
|
168
209
|
selfResume: {
|
|
169
210
|
generation: injection.generation,
|
|
170
211
|
mode: injection.mode,
|
|
212
|
+
fullContextMessageId: injection.fullContextMessageId,
|
|
171
213
|
...(command.type === "agent.clearContext" ? { afterClearCommandId: command.id } : {}),
|
|
172
214
|
...(command.type === "agent.compact" ? { afterCompactCommandId: command.id } : {}),
|
|
173
215
|
},
|
|
@@ -376,7 +418,7 @@ function hardLimit(limit: number, multiplier: number): number {
|
|
|
376
418
|
return Math.ceil(limit * multiplier);
|
|
377
419
|
}
|
|
378
420
|
|
|
379
|
-
function createNativeCompact(agentId: string, generation: number, continuation: string): Command {
|
|
421
|
+
function createNativeCompact(agentId: string, generation: number, continuation: string, fullContextMessageId: number): Command {
|
|
380
422
|
return createCommand({
|
|
381
423
|
type: "agent.compact",
|
|
382
424
|
source: "system",
|
|
@@ -384,19 +426,19 @@ function createNativeCompact(agentId: string, generation: number, continuation:
|
|
|
384
426
|
params: {
|
|
385
427
|
reason: "self-resume",
|
|
386
428
|
instructions: continuation,
|
|
387
|
-
selfResume: { generation, mode: "native", continuation },
|
|
429
|
+
selfResume: { generation, mode: "native", continuation, fullContextMessageId },
|
|
388
430
|
},
|
|
389
431
|
});
|
|
390
432
|
}
|
|
391
433
|
|
|
392
|
-
function createClearThenInject(agentId: string, generation: number, continuation: string): Command {
|
|
434
|
+
function createClearThenInject(agentId: string, generation: number, continuation: string, fullContextMessageId: number): Command {
|
|
393
435
|
return createCommand({
|
|
394
436
|
type: "agent.clearContext",
|
|
395
437
|
source: "system",
|
|
396
438
|
target: agentId,
|
|
397
439
|
params: {
|
|
398
440
|
reason: "self-resume",
|
|
399
|
-
selfResume: { generation, mode: "clear-inject", continuation },
|
|
441
|
+
selfResume: { generation, mode: "clear-inject", continuation, fullContextMessageId },
|
|
400
442
|
},
|
|
401
443
|
});
|
|
402
444
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { getCommand, updateCommand } from "../commands-db";
|
|
3
|
+
import { emitCommandEvent } from "../command-events";
|
|
4
|
+
import { noteCompactionCommandCompleted } from "../compaction-watch";
|
|
5
|
+
import { applyCommandToRecipe } from "../recipe-runner";
|
|
6
|
+
import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
|
|
7
|
+
import { isComponentAuthorizedFor } from "../security";
|
|
8
|
+
import type { Command, ComponentToken } from "../types";
|
|
9
|
+
import { commandAuthorizationResource } from "./dispatch-command";
|
|
10
|
+
import { prepareScheduledCommandResultForStorage, settleScheduledCommandTimer } from "./scheduler-command";
|
|
11
|
+
|
|
12
|
+
type BusCommandUpdateResult = {
|
|
13
|
+
status: "succeeded" | "failed" | "rejected";
|
|
14
|
+
result?: Record<string, unknown>;
|
|
15
|
+
error?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function updateCommandFromBus(input: {
|
|
19
|
+
target: string;
|
|
20
|
+
params: Record<string, unknown>;
|
|
21
|
+
componentAuth?: ComponentToken;
|
|
22
|
+
}): Promise<BusCommandUpdateResult> {
|
|
23
|
+
const command = getCommand(input.target);
|
|
24
|
+
if (!command) return { status: "failed", error: "command not found" };
|
|
25
|
+
if (!busCommandAuthorized(input.componentAuth, command)) {
|
|
26
|
+
return { status: "rejected", error: "component token lacks command scope" };
|
|
27
|
+
}
|
|
28
|
+
const status = typeof input.params.status === "string" ? input.params.status : undefined;
|
|
29
|
+
let result = isRecord(input.params.result) ? input.params.result : undefined;
|
|
30
|
+
if (result && command.type === "scheduler.command") {
|
|
31
|
+
result = await prepareScheduledCommandResultForStorage(command, result);
|
|
32
|
+
}
|
|
33
|
+
const updated = updateCommand(command.id, {
|
|
34
|
+
status: status as any,
|
|
35
|
+
result,
|
|
36
|
+
error: typeof input.params.error === "string" ? input.params.error : undefined,
|
|
37
|
+
});
|
|
38
|
+
if (updated) {
|
|
39
|
+
applyCommandToRecipe(updated);
|
|
40
|
+
if (updated.type === "scheduler.command") {
|
|
41
|
+
await settleScheduledCommandTimer(updated);
|
|
42
|
+
}
|
|
43
|
+
emitCommandEvent(updated, `command.${updated.status}`);
|
|
44
|
+
noteCompactionCommandCompleted(updated.type, updated.status, updated.id, updated.target);
|
|
45
|
+
enqueueContinuationInjectionAfterSelfResume(updated);
|
|
46
|
+
}
|
|
47
|
+
return { status: "succeeded", result: updated ? { command: updated } : undefined };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function busCommandAuthorized(
|
|
51
|
+
auth: ComponentToken | undefined,
|
|
52
|
+
command: Pick<Command, "target" | "params">,
|
|
53
|
+
): boolean {
|
|
54
|
+
if (!auth) return true;
|
|
55
|
+
return isComponentAuthorizedFor(auth, {
|
|
56
|
+
scope: "command:write",
|
|
57
|
+
resource: commandAuthorizationResource(command),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
ownedIsolatedWorkspace,
|
|
7
7
|
recordGate,
|
|
8
8
|
recordTaskDeliverable,
|
|
9
|
+
setTaskStage,
|
|
9
10
|
setTaskStatus,
|
|
10
11
|
ValidationError,
|
|
11
12
|
} from "../db";
|
|
@@ -62,7 +63,17 @@ function resolveTaskId(ctx: AuthContext, explicit: unknown): number {
|
|
|
62
63
|
throw new ValidationError("caller is bound to multiple Tasks; pass taskId explicitly");
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
function
|
|
66
|
+
function canRebindToWorkspace(ctx: AuthContext, task: TaskDetail, ws: WorkspaceRecord): boolean {
|
|
67
|
+
const caller = ctx.callerAgentId;
|
|
68
|
+
if (!caller || ws.ownerAgentId !== caller) return false;
|
|
69
|
+
return task.ownerAgentId === caller || agentBoundTaskIds(caller).has(task.id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function shouldRebindToWorkspace(task: TaskDetail, ws: WorkspaceRecord | undefined): ws is WorkspaceRecord {
|
|
73
|
+
return Boolean(task.branch && ws?.branch && task.branch !== ws.branch);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveWorkspace(ctx: AuthContext, input: { workspaceId?: string }, taskId: number, opts: { allowRebind?: boolean } = {}): WorkspaceRecord | undefined {
|
|
66
77
|
const caller = ctx.callerAgentId;
|
|
67
78
|
const explicit = input.workspaceId?.trim();
|
|
68
79
|
const ws = explicit ? getWorkspace(explicit) : caller ? ownedIsolatedWorkspace(caller) : undefined;
|
|
@@ -73,11 +84,26 @@ function resolveWorkspace(ctx: AuthContext, input: { workspaceId?: string }, tas
|
|
|
73
84
|
const task = getTaskDetail(taskId);
|
|
74
85
|
if (!task) throw new ValidationError("task not found");
|
|
75
86
|
if (task.branch && ws.branch && task.branch !== ws.branch) {
|
|
87
|
+
if (opts.allowRebind && canRebindToWorkspace(ctx, task, ws)) return ws;
|
|
76
88
|
throw new ValidationError(`task ${taskId} is bound to branch ${task.branch}, not workspace branch ${ws.branch}`);
|
|
77
89
|
}
|
|
78
90
|
return ws;
|
|
79
91
|
}
|
|
80
92
|
|
|
93
|
+
function rebindTaskToWorkspaceIfNeeded(task: TaskDetail, ws: WorkspaceRecord | undefined, actor: string): TaskDetail {
|
|
94
|
+
if (!shouldRebindToWorkspace(task, ws)) return task;
|
|
95
|
+
setTaskStatus(task.id, {
|
|
96
|
+
status: "in_progress",
|
|
97
|
+
note: `fix-forward rebind to workspace ${ws.id} (${ws.branch})`,
|
|
98
|
+
actor,
|
|
99
|
+
});
|
|
100
|
+
return setTaskStage(task.id, {
|
|
101
|
+
deliverableStage: "committed-on-branch",
|
|
102
|
+
branch: ws.branch,
|
|
103
|
+
actor,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
81
107
|
function gateWrites(gates: TaskDeliveryGateReports | undefined, deliveryKey: string): GateWrite[] {
|
|
82
108
|
const writes: GateWrite[] = [];
|
|
83
109
|
if (gates?.typecheck) writes.push({ gate: "typecheck", ...gatePayload(gates.typecheck, `${deliveryKey}:typecheck`) });
|
|
@@ -207,10 +233,11 @@ export async function deliverTask(input: DeliverTaskInput, ctx: AuthContext): Pr
|
|
|
207
233
|
if (gateProblem) throw new ValidationError(`complete delivery cannot land without passing gate block (${gateProblem})`);
|
|
208
234
|
const reason = input.disposition === "blocked" || input.disposition === "failed" ? cleanReason(input.reason, input.disposition) : undefined;
|
|
209
235
|
const blockedBy = input.disposition === "blocked" ? cleanBlockedBy(input.blockedBy) : input.blockedBy;
|
|
210
|
-
const workspace = wantsLand || input.workspaceId ? resolveWorkspace(ctx, input, taskId) : undefined;
|
|
236
|
+
const workspace = wantsLand || input.workspaceId ? resolveWorkspace(ctx, input, taskId, { allowRebind: wantsLand }) : undefined;
|
|
211
237
|
if (wantsLand && (!workspace || workspace.mode !== "isolated" || !workspace.worktreePath)) {
|
|
212
238
|
throw new ValidationError("complete delivery requires a live isolated workspace");
|
|
213
239
|
}
|
|
240
|
+
const currentTask = wantsLand ? rebindTaskToWorkspaceIfNeeded(before, workspace, actor) : before;
|
|
214
241
|
const landMeta: TaskDeliverableEventMetadata["land"] | undefined = wantsLand && workspace
|
|
215
242
|
? { requested: true, triggered: false, reason: "pending", workspaceId: workspace.id, workspaceStatus: workspace.status }
|
|
216
243
|
: undefined;
|
|
@@ -236,7 +263,7 @@ export async function deliverTask(input: DeliverTaskInput, ctx: AuthContext): Pr
|
|
|
236
263
|
} else if (isVerdictOnlyComplete) {
|
|
237
264
|
setTaskStatus(taskId, { status: "done", note: "complete delivery recorded without land", actor });
|
|
238
265
|
}
|
|
239
|
-
const detail = getTaskDetail(taskId);
|
|
266
|
+
const detail = getTaskDetail(taskId) ?? currentTask;
|
|
240
267
|
if (detail) {
|
|
241
268
|
emitTaskChanged(detail, "task.deliverable");
|
|
242
269
|
notifyDelivery(taskId, delivery.event.id, eventMeta ?? (delivery.event.metadata as unknown as TaskDeliverableEventMetadata));
|