agent-relay-server 0.22.0 → 0.24.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.
@@ -1,5 +1,6 @@
1
1
  import type { AgentProfile, Message } from "agent-relay-sdk";
2
2
  import { isRecord } from "agent-relay-sdk";
3
+ import type { SessionEvent } from "./session-insights";
3
4
 
4
5
  export type SemanticStatus = "idle" | "busy" | "offline" | "error";
5
6
  type ProviderWorkKind = "provider-turn" | "subagent";
@@ -84,6 +85,9 @@ export interface RunnerSpawnConfig {
84
85
  providerConfig: ProviderConfig;
85
86
  env: Record<string, string>;
86
87
  controlPort: number;
88
+ // Stage 2 (#215): the MCP endpoint the agent connects to — the runner-local proxy URL when the
89
+ // proxy is active. Undefined → the adapter targets the relay's MCP endpoint directly (Stage 1).
90
+ relayMcpEndpoint?: string;
87
91
  monitor?: {
88
92
  deliver(messages: Message[]): Promise<number[]>;
89
93
  };
@@ -130,6 +134,15 @@ export interface ProviderAdapter {
130
134
  shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
131
135
  compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
132
136
  clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
137
+ // Normalize the session so far into the provider-agnostic SessionEvent stream the
138
+ // Insights context-ratio signal (#183/#184) reduces. Called by the runner's
139
+ // pre-session-destroy seam before any compact/clear/restart/shutdown. The runner owns
140
+ // the per-segment cursor (it slices events since the last capture), so this returns the
141
+ // full ordered event list for the current process lifetime. `ctx.transcriptPath` is
142
+ // supplied for transcript-backed providers (Claude); event-stream providers (Codex)
143
+ // ignore it and return their accumulated log. Return null when there is nothing to
144
+ // measure. Best-effort: may be omitted by providers without a session view yet.
145
+ collectSessionEvents?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null>;
133
146
  // Interrupt the in-flight turn without ending the session (ESC for Claude's
134
147
  // tmux pane, turn/interrupt for the Codex app-server). Provider-independent at
135
148
  // the runner boundary; each adapter does what its provider actually supports.
package/src/connectors.ts CHANGED
@@ -243,10 +243,46 @@ async function refreshConnectorStatus(id: string): Promise<void> {
243
243
  }
244
244
 
245
245
  export function startConnectorStatusPoller(): void {
246
- void refreshAllConnectorStatuses();
246
+ // Relaunch connectors that were running before this process restarted (e.g. a
247
+ // deploy that took down the relay/orchestrator tree) BEFORE the first status
248
+ // poll overwrites their persisted "running" flag with the now-dead reality.
249
+ void reconcileConnectorsOnBoot().finally(() => void refreshAllConnectorStatuses());
247
250
  setInterval(() => void refreshAllConnectorStatuses(), STATUS_POLL_INTERVAL_MS);
248
251
  }
249
252
 
253
+ // On boot, bring back any connector whose last persisted state says it was
254
+ // running. `start` is idempotent — a daemon that survived the restart reports
255
+ // "already running"; one that died with the process tree is relaunched. A
256
+ // connector the operator deliberately stopped has running:false persisted, so
257
+ // it stays down. Best-effort: failures are swallowed; the poll reflects truth.
258
+ export async function reconcileConnectorsOnBoot(): Promise<void> {
259
+ for (const connector of listConnectors()) {
260
+ const wasRunning = connector.state?.running === true;
261
+ const canStart = Boolean(connector.manifest.commands.start?.length);
262
+ if (!wasRunning || !canStart) continue;
263
+ try {
264
+ await runConnectorAction(connector.id, "start");
265
+ } catch { /* boot relaunch is best-effort; status poll will surface failures */ }
266
+ }
267
+ }
268
+
269
+ // Flip a connector to running:false when the relay proves its advertised
270
+ // endpoint is unreachable (e.g. the daemon died but state.json still claims
271
+ // running:true). Stops the dashboard from lying until the 60s status poll runs.
272
+ export function markConnectorUnreachable(id: string, detail: string): void {
273
+ const path = join(connectorDir(id), "state.json");
274
+ const current = readRecordFile(path);
275
+ if (!current || current.running === false) return;
276
+ const next = {
277
+ ...current,
278
+ status: "error",
279
+ detail,
280
+ running: false,
281
+ updatedAt: new Date().toISOString(),
282
+ };
283
+ writeFileSync(path, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
284
+ }
285
+
250
286
  async function refreshAllConnectorStatuses(): Promise<void> {
251
287
  for (const connector of listConnectors()) {
252
288
  try {
@@ -268,6 +268,7 @@ export class LifecycleManager {
268
268
  graceful,
269
269
  timeoutMs: 10_000,
270
270
  reason,
271
+ orchestratorId: orch.id,
271
272
  requestedBy: "lifecycle-manager",
272
273
  requestedAt: this.now(),
273
274
  },
@@ -32,6 +32,7 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
32
32
  const grant = spawnGrantForProfile(policy.profile);
33
33
  return buildSpawnCommand({
34
34
  provider: policy.provider,
35
+ orchestratorId: policy.orchestratorId,
35
36
  cwd: policy.cwd,
36
37
  workspaceMode: effectiveManagedPolicyWorkspaceMode(policy),
37
38
  rig: policy.rig || undefined,
package/src/mcp.ts CHANGED
@@ -515,11 +515,14 @@ function senderIdentity(auth: McpAuthContext): string | undefined {
515
515
  return agents?.length === 1 ? agents[0] : undefined;
516
516
  }
517
517
 
518
- // Caller's own agent id for spawn/shutdown gating (#221). `senderIdentity` covers
518
+ // THE caller-identity resolver: the agent id behind this token, for `from`-autofill,
519
+ // relay_whoami, and spawn/shutdown gating (#221, #243). `senderIdentity` covers
519
520
  // identity-bearing tokens (interactive/mcp, constraints.agents). Managed agents spawned by
520
521
  // the orchestrator authenticate with a runner token that carries no `agents` constraint but
521
- // DOES carry its spawnRequestId/policy — resolve those back to the registered agent card.
522
- // Returns undefined for server/admin tokens (unrestricted by design).
522
+ // DOES carry its spawnRequestId/policy — resolve those back to the registered agent card so
523
+ // they never need to pass `from`. Returns undefined for server/admin tokens (unrestricted by
524
+ // design) and multi-agent tokens. Keep `resolveSender`/`relayWhoami` on THIS, not the narrower
525
+ // `senderIdentity`, or managed agents silently lose implicit identity again (the #243 drift).
523
526
  function callerAgentId(auth: McpAuthContext): string | undefined {
524
527
  const direct = senderIdentity(auth);
525
528
  if (direct) return direct;
@@ -536,14 +539,15 @@ function callerAgentId(auth: McpAuthContext): string | undefined {
536
539
 
537
540
  function resolveSender(auth: McpAuthContext, rawFrom: unknown): string {
538
541
  // Token identity wins and cannot be spoofed; any provided `from` is ignored when known.
539
- const identity = senderIdentity(auth);
542
+ // Resolves both constraints.agents tokens AND spawn/policy-managed agents (#243).
543
+ const identity = callerAgentId(auth);
540
544
  if (identity) return identity;
541
545
  // Server/integration/multi-agent tokens carry no single identity — keep requiring `from`.
542
546
  return stringField(rawFrom, "from", { required: true, max: 200 });
543
547
  }
544
548
 
545
549
  function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
546
- const agentId = senderIdentity(auth);
550
+ const agentId = callerAgentId(auth);
547
551
  const agent = agentId ? getAgent(agentId) : null;
548
552
  return {
549
553
  agentId: agentId ?? null,
@@ -0,0 +1,161 @@
1
+ import type {
2
+ ActiveMemoryClearReason,
3
+ ContextPackage,
4
+ ContextPackageRequest,
5
+ CreateMemoryInput,
6
+ Memory,
7
+ MemoryBrokerCapabilities,
8
+ MemoryBrokerContext,
9
+ MemoryQuery,
10
+ MemorySearchResult,
11
+ MemoryStats,
12
+ UpdateMemoryInput,
13
+ } from "./types";
14
+ import type { MemoryBroker } from "./memory-broker-contract";
15
+ import {
16
+ MemoryBrokerContractError,
17
+ normalizeMemory,
18
+ normalizeMemoryBrokerCapabilities,
19
+ normalizeMemorySearchResult,
20
+ } from "./memory-broker-contract";
21
+ import { isRecord } from "agent-relay-sdk";
22
+
23
+ export const DEFAULT_MEMORY_BROKER_TIMEOUT_MS = 10_000;
24
+
25
+ /**
26
+ * Shared scaffolding for transport-backed memory brokers. The command and HTTP
27
+ * brokers differ only in how a single request is dispatched — everything else
28
+ * (operation methods, response normalization, error/result unwrapping) is
29
+ * identical. Subclasses implement {@link call} and supply a {@link label} used
30
+ * in error messages.
31
+ */
32
+ export abstract class AbstractMemoryBroker implements MemoryBroker {
33
+ /** Human-readable transport name, e.g. "command" or "http". */
34
+ protected abstract readonly label: string;
35
+
36
+ /** Dispatch a single broker operation and return its raw (un-unwrapped) result. */
37
+ protected abstract call(operation: string, payload: Record<string, unknown>): Promise<unknown>;
38
+
39
+ async capabilities(): Promise<MemoryBrokerCapabilities> {
40
+ return normalizeMemoryBrokerCapabilities(await this.call("capabilities", {}));
41
+ }
42
+
43
+ async create(input: CreateMemoryInput, ctx: MemoryBrokerContext): Promise<Memory> {
44
+ return normalizeMemory(await this.call("create", { input, ctx }), { now: ctx.now });
45
+ }
46
+
47
+ async get(id: string, ctx: MemoryBrokerContext): Promise<Memory | null> {
48
+ const value = await this.call("get", { id, ctx });
49
+ return value === null ? null : normalizeMemory(value, { now: ctx.now });
50
+ }
51
+
52
+ async search(query: MemoryQuery, ctx: MemoryBrokerContext): Promise<MemorySearchResult> {
53
+ return normalizeMemorySearchResult(await this.call("search", { query, ctx }), { now: ctx.now });
54
+ }
55
+
56
+ async update(id: string, patch: UpdateMemoryInput, ctx: MemoryBrokerContext): Promise<Memory> {
57
+ return normalizeMemory(await this.call("update", { id, patch, ctx }), { now: ctx.now });
58
+ }
59
+
60
+ async delete(id: string, ctx: MemoryBrokerContext): Promise<void> {
61
+ await this.call("delete", { id, ctx });
62
+ }
63
+
64
+ async stats(ctx: MemoryBrokerContext): Promise<MemoryStats> {
65
+ return normalizeStats(await this.call("stats", { ctx }));
66
+ }
67
+
68
+ async assemble(request: ContextPackageRequest, ctx: MemoryBrokerContext): Promise<ContextPackage> {
69
+ return normalizeContextPackage(await this.call("assemble", { request, ctx }), ctx);
70
+ }
71
+
72
+ async markInjected(agentId: string, memoryIds: string[], ctx: MemoryBrokerContext): Promise<void> {
73
+ await this.call("mark-injected", { agentId, memoryIds, ctx });
74
+ }
75
+
76
+ async clearActive(agentId: string, reason: ActiveMemoryClearReason, ctx: MemoryBrokerContext): Promise<void> {
77
+ await this.call("clear-active", { agentId, reason, ctx });
78
+ }
79
+
80
+ async listActive(agentId: string, ctx: MemoryBrokerContext): Promise<Memory[]> {
81
+ return normalizeMemoryList(await this.call("list-active", { agentId, ctx }), ctx);
82
+ }
83
+
84
+ /** Parse a broker response body, treating empty output as null. */
85
+ protected parseJson(text: string, operation: string): unknown {
86
+ if (!text.trim()) return null;
87
+ try {
88
+ return JSON.parse(text);
89
+ } catch {
90
+ throw new MemoryBrokerContractError(`${this.label} memory broker ${operation} returned invalid JSON`);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Resolve a broker payload to its result. A `{ error }` field is treated as a
96
+ * failure (even on an otherwise-successful transport response), and a
97
+ * `{ result }` envelope is unwrapped; anything else is returned as-is.
98
+ */
99
+ protected unwrapResult(payload: unknown): unknown {
100
+ if (isRecord(payload) && Object.hasOwn(payload, "error") && typeof payload.error === "string") {
101
+ throw new MemoryBrokerContractError(`${this.label} memory broker failed: ${payload.error}`);
102
+ }
103
+ if (isRecord(payload) && Object.hasOwn(payload, "result")) return payload.result;
104
+ return payload;
105
+ }
106
+ }
107
+
108
+ export function normalizeContextPackage(value: unknown, ctx: MemoryBrokerContext): ContextPackage {
109
+ const record = requireRecord(value, "context package");
110
+ const rawMemories = record.memories;
111
+ if (!Array.isArray(rawMemories)) throw new MemoryBrokerContractError("context package.memories must be an array");
112
+ return {
113
+ memories: rawMemories.map((item) => {
114
+ const packaged = requireRecord(item, "packaged memory");
115
+ const priority = packaged.priority;
116
+ if (priority !== 1 && priority !== 2 && priority !== 3) throw new MemoryBrokerContractError("packaged memory.priority must be 1, 2, or 3");
117
+ return {
118
+ memory: normalizeMemory(packaged.memory, { now: ctx.now }),
119
+ reason: typeof packaged.reason === "string" ? packaged.reason : "broker selected",
120
+ priority,
121
+ score: typeof packaged.score === "number" && Number.isFinite(packaged.score) ? packaged.score : undefined,
122
+ };
123
+ }),
124
+ estimatedTokens: typeof record.estimatedTokens === "number" && Number.isFinite(record.estimatedTokens) ? record.estimatedTokens : 0,
125
+ rolePrompt: typeof record.rolePrompt === "string" ? record.rolePrompt : undefined,
126
+ recentContext: typeof record.recentContext === "string" ? record.recentContext : undefined,
127
+ };
128
+ }
129
+
130
+ function normalizeMemoryList(value: unknown, ctx: MemoryBrokerContext): Memory[] {
131
+ if (Array.isArray(value)) return value.map((item) => normalizeMemory(item, { now: ctx.now }));
132
+ if (isRecord(value) && Array.isArray(value.memories)) return value.memories.map((item) => normalizeMemory(item, { now: ctx.now }));
133
+ throw new MemoryBrokerContractError("memory list-active result must be an array or object with memories array");
134
+ }
135
+
136
+ function normalizeStats(value: unknown): MemoryStats {
137
+ const record = requireRecord(value, "memory stats");
138
+ return {
139
+ total: numberField(record.total, "memory stats.total"),
140
+ byType: objectField(record.byType, "memory stats.byType"),
141
+ byScope: objectField(record.byScope, "memory stats.byScope"),
142
+ bySensitivity: objectField(record.bySensitivity, "memory stats.bySensitivity"),
143
+ } as MemoryStats;
144
+ }
145
+
146
+ function objectField(value: unknown, field: string): Record<string, number> {
147
+ const record = requireRecord(value, field);
148
+ const out: Record<string, number> = {};
149
+ for (const [key, count] of Object.entries(record)) out[key] = numberField(count, `${field}.${key}`);
150
+ return out;
151
+ }
152
+
153
+ function numberField(value: unknown, field: string): number {
154
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new MemoryBrokerContractError(`${field} must be a number`);
155
+ return value;
156
+ }
157
+
158
+ function requireRecord(value: unknown, field: string): Record<string, unknown> {
159
+ if (!isRecord(value)) throw new MemoryBrokerContractError(`${field} must be an object`);
160
+ return value;
161
+ }
@@ -1,82 +1,18 @@
1
- import type {
2
- ActiveMemoryClearReason,
3
- CommandMemoryBrokerConfig,
4
- ContextPackage,
5
- ContextPackageRequest,
6
- CreateMemoryInput,
7
- Memory,
8
- MemoryBrokerCapabilities,
9
- MemoryBrokerContext,
10
- MemoryQuery,
11
- MemorySearchResult,
12
- MemoryStats,
13
- UpdateMemoryInput,
14
- } from "./types";
15
- import type { MemoryBroker } from "./memory-broker-contract";
16
- import {
17
- MemoryBrokerContractError,
18
- normalizeMemory,
19
- normalizeMemoryBrokerCapabilities,
20
- normalizeMemorySearchResult,
21
- } from "./memory-broker-contract";
22
- import { normalizeContextPackage } from "./memory-http-broker";
23
- import { errMessage, isRecord } from "agent-relay-sdk";
1
+ import type { CommandMemoryBrokerConfig } from "./types";
2
+ import { MemoryBrokerContractError } from "./memory-broker-contract";
3
+ import { AbstractMemoryBroker, DEFAULT_MEMORY_BROKER_TIMEOUT_MS } from "./memory-broker-base";
4
+ import { errMessage } from "agent-relay-sdk";
24
5
 
25
- const DEFAULT_TIMEOUT_MS = 10_000;
26
-
27
- export class CommandMemoryBroker implements MemoryBroker {
6
+ export class CommandMemoryBroker extends AbstractMemoryBroker {
7
+ protected readonly label = "command";
28
8
  private readonly timeoutMs: number;
29
9
 
30
10
  constructor(private readonly config: CommandMemoryBrokerConfig) {
31
- this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
32
- }
33
-
34
- async capabilities(): Promise<MemoryBrokerCapabilities> {
35
- return normalizeMemoryBrokerCapabilities(await this.call("capabilities", {}));
36
- }
37
-
38
- async create(input: CreateMemoryInput, ctx: MemoryBrokerContext): Promise<Memory> {
39
- return normalizeMemory(await this.call("create", { input, ctx }), { now: ctx.now });
40
- }
41
-
42
- async get(id: string, ctx: MemoryBrokerContext): Promise<Memory | null> {
43
- const value = await this.call("get", { id, ctx });
44
- return value === null ? null : normalizeMemory(value, { now: ctx.now });
45
- }
46
-
47
- async search(query: MemoryQuery, ctx: MemoryBrokerContext): Promise<MemorySearchResult> {
48
- return normalizeMemorySearchResult(await this.call("search", { query, ctx }), { now: ctx.now });
49
- }
50
-
51
- async update(id: string, patch: UpdateMemoryInput, ctx: MemoryBrokerContext): Promise<Memory> {
52
- return normalizeMemory(await this.call("update", { id, patch, ctx }), { now: ctx.now });
53
- }
54
-
55
- async delete(id: string, ctx: MemoryBrokerContext): Promise<void> {
56
- await this.call("delete", { id, ctx });
57
- }
58
-
59
- async stats(ctx: MemoryBrokerContext): Promise<MemoryStats> {
60
- return normalizeStats(await this.call("stats", { ctx }));
61
- }
62
-
63
- async assemble(request: ContextPackageRequest, ctx: MemoryBrokerContext): Promise<ContextPackage> {
64
- return normalizeContextPackage(await this.call("assemble", { request, ctx }), ctx);
65
- }
66
-
67
- async markInjected(agentId: string, memoryIds: string[], ctx: MemoryBrokerContext): Promise<void> {
68
- await this.call("mark-injected", { agentId, memoryIds, ctx });
11
+ super();
12
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_MEMORY_BROKER_TIMEOUT_MS;
69
13
  }
70
14
 
71
- async clearActive(agentId: string, reason: ActiveMemoryClearReason, ctx: MemoryBrokerContext): Promise<void> {
72
- await this.call("clear-active", { agentId, reason, ctx });
73
- }
74
-
75
- async listActive(agentId: string, ctx: MemoryBrokerContext): Promise<Memory[]> {
76
- return normalizeMemoryList(await this.call("list-active", { agentId, ctx }), ctx);
77
- }
78
-
79
- private async call(operation: string, payload: Record<string, unknown>): Promise<unknown> {
15
+ protected async call(operation: string, payload: Record<string, unknown>): Promise<unknown> {
80
16
  const proc = Bun.spawn([this.config.command, ...(this.config.args ?? [])], {
81
17
  stdin: "pipe",
82
18
  stdout: "pipe",
@@ -102,7 +38,7 @@ export class CommandMemoryBroker implements MemoryBroker {
102
38
  if (exitCode !== 0) {
103
39
  throw new MemoryBrokerContractError(`command memory broker ${operation} failed: exit ${exitCode}${stderr.trim() ? ` ${stderr.trim()}` : ""}`);
104
40
  }
105
- return unwrapResult(parseJson(stdout, operation));
41
+ return this.unwrapResult(this.parseJson(stdout, operation));
106
42
  } catch (error) {
107
43
  if (error instanceof MemoryBrokerContractError) throw error;
108
44
  throw new MemoryBrokerContractError(`command memory broker ${operation} failed: ${errMessage(error)}`);
@@ -111,48 +47,3 @@ export class CommandMemoryBroker implements MemoryBroker {
111
47
  }
112
48
  }
113
49
  }
114
-
115
- function parseJson(text: string, operation: string): unknown {
116
- if (!text.trim()) return null;
117
- try {
118
- return JSON.parse(text);
119
- } catch {
120
- throw new MemoryBrokerContractError(`command memory broker ${operation} returned invalid JSON`);
121
- }
122
- }
123
-
124
- function unwrapResult(payload: unknown): unknown {
125
- if (isRecord(payload) && Object.hasOwn(payload, "error") && typeof payload.error === "string") {
126
- throw new MemoryBrokerContractError(`command memory broker failed: ${payload.error}`);
127
- }
128
- if (isRecord(payload) && Object.hasOwn(payload, "result")) return payload.result;
129
- return payload;
130
- }
131
-
132
- function normalizeMemoryList(value: unknown, ctx: MemoryBrokerContext): Memory[] {
133
- if (Array.isArray(value)) return value.map((item) => normalizeMemory(item, { now: ctx.now }));
134
- if (isRecord(value) && Array.isArray(value.memories)) return value.memories.map((item) => normalizeMemory(item, { now: ctx.now }));
135
- throw new MemoryBrokerContractError("memory list-active result must be an array or object with memories array");
136
- }
137
-
138
- function normalizeStats(value: unknown): MemoryStats {
139
- if (!isRecord(value)) throw new MemoryBrokerContractError("memory stats must be an object");
140
- return {
141
- total: numberField(value.total, "memory stats.total"),
142
- byType: objectField(value.byType, "memory stats.byType"),
143
- byScope: objectField(value.byScope, "memory stats.byScope"),
144
- bySensitivity: objectField(value.bySensitivity, "memory stats.bySensitivity"),
145
- } as MemoryStats;
146
- }
147
-
148
- function objectField(value: unknown, field: string): Record<string, number> {
149
- if (!isRecord(value)) throw new MemoryBrokerContractError(`${field} must be an object`);
150
- const out: Record<string, number> = {};
151
- for (const [key, count] of Object.entries(value)) out[key] = numberField(count, `${field}.${key}`);
152
- return out;
153
- }
154
-
155
- function numberField(value: unknown, field: string): number {
156
- if (typeof value !== "number" || !Number.isFinite(value)) throw new MemoryBrokerContractError(`${field} must be a number`);
157
- return value;
158
- }
@@ -1,83 +1,22 @@
1
- import type {
2
- ActiveMemoryClearReason,
3
- ContextPackage,
4
- ContextPackageRequest,
5
- CreateMemoryInput,
6
- HttpMemoryBrokerConfig,
7
- Memory,
8
- MemoryBrokerCapabilities,
9
- MemoryBrokerContext,
10
- MemoryQuery,
11
- MemorySearchResult,
12
- MemoryStats,
13
- UpdateMemoryInput,
14
- } from "./types";
15
- import type { MemoryBroker } from "./memory-broker-contract";
16
- import {
17
- MemoryBrokerContractError,
18
- normalizeMemory,
19
- normalizeMemoryBrokerCapabilities,
20
- normalizeMemorySearchResult,
21
- } from "./memory-broker-contract";
1
+ import type { HttpMemoryBrokerConfig } from "./types";
2
+ import { MemoryBrokerContractError } from "./memory-broker-contract";
3
+ import { AbstractMemoryBroker, DEFAULT_MEMORY_BROKER_TIMEOUT_MS } from "./memory-broker-base";
22
4
  import { errMessage, isRecord } from "agent-relay-sdk";
23
5
 
24
- const DEFAULT_TIMEOUT_MS = 10_000;
6
+ export { normalizeContextPackage } from "./memory-broker-base";
25
7
 
26
- export class HttpMemoryBroker implements MemoryBroker {
8
+ export class HttpMemoryBroker extends AbstractMemoryBroker {
9
+ protected readonly label = "http";
27
10
  private readonly baseUrl: string;
28
11
  private readonly timeoutMs: number;
29
12
 
30
13
  constructor(private readonly config: HttpMemoryBrokerConfig) {
14
+ super();
31
15
  this.baseUrl = config.url.replace(/\/+$/, "");
32
- this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
16
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_MEMORY_BROKER_TIMEOUT_MS;
33
17
  }
34
18
 
35
- async capabilities(): Promise<MemoryBrokerCapabilities> {
36
- return normalizeMemoryBrokerCapabilities(await this.call("capabilities", {}));
37
- }
38
-
39
- async create(input: CreateMemoryInput, ctx: MemoryBrokerContext): Promise<Memory> {
40
- return normalizeMemory(await this.call("create", { input, ctx }), { now: ctx.now });
41
- }
42
-
43
- async get(id: string, ctx: MemoryBrokerContext): Promise<Memory | null> {
44
- const value = await this.call("get", { id, ctx });
45
- return value === null ? null : normalizeMemory(value, { now: ctx.now });
46
- }
47
-
48
- async search(query: MemoryQuery, ctx: MemoryBrokerContext): Promise<MemorySearchResult> {
49
- return normalizeMemorySearchResult(await this.call("search", { query, ctx }), { now: ctx.now });
50
- }
51
-
52
- async update(id: string, patch: UpdateMemoryInput, ctx: MemoryBrokerContext): Promise<Memory> {
53
- return normalizeMemory(await this.call("update", { id, patch, ctx }), { now: ctx.now });
54
- }
55
-
56
- async delete(id: string, ctx: MemoryBrokerContext): Promise<void> {
57
- await this.call("delete", { id, ctx });
58
- }
59
-
60
- async stats(ctx: MemoryBrokerContext): Promise<MemoryStats> {
61
- return normalizeStats(await this.call("stats", { ctx }));
62
- }
63
-
64
- async assemble(request: ContextPackageRequest, ctx: MemoryBrokerContext): Promise<ContextPackage> {
65
- return normalizeContextPackage(await this.call("assemble", { request, ctx }), ctx);
66
- }
67
-
68
- async markInjected(agentId: string, memoryIds: string[], ctx: MemoryBrokerContext): Promise<void> {
69
- await this.call("mark-injected", { agentId, memoryIds, ctx });
70
- }
71
-
72
- async clearActive(agentId: string, reason: ActiveMemoryClearReason, ctx: MemoryBrokerContext): Promise<void> {
73
- await this.call("clear-active", { agentId, reason, ctx });
74
- }
75
-
76
- async listActive(agentId: string, ctx: MemoryBrokerContext): Promise<Memory[]> {
77
- return normalizeMemoryList(await this.call("list-active", { agentId, ctx }), ctx);
78
- }
79
-
80
- private async call(operation: string, body: Record<string, unknown>): Promise<unknown> {
19
+ protected async call(operation: string, body: Record<string, unknown>): Promise<unknown> {
81
20
  const controller = new AbortController();
82
21
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
83
22
  try {
@@ -88,12 +27,12 @@ export class HttpMemoryBroker implements MemoryBroker {
88
27
  signal: controller.signal,
89
28
  });
90
29
  const text = await res.text();
91
- const payload = parseJson(text, operation);
30
+ const payload = this.parseJson(text, operation);
92
31
  if (!res.ok) {
93
32
  const detail = isRecord(payload) && typeof payload.error === "string" ? payload.error : text.trim();
94
33
  throw new MemoryBrokerContractError(`http memory broker ${operation} failed: ${res.status}${detail ? ` ${detail}` : ""}`);
95
34
  }
96
- return unwrapResult(payload);
35
+ return this.unwrapResult(payload);
97
36
  } catch (error) {
98
37
  if (error instanceof MemoryBrokerContractError) throw error;
99
38
  if (error instanceof DOMException && error.name === "AbortError") {
@@ -114,72 +53,3 @@ export class HttpMemoryBroker implements MemoryBroker {
114
53
  return headers;
115
54
  }
116
55
  }
117
-
118
- function parseJson(text: string, operation: string): unknown {
119
- if (!text.trim()) return null;
120
- try {
121
- return JSON.parse(text);
122
- } catch {
123
- throw new MemoryBrokerContractError(`http memory broker ${operation} returned invalid JSON`);
124
- }
125
- }
126
-
127
- function unwrapResult(payload: unknown): unknown {
128
- if (isRecord(payload) && Object.hasOwn(payload, "result")) return payload.result;
129
- return payload;
130
- }
131
-
132
- export function normalizeContextPackage(value: unknown, ctx: MemoryBrokerContext): ContextPackage {
133
- const record = requireRecord(value, "context package");
134
- const rawMemories = record.memories;
135
- if (!Array.isArray(rawMemories)) throw new MemoryBrokerContractError("context package.memories must be an array");
136
- return {
137
- memories: rawMemories.map((item) => {
138
- const packaged = requireRecord(item, "packaged memory");
139
- const priority = packaged.priority;
140
- if (priority !== 1 && priority !== 2 && priority !== 3) throw new MemoryBrokerContractError("packaged memory.priority must be 1, 2, or 3");
141
- return {
142
- memory: normalizeMemory(packaged.memory, { now: ctx.now }),
143
- reason: typeof packaged.reason === "string" ? packaged.reason : "broker selected",
144
- priority,
145
- score: typeof packaged.score === "number" && Number.isFinite(packaged.score) ? packaged.score : undefined,
146
- };
147
- }),
148
- estimatedTokens: typeof record.estimatedTokens === "number" && Number.isFinite(record.estimatedTokens) ? record.estimatedTokens : 0,
149
- rolePrompt: typeof record.rolePrompt === "string" ? record.rolePrompt : undefined,
150
- recentContext: typeof record.recentContext === "string" ? record.recentContext : undefined,
151
- };
152
- }
153
-
154
- function normalizeMemoryList(value: unknown, ctx: MemoryBrokerContext): Memory[] {
155
- if (Array.isArray(value)) return value.map((item) => normalizeMemory(item, { now: ctx.now }));
156
- if (isRecord(value) && Array.isArray(value.memories)) return value.memories.map((item) => normalizeMemory(item, { now: ctx.now }));
157
- throw new MemoryBrokerContractError("memory list-active result must be an array or object with memories array");
158
- }
159
-
160
- function normalizeStats(value: unknown): MemoryStats {
161
- const record = requireRecord(value, "memory stats");
162
- return {
163
- total: numberField(record.total, "memory stats.total"),
164
- byType: objectField(record.byType, "memory stats.byType"),
165
- byScope: objectField(record.byScope, "memory stats.byScope"),
166
- bySensitivity: objectField(record.bySensitivity, "memory stats.bySensitivity"),
167
- } as MemoryStats;
168
- }
169
-
170
- function objectField(value: unknown, field: string): Record<string, number> {
171
- const record = requireRecord(value, field);
172
- const out: Record<string, number> = {};
173
- for (const [key, count] of Object.entries(record)) out[key] = numberField(count, `${field}.${key}`);
174
- return out;
175
- }
176
-
177
- function numberField(value: unknown, field: string): number {
178
- if (typeof value !== "number" || !Number.isFinite(value)) throw new MemoryBrokerContractError(`${field} must be a number`);
179
- return value;
180
- }
181
-
182
- function requireRecord(value: unknown, field: string): Record<string, unknown> {
183
- if (!isRecord(value)) throw new MemoryBrokerContractError(`${field} must be an object`);
184
- return value;
185
- }