agent-relay-server 0.23.0 → 0.25.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/package.json +2 -2
- package/public/index.html +84 -63
- package/runner/src/adapter.ts +10 -0
- package/src/branch-landed.ts +77 -0
- package/src/config-store.ts +31 -0
- package/src/connectors.ts +37 -1
- package/src/lifecycle-manager.ts +1 -0
- package/src/maintenance.ts +16 -20
- package/src/managed-policy.ts +1 -0
- package/src/mcp.ts +9 -5
- package/src/memory-broker-base.ts +161 -0
- package/src/memory-command-broker.ts +10 -119
- package/src/memory-http-broker.ts +11 -141
- package/src/notify.ts +31 -0
- package/src/routes.ts +26 -9
- package/src/workspace-phase.ts +51 -3
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
6
|
+
export { normalizeContextPackage } from "./memory-broker-base";
|
|
25
7
|
|
|
26
|
-
export class HttpMemoryBroker
|
|
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 ??
|
|
16
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_MEMORY_BROKER_TIMEOUT_MS;
|
|
33
17
|
}
|
|
34
18
|
|
|
35
|
-
async
|
|
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
|
-
}
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sendMessage } from "./db";
|
|
2
|
+
import { emitNewMessage } from "./sse";
|
|
3
|
+
import type { Message, MessageKind } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface SystemNotifyOptions {
|
|
6
|
+
subject?: string;
|
|
7
|
+
body: string;
|
|
8
|
+
payload?: Record<string, unknown>;
|
|
9
|
+
/** Defaults to "system" — a bypass-targeting kind that wakes the recipient like a prompt. */
|
|
10
|
+
kind?: MessageKind;
|
|
11
|
+
/** Sender id; defaults to "system". */
|
|
12
|
+
from?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Post a system DM to one agent and fan it out over the bus. This is the one home for
|
|
17
|
+
* "relay tells an agent something happened" — store-ahead delivers it on next poll if the
|
|
18
|
+
* recipient is offline (#234). Used by the GC sweep (maintenance) and lifecycle events (#239).
|
|
19
|
+
*/
|
|
20
|
+
export function notifySystemMessage(to: string, opts: SystemNotifyOptions): Message {
|
|
21
|
+
const msg = sendMessage({
|
|
22
|
+
from: opts.from ?? "system",
|
|
23
|
+
to,
|
|
24
|
+
kind: opts.kind ?? "system",
|
|
25
|
+
subject: opts.subject,
|
|
26
|
+
body: opts.body,
|
|
27
|
+
payload: opts.payload,
|
|
28
|
+
});
|
|
29
|
+
emitNewMessage(msg);
|
|
30
|
+
return msg;
|
|
31
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -139,6 +139,7 @@ import { createToken, deleteTokenProfile, getToken, getTokenProfile, listTokenPr
|
|
|
139
139
|
import {
|
|
140
140
|
getConnector,
|
|
141
141
|
listConnectors,
|
|
142
|
+
markConnectorUnreachable,
|
|
142
143
|
readConnectorConfig,
|
|
143
144
|
registerConnectorManifest,
|
|
144
145
|
runConnectorAction,
|
|
@@ -176,6 +177,7 @@ import {
|
|
|
176
177
|
WORKSPACE_ACTIONS,
|
|
177
178
|
} from "./workspace-actions";
|
|
178
179
|
import { describeWorkspacePhase, landReceipt, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
180
|
+
import { notifyBranchLanded } from "./branch-landed";
|
|
179
181
|
import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
|
|
180
182
|
import {
|
|
181
183
|
getComponentAuth,
|
|
@@ -2197,7 +2199,7 @@ function restartSpawnParamsForAgent(
|
|
|
2197
2199
|
const policy = policyName ? getSpawnPolicy(policyName) : null;
|
|
2198
2200
|
const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
|
|
2199
2201
|
if (policy) {
|
|
2200
|
-
const params = { ...
|
|
2202
|
+
const params = { ...buildManagedSpawnParams(policy.value, requestId, { createdBy: "managed-agent" }), agentId: agent.id, requestedBy };
|
|
2201
2203
|
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
2202
2204
|
}
|
|
2203
2205
|
|
|
@@ -4477,6 +4479,9 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4477
4479
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
4478
4480
|
const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
4479
4481
|
if (workspaceId && resultStatus) {
|
|
4482
|
+
// Snapshot the row BEFORE the recycle repoints `branch` (#206) — the landed
|
|
4483
|
+
// branch name + author (#239 branch.landed push) come from this pre-mutation state.
|
|
4484
|
+
const landedWorkspace = getWorkspace(workspaceId);
|
|
4480
4485
|
updateWorkspaceStatus(workspaceId, resultStatus, {
|
|
4481
4486
|
mergeResult: command.result,
|
|
4482
4487
|
mergeCommandId: command.id,
|
|
@@ -4490,10 +4495,20 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4490
4495
|
// Land-and-continue (#206): the worktree was recycled onto a fresh branch.
|
|
4491
4496
|
// Repoint the row so the next merge targets the live branch, not the deleted one.
|
|
4492
4497
|
const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
|
|
4498
|
+
const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
|
|
4493
4499
|
if (newBranch) {
|
|
4494
|
-
const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
|
|
4495
4500
|
setWorkspaceBranch(workspaceId, newBranch, mergedSha);
|
|
4496
4501
|
}
|
|
4502
|
+
// #239 — push the author a "your branch landed" notice (no polling). Only on a
|
|
4503
|
+
// real land; a no-op resolution (#230) merged nothing, so it earns no notice.
|
|
4504
|
+
if (command.result.merged === true && landedWorkspace) {
|
|
4505
|
+
notifyBranchLanded({
|
|
4506
|
+
workspace: landedWorkspace,
|
|
4507
|
+
mergedSha,
|
|
4508
|
+
subject: cleanString(command.result.subject, "result.subject", { max: 200 }),
|
|
4509
|
+
newBranch,
|
|
4510
|
+
});
|
|
4511
|
+
}
|
|
4497
4512
|
}
|
|
4498
4513
|
} else if (command.status === "failed" && command.correlationId) {
|
|
4499
4514
|
// Merge couldn't complete — don't leave it stuck in merge_planned.
|
|
@@ -4811,11 +4826,6 @@ function policyStatusPayload(policy: SpawnPolicy) {
|
|
|
4811
4826
|
};
|
|
4812
4827
|
}
|
|
4813
4828
|
|
|
4814
|
-
function managedSpawnParams(policy: SpawnPolicy, requestId: string): Record<string, unknown> {
|
|
4815
|
-
return buildManagedSpawnParams(policy, requestId, { createdBy: "managed-agent" });
|
|
4816
|
-
}
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
4829
|
function requirePolicyAndOrchestrator(name: string): { policy: SpawnPolicy; orch: NonNullable<ReturnType<typeof getOrchestrator>> } | Response {
|
|
4820
4830
|
const entry = getSpawnPolicy(name);
|
|
4821
4831
|
if (!entry) return error("spawn policy not found", 404);
|
|
@@ -4861,7 +4871,7 @@ function enqueuePolicyStart(policy: SpawnPolicy, reason: string): Command | Resp
|
|
|
4861
4871
|
target: orch.agentId,
|
|
4862
4872
|
correlationId: requestId,
|
|
4863
4873
|
params: {
|
|
4864
|
-
...
|
|
4874
|
+
...buildManagedSpawnParams(policy, requestId, { createdBy: "managed-agent" }),
|
|
4865
4875
|
reason,
|
|
4866
4876
|
orchestratorId: orch.id,
|
|
4867
4877
|
},
|
|
@@ -4875,7 +4885,7 @@ function enqueuePolicyStop(policy: SpawnPolicy, action: "shutdown" | "restart",
|
|
|
4875
4885
|
if (!orch) return error("orchestrator not found", 404);
|
|
4876
4886
|
const state = getManagedAgentState(policy.name);
|
|
4877
4887
|
const restartRequestId = action === "restart" ? spawnRequestId() : undefined;
|
|
4878
|
-
const restartSpawn = restartRequestId ?
|
|
4888
|
+
const restartSpawn = restartRequestId ? buildManagedSpawnParams(policy, restartRequestId, { createdBy: "managed-agent" }) : undefined;
|
|
4879
4889
|
const nextState = upsertManagedAgentState({
|
|
4880
4890
|
policyName: policy.name,
|
|
4881
4891
|
status: "stopping",
|
|
@@ -5784,6 +5794,13 @@ const postConnectorCall: Handler = async (req, params) => {
|
|
|
5784
5794
|
headers: { "content-type": res.headers.get("content-type") || "application/json" },
|
|
5785
5795
|
});
|
|
5786
5796
|
} catch (e) {
|
|
5797
|
+
// A connection error (refused/reset/DNS) means the advertised endpoint is
|
|
5798
|
+
// genuinely down — reconcile the stale running:true so the dashboard stops
|
|
5799
|
+
// claiming the connector is healthy. A timeout (AbortError) may just be a
|
|
5800
|
+
// slow daemon, so leave its state alone.
|
|
5801
|
+
if ((e as Error).name !== "AbortError") {
|
|
5802
|
+
markConnectorUnreachable(params.id!, `endpoint unreachable: ${(e as Error).message}`);
|
|
5803
|
+
}
|
|
5787
5804
|
return error(`failed to reach connector: ${(e as Error).message}`, 502);
|
|
5788
5805
|
}
|
|
5789
5806
|
};
|