agent-relay-server 0.40.0 → 0.41.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/docs/openapi.json +29 -1
- package/package.json +2 -2
- package/public/assets/display-ConJ9cJB.js.map +1 -1
- package/runner/src/adapter.ts +7 -2
- package/src/bus.ts +2 -0
- package/src/config-store.ts +20 -8
- package/src/context-advisory.ts +110 -0
- package/src/context-threshold-config.ts +40 -0
- package/src/db/agents.ts +21 -5
- package/src/db/continuation-archives.ts +179 -0
- package/src/db/continuations.ts +151 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrations.ts +29 -0
- package/src/db/schema.ts +24 -0
- package/src/lifecycle-manager.ts +2 -2
- package/src/mcp.ts +77 -0
- package/src/routes/commands.ts +2 -0
- package/src/routes/continuation-archives.ts +37 -0
- package/src/routes/index.ts +2 -0
- package/src/security.ts +2 -0
- package/src/self-resume-config.ts +44 -0
- package/src/self-resume.ts +232 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { parseJson } from "../utils";
|
|
3
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
4
|
+
import type { ContinuationBudget, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
interface ContinuationRow {
|
|
7
|
+
agent_id: string;
|
|
8
|
+
objective: string;
|
|
9
|
+
generation: number;
|
|
10
|
+
ledger: string;
|
|
11
|
+
working_state: string;
|
|
12
|
+
archive_refs: string;
|
|
13
|
+
budget: string;
|
|
14
|
+
created_at: number;
|
|
15
|
+
updated_at: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getContinuationEnvelope(agentId: string): ContinuationEnvelope | null {
|
|
19
|
+
const row = getDb().query("SELECT * FROM agent_continuations WHERE agent_id = ?").get(agentId) as ContinuationRow | undefined;
|
|
20
|
+
return row ? rowToEnvelope(row) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ensureContinuationEnvelope(input: {
|
|
24
|
+
agentId: string;
|
|
25
|
+
objective?: string;
|
|
26
|
+
config: SelfResumeConfig;
|
|
27
|
+
now?: number;
|
|
28
|
+
}): ContinuationEnvelope {
|
|
29
|
+
const existing = getContinuationEnvelope(input.agentId);
|
|
30
|
+
if (existing) return existing;
|
|
31
|
+
const objective = input.objective;
|
|
32
|
+
if (!objective?.trim()) throw new ValidationError("objective required for first self-resume when no spawn prompt was recorded");
|
|
33
|
+
const now = input.now ?? Date.now();
|
|
34
|
+
const budget: ContinuationBudget = {
|
|
35
|
+
...input.config.budget,
|
|
36
|
+
spent: { generations: 0, wallClockMs: 0, tokens: 0 },
|
|
37
|
+
};
|
|
38
|
+
getDb().query(`
|
|
39
|
+
INSERT INTO agent_continuations (agent_id, objective, generation, ledger, working_state, archive_refs, budget, created_at, updated_at)
|
|
40
|
+
VALUES (?, ?, 0, '[]', '', '[]', ?, ?, ?)
|
|
41
|
+
`).run(input.agentId, objective, JSON.stringify(budget), now, now);
|
|
42
|
+
return getContinuationEnvelope(input.agentId)!;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function seedContinuationObjectiveFromSpawnPrompt(agentId: string, spawnRequestId: string | undefined, config: SelfResumeConfig, now = Date.now()): ContinuationEnvelope | null {
|
|
46
|
+
if (!spawnRequestId || getContinuationEnvelope(agentId)) return getContinuationEnvelope(agentId);
|
|
47
|
+
const row = getDb().query(`
|
|
48
|
+
SELECT params FROM commands
|
|
49
|
+
WHERE type = 'agent.spawn' AND correlation_id = ?
|
|
50
|
+
ORDER BY created_at DESC
|
|
51
|
+
LIMIT 1
|
|
52
|
+
`).get(spawnRequestId) as { params: string } | undefined;
|
|
53
|
+
const params = row ? parseJson<unknown>(row.params, {}) : {};
|
|
54
|
+
if (!isRecord(params) || typeof params.prompt !== "string" || !params.prompt.trim()) return null;
|
|
55
|
+
return ensureContinuationEnvelope({ agentId, objective: params.prompt, config, now });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function updateContinuationAfterCompact(input: {
|
|
59
|
+
agentId: string;
|
|
60
|
+
generation: number;
|
|
61
|
+
ledgerEntries: ContinuationLedgerEntry[];
|
|
62
|
+
workingState: string;
|
|
63
|
+
archiveRefs?: string[];
|
|
64
|
+
spentTokens: number;
|
|
65
|
+
now?: number;
|
|
66
|
+
}): ContinuationEnvelope {
|
|
67
|
+
const existing = getContinuationEnvelope(input.agentId);
|
|
68
|
+
if (!existing) throw new ValidationError("continuation envelope not found");
|
|
69
|
+
const now = input.now ?? Date.now();
|
|
70
|
+
const nextBudget: ContinuationBudget = {
|
|
71
|
+
...existing.budget,
|
|
72
|
+
spent: {
|
|
73
|
+
generations: input.generation,
|
|
74
|
+
wallClockMs: Math.max(0, now - existing.createdAt),
|
|
75
|
+
tokens: existing.budget.spent.tokens + Math.max(0, input.spentTokens),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
getDb().query(`
|
|
79
|
+
UPDATE agent_continuations
|
|
80
|
+
SET generation = ?, ledger = ?, working_state = ?, archive_refs = ?, budget = ?, updated_at = ?
|
|
81
|
+
WHERE agent_id = ?
|
|
82
|
+
`).run(
|
|
83
|
+
input.generation,
|
|
84
|
+
JSON.stringify([...existing.ledger, ...input.ledgerEntries]),
|
|
85
|
+
input.workingState,
|
|
86
|
+
JSON.stringify([...existing.archiveRefs, ...(input.archiveRefs ?? [])]),
|
|
87
|
+
JSON.stringify(nextBudget),
|
|
88
|
+
now,
|
|
89
|
+
input.agentId,
|
|
90
|
+
);
|
|
91
|
+
return getContinuationEnvelope(input.agentId)!;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function appendContinuationArchiveRefs(agentId: string, archiveRefs: string[]): ContinuationEnvelope | null {
|
|
95
|
+
if (archiveRefs.length === 0) return getContinuationEnvelope(agentId);
|
|
96
|
+
const existing = getContinuationEnvelope(agentId);
|
|
97
|
+
if (!existing) return null;
|
|
98
|
+
const refs = [...existing.archiveRefs];
|
|
99
|
+
for (const ref of archiveRefs) {
|
|
100
|
+
if (!refs.includes(ref)) refs.push(ref);
|
|
101
|
+
}
|
|
102
|
+
getDb().query(`
|
|
103
|
+
UPDATE agent_continuations
|
|
104
|
+
SET archive_refs = ?, updated_at = ?
|
|
105
|
+
WHERE agent_id = ?
|
|
106
|
+
`).run(JSON.stringify(refs), Date.now(), agentId);
|
|
107
|
+
return getContinuationEnvelope(agentId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rowToEnvelope(row: ContinuationRow): ContinuationEnvelope {
|
|
111
|
+
return {
|
|
112
|
+
agentId: row.agent_id,
|
|
113
|
+
objective: row.objective,
|
|
114
|
+
generation: row.generation,
|
|
115
|
+
ledger: parseJson<ContinuationLedgerEntry[]>(row.ledger, []).filter(isLedgerEntry),
|
|
116
|
+
workingState: row.working_state,
|
|
117
|
+
archiveRefs: parseJson<string[]>(row.archive_refs, []).filter((value): value is string => typeof value === "string"),
|
|
118
|
+
budget: parseBudget(row.budget),
|
|
119
|
+
createdAt: row.created_at,
|
|
120
|
+
updatedAt: row.updated_at,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseBudget(raw: string): ContinuationBudget {
|
|
125
|
+
const parsed = parseJson<unknown>(raw, null);
|
|
126
|
+
if (!isRecord(parsed) || !isRecord(parsed.spent)) {
|
|
127
|
+
return { maxGenerations: 1, maxWallClockMs: 1, maxTokens: 1, spent: { generations: 0, wallClockMs: 0, tokens: 0 } };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
maxGenerations: numberValue(parsed.maxGenerations),
|
|
131
|
+
maxWallClockMs: numberValue(parsed.maxWallClockMs),
|
|
132
|
+
maxTokens: numberValue(parsed.maxTokens),
|
|
133
|
+
spent: {
|
|
134
|
+
generations: numberValue(parsed.spent.generations),
|
|
135
|
+
wallClockMs: numberValue(parsed.spent.wallClockMs),
|
|
136
|
+
tokens: numberValue(parsed.spent.tokens),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isLedgerEntry(value: unknown): value is ContinuationLedgerEntry {
|
|
142
|
+
return isRecord(value)
|
|
143
|
+
&& typeof value.gen === "number"
|
|
144
|
+
&& typeof value.ruledOut === "string"
|
|
145
|
+
&& typeof value.why === "string"
|
|
146
|
+
&& typeof value.at === "number";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function numberValue(value: unknown): number {
|
|
150
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
151
|
+
}
|
package/src/db/index.ts
CHANGED
package/src/db/migrations.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
17
|
import { getDb } from "./connection.ts";
|
|
18
|
+
import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
|
|
18
19
|
import { normalizeReactionEmoji } from "./mappers.ts";
|
|
19
20
|
import type {
|
|
20
21
|
AgentCard,
|
|
@@ -176,6 +177,34 @@ export function applyMigrations(): void {
|
|
|
176
177
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_delivery_status ON messages(delivery_status)");
|
|
177
178
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_resolved_to_agent ON messages(resolved_to_agent)");
|
|
178
179
|
|
|
180
|
+
getDb().run(`
|
|
181
|
+
CREATE TABLE IF NOT EXISTS agent_continuations (
|
|
182
|
+
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
|
183
|
+
objective TEXT NOT NULL,
|
|
184
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
185
|
+
ledger TEXT NOT NULL DEFAULT '[]',
|
|
186
|
+
working_state TEXT NOT NULL DEFAULT '',
|
|
187
|
+
archive_refs TEXT NOT NULL DEFAULT '[]',
|
|
188
|
+
budget TEXT NOT NULL,
|
|
189
|
+
created_at INTEGER NOT NULL,
|
|
190
|
+
updated_at INTEGER NOT NULL
|
|
191
|
+
)
|
|
192
|
+
`);
|
|
193
|
+
getDb().run(`
|
|
194
|
+
CREATE TABLE IF NOT EXISTS continuation_archives (
|
|
195
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
196
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
197
|
+
generation INTEGER NOT NULL,
|
|
198
|
+
segment TEXT NOT NULL,
|
|
199
|
+
created_at INTEGER NOT NULL
|
|
200
|
+
)
|
|
201
|
+
`);
|
|
202
|
+
getDb().run(`
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_continuation_archives_agent_generation
|
|
204
|
+
ON continuation_archives(agent_id, generation, created_at DESC)
|
|
205
|
+
`);
|
|
206
|
+
ensureContinuationArchiveSearchSchema();
|
|
207
|
+
|
|
179
208
|
const tokenCols = getDb().query("PRAGMA table_info(tokens)").all() as any[];
|
|
180
209
|
const tokenColNames = tokenCols.map((c: any) => c.name);
|
|
181
210
|
if (!tokenColNames.includes("constraints")) {
|
package/src/db/schema.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
17
|
import { applyConnectionPragmas, getDb, setDb } from "./connection.ts";
|
|
18
|
+
import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
|
|
18
19
|
import { applyMigrations } from "./migrations.ts";
|
|
19
20
|
import type {
|
|
20
21
|
AgentCard,
|
|
@@ -174,6 +175,28 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
174
175
|
);
|
|
175
176
|
CREATE INDEX IF NOT EXISTS idx_context_snapshots_agent_time ON context_snapshots(agent_id, captured_at DESC);
|
|
176
177
|
|
|
178
|
+
CREATE TABLE IF NOT EXISTS agent_continuations (
|
|
179
|
+
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
|
180
|
+
objective TEXT NOT NULL,
|
|
181
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
182
|
+
ledger TEXT NOT NULL DEFAULT '[]',
|
|
183
|
+
working_state TEXT NOT NULL DEFAULT '',
|
|
184
|
+
archive_refs TEXT NOT NULL DEFAULT '[]',
|
|
185
|
+
budget TEXT NOT NULL,
|
|
186
|
+
created_at INTEGER NOT NULL,
|
|
187
|
+
updated_at INTEGER NOT NULL
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
CREATE TABLE IF NOT EXISTS continuation_archives (
|
|
191
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
192
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
193
|
+
generation INTEGER NOT NULL,
|
|
194
|
+
segment TEXT NOT NULL,
|
|
195
|
+
created_at INTEGER NOT NULL
|
|
196
|
+
);
|
|
197
|
+
CREATE INDEX IF NOT EXISTS idx_continuation_archives_agent_generation
|
|
198
|
+
ON continuation_archives(agent_id, generation, created_at DESC);
|
|
199
|
+
|
|
177
200
|
CREATE TABLE IF NOT EXISTS memories (
|
|
178
201
|
id TEXT PRIMARY KEY,
|
|
179
202
|
type TEXT NOT NULL,
|
|
@@ -743,6 +766,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
743
766
|
CREATE INDEX IF NOT EXISTS idx_insights_obs_session ON insights_observations(session_id);
|
|
744
767
|
`);
|
|
745
768
|
|
|
769
|
+
ensureContinuationArchiveSearchSchema();
|
|
746
770
|
applyMigrations();
|
|
747
771
|
|
|
748
772
|
// Bootstrap planner statistics on first run (sqlite_stat1 absent means ANALYZE
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
upsertManagedAgentState,
|
|
10
10
|
} from "./config-store";
|
|
11
11
|
import { emitRelayEvent } from "./events";
|
|
12
|
+
import { handleContextPressure } from "./context-advisory";
|
|
12
13
|
import { markManagedAgentRunning, emitManagedState } from "./services/managed-running";
|
|
13
14
|
import { authContextFromSystem } from "./services/auth-context";
|
|
14
15
|
import { emitCommandEvent } from "./command-events";
|
|
@@ -25,7 +26,6 @@ const STOP_TIMEOUT_MS = 60_000;
|
|
|
25
26
|
const FAST_FAIL_RUNTIME_MS = 30_000;
|
|
26
27
|
const MAX_FAST_FAILURES = 3;
|
|
27
28
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
28
|
-
const DEFAULT_COMPACT_THRESHOLD = 0.75;
|
|
29
29
|
const DEFAULT_COMPACT_TARGET = 0.3;
|
|
30
30
|
const DEFAULT_MAX_TASKS_BEFORE_COMPACT = 10;
|
|
31
31
|
const DEFAULT_COMPACT_COOLDOWN_MS = 60_000;
|
|
@@ -354,6 +354,7 @@ export class LifecycleManager {
|
|
|
354
354
|
this.stopAgent(policy, true, "idle-timeout");
|
|
355
355
|
return;
|
|
356
356
|
}
|
|
357
|
+
if (handleContextPressure({ agent, now: this.now, hasActiveCommand: (target, type) => this.hasActiveCommand(target, type), compact: (reason) => this.compactAgent(policy, agent.id, reason, alwaysReloadTags(agent.tags)) })) return;
|
|
357
358
|
if (this.shouldCompact(agent)) {
|
|
358
359
|
this.compactAgent(policy, agent.id, "context-threshold", alwaysReloadTags(agent.tags));
|
|
359
360
|
return;
|
|
@@ -524,7 +525,6 @@ export class LifecycleManager {
|
|
|
524
525
|
if (agent.providerCapabilities?.context?.compact !== true) return false;
|
|
525
526
|
if (this.hasActiveCommand(agent.id, "agent.compact")) return false;
|
|
526
527
|
|
|
527
|
-
if (context.utilization > DEFAULT_COMPACT_THRESHOLD) return true;
|
|
528
528
|
if (context.tasksSinceCompact > DEFAULT_MAX_TASKS_BEFORE_COMPACT) return true;
|
|
529
529
|
return context.lifecycleState === "cooling" &&
|
|
530
530
|
this.now() - context.lastUpdatedAt >= DEFAULT_COMPACT_COOLDOWN_MS &&
|
package/src/mcp.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
listAgents,
|
|
25
25
|
listWorkspaces,
|
|
26
26
|
ownedIsolatedWorkspace,
|
|
27
|
+
searchContinuationArchives,
|
|
27
28
|
searchAgents,
|
|
28
29
|
type AgentSearchFilter,
|
|
29
30
|
type AgentSearchSort,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
import { resolveCallerAgentId, type DeliveryReceipt } from "./agent-ref";
|
|
36
37
|
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "./services/shutdown-agent";
|
|
37
38
|
import { emitRelayEvent } from "./events";
|
|
39
|
+
import { compactAndResume } from "./self-resume";
|
|
38
40
|
import {
|
|
39
41
|
getComponentAuth,
|
|
40
42
|
getIntegrationAuth,
|
|
@@ -246,6 +248,52 @@ const TOOLS: ToolDefinition[] = [
|
|
|
246
248
|
additionalProperties: false,
|
|
247
249
|
},
|
|
248
250
|
},
|
|
251
|
+
{
|
|
252
|
+
name: "relay_compact_and_resume",
|
|
253
|
+
description: "Self-resume this same agent in place: Relay stores the continuation envelope, compacts or clears context, then resumes with the relay-built continuation. Use at a clean seam after a context advisory.",
|
|
254
|
+
requiredScopes: ["command:write"],
|
|
255
|
+
inputSchema: {
|
|
256
|
+
type: "object",
|
|
257
|
+
properties: {
|
|
258
|
+
workingState: { type: "string", description: "Current replaceable working state for the next generation." },
|
|
259
|
+
ruledOut: {
|
|
260
|
+
type: "array",
|
|
261
|
+
items: {
|
|
262
|
+
oneOf: [
|
|
263
|
+
{ type: "string" },
|
|
264
|
+
{
|
|
265
|
+
type: "object",
|
|
266
|
+
properties: {
|
|
267
|
+
ruledOut: { type: "string" },
|
|
268
|
+
why: { type: "string" },
|
|
269
|
+
},
|
|
270
|
+
required: ["ruledOut"],
|
|
271
|
+
additionalProperties: false,
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
mode: { type: "string", enum: ["clear-inject", "native"] },
|
|
277
|
+
objective: { type: "string", description: "Seed-only objective. Used only on the first call when no spawn prompt seeded the envelope." },
|
|
278
|
+
},
|
|
279
|
+
required: ["workingState"],
|
|
280
|
+
additionalProperties: false,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: "relay_recall",
|
|
285
|
+
description: "Recall this same agent's archived pre-compaction transcript segments by keyword/BM25 search. Self-only: caller identity comes from the MCP token; no agentId parameter is accepted.",
|
|
286
|
+
requiredScopes: ["memory:read"],
|
|
287
|
+
inputSchema: {
|
|
288
|
+
type: "object",
|
|
289
|
+
properties: {
|
|
290
|
+
query: { type: "string", description: "Keyword query over archived compact/clear segments." },
|
|
291
|
+
limit: { type: "integer", minimum: 1, maximum: 20 },
|
|
292
|
+
},
|
|
293
|
+
required: ["query"],
|
|
294
|
+
additionalProperties: false,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
249
297
|
{
|
|
250
298
|
name: "relay_spawn_agent",
|
|
251
299
|
description: "Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via `prompt` in the same call. Defaults to your own host (override with orchestratorId) and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
|
|
@@ -506,6 +554,8 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
506
554
|
else if (name === "relay_agent_status") result = relayAgentStatus(args);
|
|
507
555
|
else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
|
|
508
556
|
else if (name === "relay_whoami") result = relayWhoami(auth);
|
|
557
|
+
else if (name === "relay_compact_and_resume") result = relayCompactAndResume(auth, args);
|
|
558
|
+
else if (name === "relay_recall") result = relayRecall(auth, args);
|
|
509
559
|
else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
|
|
510
560
|
else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
|
|
511
561
|
else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
|
|
@@ -557,6 +607,33 @@ function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
|
|
|
557
607
|
};
|
|
558
608
|
}
|
|
559
609
|
|
|
610
|
+
function relayCompactAndResume(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
611
|
+
const agentId = callerAgentId(auth);
|
|
612
|
+
if (!agentId) throw new McpAuthError("relay_compact_and_resume requires a single caller agent identity");
|
|
613
|
+
return compactAndResume({
|
|
614
|
+
agentId,
|
|
615
|
+
workingState: stringField(args.workingState, "workingState", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
616
|
+
ruledOut: args.ruledOut,
|
|
617
|
+
mode: optionalEnum(args.mode, "mode", ["clear-inject", "native"] as const),
|
|
618
|
+
objective: optionalString(args.objective, "objective", 64_000),
|
|
619
|
+
}) as unknown as Record<string, unknown>;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function relayRecall(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
623
|
+
const agentId = callerAgentId(auth);
|
|
624
|
+
if (!agentId) throw new McpAuthError("relay_recall requires a single caller agent identity");
|
|
625
|
+
const matches = searchContinuationArchives({
|
|
626
|
+
agentId,
|
|
627
|
+
query: stringField(args.query, "query", { required: true, max: 1000 }),
|
|
628
|
+
limit: optionalPositiveInt(args.limit, "limit"),
|
|
629
|
+
});
|
|
630
|
+
return {
|
|
631
|
+
query: stringField(args.query, "query", { required: true, max: 1000 }),
|
|
632
|
+
matches,
|
|
633
|
+
count: matches.length,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
560
637
|
// MCP transport adapter over the shared send service (epic #342). The service owns from-resolution
|
|
561
638
|
// (token identity via ctx.callerAgentId), reply routing, target resolution + the converged
|
|
562
639
|
// store-ahead policy, authorization, persistence, and emit. This adapter only builds the input,
|
package/src/routes/commands.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { ServiceAuthError } from "../services/errors";
|
|
|
15
15
|
import { notifyBranchLanded } from "../branch-landed";
|
|
16
16
|
import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
|
|
17
17
|
import { handleReviewerMergeResult } from "../reviewer-pipeline";
|
|
18
|
+
import { enqueueContinuationInjectionAfterClear } from "../self-resume";
|
|
18
19
|
import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
|
|
19
20
|
|
|
20
21
|
const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
|
|
@@ -173,6 +174,7 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
173
174
|
// hook so a command that landed in the wrong context gets flagged.
|
|
174
175
|
getCompactionWatch().noteCommandSucceeded(command.type, command.id, command.target);
|
|
175
176
|
}
|
|
177
|
+
enqueueContinuationInjectionAfterClear(command);
|
|
176
178
|
if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
|
|
177
179
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
178
180
|
if (workspaceId) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Auto-split from routes.ts (#299). Domain: continuation archives.
|
|
2
|
+
import { isRecord } from "agent-relay-sdk";
|
|
3
|
+
import { resolveCallerAgentId } from "../agent-ref";
|
|
4
|
+
import { archiveContinuationSegment, appendContinuationArchiveRefs, getAgent, listAgents, ValidationError } from "../db";
|
|
5
|
+
import { getComponentAuth } from "../security";
|
|
6
|
+
import { cleanEpoch, cleanPositiveId, cleanString } from "../validation";
|
|
7
|
+
import { error, json, parseBody, type Handler } from "./_shared";
|
|
8
|
+
|
|
9
|
+
export const postContinuationArchiveRoute: Handler = async (req) => {
|
|
10
|
+
const parsed = await parseBody<unknown>(req);
|
|
11
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
12
|
+
if (!isRecord(parsed.body)) return error("JSON object body required", 400);
|
|
13
|
+
try {
|
|
14
|
+
const agentId = cleanString(parsed.body.agentId, "agentId", { required: true, max: 240 })!;
|
|
15
|
+
const denied = authorizeArchiveWriter(req, agentId);
|
|
16
|
+
if (denied) return denied;
|
|
17
|
+
if (!getAgent(agentId)) return error("agent not found", 404);
|
|
18
|
+
const archive = archiveContinuationSegment({
|
|
19
|
+
agentId,
|
|
20
|
+
segment: cleanString(parsed.body.segment, "segment", { required: true, max: 64_000 })!,
|
|
21
|
+
generation: cleanPositiveId(parsed.body.generation, "generation"),
|
|
22
|
+
now: cleanEpoch(parsed.body.occurredAt, "occurredAt"),
|
|
23
|
+
});
|
|
24
|
+
const envelope = appendContinuationArchiveRefs(agentId, [archive.ref]);
|
|
25
|
+
return json({ archive, envelope }, 201);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
28
|
+
throw e;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function authorizeArchiveWriter(req: Request, agentId: string): Response | null {
|
|
33
|
+
const component = getComponentAuth(req);
|
|
34
|
+
if (!component) return null;
|
|
35
|
+
const caller = resolveCallerAgentId(component.constraints, listAgents);
|
|
36
|
+
return caller === agentId ? null : error("forbidden", 403);
|
|
37
|
+
}
|
package/src/routes/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { getApiDocs, getApiSpec } from "./spec";
|
|
|
20
20
|
import { getChannelBindings, getChannels, getIntegrations, postChannelActivity, postChannelBinding, postChannelEvent, postIntegrationEvent, putIntegrationRegistryRoute } from "./integrations";
|
|
21
21
|
import { getCommandById, getCommands, patchCommand, postCommand } from "./commands";
|
|
22
22
|
import { getConnectorById, getConnectorConfig, getConnectors, postConnectorAction, postConnectorCall, postConnectorRegister, putConnectorConfig } from "./connectors";
|
|
23
|
+
import { postContinuationArchiveRoute } from "./continuation-archives";
|
|
23
24
|
import { getEvents } from "./sse";
|
|
24
25
|
import { getHostDirectories, postAgent, postAgentSpawn, postRouteAdvice } from "./agents-spawn";
|
|
25
26
|
import { getInsightsConfigRoute, getInsightsObservationsRoute, postInsightsObservationRoute, putInsightsConfigRoute } from "./insights";
|
|
@@ -170,6 +171,7 @@ const routes: Route[] = [
|
|
|
170
171
|
route("PUT", "/api/insights/config", putInsightsConfigRoute),
|
|
171
172
|
route("GET", "/api/insights/observations", getInsightsObservationsRoute),
|
|
172
173
|
route("POST", "/api/insights/observations", postInsightsObservationRoute),
|
|
174
|
+
route("POST", "/api/continuation-archives", postContinuationArchiveRoute),
|
|
173
175
|
route("GET", "/api/config/:namespace", getConfigNamespace),
|
|
174
176
|
route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
|
|
175
177
|
route("GET", "/api/config/:namespace/:key", getConfigKey),
|
package/src/security.ts
CHANGED
|
@@ -207,6 +207,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
207
207
|
// Insights config (the feature toggle) stays admin-only via the default; only the
|
|
208
208
|
// mechanical observation feed is writable by lower-privilege callers.
|
|
209
209
|
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
210
|
+
if (pathname === "/api/continuation-archives") return "insights:write";
|
|
210
211
|
if (pathname.startsWith("/api/system/")) return "system:admin";
|
|
211
212
|
return null;
|
|
212
213
|
}
|
|
@@ -281,6 +282,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
281
282
|
// below and every observation was 403-dropped — the whole Insights feed stayed empty.
|
|
282
283
|
// Config (the feature toggle) intentionally keeps falling through to system:admin.
|
|
283
284
|
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
285
|
+
if (pathname === "/api/continuation-archives") return "insights:write";
|
|
284
286
|
if (pathname.startsWith("/api/system/")) return "system:admin";
|
|
285
287
|
return "system:admin";
|
|
286
288
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { ValidationError } from "./db/connection.ts";
|
|
3
|
+
import type { SelfResumeConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SELF_RESUME_CONFIG: SelfResumeConfig = {
|
|
6
|
+
defaultMode: "clear-inject",
|
|
7
|
+
budget: {
|
|
8
|
+
maxGenerations: 8,
|
|
9
|
+
maxWallClockMs: 24 * 60 * 60 * 1000,
|
|
10
|
+
maxTokens: 2_000_000,
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const VALID_SELF_RESUME_MODES = ["clear-inject", "native"] as const;
|
|
15
|
+
|
|
16
|
+
export function validateSelfResumeConfig(value: unknown): SelfResumeConfig {
|
|
17
|
+
if (!isRecord(value)) throw new ValidationError("self-resume config value must be an object");
|
|
18
|
+
const budget = isRecord(value.budget) ? value.budget : {};
|
|
19
|
+
return {
|
|
20
|
+
defaultMode: value.defaultMode === undefined || value.defaultMode === null
|
|
21
|
+
? DEFAULT_SELF_RESUME_CONFIG.defaultMode
|
|
22
|
+
: cleanMode(value.defaultMode, "defaultMode"),
|
|
23
|
+
budget: {
|
|
24
|
+
maxGenerations: cleanPositiveInt(budget.maxGenerations, "budget.maxGenerations", DEFAULT_SELF_RESUME_CONFIG.budget.maxGenerations),
|
|
25
|
+
maxWallClockMs: cleanPositiveInt(budget.maxWallClockMs, "budget.maxWallClockMs", DEFAULT_SELF_RESUME_CONFIG.budget.maxWallClockMs),
|
|
26
|
+
maxTokens: cleanPositiveInt(budget.maxTokens, "budget.maxTokens", DEFAULT_SELF_RESUME_CONFIG.budget.maxTokens),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanMode(value: unknown, field: string): SelfResumeConfig["defaultMode"] {
|
|
32
|
+
if (typeof value !== "string" || !VALID_SELF_RESUME_MODES.includes(value as any)) {
|
|
33
|
+
throw new ValidationError(`${field} must be one of: ${VALID_SELF_RESUME_MODES.join(", ")}`);
|
|
34
|
+
}
|
|
35
|
+
return value as SelfResumeConfig["defaultMode"];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanPositiveInt(value: unknown, field: string, fallback: number): number {
|
|
39
|
+
if (value === undefined || value === null) return fallback;
|
|
40
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
41
|
+
throw new ValidationError(`${field} must be a positive integer`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|