agent-relay-server 0.17.0 → 0.19.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 +101 -1
- package/package.json +2 -2
- package/public/index.html +39 -32
- package/public/sw.js +51 -16
- package/runner/src/adapter.ts +1 -4
- package/runner/src/config.ts +3 -5
- package/scripts/orchestrator-spawn-smoke.ts +2 -1
- package/src/automations.ts +20 -59
- package/src/bus.ts +3 -18
- package/src/cli.ts +244 -7
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +12 -47
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/daemon.ts +1 -4
- package/src/db.ts +23 -17
- package/src/dev.ts +1 -4
- package/src/http-body.ts +49 -0
- package/src/index.ts +101 -5
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +28 -22
- package/src/managed-policy.ts +9 -28
- package/src/mcp.ts +35 -110
- package/src/memory-broker-smoke.ts +4 -2
- package/src/memory-command-broker.ts +2 -5
- package/src/memory-http-broker.ts +2 -5
- package/src/memory-service.ts +1 -4
- package/src/memory-sqlite-broker.ts +1 -8
- package/src/orchestrator-lookup.ts +29 -0
- package/src/provider-catalog-store.ts +3 -11
- package/src/recipe-loader.ts +1 -4
- package/src/recipe-validator.ts +2 -5
- package/src/routes.ts +417 -309
- package/src/security.ts +3 -7
- package/src/setup.ts +1 -4
- package/src/spawn-command.ts +151 -0
- package/src/sse.ts +1 -4
- package/src/steward.ts +17 -21
- package/src/upgrade.ts +40 -13
- package/src/utils.ts +38 -0
- package/src/validation.ts +80 -0
- package/src/workspace-claim.ts +29 -0
- package/src/workspace-merge.ts +21 -9
package/src/maintenance.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
} from "./db";
|
|
34
34
|
import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
35
35
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
36
|
+
import { workspaceActiveClaim } from "./workspace-claim";
|
|
37
|
+
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
36
38
|
import { getStewardConfig } from "./config-store";
|
|
37
39
|
import { ensureRepoSteward } from "./steward";
|
|
38
40
|
import { emitRelayEvent } from "./events";
|
|
@@ -392,7 +394,7 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
392
394
|
{
|
|
393
395
|
id: "workspace-auto-merge",
|
|
394
396
|
title: "Workspace auto-merge",
|
|
395
|
-
description: "Auto-merge
|
|
397
|
+
description: "Auto-merge any non-conflicting review_requested worktree into base under the per-repo lease (rebasing when the base moved on); only real or unknown conflicts are left for the steward.",
|
|
396
398
|
intervalMs: WORKSPACE_AUTO_MERGE_INTERVAL_MS,
|
|
397
399
|
runOnStart: false,
|
|
398
400
|
timeoutMs: 60 * 1000,
|
|
@@ -421,7 +423,7 @@ async function fetchHostMergePreview(apiUrl: string, workspace: WorkspaceRecord)
|
|
|
421
423
|
if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
|
|
422
424
|
const headers: Record<string, string> = {};
|
|
423
425
|
const token = process.env.AGENT_RELAY_TOKEN;
|
|
424
|
-
if (token) headers[
|
|
426
|
+
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
425
427
|
try {
|
|
426
428
|
const res = await fetch(`${apiUrl}/api/workspace/merge-preview?${query.toString()}`, { headers, signal: AbortSignal.timeout(8_000) });
|
|
427
429
|
if (!res.ok) return null;
|
|
@@ -535,7 +537,7 @@ function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
|
|
|
535
537
|
to: `policy:${policyName}`,
|
|
536
538
|
kind: "system",
|
|
537
539
|
subject: `Steward: ${ws.status} workspace needs attention`,
|
|
538
|
-
body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks,
|
|
540
|
+
body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
|
|
539
541
|
payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
|
|
540
542
|
});
|
|
541
543
|
emitNewMessage(msg);
|
|
@@ -655,14 +657,12 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
655
657
|
return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards };
|
|
656
658
|
}
|
|
657
659
|
|
|
658
|
-
// Deterministic auto-land (Layer 0, issue #167). Walk the "ready to land"
|
|
659
|
-
// (`review_requested` isolated worktrees) and
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
// agent): per the chosen "Clean FF immediate" gate, anything needing a rebase or
|
|
665
|
-
// conflict reasoning is not auto-landed. No agent in the loop for the easy case.
|
|
660
|
+
// Deterministic auto-land (Layer 0, issue #167 / #207). Walk the "ready to land"
|
|
661
|
+
// queue (`review_requested` isolated worktrees) and land any whose merge is
|
|
662
|
+
// predicted conflict-free, via the shared lease-serialized merge helper — even
|
|
663
|
+
// when the base moved on (behind>0): mergeRebaseFf rebases onto the current base
|
|
664
|
+
// before fast-forwarding. Only a predicted conflict or an unknown merge state is
|
|
665
|
+
// left for the steward; clean parallel work lands with no agent in the loop.
|
|
666
666
|
async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
667
667
|
if (process.env.AGENT_RELAY_WORKSPACE_AUTO_MERGE === "0") return { skipped: "disabled" };
|
|
668
668
|
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
|
|
@@ -674,10 +674,13 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
674
674
|
const stewardEnabled = getStewardConfig().enabled;
|
|
675
675
|
const merged: string[] = [];
|
|
676
676
|
const heldByLease: string[] = [];
|
|
677
|
+
const heldByClaim: string[] = [];
|
|
677
678
|
const leftForSteward: string[] = [];
|
|
678
679
|
const wokeStewards: string[] = [];
|
|
679
680
|
|
|
680
681
|
for (const ws of candidates) {
|
|
682
|
+
// A claimed workspace is being validated by a steward — don't race it (#208).
|
|
683
|
+
if (workspaceActiveClaim(ws)) { heldByClaim.push(ws.id); continue; }
|
|
681
684
|
const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
|
|
682
685
|
if (!orch?.apiUrl) continue;
|
|
683
686
|
const preview = await fetchHostMergePreview(orch.apiUrl, ws);
|
|
@@ -686,14 +689,17 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
686
689
|
if (p.error || p.missing) continue;
|
|
687
690
|
|
|
688
691
|
const ahead = p.unmergedAhead ?? p.ahead ?? 0;
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
692
|
+
const behind = p.behind ?? 0;
|
|
693
|
+
// Land anything that won't conflict — including a base that moved on (behind>0)
|
|
694
|
+
// but rebases cleanly. The merge-tree prediction already accounts for the moved
|
|
695
|
+
// base, and mergeRebaseFf rebases then aborts-to-conflict on a real replay
|
|
696
|
+
// conflict, so a too-optimistic prediction degrades safely to review_requested.
|
|
697
|
+
// Only a predicted conflict (true) or unknown state (undefined) goes to the steward.
|
|
698
|
+
const canLand = p.conflict === false && ahead > 0;
|
|
699
|
+
if (!canLand) {
|
|
694
700
|
leftForSteward.push(ws.id);
|
|
695
701
|
if (stewardEnabled) {
|
|
696
|
-
const woke = wakeRepoSteward(ws,
|
|
702
|
+
const woke = wakeRepoSteward(ws, p.conflict === true ? "conflict" : "merge state unknown");
|
|
697
703
|
if (woke) wokeStewards.push(woke);
|
|
698
704
|
}
|
|
699
705
|
continue;
|
|
@@ -710,8 +716,8 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
710
716
|
createActivityEvent({
|
|
711
717
|
clientId: `workspace-auto-merge-${ws.id}-${Date.now()}`,
|
|
712
718
|
kind: "state",
|
|
713
|
-
title: "Workspace auto-merging (clean fast-forward)",
|
|
714
|
-
body: `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead, clean)`,
|
|
719
|
+
title: behind > 0 ? "Workspace auto-merging (rebase)" : "Workspace auto-merging (clean fast-forward)",
|
|
720
|
+
body: `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead${behind > 0 ? `, ${behind} behind — rebasing` : ", clean"})`,
|
|
715
721
|
meta: ws.branch ?? ws.id,
|
|
716
722
|
icon: "ti-git-merge",
|
|
717
723
|
view: "orchestrators",
|
|
@@ -719,7 +725,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
719
725
|
});
|
|
720
726
|
}
|
|
721
727
|
|
|
722
|
-
return { scanned: candidates.length, merged, heldByLease, leftForSteward, wokeStewards };
|
|
728
|
+
return { scanned: candidates.length, merged, heldByLease, heldByClaim, leftForSteward, wokeStewards };
|
|
723
729
|
}
|
|
724
730
|
|
|
725
731
|
// Send a system DM, swallowing failures (a stale/missing/misconfigured target
|
|
@@ -925,7 +931,7 @@ async function runDueMaintenanceJobs(): Promise<void> {
|
|
|
925
931
|
for (const row of rows) {
|
|
926
932
|
const definition = definitions.find((job) => job.id === row.id);
|
|
927
933
|
if (definition) void runJob(definition).catch((error) => {
|
|
928
|
-
console.warn(`maintenance job ${definition.id} failed: ${
|
|
934
|
+
console.warn(`maintenance job ${definition.id} failed: ${errMessage(error)}`);
|
|
929
935
|
});
|
|
930
936
|
}
|
|
931
937
|
}
|
|
@@ -993,7 +999,7 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
|
|
|
993
999
|
} catch (error) {
|
|
994
1000
|
const finishedAt = Date.now();
|
|
995
1001
|
const durationMs = finishedAt - startedAt;
|
|
996
|
-
const message =
|
|
1002
|
+
const message = errMessage(error);
|
|
997
1003
|
db.query(`
|
|
998
1004
|
UPDATE maintenance_jobs
|
|
999
1005
|
SET last_run_at = ?, next_run_at = ?, last_duration_ms = ?, last_status = 'failed',
|
package/src/managed-policy.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getAgentProfile, workspaceSpawnParams } from "./config-store";
|
|
1
|
+
import { getAgentProfile } from "./config-store";
|
|
3
2
|
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
3
|
+
import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
|
|
4
4
|
import type { SpawnPolicy, WorkspaceMode } from "./types";
|
|
5
5
|
|
|
6
6
|
export function managedPolicyProviderArgs(policy: SpawnPolicy): string[] {
|
|
@@ -20,23 +20,6 @@ export function effectiveManagedPolicyWorkspaceMode(policy: SpawnPolicy): Worksp
|
|
|
20
20
|
return policy.binding?.type === "channel" ? "shared" : "inherit";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function resolvedModelParams(policy: SpawnPolicy): Record<string, string> {
|
|
24
|
-
if (!policy.model && !policy.effort) return {};
|
|
25
|
-
try {
|
|
26
|
-
const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
|
|
27
|
-
return {
|
|
28
|
-
...(selection.modelAlias ? { model: selection.modelAlias } : {}),
|
|
29
|
-
...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
|
|
30
|
-
...(selection.effort ? { effort: selection.effort } : {}),
|
|
31
|
-
};
|
|
32
|
-
} catch {
|
|
33
|
-
return {
|
|
34
|
-
...(policy.model ? { model: policy.model } : {}),
|
|
35
|
-
...(policy.effort ? { effort: policy.effort } : {}),
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
23
|
export interface ManagedSpawnContext {
|
|
41
24
|
createdBy: string;
|
|
42
25
|
requestedAt?: number;
|
|
@@ -46,23 +29,21 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
|
|
|
46
29
|
const providerArgs = managedPolicyProviderArgs(policy);
|
|
47
30
|
const prompt = managedPolicyLaunchPrompt(policy);
|
|
48
31
|
const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
|
|
49
|
-
return {
|
|
50
|
-
action: "spawn",
|
|
32
|
+
return buildSpawnCommand({
|
|
51
33
|
provider: policy.provider,
|
|
52
34
|
cwd: policy.cwd,
|
|
53
35
|
workspaceMode: effectiveManagedPolicyWorkspaceMode(policy),
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
...workspaceSpawnParams(),
|
|
36
|
+
rig: policy.rig || undefined,
|
|
37
|
+
modelParams: resolveSpawnModelParams(policy.provider, policy.model, policy.effort, { onError: "passthrough", skipDefaultWhenEmpty: true }),
|
|
38
|
+
profile: policy.profile || undefined,
|
|
39
|
+
agentProfile,
|
|
59
40
|
label: policy.label,
|
|
60
41
|
tags: policy.tags,
|
|
61
42
|
capabilities: policy.capabilities,
|
|
62
43
|
approvalMode: policy.permissionMode,
|
|
63
44
|
permissionMode: policy.permissionMode,
|
|
64
45
|
providerArgs,
|
|
65
|
-
|
|
46
|
+
prompt: prompt || undefined,
|
|
66
47
|
headless: true,
|
|
67
48
|
policyName: policy.name,
|
|
68
49
|
spawnRequestId: requestId,
|
|
@@ -77,5 +58,5 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
|
|
|
77
58
|
}),
|
|
78
59
|
requestedBy: ctx.createdBy,
|
|
79
60
|
requestedAt: ctx.requestedAt ?? Date.now(),
|
|
80
|
-
};
|
|
61
|
+
});
|
|
81
62
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
2
|
import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
|
|
5
3
|
import { createCommand } from "./commands-db";
|
|
4
|
+
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
|
|
5
|
+
import { isPathWithinBase } from "./utils";
|
|
6
|
+
import { optionalEnum } from "./validation";
|
|
7
|
+
import { listManagedOrchestratorsForAgent } from "./orchestrator-lookup";
|
|
8
|
+
import { bytesToStream, readBodyBytes } from "./http-body";
|
|
6
9
|
import { MAX_BODY_BYTES, VERSION } from "./config";
|
|
7
10
|
import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
|
|
8
11
|
import {
|
|
@@ -33,7 +36,8 @@ import {
|
|
|
33
36
|
isIntegrationAllowed,
|
|
34
37
|
} from "./security";
|
|
35
38
|
import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider } from "./types";
|
|
36
|
-
import {
|
|
39
|
+
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
40
|
+
import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
|
|
37
41
|
import { childRunnerRuntimeTokenEnv, runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
38
42
|
|
|
39
43
|
type JsonRpcId = string | number | null;
|
|
@@ -66,9 +70,7 @@ const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive",
|
|
|
66
70
|
const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"] as const;
|
|
67
71
|
const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
|
|
68
72
|
const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep", "channelEvent"] as const;
|
|
69
|
-
|
|
70
|
-
const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
71
|
-
const VALID_PROVIDER_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
73
|
+
|
|
72
74
|
|
|
73
75
|
const TOOLS: ToolDefinition[] = [
|
|
74
76
|
{
|
|
@@ -201,13 +203,13 @@ const TOOLS: ToolDefinition[] = [
|
|
|
201
203
|
inputSchema: {
|
|
202
204
|
type: "object",
|
|
203
205
|
properties: {
|
|
204
|
-
provider: { type: "string", enum:
|
|
206
|
+
provider: { type: "string", enum: SPAWN_PROVIDERS },
|
|
205
207
|
orchestratorId: { type: "string" },
|
|
206
208
|
cwd: { type: "string" },
|
|
207
209
|
label: { type: "string" },
|
|
208
210
|
model: { type: "string" },
|
|
209
|
-
effort: { type: "string", enum:
|
|
210
|
-
approvalMode: { type: "string", enum:
|
|
211
|
+
effort: { type: "string", enum: VALID_EFFORTS },
|
|
212
|
+
approvalMode: { type: "string", enum: APPROVAL_MODES },
|
|
211
213
|
prompt: { type: "string" },
|
|
212
214
|
systemPromptAppend: { type: "string" },
|
|
213
215
|
tags: { type: "array", items: { type: "string" } },
|
|
@@ -273,7 +275,7 @@ export async function postMcp(req: Request): Promise<Response> {
|
|
|
273
275
|
}
|
|
274
276
|
return Response.json(jsonRpcError(id, -32601, `unknown MCP method: ${method}`));
|
|
275
277
|
} catch (error) {
|
|
276
|
-
const message =
|
|
278
|
+
const message = errMessage(error);
|
|
277
279
|
const status = error instanceof McpAuthError ? 403 : error instanceof McpNotFoundError ? 404 : 400;
|
|
278
280
|
const code = error instanceof McpAuthError ? -32001 : error instanceof McpNotFoundError ? -32004 : -32602;
|
|
279
281
|
return Response.json(jsonRpcError(id, code, message, { status }));
|
|
@@ -285,23 +287,11 @@ async function parseJsonRpcRequest(req: Request): Promise<
|
|
|
285
287
|
| { ok: false; status: number; error: string }
|
|
286
288
|
> {
|
|
287
289
|
if (!req.body) return { ok: false, status: 400, error: "JSON-RPC body required" };
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
while (true) {
|
|
292
|
-
const { done, value } = await reader.read();
|
|
293
|
-
if (done) break;
|
|
294
|
-
if (!value) continue;
|
|
295
|
-
total += value.byteLength;
|
|
296
|
-
const maxBodyBytes = Math.max(MAX_BODY_BYTES, maxArtifactBytes() + MCP_BODY_OVERHEAD_BYTES);
|
|
297
|
-
if (total > maxBodyBytes) {
|
|
298
|
-
await reader.cancel().catch(() => {});
|
|
299
|
-
return { ok: false, status: 413, error: `request body exceeds ${maxBodyBytes} bytes` };
|
|
300
|
-
}
|
|
301
|
-
chunks.push(value);
|
|
302
|
-
}
|
|
290
|
+
const maxBodyBytes = Math.max(MAX_BODY_BYTES, maxArtifactBytes() + MCP_BODY_OVERHEAD_BYTES);
|
|
291
|
+
const read = await readBodyBytes(req.body, maxBodyBytes);
|
|
292
|
+
if (!read.ok) return read;
|
|
303
293
|
try {
|
|
304
|
-
const body = JSON.parse(new TextDecoder().decode(
|
|
294
|
+
const body = JSON.parse(new TextDecoder().decode(read.bytes)) as unknown;
|
|
305
295
|
if (!isRecord(body)) return { ok: false, status: 400, error: "JSON-RPC body must be an object" };
|
|
306
296
|
return { ok: true, request: body as JsonRpcRequest };
|
|
307
297
|
} catch {
|
|
@@ -309,17 +299,6 @@ async function parseJsonRpcRequest(req: Request): Promise<
|
|
|
309
299
|
}
|
|
310
300
|
}
|
|
311
301
|
|
|
312
|
-
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
|
313
|
-
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
314
|
-
const output = new Uint8Array(total);
|
|
315
|
-
let offset = 0;
|
|
316
|
-
for (const chunk of chunks) {
|
|
317
|
-
output.set(chunk, offset);
|
|
318
|
-
offset += chunk.byteLength;
|
|
319
|
-
}
|
|
320
|
-
return output;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
302
|
function mcpAuthContext(req: Request): McpAuthContext {
|
|
324
303
|
const component = getComponentAuth(req);
|
|
325
304
|
if (component) return { actor: component.sub, kind: "component", scopes: component.scope, component };
|
|
@@ -355,7 +334,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
355
334
|
auditToolCall(auth, name, "ok", result);
|
|
356
335
|
return toolResult(result);
|
|
357
336
|
} catch (error) {
|
|
358
|
-
const message =
|
|
337
|
+
const message = errMessage(error);
|
|
359
338
|
auditToolCall(auth, name, error instanceof McpAuthError ? "denied" : "error", undefined, message);
|
|
360
339
|
throw error;
|
|
361
340
|
}
|
|
@@ -519,16 +498,16 @@ function relayAgentStatus(args: Record<string, unknown>): Record<string, unknown
|
|
|
519
498
|
}
|
|
520
499
|
|
|
521
500
|
function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
522
|
-
const provider = enumField(args.provider, "provider",
|
|
501
|
+
const provider = enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider;
|
|
523
502
|
const cwd = optionalString(args.cwd, "cwd", 500);
|
|
524
503
|
const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200), cwd);
|
|
525
504
|
const resolvedCwd = cwd || orchestrator.baseDir;
|
|
526
|
-
if (cwd && !
|
|
505
|
+
if (cwd && !isPathWithinBase(cwd, orchestrator.baseDir)) {
|
|
527
506
|
throw new ValidationError(`cwd must be within orchestrator base directory: ${orchestrator.baseDir}`);
|
|
528
507
|
}
|
|
529
508
|
const selection = providerSelection(provider, args);
|
|
530
|
-
const approvalMode = optionalEnum(args.approvalMode, "approvalMode",
|
|
531
|
-
const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ??
|
|
509
|
+
const approvalMode = optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined ?? "guarded";
|
|
510
|
+
const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ?? generateSpawnRequestId();
|
|
532
511
|
const label = optionalString(args.label, "label", 120);
|
|
533
512
|
const policyName = optionalString(args.policyName, "policyName", 120);
|
|
534
513
|
assertComponentResourceAllowed(auth, {
|
|
@@ -558,12 +537,9 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
558
537
|
source: "system",
|
|
559
538
|
target: orchestrator.agentId,
|
|
560
539
|
correlationId: spawnRequestId,
|
|
561
|
-
params: {
|
|
562
|
-
action: "spawn",
|
|
540
|
+
params: buildSpawnCommand({
|
|
563
541
|
provider,
|
|
564
|
-
|
|
565
|
-
providerModel: selection.providerModel,
|
|
566
|
-
effort: selection.effort,
|
|
542
|
+
modelParams: selection,
|
|
567
543
|
cwd: resolvedCwd,
|
|
568
544
|
label,
|
|
569
545
|
tags: optionalStringArray(args.tags, "tags") ?? [],
|
|
@@ -580,7 +556,7 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
580
556
|
requestedVia: "mcp",
|
|
581
557
|
requestedAt: Date.now(),
|
|
582
558
|
orchestratorId: orchestrator.id,
|
|
583
|
-
},
|
|
559
|
+
}),
|
|
584
560
|
});
|
|
585
561
|
emitCommand(command);
|
|
586
562
|
return { ok: true, orchestratorId: orchestrator.id, provider, command };
|
|
@@ -714,7 +690,7 @@ function selectSpawnOrchestrator(provider: SpawnProvider, orchestratorId?: strin
|
|
|
714
690
|
}
|
|
715
691
|
const candidates = listOrchestrators().filter((item) => item.status === "online" && item.providers.includes(provider));
|
|
716
692
|
if (cwd) {
|
|
717
|
-
const match = candidates.find((item) =>
|
|
693
|
+
const match = candidates.find((item) => isPathWithinBase(cwd, item.baseDir));
|
|
718
694
|
if (match) return match;
|
|
719
695
|
}
|
|
720
696
|
const orchestrator = candidates[0];
|
|
@@ -736,7 +712,7 @@ function selectControlOrchestrator(input: {
|
|
|
736
712
|
return orchestrator;
|
|
737
713
|
}
|
|
738
714
|
const agent = input.agentId ? getAgent(input.agentId) : null;
|
|
739
|
-
const orchestrator = agent ? managedControlOrchestrator(agent, input) :
|
|
715
|
+
const orchestrator = agent ? managedControlOrchestrator(agent, input) : (listManagedOrchestratorsForAgent(input)[0] ?? null);
|
|
740
716
|
if (!orchestrator) throw new McpNotFoundError("no orchestrator found for agent control target");
|
|
741
717
|
if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
|
|
742
718
|
return orchestrator;
|
|
@@ -746,60 +722,19 @@ function managedControlOrchestrator(
|
|
|
746
722
|
agent: AgentCard,
|
|
747
723
|
input: { policyName?: string; spawnRequestId?: string; tmuxSession?: string },
|
|
748
724
|
): NonNullable<ReturnType<typeof getOrchestrator>> | null {
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
const metaSpawnRequestId = input.spawnRequestId ?? (typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : "");
|
|
752
|
-
return findManagedOrchestrator({
|
|
725
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
726
|
+
return listManagedOrchestratorsForAgent({
|
|
753
727
|
agentId: agent.id,
|
|
754
|
-
policyName:
|
|
755
|
-
spawnRequestId:
|
|
756
|
-
tmuxSession: input.tmuxSession ??
|
|
757
|
-
}) ?? (agent.machine ? getOrchestrator(agent.machine) : null);
|
|
728
|
+
policyName: input.policyName ?? str(agent.meta?.policyName),
|
|
729
|
+
spawnRequestId: input.spawnRequestId ?? str(agent.meta?.spawnRequestId),
|
|
730
|
+
tmuxSession: input.tmuxSession ?? str(agent.meta?.tmuxSession),
|
|
731
|
+
})[0] ?? (agent.machine ? getOrchestrator(agent.machine) : null);
|
|
758
732
|
}
|
|
759
733
|
|
|
760
|
-
function
|
|
761
|
-
agentId?: string;
|
|
762
|
-
policyName?: string;
|
|
763
|
-
spawnRequestId?: string;
|
|
764
|
-
tmuxSession?: string;
|
|
765
|
-
}): NonNullable<ReturnType<typeof getOrchestrator>> | null {
|
|
766
|
-
return listOrchestrators().find((orchestrator) => orchestrator.managedAgents.some((managed) =>
|
|
767
|
-
(!!input.agentId && managed.agentId === input.agentId) ||
|
|
768
|
-
(!!input.tmuxSession && managed.tmuxSession === input.tmuxSession) ||
|
|
769
|
-
(!!input.policyName && managed.policyName === input.policyName) ||
|
|
770
|
-
(!!input.spawnRequestId && managed.spawnRequestId === input.spawnRequestId)
|
|
771
|
-
)) ?? null;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function providerSelection(provider: SpawnProvider, args: Record<string, unknown>): { model?: string; effort?: ProviderEffort; providerModel?: string } {
|
|
734
|
+
function providerSelection(provider: SpawnProvider, args: Record<string, unknown>): SpawnModelParams {
|
|
775
735
|
const model = optionalString(args.model, "model", 120);
|
|
776
|
-
const effort = optionalEnum(args.effort, "effort",
|
|
777
|
-
|
|
778
|
-
const resolved = resolveProviderSelection({ provider, model, effort });
|
|
779
|
-
return {
|
|
780
|
-
model: resolved.modelAlias,
|
|
781
|
-
providerModel: resolved.providerModel,
|
|
782
|
-
effort: resolved.effort,
|
|
783
|
-
};
|
|
784
|
-
} catch (error) {
|
|
785
|
-
throw new ValidationError(error instanceof Error ? error.message : String(error));
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function pathWithinBase(path: string, baseDir: string): boolean {
|
|
790
|
-
const base = resolve(baseDir);
|
|
791
|
-
const target = resolve(path);
|
|
792
|
-
const rel = relative(base, target);
|
|
793
|
-
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
797
|
-
return new ReadableStream<Uint8Array>({
|
|
798
|
-
start(controller) {
|
|
799
|
-
controller.enqueue(bytes);
|
|
800
|
-
controller.close();
|
|
801
|
-
},
|
|
802
|
-
});
|
|
736
|
+
const effort = optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
|
|
737
|
+
return resolveSpawnModelParams(provider, model, effort);
|
|
803
738
|
}
|
|
804
739
|
|
|
805
740
|
function artifactBytes(args: Record<string, unknown>): Uint8Array {
|
|
@@ -937,10 +872,6 @@ function auditToolCall(
|
|
|
937
872
|
}
|
|
938
873
|
}
|
|
939
874
|
|
|
940
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
941
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
942
|
-
}
|
|
943
|
-
|
|
944
875
|
function recordField(value: unknown, field: string): Record<string, unknown> {
|
|
945
876
|
if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
|
|
946
877
|
return value;
|
|
@@ -980,12 +911,6 @@ function optionalBoolean(value: unknown, field: string): boolean | undefined {
|
|
|
980
911
|
return value;
|
|
981
912
|
}
|
|
982
913
|
|
|
983
|
-
function optionalEnum<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] | undefined {
|
|
984
|
-
if (value === undefined || value === null) return undefined;
|
|
985
|
-
if (typeof value !== "string" || !valid.includes(value as T[number])) throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
|
|
986
|
-
return value as T[number];
|
|
987
|
-
}
|
|
988
|
-
|
|
989
914
|
function enumField<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
|
|
990
915
|
const cleaned = optionalEnum(value, field, valid);
|
|
991
916
|
if (!cleaned) throw new ValidationError(`${field} required`);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
2
|
+
|
|
1
3
|
interface MemoryBrokerSmokeOptions {
|
|
2
4
|
baseUrl?: string;
|
|
3
5
|
token?: string;
|
|
@@ -39,7 +41,7 @@ export async function runMemoryBrokerSmoke(options: MemoryBrokerSmokeOptions = {
|
|
|
39
41
|
|
|
40
42
|
const request = async <T>(method: string, path: string, body?: unknown): Promise<T> => {
|
|
41
43
|
const headers: Record<string, string> = {};
|
|
42
|
-
if (token) headers[
|
|
44
|
+
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
43
45
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
44
46
|
const response = await fetchImpl(new URL(path, baseUrl), {
|
|
45
47
|
method,
|
|
@@ -135,7 +137,7 @@ async function step<T>(steps: MemoryBrokerSmokeStep[], name: string, detail: str
|
|
|
135
137
|
steps.push({ name, ok: true, detail });
|
|
136
138
|
return result;
|
|
137
139
|
} catch (error) {
|
|
138
|
-
const message =
|
|
140
|
+
const message = errMessage(error);
|
|
139
141
|
steps.push({ name, ok: false, detail: message });
|
|
140
142
|
throw error;
|
|
141
143
|
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
normalizeMemorySearchResult,
|
|
21
21
|
} from "./memory-broker-contract";
|
|
22
22
|
import { normalizeContextPackage } from "./memory-http-broker";
|
|
23
|
+
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
23
24
|
|
|
24
25
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
25
26
|
|
|
@@ -104,7 +105,7 @@ export class CommandMemoryBroker implements MemoryBroker {
|
|
|
104
105
|
return unwrapResult(parseJson(stdout, operation));
|
|
105
106
|
} catch (error) {
|
|
106
107
|
if (error instanceof MemoryBrokerContractError) throw error;
|
|
107
|
-
throw new MemoryBrokerContractError(`command memory broker ${operation} failed: ${
|
|
108
|
+
throw new MemoryBrokerContractError(`command memory broker ${operation} failed: ${errMessage(error)}`);
|
|
108
109
|
} finally {
|
|
109
110
|
clearTimeout(timer);
|
|
110
111
|
}
|
|
@@ -155,7 +156,3 @@ function numberField(value: unknown, field: string): number {
|
|
|
155
156
|
if (typeof value !== "number" || !Number.isFinite(value)) throw new MemoryBrokerContractError(`${field} must be a number`);
|
|
156
157
|
return value;
|
|
157
158
|
}
|
|
158
|
-
|
|
159
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
160
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
161
|
-
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
normalizeMemoryBrokerCapabilities,
|
|
20
20
|
normalizeMemorySearchResult,
|
|
21
21
|
} from "./memory-broker-contract";
|
|
22
|
+
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
22
23
|
|
|
23
24
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
24
25
|
|
|
@@ -98,7 +99,7 @@ export class HttpMemoryBroker implements MemoryBroker {
|
|
|
98
99
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
99
100
|
throw new MemoryBrokerContractError(`http memory broker ${operation} timed out after ${this.timeoutMs}ms`);
|
|
100
101
|
}
|
|
101
|
-
throw new MemoryBrokerContractError(`http memory broker ${operation} failed: ${
|
|
102
|
+
throw new MemoryBrokerContractError(`http memory broker ${operation} failed: ${errMessage(error)}`);
|
|
102
103
|
} finally {
|
|
103
104
|
clearTimeout(timer);
|
|
104
105
|
}
|
|
@@ -182,7 +183,3 @@ function requireRecord(value: unknown, field: string): Record<string, unknown> {
|
|
|
182
183
|
if (!isRecord(value)) throw new MemoryBrokerContractError(`${field} must be an object`);
|
|
183
184
|
return value;
|
|
184
185
|
}
|
|
185
|
-
|
|
186
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
187
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
188
|
-
}
|
package/src/memory-service.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createCommand } from "./commands-db";
|
|
2
2
|
import { getAgent, ValidationError } from "./db";
|
|
3
|
+
import { isRecord } from "agent-relay-sdk";
|
|
3
4
|
import { assembleContextPackage, contextBudgetForAgent, estimateMemoryTokens } from "./memory-broker";
|
|
4
5
|
import { createMemoryBrokerRegistry, memoryBrokerConfigFromEnv } from "./memory-broker-registry";
|
|
5
6
|
import { filterMemoriesForInjection, formatUntrustedMemoryBlock } from "./memory-broker-contract";
|
|
@@ -349,7 +350,3 @@ function stringArray(value: unknown): string[] | undefined {
|
|
|
349
350
|
function isPositiveInteger(value: unknown): value is number {
|
|
350
351
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
351
352
|
}
|
|
352
|
-
|
|
353
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
354
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
355
|
-
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { Database } from "bun:sqlite";
|
|
3
3
|
import { getDb, ValidationError } from "./db";
|
|
4
|
+
import { parseJson } from "./utils";
|
|
4
5
|
import type {
|
|
5
6
|
ActiveMemoryClearReason,
|
|
6
7
|
ContextPackage,
|
|
@@ -327,14 +328,6 @@ function rowToMemory(row: MemoryRow): Memory {
|
|
|
327
328
|
});
|
|
328
329
|
}
|
|
329
330
|
|
|
330
|
-
function parseJson<T>(raw: string, fallback: T): T {
|
|
331
|
-
try {
|
|
332
|
-
return JSON.parse(raw) as T;
|
|
333
|
-
} catch {
|
|
334
|
-
return fallback;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
331
|
function tagOverlap(tags: string[], queryTags: Set<string>): number {
|
|
339
332
|
if (queryTags.size === 0) return 0;
|
|
340
333
|
const memoryTags = new Set(tags.map((tag) => tag.toLowerCase()));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { listOrchestrators } from "./db";
|
|
2
|
+
|
|
3
|
+
export interface ManagedAgentMatch {
|
|
4
|
+
agentId?: string;
|
|
5
|
+
sessionName?: string;
|
|
6
|
+
tmuxSession?: string;
|
|
7
|
+
policyName?: string;
|
|
8
|
+
spawnRequestId?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every orchestrator that owns a managed agent matching ANY provided identity
|
|
13
|
+
* key. Single home for the managed-agent→orchestrator lookup that routes.ts and
|
|
14
|
+
* mcp.ts each open-coded. routes also matches `sessionName`; mcp never passed it,
|
|
15
|
+
* so leaving the key undefined reproduces mcp's behavior exactly. Callers layer
|
|
16
|
+
* their own online / runner-managed / machine-fallback policy on top — routes
|
|
17
|
+
* picks the first online candidate, mcp takes the first match.
|
|
18
|
+
*/
|
|
19
|
+
export function listManagedOrchestratorsForAgent(match: ManagedAgentMatch): ReturnType<typeof listOrchestrators> {
|
|
20
|
+
return listOrchestrators().filter((orch) =>
|
|
21
|
+
orch.managedAgents.some((managed) =>
|
|
22
|
+
(!!match.agentId && managed.agentId === match.agentId) ||
|
|
23
|
+
(!!match.sessionName && managed.sessionName === match.sessionName) ||
|
|
24
|
+
(!!match.tmuxSession && managed.tmuxSession === match.tmuxSession) ||
|
|
25
|
+
(!!match.policyName && managed.policyName === match.policyName) ||
|
|
26
|
+
(!!match.spawnRequestId && managed.spawnRequestId === match.spawnRequestId),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -3,10 +3,10 @@ import {
|
|
|
3
3
|
type ProviderCatalogEntry,
|
|
4
4
|
type ProviderModelCatalogEntry,
|
|
5
5
|
} from "agent-relay-sdk/provider-catalog";
|
|
6
|
+
import { SPAWN_PROVIDERS } from "agent-relay-sdk";
|
|
6
7
|
import type { SpawnProvider } from "./types";
|
|
7
8
|
import { getDb, ValidationError } from "./db";
|
|
8
|
-
|
|
9
|
-
const VALID_PROVIDERS: SpawnProvider[] = ["claude", "codex"];
|
|
9
|
+
import { parseJson } from "./utils";
|
|
10
10
|
|
|
11
11
|
interface ProviderModelOverrideRow {
|
|
12
12
|
provider: SpawnProvider;
|
|
@@ -111,7 +111,7 @@ export function mergeProviderCatalog(
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
function normalizeOverrideInput(input: ProviderModelOverrideInput): ProviderModelOverrideInput {
|
|
114
|
-
if (!
|
|
114
|
+
if (!SPAWN_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
|
|
115
115
|
const alias = input.alias.trim();
|
|
116
116
|
if (!alias) throw new ValidationError("model alias is required");
|
|
117
117
|
if (input.entry.alias !== alias) throw new ValidationError("model entry alias must match override alias");
|
|
@@ -154,11 +154,3 @@ function cloneProviderEntry(entry: ProviderCatalogEntry): ProviderCatalogEntry {
|
|
|
154
154
|
function cloneModelEntry(entry: ProviderModelCatalogEntry): ProviderModelCatalogEntry {
|
|
155
155
|
return JSON.parse(JSON.stringify(entry)) as ProviderModelCatalogEntry;
|
|
156
156
|
}
|
|
157
|
-
|
|
158
|
-
function parseJson<T>(raw: string, fallback: T): T {
|
|
159
|
-
try {
|
|
160
|
-
return JSON.parse(raw) as T;
|
|
161
|
-
} catch {
|
|
162
|
-
return fallback;
|
|
163
|
-
}
|
|
164
|
-
}
|