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
package/runner/src/adapter.ts
CHANGED
|
@@ -136,7 +136,8 @@ export interface ProviderAdapter {
|
|
|
136
136
|
provider: string;
|
|
137
137
|
spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
|
|
138
138
|
shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
|
|
139
|
-
compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
139
|
+
compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
|
|
140
|
+
compactSupportsInstructions?: boolean;
|
|
140
141
|
clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
141
142
|
// Normalize the session so far into the provider-agnostic SessionEvent stream the
|
|
142
143
|
// Insights context-ratio signal (#183/#184) reduces. Called by the runner's
|
|
@@ -147,6 +148,10 @@ export interface ProviderAdapter {
|
|
|
147
148
|
// ignore it and return their accumulated log. Return null when there is nothing to
|
|
148
149
|
// measure. Best-effort: may be omitted by providers without a session view yet.
|
|
149
150
|
collectSessionEvents?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null>;
|
|
151
|
+
// Full searchable transcript/archive source for destructive boundaries. The runner
|
|
152
|
+
// slices the returned stream by cursor, so adapters should return the session-so-far
|
|
153
|
+
// view when they have one.
|
|
154
|
+
collectSessionArchiveSegment?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null>;
|
|
150
155
|
// Interrupt the in-flight turn without ending the session (ESC for Claude's
|
|
151
156
|
// tmux pane, turn/interrupt for the Codex app-server). Provider-independent at
|
|
152
157
|
// the runner boundary; each adapter does what its provider actually supports.
|
|
@@ -185,7 +190,7 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
|
|
|
185
190
|
return config.agentProfile?.relay?.[feature] !== false;
|
|
186
191
|
}
|
|
187
192
|
|
|
188
|
-
export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
|
|
193
|
+
export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
|
|
189
194
|
|
|
190
195
|
// #306 — deliver the FULL message body by default. Only a pathological body beyond this
|
|
191
196
|
// high cap truncates (with a get-message hint) so it can't nuke an agent's context; the 99%
|
package/src/bus.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { commandAuthorizationResource, dispatchCommand } from "./services/dispat
|
|
|
12
12
|
import { ServiceAuthError } from "./services/errors";
|
|
13
13
|
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
|
|
14
14
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
15
|
+
import { enqueueContinuationInjectionAfterClear } from "./self-resume";
|
|
15
16
|
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
16
17
|
import {
|
|
17
18
|
BusProtocolError,
|
|
@@ -219,6 +220,7 @@ function handleCommandFrame(
|
|
|
219
220
|
// Production command completions arrive here over the bus, not the HTTP
|
|
220
221
|
// PATCH route — arm the stall watch for finished compact/clear commands.
|
|
221
222
|
noteCompactionCommandCompleted(updated.type, updated.status, updated.id, updated.target);
|
|
223
|
+
enqueueContinuationInjectionAfterClear(updated);
|
|
222
224
|
}
|
|
223
225
|
sendCommandResult(ws, frameId, "succeeded", updated ? { command: updated } : undefined);
|
|
224
226
|
return;
|
package/src/config-store.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getDb, ValidationError } from "./db";
|
|
2
2
|
import { cleanEnum, cleanString, cleanStringArray } from "./validation";
|
|
3
|
+
import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
|
|
4
|
+
import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
|
|
3
5
|
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
4
6
|
import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
5
7
|
import type {
|
|
@@ -12,6 +14,7 @@ import type {
|
|
|
12
14
|
ManagedAgentState,
|
|
13
15
|
ManagedAgentStatus,
|
|
14
16
|
NotificationsConfig,
|
|
17
|
+
SelfResumeConfig,
|
|
15
18
|
SpawnApprovalMode,
|
|
16
19
|
SpawnPolicy,
|
|
17
20
|
SpawnProvider,
|
|
@@ -29,6 +32,8 @@ const INSIGHTS_NAMESPACE = "insights";
|
|
|
29
32
|
const INSIGHTS_KEY = "default";
|
|
30
33
|
const NOTIFICATIONS_NAMESPACE = "notifications";
|
|
31
34
|
const NOTIFICATIONS_KEY = "default";
|
|
35
|
+
const SELF_RESUME_NAMESPACE = "self-resume";
|
|
36
|
+
const SELF_RESUME_KEY = "default";
|
|
32
37
|
const WORKSPACE_NAMESPACE = "workspace";
|
|
33
38
|
const WORKSPACE_KEY = "default";
|
|
34
39
|
const LANDING_NAMESPACE = "landing";
|
|
@@ -475,17 +480,11 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
|
|
|
475
480
|
// Relay-driven lifecycle push notifications (#239 event bus). Default-on; the
|
|
476
481
|
// operator can flip the master switch or individual events off via the generic
|
|
477
482
|
// config route. Push messages wake recipients, so they must be suppressible.
|
|
478
|
-
const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
|
|
479
|
-
enabled: true,
|
|
480
|
-
branchLanded: true,
|
|
481
|
-
agentReady: true,
|
|
482
|
-
agentExited: true,
|
|
483
|
-
agentSpawnFailed: true,
|
|
484
|
-
};
|
|
483
|
+
const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = { enabled: true, branchLanded: true, agentReady: true, agentExited: true, agentSpawnFailed: true, contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG };
|
|
485
484
|
|
|
486
485
|
function validateNotificationsConfig(value: unknown): NotificationsConfig {
|
|
487
486
|
if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
|
|
488
|
-
const bool = (key:
|
|
487
|
+
const bool = (key: "enabled" | "branchLanded" | "agentReady" | "agentExited" | "agentSpawnFailed"): boolean =>
|
|
489
488
|
value[key] === undefined ? NOTIFICATIONS_CONFIG_DEFAULTS[key] : cleanBoolean(value[key], key);
|
|
490
489
|
return {
|
|
491
490
|
enabled: bool("enabled"),
|
|
@@ -493,6 +492,7 @@ function validateNotificationsConfig(value: unknown): NotificationsConfig {
|
|
|
493
492
|
agentReady: bool("agentReady"),
|
|
494
493
|
agentExited: bool("agentExited"),
|
|
495
494
|
agentSpawnFailed: bool("agentSpawnFailed"),
|
|
495
|
+
contextThreshold: validateContextThresholdConfig(value.contextThreshold),
|
|
496
496
|
};
|
|
497
497
|
}
|
|
498
498
|
|
|
@@ -537,6 +537,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
|
|
|
537
537
|
if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
|
|
538
538
|
if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
|
|
539
539
|
if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
|
|
540
|
+
if (namespace === SELF_RESUME_NAMESPACE) return validateSelfResumeConfig(value);
|
|
540
541
|
if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
|
|
541
542
|
if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
|
|
542
543
|
if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
|
|
@@ -682,6 +683,17 @@ export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEnt
|
|
|
682
683
|
return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
|
|
683
684
|
}
|
|
684
685
|
|
|
686
|
+
/** Relay-owned self-resume defaults (#316), merged over defaults (always usable). */
|
|
687
|
+
export function getSelfResumeConfig(): SelfResumeConfig {
|
|
688
|
+
const entry = getConfig<Partial<SelfResumeConfig>>(SELF_RESUME_NAMESPACE, SELF_RESUME_KEY);
|
|
689
|
+
if (!entry) return { ...DEFAULT_SELF_RESUME_CONFIG, budget: { ...DEFAULT_SELF_RESUME_CONFIG.budget } };
|
|
690
|
+
return validateSelfResumeConfig({
|
|
691
|
+
...DEFAULT_SELF_RESUME_CONFIG,
|
|
692
|
+
...entry.value,
|
|
693
|
+
budget: { ...DEFAULT_SELF_RESUME_CONFIG.budget, ...(entry.value.budget ?? {}) },
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
685
697
|
/** Global workspace config, merged over defaults (always returns a usable value). */
|
|
686
698
|
export function getWorkspaceConfig(): WorkspaceConfig {
|
|
687
699
|
const entry = getConfig<Partial<WorkspaceConfig>>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { getNotificationsConfig } from "./config-store";
|
|
2
|
+
import { getDb } from "./db/connection.ts";
|
|
3
|
+
import { emitRelayEvent } from "./events";
|
|
4
|
+
import { notifySystemMessage } from "./notify";
|
|
5
|
+
import type { AgentCard, Command, ContextState } from "./types";
|
|
6
|
+
|
|
7
|
+
interface ContextAdvisoryInput {
|
|
8
|
+
agentId: string;
|
|
9
|
+
tokens: number;
|
|
10
|
+
percent: number;
|
|
11
|
+
windowTokens: number;
|
|
12
|
+
band: number;
|
|
13
|
+
hardBackstopPercent: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* #315 — context pressure is a relay-native signal: durable event for observers,
|
|
18
|
+
* plus an agent-facing system push when lifecycle notifications are enabled.
|
|
19
|
+
*/
|
|
20
|
+
function notifyContextAdvisory(input: ContextAdvisoryInput): void {
|
|
21
|
+
const payload = {
|
|
22
|
+
kind: "agent.context_threshold",
|
|
23
|
+
agentId: input.agentId,
|
|
24
|
+
tokens: input.tokens,
|
|
25
|
+
percent: input.percent,
|
|
26
|
+
windowTokens: input.windowTokens,
|
|
27
|
+
band: input.band,
|
|
28
|
+
hardBackstopPercent: input.hardBackstopPercent,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
emitRelayEvent({
|
|
32
|
+
type: "agent.context_threshold",
|
|
33
|
+
source: "server",
|
|
34
|
+
subject: input.agentId,
|
|
35
|
+
data: payload,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const config = getNotificationsConfig();
|
|
39
|
+
if (!config.enabled || !config.contextThreshold.enabled) return;
|
|
40
|
+
|
|
41
|
+
notifySystemMessage(input.agentId, {
|
|
42
|
+
subject: "Context threshold reached",
|
|
43
|
+
body: `Context advisory: ${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band). Compact at a clean seam before the ${input.hardBackstopPercent}% hard backstop may request compaction.`,
|
|
44
|
+
payload,
|
|
45
|
+
replyExpected: false,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function contextAdvisoryBand(utilization: number, advisoryUtilization: number, bandSize: number): number | null {
|
|
50
|
+
if (!Number.isFinite(utilization) || utilization < advisoryUtilization) return null;
|
|
51
|
+
const percent = Math.min(100, Math.max(0, utilization * 100));
|
|
52
|
+
const step = Math.max(1, bandSize * 100);
|
|
53
|
+
return Math.min(100, Math.floor(percent / step) * step);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function handleContextPressure(input: {
|
|
57
|
+
agent: AgentCard;
|
|
58
|
+
now: () => number;
|
|
59
|
+
hasActiveCommand: (target: string, type: string) => boolean;
|
|
60
|
+
compact: (reason: string) => Command | null;
|
|
61
|
+
}): boolean {
|
|
62
|
+
const { agent, now } = input;
|
|
63
|
+
const context = agent.context;
|
|
64
|
+
if (!context || agent.status === "busy") return false;
|
|
65
|
+
if (context.lifecycleState === "compacting" || context.lifecycleState === "hibernating") return false;
|
|
66
|
+
if (typeof context.utilization !== "number" || !Number.isFinite(context.utilization)) return false;
|
|
67
|
+
const threshold = getNotificationsConfig().contextThreshold;
|
|
68
|
+
const band = contextAdvisoryBand(context.utilization, threshold.advisoryUtilization, threshold.bandSize);
|
|
69
|
+
if (band === null) {
|
|
70
|
+
if (context.lastContextAdvisoryBand !== undefined || context.lastContextAdvisoryAt !== undefined) {
|
|
71
|
+
persistContextState(agent.id, clearContextAdvisory(context));
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (context.utilization >= threshold.hardBackstopUtilization && context.lastContextAdvisoryAt !== undefined && agent.providerCapabilities?.context?.compact === true && !input.hasActiveCommand(agent.id, "agent.compact")) {
|
|
76
|
+
input.compact("context-hard-backstop");
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (context.lastContextAdvisoryBand === band) return false;
|
|
80
|
+
const tokens = finiteNumber(context.tokensUsed);
|
|
81
|
+
const windowTokens = finiteNumber(context.tokensMax);
|
|
82
|
+
if (tokens === undefined || windowTokens === undefined) return false;
|
|
83
|
+
notifyContextAdvisory({
|
|
84
|
+
agentId: agent.id,
|
|
85
|
+
tokens,
|
|
86
|
+
percent: Math.round(context.utilization * 100),
|
|
87
|
+
windowTokens,
|
|
88
|
+
band,
|
|
89
|
+
hardBackstopPercent: Math.round(threshold.hardBackstopUtilization * 100),
|
|
90
|
+
});
|
|
91
|
+
persistContextState(agent.id, { ...context, lastContextAdvisoryBand: band, lastContextAdvisoryAt: now() });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clearContextAdvisory(context: ContextState): ContextState {
|
|
96
|
+
const { lastContextAdvisoryBand: _band, lastContextAdvisoryAt: _at, ...rest } = context;
|
|
97
|
+
return rest;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function persistContextState(agentId: string, context: ContextState): void {
|
|
101
|
+
getDb().query("UPDATE agents SET context_state = ? WHERE id = ?").run(JSON.stringify(context), agentId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
105
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatTokens(value: number): string {
|
|
109
|
+
return Math.round(value).toLocaleString("en-US");
|
|
110
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { ValidationError } from "./db/connection.ts";
|
|
3
|
+
import type { NotificationsConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONTEXT_THRESHOLD_CONFIG: NotificationsConfig["contextThreshold"] = {
|
|
6
|
+
enabled: true,
|
|
7
|
+
advisoryUtilization: 0.7,
|
|
8
|
+
hardBackstopUtilization: 0.9,
|
|
9
|
+
bandSize: 0.1,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function validateContextThresholdConfig(value: unknown): NotificationsConfig["contextThreshold"] {
|
|
13
|
+
const input = isRecord(value) ? value : {};
|
|
14
|
+
const bandSize = cleanFraction(input.bandSize, "contextThreshold.bandSize", DEFAULT_CONTEXT_THRESHOLD_CONFIG.bandSize);
|
|
15
|
+
if (bandSize <= 0) throw new ValidationError("contextThreshold.bandSize must be greater than 0");
|
|
16
|
+
const advisoryUtilization = cleanFraction(input.advisoryUtilization, "contextThreshold.advisoryUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.advisoryUtilization);
|
|
17
|
+
const hardBackstopUtilization = cleanFraction(input.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.hardBackstopUtilization);
|
|
18
|
+
if (hardBackstopUtilization <= advisoryUtilization) {
|
|
19
|
+
throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.advisoryUtilization");
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
enabled: input.enabled === undefined ? DEFAULT_CONTEXT_THRESHOLD_CONFIG.enabled : cleanBoolean(input.enabled, "contextThreshold.enabled"),
|
|
23
|
+
advisoryUtilization,
|
|
24
|
+
hardBackstopUtilization,
|
|
25
|
+
bandSize,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cleanBoolean(value: unknown, field: string): boolean {
|
|
30
|
+
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cleanFraction(value: unknown, field: string, fallback: number): number {
|
|
35
|
+
if (value === undefined || value === null) return fallback;
|
|
36
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
37
|
+
throw new ValidationError(`${field} must be a number between 0 and 1`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
package/src/db/agents.ts
CHANGED
|
@@ -15,11 +15,13 @@ 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 { evaluatePoolBindings, expectedChannelAgentId, upsertChannelForAgent } from "./channels.ts";
|
|
18
|
+
import { seedContinuationObjectiveFromSpawnPrompt } from "./continuations.ts";
|
|
18
19
|
import { ValidationError, getDb } from "./connection.ts";
|
|
19
20
|
import { rowToAgent, rowToContextSnapshot } from "./mappers.ts";
|
|
20
21
|
import { closeOpenPairsForAgent } from "./pairs.ts";
|
|
21
22
|
import { TASK_SELECT, insertTaskEvent } from "./tasks.ts";
|
|
22
23
|
import { electWorkspaceStewards, electWorkspaceStewardsForAgent } from "./workspaces.ts";
|
|
24
|
+
import { DEFAULT_SELF_RESUME_CONFIG } from "../self-resume-config";
|
|
23
25
|
import type {
|
|
24
26
|
AgentCard,
|
|
25
27
|
ActivityEvent,
|
|
@@ -153,6 +155,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
153
155
|
const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
|
|
154
156
|
const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
|
|
155
157
|
const instanceProvided = Boolean(input.instanceId);
|
|
158
|
+
const contextState = mergeRelayContextMarkers(input.id, input.context);
|
|
156
159
|
const stmt = getDb().query(`
|
|
157
160
|
INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, spawned_by, last_seen, created_at)
|
|
158
161
|
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $spawnedBy, $now, $now)
|
|
@@ -195,14 +198,15 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
195
198
|
$instanceProvided: instanceProvided ? 1 : 0,
|
|
196
199
|
$initialEpoch: instanceProvided ? 1 : 0,
|
|
197
200
|
$providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
|
|
198
|
-
$contextState:
|
|
201
|
+
$contextState: contextState ? JSON.stringify(contextState) : null,
|
|
199
202
|
$meta: JSON.stringify(input.meta ?? {}),
|
|
200
203
|
$spawnedBy: input.spawnedBy ?? null,
|
|
201
204
|
$now: now,
|
|
202
205
|
});
|
|
203
|
-
if (
|
|
206
|
+
if (contextState) recordContextSnapshot(input.id, contextState, now);
|
|
204
207
|
|
|
205
208
|
const agent = getAgent(input.id)!;
|
|
209
|
+
seedContinuationObjectiveFromSpawnPrompt(agent.id, stringValue(agent.meta?.spawnRequestId), DEFAULT_SELF_RESUME_CONFIG, now);
|
|
206
210
|
if (agent.kind === "channel") upsertChannelForAgent(agent);
|
|
207
211
|
evaluatePoolBindings();
|
|
208
212
|
// A (re)joining agent may revive a dormant repo steward — re-elect for the
|
|
@@ -335,6 +339,7 @@ export function heartbeat(
|
|
|
335
339
|
if (!validateAgentSession(id, guard).ok) return false;
|
|
336
340
|
const now = Date.now();
|
|
337
341
|
if (runtime?.providerCapabilities || runtime?.context) {
|
|
342
|
+
const contextState = mergeRelayContextMarkers(id, runtime.context);
|
|
338
343
|
const result = getDb()
|
|
339
344
|
.query(`
|
|
340
345
|
UPDATE agents SET
|
|
@@ -347,10 +352,10 @@ export function heartbeat(
|
|
|
347
352
|
.run(
|
|
348
353
|
now,
|
|
349
354
|
runtime.providerCapabilities ? JSON.stringify(runtime.providerCapabilities) : null,
|
|
350
|
-
|
|
355
|
+
contextState ? JSON.stringify(contextState) : null,
|
|
351
356
|
id,
|
|
352
357
|
);
|
|
353
|
-
if (result.changes > 0 &&
|
|
358
|
+
if (result.changes > 0 && contextState) recordContextSnapshot(id, contextState, now);
|
|
354
359
|
return result.changes > 0;
|
|
355
360
|
}
|
|
356
361
|
const result = getDb()
|
|
@@ -412,6 +417,18 @@ export function recordContextSnapshot(agentId: string, context: ContextState, no
|
|
|
412
417
|
pruneContextSnapshots(agentId, now - DAY_MS);
|
|
413
418
|
}
|
|
414
419
|
|
|
420
|
+
function mergeRelayContextMarkers(agentId: string, next: ContextState | undefined): ContextState | undefined {
|
|
421
|
+
if (!next) return undefined;
|
|
422
|
+
const row = getDb().query("SELECT context_state FROM agents WHERE id = ?").get(agentId) as { context_state?: string | null } | undefined;
|
|
423
|
+
const previous = row?.context_state ? parseJson<ContextState | undefined>(row.context_state, undefined) : undefined;
|
|
424
|
+
if (!previous) return next;
|
|
425
|
+
return {
|
|
426
|
+
...next,
|
|
427
|
+
lastContextAdvisoryBand: next.lastContextAdvisoryBand ?? previous.lastContextAdvisoryBand,
|
|
428
|
+
lastContextAdvisoryAt: next.lastContextAdvisoryAt ?? previous.lastContextAdvisoryAt,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
415
432
|
|
|
416
433
|
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
417
434
|
const now = Date.now();
|
|
@@ -548,4 +565,3 @@ export function runtimeTokenJtisFromMeta(meta: Record<string, unknown>): string[
|
|
|
548
565
|
}
|
|
549
566
|
|
|
550
567
|
// --- Tasks ---
|
|
551
|
-
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { MAX_BODY_BYTES } from "../config";
|
|
2
|
+
import { getContinuationEnvelope } from "./continuations.ts";
|
|
3
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
4
|
+
|
|
5
|
+
export interface ContinuationArchive {
|
|
6
|
+
id: number;
|
|
7
|
+
ref: string;
|
|
8
|
+
agentId: string;
|
|
9
|
+
generation: number;
|
|
10
|
+
segment: string;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ContinuationArchiveSearchResult extends ContinuationArchive {
|
|
15
|
+
score: number | null;
|
|
16
|
+
searchMode: "fts5" | "like";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ContinuationArchiveRow {
|
|
20
|
+
id: number;
|
|
21
|
+
agent_id: string;
|
|
22
|
+
generation: number;
|
|
23
|
+
segment: string;
|
|
24
|
+
created_at: number;
|
|
25
|
+
score?: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_RECALL_LIMIT = 20;
|
|
29
|
+
|
|
30
|
+
export function ensureContinuationArchiveSearchSchema(): void {
|
|
31
|
+
if (process.env.AGENT_RELAY_DISABLE_CONTINUATION_ARCHIVE_FTS === "1") return;
|
|
32
|
+
try {
|
|
33
|
+
getDb().query(`
|
|
34
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS continuation_archive_segments
|
|
35
|
+
USING fts5(segment, content='continuation_archives', content_rowid='id')
|
|
36
|
+
`).run();
|
|
37
|
+
getDb().query(`
|
|
38
|
+
CREATE TRIGGER IF NOT EXISTS continuation_archives_ai AFTER INSERT ON continuation_archives BEGIN
|
|
39
|
+
INSERT INTO continuation_archive_segments(rowid, segment) VALUES (new.id, new.segment);
|
|
40
|
+
END
|
|
41
|
+
`).run();
|
|
42
|
+
getDb().query(`
|
|
43
|
+
CREATE TRIGGER IF NOT EXISTS continuation_archives_ad AFTER DELETE ON continuation_archives BEGIN
|
|
44
|
+
INSERT INTO continuation_archive_segments(continuation_archive_segments, rowid, segment)
|
|
45
|
+
VALUES('delete', old.id, old.segment);
|
|
46
|
+
END
|
|
47
|
+
`).run();
|
|
48
|
+
getDb().query(`
|
|
49
|
+
CREATE TRIGGER IF NOT EXISTS continuation_archives_au AFTER UPDATE ON continuation_archives BEGIN
|
|
50
|
+
INSERT INTO continuation_archive_segments(continuation_archive_segments, rowid, segment)
|
|
51
|
+
VALUES('delete', old.id, old.segment);
|
|
52
|
+
INSERT INTO continuation_archive_segments(rowid, segment) VALUES (new.id, new.segment);
|
|
53
|
+
END
|
|
54
|
+
`).run();
|
|
55
|
+
getDb().query("INSERT INTO continuation_archive_segments(continuation_archive_segments) VALUES('rebuild')").run();
|
|
56
|
+
} catch {
|
|
57
|
+
// FTS5 is expected in bun:sqlite, but recall stays available through LIKE when
|
|
58
|
+
// a custom SQLite build omits it.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function archiveContinuationSegment(input: {
|
|
63
|
+
agentId: string;
|
|
64
|
+
segment: string;
|
|
65
|
+
generation?: number;
|
|
66
|
+
now?: number;
|
|
67
|
+
}): ContinuationArchive {
|
|
68
|
+
const agentId = input.agentId.trim();
|
|
69
|
+
if (!agentId) throw new ValidationError("agentId required");
|
|
70
|
+
const segment = input.segment.trim();
|
|
71
|
+
if (!segment) throw new ValidationError("segment required");
|
|
72
|
+
if (new TextEncoder().encode(segment).byteLength > MAX_BODY_BYTES) throw new ValidationError(`segment exceeds ${MAX_BODY_BYTES} bytes`);
|
|
73
|
+
const generation = input.generation ?? nextContinuationArchiveGeneration(agentId);
|
|
74
|
+
if (!Number.isSafeInteger(generation) || generation < 0) throw new ValidationError("generation must be a non-negative integer");
|
|
75
|
+
const createdAt = input.now ?? Date.now();
|
|
76
|
+
const info = getDb().query(`
|
|
77
|
+
INSERT INTO continuation_archives (agent_id, generation, segment, created_at)
|
|
78
|
+
VALUES (?, ?, ?, ?)
|
|
79
|
+
`).run(agentId, generation, segment, createdAt);
|
|
80
|
+
return getContinuationArchive(Number(info.lastInsertRowid))!;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getContinuationArchive(id: number): ContinuationArchive | null {
|
|
84
|
+
const row = getDb().query("SELECT * FROM continuation_archives WHERE id = ?").get(id) as ContinuationArchiveRow | undefined;
|
|
85
|
+
return row ? rowToArchive(row) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function searchContinuationArchives(input: {
|
|
89
|
+
agentId: string;
|
|
90
|
+
query: string;
|
|
91
|
+
limit?: number;
|
|
92
|
+
}): ContinuationArchiveSearchResult[] {
|
|
93
|
+
const agentId = input.agentId.trim();
|
|
94
|
+
if (!agentId) throw new ValidationError("agentId required");
|
|
95
|
+
const query = input.query.trim();
|
|
96
|
+
if (!query) throw new ValidationError("query required");
|
|
97
|
+
const limit = clampLimit(input.limit);
|
|
98
|
+
const fts = ftsQuery(query);
|
|
99
|
+
if (continuationArchiveSearchMode() === "fts5" && fts) {
|
|
100
|
+
try {
|
|
101
|
+
const rows = getDb().query(`
|
|
102
|
+
SELECT a.*, bm25(continuation_archive_segments) AS score
|
|
103
|
+
FROM continuation_archive_segments
|
|
104
|
+
JOIN continuation_archives a ON a.id = continuation_archive_segments.rowid
|
|
105
|
+
WHERE continuation_archive_segments MATCH ? AND a.agent_id = ?
|
|
106
|
+
ORDER BY score ASC, a.created_at DESC, a.id DESC
|
|
107
|
+
LIMIT ?
|
|
108
|
+
`).all(fts, agentId, limit) as ContinuationArchiveRow[];
|
|
109
|
+
return rows.map((row) => ({ ...rowToArchive(row), score: row.score ?? null, searchMode: "fts5" as const }));
|
|
110
|
+
} catch {
|
|
111
|
+
// Bad MATCH syntax should not make recall fail; the LIKE fallback is less
|
|
112
|
+
// precise but deterministic.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return searchContinuationArchivesLike(agentId, query, limit);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function continuationArchiveSearchMode(): "fts5" | "like" {
|
|
119
|
+
if (process.env.AGENT_RELAY_DISABLE_CONTINUATION_ARCHIVE_FTS === "1") return "like";
|
|
120
|
+
const row = getDb().query(`
|
|
121
|
+
SELECT 1 FROM sqlite_master
|
|
122
|
+
WHERE type = 'table' AND name = 'continuation_archive_segments'
|
|
123
|
+
`).get();
|
|
124
|
+
return row ? "fts5" : "like";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function nextContinuationArchiveGeneration(agentId: string): number {
|
|
128
|
+
const row = getDb().query(`
|
|
129
|
+
SELECT max(generation) AS generation FROM continuation_archives WHERE agent_id = ?
|
|
130
|
+
`).get(agentId) as { generation: number | null } | undefined;
|
|
131
|
+
const archiveGeneration = typeof row?.generation === "number" ? row.generation : 0;
|
|
132
|
+
const envelopeGeneration = getContinuationEnvelope(agentId)?.generation ?? 0;
|
|
133
|
+
return Math.max(archiveGeneration, envelopeGeneration) + 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function searchContinuationArchivesLike(agentId: string, query: string, limit: number): ContinuationArchiveSearchResult[] {
|
|
137
|
+
const terms = likeTerms(query);
|
|
138
|
+
if (terms.length === 0) return [];
|
|
139
|
+
const clauses = terms.map(() => "lower(segment) LIKE ? ESCAPE '\\'").join(" AND ");
|
|
140
|
+
const rows = getDb().query(`
|
|
141
|
+
SELECT *, NULL AS score FROM continuation_archives
|
|
142
|
+
WHERE agent_id = ? AND ${clauses}
|
|
143
|
+
ORDER BY created_at DESC, id DESC
|
|
144
|
+
LIMIT ?
|
|
145
|
+
`).all(agentId, ...terms.map((term) => `%${escapeLike(term)}%`), limit) as ContinuationArchiveRow[];
|
|
146
|
+
return rows.map((row) => ({ ...rowToArchive(row), score: null, searchMode: "like" as const }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clampLimit(value: number | undefined): number {
|
|
150
|
+
if (value === undefined) return 5;
|
|
151
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new ValidationError("limit must be a positive integer");
|
|
152
|
+
return Math.min(value, MAX_RECALL_LIMIT);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function ftsQuery(query: string): string {
|
|
156
|
+
return likeTerms(query)
|
|
157
|
+
.slice(0, 16)
|
|
158
|
+
.map((term) => `"${term.replace(/"/g, '""')}"`)
|
|
159
|
+
.join(" ");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function likeTerms(query: string): string[] {
|
|
163
|
+
return [...new Set((query.toLowerCase().match(/[\p{L}\p{N}_-]+/gu) ?? []).filter(Boolean))];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function escapeLike(value: string): string {
|
|
167
|
+
return value.replace(/[\\%_]/g, (match) => `\\${match}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function rowToArchive(row: ContinuationArchiveRow): ContinuationArchive {
|
|
171
|
+
return {
|
|
172
|
+
id: row.id,
|
|
173
|
+
ref: String(row.id),
|
|
174
|
+
agentId: row.agent_id,
|
|
175
|
+
generation: row.generation,
|
|
176
|
+
segment: row.segment,
|
|
177
|
+
createdAt: row.created_at,
|
|
178
|
+
};
|
|
179
|
+
}
|