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.
@@ -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
@@ -21,3 +21,5 @@ export * from "./stats.ts";
21
21
  export * from "./orchestrators.ts";
22
22
  export * from "./workspaces.ts";
23
23
  export * from "./merge-lease.ts";
24
+ export * from "./continuations.ts";
25
+ export * from "./continuation-archives.ts";
@@ -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
@@ -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,
@@ -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
+ }
@@ -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
+ }