agent-relay-server 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
2
1
  import { createCommand } from "./commands-db";
2
+ import { isPathWithinBase } from "./utils";
3
3
  import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces, resolveQueuedPolicyMessages } from "./db";
4
4
  import {
5
5
  getManagedAgentState,
@@ -8,7 +8,9 @@ import {
8
8
  upsertManagedAgentState,
9
9
  } from "./config-store";
10
10
  import { emitRelayEvent } from "./events";
11
+ import { emitCommandEvent } from "./command-events";
11
12
  import { buildManagedSpawnParams } from "./managed-policy";
13
+ import { generateSpawnRequestId } from "./spawn-command";
12
14
  import type { Command, ManagedAgent, ManagedAgentState, ManagedSessionExitDiagnostics, SpawnPolicy } from "./types";
13
15
 
14
16
  const DEFAULT_TICK_MS = 10_000;
@@ -208,7 +210,7 @@ export class LifecycleManager {
208
210
  this.emitState(state);
209
211
  return null;
210
212
  }
211
- const spawnRequestId = `sp_${crypto.randomUUID()}`;
213
+ const spawnRequestId = generateSpawnRequestId();
212
214
  const state = upsertManagedAgentState({
213
215
  policyName: policy.name,
214
216
  status: "starting",
@@ -230,7 +232,7 @@ export class LifecycleManager {
230
232
  reason,
231
233
  },
232
234
  });
233
- this.emitCommand(command);
235
+ emitCommandEvent(command, "command.requested");
234
236
  return command;
235
237
  }
236
238
 
@@ -270,7 +272,7 @@ export class LifecycleManager {
270
272
  requestedAt: this.now(),
271
273
  },
272
274
  });
273
- this.emitCommand(command);
275
+ emitCommandEvent(command, "command.requested");
274
276
  return command;
275
277
  }
276
278
 
@@ -278,7 +280,7 @@ export class LifecycleManager {
278
280
  const orch = getOrchestrator(policy.orchestratorId);
279
281
  if (!orch) return null;
280
282
  const state = getManagedAgentState(policy.name);
281
- const restartRequestId = `sp_${crypto.randomUUID()}`;
283
+ const restartRequestId = generateSpawnRequestId();
282
284
  const restartSpawn = buildManagedSpawnParams(policy, restartRequestId, { createdBy: "lifecycle-manager", requestedAt: this.now() });
283
285
  const restarted = upsertManagedAgentState({
284
286
  policyName: policy.name,
@@ -314,7 +316,7 @@ export class LifecycleManager {
314
316
  requestedAt: this.now(),
315
317
  },
316
318
  });
317
- this.emitCommand(command);
319
+ emitCommandEvent(command, "command.requested");
318
320
  return command;
319
321
  }
320
322
 
@@ -406,7 +408,7 @@ export class LifecycleManager {
406
408
  requestedAt: this.now(),
407
409
  },
408
410
  });
409
- this.emitCommand(command);
411
+ emitCommandEvent(command, "command.requested");
410
412
  return command;
411
413
  }
412
414
 
@@ -564,15 +566,6 @@ export class LifecycleManager {
564
566
  });
565
567
  }
566
568
 
567
- private emitCommand(command: Command): void {
568
- emitRelayEvent({
569
- type: "command.requested",
570
- source: command.source,
571
- subject: command.id,
572
- data: { command },
573
- });
574
- }
575
-
576
569
  // When an agent disappears, its isolated worktrees would otherwise sit
577
570
  // `active` on disk forever. Dispatch a workspace.reconcile command to the
578
571
  // owning orchestrator, which probes the worktree and either removes it (no
@@ -593,7 +586,7 @@ export class LifecycleManager {
593
586
  const orchestrators = listOrchestrators();
594
587
  for (const ws of candidates) {
595
588
  const orch = orchestrators.find(
596
- (candidate) => candidate.status === "online" && pathWithinBaseDir(ws.sourceCwd, candidate.baseDir),
589
+ (candidate) => candidate.status === "online" && isPathWithinBase(ws.sourceCwd, candidate.baseDir),
597
590
  );
598
591
  if (!orch) continue;
599
592
  const command = createCommand({
@@ -614,17 +607,11 @@ export class LifecycleManager {
614
607
  requestedAt: this.now(),
615
608
  },
616
609
  });
617
- this.emitCommand(command);
610
+ emitCommandEvent(command, "command.requested");
618
611
  }
619
612
  }
620
613
  }
621
614
 
622
- function pathWithinBaseDir(path: string | undefined, baseDir: string | undefined): boolean {
623
- if (!path || !baseDir) return false;
624
- const rel = relative(resolve(baseDir), resolve(path));
625
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
626
- }
627
-
628
615
  let singleton: LifecycleManager | null = null;
629
616
 
630
617
  export function getLifecycleManager(): LifecycleManager {
@@ -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 { 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
@@ -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,8 @@
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
6
  import { MAX_BODY_BYTES, VERSION } from "./config";
7
7
  import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
8
8
  import {
@@ -33,7 +33,8 @@ import {
33
33
  isIntegrationAllowed,
34
34
  } from "./security";
35
35
  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";
36
+ import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
37
+ import { isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
37
38
  import { childRunnerRuntimeTokenEnv, runnerRuntimeTokenEnv } from "./runtime-tokens";
38
39
 
39
40
  type JsonRpcId = string | number | null;
@@ -66,9 +67,7 @@ const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive",
66
67
  const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"] as const;
67
68
  const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
68
69
  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;
70
+
72
71
 
73
72
  const TOOLS: ToolDefinition[] = [
74
73
  {
@@ -201,13 +200,13 @@ const TOOLS: ToolDefinition[] = [
201
200
  inputSchema: {
202
201
  type: "object",
203
202
  properties: {
204
- provider: { type: "string", enum: VALID_SPAWN_PROVIDERS },
203
+ provider: { type: "string", enum: SPAWN_PROVIDERS },
205
204
  orchestratorId: { type: "string" },
206
205
  cwd: { type: "string" },
207
206
  label: { type: "string" },
208
207
  model: { type: "string" },
209
- effort: { type: "string", enum: VALID_PROVIDER_EFFORTS },
210
- approvalMode: { type: "string", enum: VALID_SPAWN_APPROVALS },
208
+ effort: { type: "string", enum: VALID_EFFORTS },
209
+ approvalMode: { type: "string", enum: APPROVAL_MODES },
211
210
  prompt: { type: "string" },
212
211
  systemPromptAppend: { type: "string" },
213
212
  tags: { type: "array", items: { type: "string" } },
@@ -519,16 +518,16 @@ function relayAgentStatus(args: Record<string, unknown>): Record<string, unknown
519
518
  }
520
519
 
521
520
  function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
522
- const provider = enumField(args.provider, "provider", VALID_SPAWN_PROVIDERS) as SpawnProvider;
521
+ const provider = enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider;
523
522
  const cwd = optionalString(args.cwd, "cwd", 500);
524
523
  const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200), cwd);
525
524
  const resolvedCwd = cwd || orchestrator.baseDir;
526
- if (cwd && !pathWithinBase(cwd, orchestrator.baseDir)) {
525
+ if (cwd && !isPathWithinBase(cwd, orchestrator.baseDir)) {
527
526
  throw new ValidationError(`cwd must be within orchestrator base directory: ${orchestrator.baseDir}`);
528
527
  }
529
528
  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()}`;
529
+ const approvalMode = optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined ?? "guarded";
530
+ const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ?? generateSpawnRequestId();
532
531
  const label = optionalString(args.label, "label", 120);
533
532
  const policyName = optionalString(args.policyName, "policyName", 120);
534
533
  assertComponentResourceAllowed(auth, {
@@ -558,12 +557,9 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
558
557
  source: "system",
559
558
  target: orchestrator.agentId,
560
559
  correlationId: spawnRequestId,
561
- params: {
562
- action: "spawn",
560
+ params: buildSpawnCommand({
563
561
  provider,
564
- model: selection.model,
565
- providerModel: selection.providerModel,
566
- effort: selection.effort,
562
+ modelParams: selection,
567
563
  cwd: resolvedCwd,
568
564
  label,
569
565
  tags: optionalStringArray(args.tags, "tags") ?? [],
@@ -580,7 +576,7 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
580
576
  requestedVia: "mcp",
581
577
  requestedAt: Date.now(),
582
578
  orchestratorId: orchestrator.id,
583
- },
579
+ }),
584
580
  });
585
581
  emitCommand(command);
586
582
  return { ok: true, orchestratorId: orchestrator.id, provider, command };
@@ -714,7 +710,7 @@ function selectSpawnOrchestrator(provider: SpawnProvider, orchestratorId?: strin
714
710
  }
715
711
  const candidates = listOrchestrators().filter((item) => item.status === "online" && item.providers.includes(provider));
716
712
  if (cwd) {
717
- const match = candidates.find((item) => pathWithinBase(cwd, item.baseDir));
713
+ const match = candidates.find((item) => isPathWithinBase(cwd, item.baseDir));
718
714
  if (match) return match;
719
715
  }
720
716
  const orchestrator = candidates[0];
@@ -771,26 +767,10 @@ function findManagedOrchestrator(input: {
771
767
  )) ?? null;
772
768
  }
773
769
 
774
- function providerSelection(provider: SpawnProvider, args: Record<string, unknown>): { model?: string; effort?: ProviderEffort; providerModel?: string } {
770
+ function providerSelection(provider: SpawnProvider, args: Record<string, unknown>): SpawnModelParams {
775
771
  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));
772
+ const effort = optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
773
+ return resolveSpawnModelParams(provider, model, effort);
794
774
  }
795
775
 
796
776
  function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
@@ -937,10 +917,6 @@ function auditToolCall(
937
917
  }
938
918
  }
939
919
 
940
- function isRecord(value: unknown): value is Record<string, unknown> {
941
- return typeof value === "object" && value !== null && !Array.isArray(value);
942
- }
943
-
944
920
  function recordField(value: unknown, field: string): Record<string, unknown> {
945
921
  if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
946
922
  return value;
@@ -1,3 +1,5 @@
1
+ import { 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,
@@ -20,6 +20,7 @@ import {
20
20
  normalizeMemorySearchResult,
21
21
  } from "./memory-broker-contract";
22
22
  import { normalizeContextPackage } from "./memory-http-broker";
23
+ import { isRecord } from "agent-relay-sdk";
23
24
 
24
25
  const DEFAULT_TIMEOUT_MS = 10_000;
25
26
 
@@ -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 { isRecord } from "agent-relay-sdk";
22
23
 
23
24
  const DEFAULT_TIMEOUT_MS = 10_000;
24
25
 
@@ -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()));
@@ -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
- }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import { validateRecipe } from "./recipe-validator";
4
4
  import type { Recipe } from "./types";
5
+ import { isRecord } from "agent-relay-sdk";
5
6
 
6
7
  interface LoadedRecipe {
7
8
  name: string;
@@ -113,7 +114,3 @@ function parseScalar(value: string): unknown {
113
114
  if (/^\d+$/.test(trimmed)) return Number(trimmed);
114
115
  return trimmed.replace(/^["']|["']$/g, "");
115
116
  }
116
-
117
- function isRecord(value: unknown): value is Record<string, unknown> {
118
- return typeof value === "object" && value !== null && !Array.isArray(value);
119
- }
@@ -1,5 +1,6 @@
1
1
  import type { MemoryType, Recipe, RecipeAgent, RecipeMemoryPolicy } from "./types";
2
2
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
3
+ import { isRecord } from "agent-relay-sdk";
3
4
 
4
5
  class RecipeValidationError extends Error {}
5
6
 
@@ -131,7 +132,3 @@ function optionalPositiveInteger(value: unknown, field: string): number | undefi
131
132
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new RecipeValidationError(`${field} must be a positive integer`);
132
133
  return value;
133
134
  }
134
-
135
- function isRecord(value: unknown): value is Record<string, unknown> {
136
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
137
- }