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.
@@ -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 clean fast-forward review_requested worktrees into base under the per-repo lease; conflicts and diverged bases are left for the steward.",
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["X-Agent-Relay-Token"] = token;
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, then land it via POST /api/workspaces/${ws.id}/actions {"action":"merge","strategy":"rebase-ff"} — or escalate if you can't.`,
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" queue
659
- // (`review_requested` isolated worktrees) and, for any whose work is a strict
660
- // clean fast-forward (no conflict, base hasn't moved, real commits ahead), land
661
- // it via the shared merge helper the same lease-serialized path the merge route
662
- // uses. Conflicts and diverged bases (`behind>0`, even if cleanly rebasable) are
663
- // deliberately left for the steward (a human or, later, the managed steward
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 cleanFF = p.cleanFastForward === true && p.conflict !== true && (p.behind ?? 0) === 0 && ahead > 0;
690
- if (!cleanFF) {
691
- // Base moved on (behind>0) or conflict needs reasoning/rebase, which is the
692
- // steward's job. Wake the managed steward when enabled (cooldown-guarded);
693
- // otherwise leave it for conflict-scan's legacy ping / human review.
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, (p.behind ?? 0) > 0 ? "base moved on (behind>0)" : "conflict");
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: ${error instanceof Error ? error.message : String(error)}`);
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 = error instanceof Error ? error.message : String(error);
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',
@@ -1,6 +1,6 @@
1
- import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
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
- ...(policy.rig ? { rig: policy.rig } : {}),
55
- ...resolvedModelParams(policy),
56
- ...(policy.profile ? { profile: policy.profile } : {}),
57
- ...(agentProfile ? { agentProfile } : {}),
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
- ...(prompt ? { prompt } : {}),
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 { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
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
- const VALID_SPAWN_PROVIDERS = ["claude", "codex"] as const;
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: VALID_SPAWN_PROVIDERS },
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: VALID_PROVIDER_EFFORTS },
210
- approvalMode: { type: "string", enum: VALID_SPAWN_APPROVALS },
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 = error instanceof Error ? error.message : String(error);
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 reader = req.body.getReader();
289
- const chunks: Uint8Array[] = [];
290
- let total = 0;
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(concatBytes(chunks))) as unknown;
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 = error instanceof Error ? error.message : String(error);
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", VALID_SPAWN_PROVIDERS) as SpawnProvider;
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 && !pathWithinBase(cwd, orchestrator.baseDir)) {
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", VALID_SPAWN_APPROVALS) as SpawnApprovalMode | undefined ?? "guarded";
531
- const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ?? `sp_${randomUUID()}`;
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
- model: selection.model,
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) => pathWithinBase(cwd, item.baseDir));
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) : findManagedOrchestrator(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 metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : "";
750
- const metaPolicyName = input.policyName ?? (typeof agent.meta?.policyName === "string" ? agent.meta.policyName : "");
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: metaPolicyName,
755
- spawnRequestId: metaSpawnRequestId,
756
- tmuxSession: input.tmuxSession ?? metaTmuxSession,
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 findManagedOrchestrator(input: {
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", VALID_PROVIDER_EFFORTS) as ProviderEffort | undefined;
777
- try {
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["X-Agent-Relay-Token"] = token;
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 = error instanceof Error ? error.message : String(error);
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: ${error instanceof Error ? error.message : String(error)}`);
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: ${error instanceof Error ? error.message : String(error)}`);
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
- }
@@ -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 (!VALID_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
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
- }