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.
- package/docs/openapi.json +101 -1
- package/package.json +2 -2
- package/public/index.html +25 -22
- package/public/sw.js +51 -16
- package/runner/src/adapter.ts +1 -4
- package/runner/src/config.ts +1 -4
- package/scripts/orchestrator-spawn-smoke.ts +2 -1
- package/src/automations.ts +9 -33
- package/src/bus.ts +2 -17
- package/src/cli.ts +179 -3
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +5 -22
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/db.ts +14 -15
- package/src/index.ts +99 -4
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +26 -20
- package/src/managed-policy.ts +9 -28
- package/src/mcp.ts +19 -43
- package/src/memory-broker-smoke.ts +3 -1
- package/src/memory-command-broker.ts +1 -4
- package/src/memory-http-broker.ts +1 -4
- package/src/memory-service.ts +1 -4
- package/src/memory-sqlite-broker.ts +1 -8
- package/src/provider-catalog-store.ts +3 -11
- package/src/recipe-loader.ts +1 -4
- package/src/recipe-validator.ts +1 -4
- package/src/routes.ts +249 -142
- package/src/security.ts +3 -7
- package/src/spawn-command.ts +150 -0
- package/src/sse.ts +1 -4
- package/src/steward.ts +16 -21
- package/src/upgrade.ts +3 -2
- package/src/utils.ts +38 -0
- package/src/validation.ts +28 -0
- package/src/workspace-claim.ts +29 -0
- package/src/workspace-merge.ts +21 -9
package/src/lifecycle-manager.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
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
|
-
|
|
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 {
|
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 { 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
|
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,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 {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
210
|
-
approvalMode: { type: "string", enum:
|
|
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",
|
|
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 && !
|
|
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",
|
|
531
|
-
const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ??
|
|
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
|
-
|
|
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) =>
|
|
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>):
|
|
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",
|
|
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));
|
|
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[
|
|
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
|
-
}
|
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()));
|
|
@@ -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
|
-
}
|
package/src/recipe-loader.ts
CHANGED
|
@@ -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
|
-
}
|
package/src/recipe-validator.ts
CHANGED
|
@@ -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
|
-
}
|