agent-relay-server 0.70.0 → 0.71.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.
@@ -49,16 +49,18 @@ const VALID_PROFILE_FILESYSTEM_SCOPES = ["repo", "workspace", "host"] as const;
49
49
  const VALID_PERMISSION_MODES = ["open", "guarded", "read-only"] as const;
50
50
  const VALID_POLICY_MODES = ["always-on", "on-demand"] as const;
51
51
  const VALID_MANAGED_STATUSES = ["stopped", "starting", "running", "stopping", "backoff", "failed"] as const;
52
+ export const DEFAULT_SPAWN_PROFILE_NAME = "default-relay";
52
53
  const COORDINATION_CHARTER = "Coordination charter: stay at helicopter altitude; delegate implementation to spawned workers; write briefs, maintain the blackboard and plan DAG; coordinate, review, and release; do not drift into mechanical edits when a worker can do them; protect your context for coordination.";
53
54
  const OPS_ASSISTANT_BRIEF = [
54
55
  "Ops-assistant standing brief: execute mechanical operations on your coordinator's behalf: test suites, release commands such as `bun run release`, smoke and verification probes, gates, and host/version/status checks.",
56
+ "When running a self-verifying long script, especially `bun run release` (gates -> CI-watch -> deploy -> host-verify, then one final result), run it and wait for completion. Do not poll, monitor, tail logs, run parallel status/version checks, or otherwise babysit it while it runs. Trust the script; act only on its final result.",
55
57
  "Hold and apply session-local ops conventions the coordinator gives you. Report crisp outcomes only: command/gate, pass/fail, commit/version/host evidence, and the next blocker when one exists. Do not dump logs unless asked.",
56
58
  "Do not make strategy, design, scope, or release-timing calls. Escalate those decisions to your spawn-parent. Report operational results to your spawn-parent by default and keep their context clean.",
57
59
  ].join("\n");
58
- const BUILT_IN_AGENT_PROFILE_NAMES = new Set(["default-relay", "minimal", "isolated-research", "vanilla", "lean-worker", "coordinator", "ops-assistant", "worker", "reviewer"]);
60
+ const BUILT_IN_AGENT_PROFILE_NAMES = new Set([DEFAULT_SPAWN_PROFILE_NAME, "minimal", "isolated-research", "vanilla", "lean-worker", "coordinator", "ops-assistant", "worker", "reviewer"]);
59
61
 
60
62
  const BUILT_IN_AGENT_PROFILES: AgentProfile[] = [
61
- agentProfileDefaults({ name: "default-relay", description: "Relay-aware spawned agent with bundled Relay context, skills, plugins, and status integration.", base: "host" }),
63
+ agentProfileDefaults({ name: DEFAULT_SPAWN_PROFILE_NAME, description: "Relay-aware spawned agent with bundled Relay context, skills, plugins, and status integration.", base: "host" }),
62
64
  agentProfileDefaults({ name: "minimal", description: "Repo-focused spawned agent with Relay affordances disabled and host/global extras avoided where supported.", base: "minimal" }),
63
65
  agentProfileDefaults({ name: "isolated-research", description: "Strict profile for autonomous research runs: repo context only, no Relay prompt surface, no global extras where supported.", base: "isolated" }),
64
66
  agentProfileDefaults({ name: "vanilla", description: "Zero host bleed (#552): isolated provider home with host plugins, skills, hooks, MCP, and the CLAUDE.md/AGENTS.md cascade (repo + global) all suppressed at launch. Relay provisions any surface explicitly per profile.", base: "vanilla" }),
@@ -1,4 +1,4 @@
1
- import { deriveContextThresholdFromWindowTokens, resolveProviderContextThreshold, type ProviderModelCatalogEntry, type ResolvedProviderContextThreshold } from "agent-relay-sdk/provider-catalog";
1
+ import { deriveContextThresholdFromWindowTokens, findProviderCatalogModel, resolveProviderContextThreshold, type ProviderModelCatalogEntry, type ResolvedProviderContextThreshold } from "agent-relay-sdk/provider-catalog";
2
2
  import { isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
3
3
  import { getConfig, getNotificationsConfig } from "./config-store";
4
4
  import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
@@ -132,10 +132,11 @@ export function handleContextPressure(input: {
132
132
  }
133
133
 
134
134
  function effectiveContextThreshold(agent: AgentCard): EffectiveContextThreshold {
135
- const model = findAgentCatalogModel(agent);
136
- const windowTokens = model?.limits?.contextWindowTokens?.value
137
- ?? finiteNumber(agent.providerCapabilities?.context?.windowTokens)
135
+ const observedWindowTokens = finiteNumber(agent.providerCapabilities?.context?.windowTokens)
138
136
  ?? finiteNumber(agent.context?.tokensMax);
137
+ const model = findAgentCatalogModel(agent, observedWindowTokens);
138
+ const windowTokens = model?.limits?.contextWindowTokens?.value
139
+ ?? observedWindowTokens;
139
140
  if (model?.contextThreshold) {
140
141
  return { ...resolveProviderContextThreshold(model), source: "model" };
141
142
  }
@@ -157,16 +158,14 @@ function explicitContextThresholdConfig(): EffectiveContextThreshold | undefined
157
158
  };
158
159
  }
159
160
 
160
- function findAgentCatalogModel(agent: AgentCard): ProviderModelCatalogEntry | undefined {
161
+ function findAgentCatalogModel(agent: AgentCard, observedWindowTokens: number | undefined): ProviderModelCatalogEntry | undefined {
161
162
  const runtime = agent.providerCapabilities?.model;
162
163
  const provider = runtime?.provider;
163
164
  if (!provider || !(SPAWN_PROVIDERS as readonly string[]).includes(provider)) return undefined;
164
165
  const catalog = effectiveProviderCatalogList().find((entry) => entry.provider === provider);
165
166
  if (!catalog) return undefined;
166
167
  const ids = [runtime.alias, runtime.id, runtime.providerModel].filter((value): value is string => Boolean(value));
167
- const lowerIds = new Set(ids.map((id) => id.toLowerCase()));
168
- return catalog.models.find((model) => ids.includes(model.alias) || ids.includes(model.providerModel))
169
- ?? catalog.models.find((model) => lowerIds.has(model.alias.toLowerCase()) || lowerIds.has(model.providerModel.toLowerCase()));
168
+ return findProviderCatalogModel(catalog, ids, { observedWindowTokens });
170
169
  }
171
170
 
172
171
  function clearContextAdvisory(context: ContextState): ContextState {
@@ -5,6 +5,7 @@ import { buildSpawnTargets } from "../spawn-targets";
5
5
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
6
6
  import { parseProjectInstructions, PROJECT_INSTRUCTION_POLICY_NAMES } from "../project-instructions";
7
7
  import { ValidationError } from "../db";
8
+ import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
8
9
  import { optionalEnum } from "../validation";
9
10
  import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
10
11
  import type { ProviderEffort } from "agent-relay-sdk/provider-catalog";
@@ -16,7 +17,7 @@ import { enumField, optionalNonNegativeInt, optionalPositiveInt, optionalString,
16
17
  export const SPAWN_TOOLS: ToolDefinition[] = [
17
18
  {
18
19
  name: "relay_spawn_agent",
19
- description: "Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via `prompt` in the same call. Defaults to your own host (override with orchestratorId) and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
20
+ description: `Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via \`prompt\` in the same call. Defaults to your own host (override with orchestratorId), applies profile \`${DEFAULT_SPAWN_PROFILE_NAME}\` when profile is omitted, and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).`,
20
21
  requiredScopes: ["command:spawn"],
21
22
  inputSchema: {
22
23
  type: "object",
@@ -32,7 +33,7 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
32
33
  expectReply: { type: "boolean", description: "Require the worker to report back: it gets a pending reply obligation to you and CANNOT go idle until it delivers its result (which routes to your inbox and wakes you). Defaults true when you pass a prompt — set false for a true fire-and-forget worker that needn't report (e.g. one that only lands a branch)." },
33
34
  systemPromptAppend: { type: "string" },
34
35
  projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
35
- profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
36
+ profile: { type: "string", description: `Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota). Omit to use \`${DEFAULT_SPAWN_PROFILE_NAME}\`.` },
36
37
  tags: { type: "array", items: { type: "string" } },
37
38
  capabilities: { type: "array", items: { type: "string" } },
38
39
  providerArgs: { type: "array", items: { type: "string" } },
@@ -62,7 +62,7 @@ type LandIssueRefSource = "association" | "commit" | "backlink";
62
62
 
63
63
  type CloseIssueLifecycleResult =
64
64
  | { issueRef: IssueRef; action: "closed"; source: LandIssueRefSource; landKey: string }
65
- | { issueRef: IssueRef; action: "skipped"; reason: "already-closed" | "already-processed"; landKey: string }
65
+ | { issueRef: IssueRef; action: "skipped"; reason: "already-closed" | "already-processed" | "open-sub-issues"; landKey: string }
66
66
  | { issueRef: IssueRef; action: "failed"; error: string; landKey: string };
67
67
 
68
68
  type ReopenIssueLifecycleResult =
@@ -208,6 +208,16 @@ function closeAuditComment(input: LandedIssueLifecycleInput): string {
208
208
  return `Closed by \`${commit}\` on merge to \`main\` - reopen if release surfaces more work.`;
209
209
  }
210
210
 
211
+ function issueLabel(ref: IssueRef, parent: IssueRef): string {
212
+ return ref.repo === parent.repo ? `#${ref.externalKey}` : `${ref.repo}#${ref.externalKey}`;
213
+ }
214
+
215
+ function backlinkProgressComment(input: LandedIssueLifecycleInput, parent: IssueRef, openSubIssues: IssueRef[]): string {
216
+ const commit = input.mergedSha ? input.mergedSha.slice(0, 12) : input.workspace.branch ?? input.workspace.id;
217
+ const childList = openSubIssues.map((ref) => issueLabel(ref, parent)).join(", ");
218
+ return `Linked landed slice \`${commit}\` to this parent issue, but Agent Relay left it open because these sub-issues are still open: ${childList}.`;
219
+ }
220
+
211
221
  async function reconcileClosedIssueRefBeforeSkip(issueRef: IssueRef, opts: IssueLifecycleOptions): Promise<{
212
222
  issueRef: IssueRef;
213
223
  liveStatus?: IssueRef["status"];
@@ -240,6 +250,19 @@ async function closeIssueRefForLand(
240
250
  if (reconciled.liveStatus !== "open") return { issueRef: latest, action: "skipped", reason: "already-closed", landKey: key };
241
251
  }
242
252
 
253
+ const openSubIssues = source === "backlink"
254
+ ? listIssuesForEntity("epic", latest.id).map((item) => item.issueRef).filter((ref) => ref.status === "open")
255
+ : [];
256
+ if (openSubIssues.length > 0) {
257
+ const comment = backlinkProgressComment(input, latest, openSubIssues);
258
+ try {
259
+ await adapterFor(latest, opts).comment(latest, comment);
260
+ } catch (error) {
261
+ logFailure(opts, `issue lifecycle epic backlink comment failed for ${latest.id} on ${key}: ${errMessage(error)}`);
262
+ }
263
+ return { issueRef: latest, action: "skipped", reason: "open-sub-issues", landKey: key };
264
+ }
265
+
243
266
  const comment = closeAuditComment(input);
244
267
  try {
245
268
  await adapterFor(latest, opts).close(latest, { comment, labels: ["fixed"] });
@@ -31,7 +31,7 @@ import { emitCommandEvent } from "../command-events";
31
31
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
32
32
  import { runnerRuntimeTokenEnv } from "../runtime-tokens";
33
33
  import { selectSpawnOrchestrator } from "../spawn-targets";
34
- import { getAgentProfile } from "../config-store";
34
+ import { DEFAULT_SPAWN_PROFILE_NAME, getAgentProfile } from "../config-store";
35
35
  import { composeSpawnInstructions, type ProjectInstructionPolicy } from "../project-instructions";
36
36
  import { autoSeedProjectForCwd } from "./project";
37
37
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
@@ -167,10 +167,11 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
167
167
  const spawnRequestId = input.spawnRequestId ?? generateSpawnRequestId();
168
168
 
169
169
  // Resolve + validate the named agent profile server-side (was HTTP-only; the MCP path passed
170
- // the name through unvalidated). Both transports now 404 on an unknown profile and ship the
171
- // resolved value to the orchestrator.
172
- const agentProfile = withResolvedProvisioning(input.profile ? getAgentProfile(input.profile)?.value : undefined, input.provider);
173
- if (input.profile && !agentProfile) throw new McpNotFoundError("agent profile not found");
170
+ // the name through unvalidated). Omitted profile uses Relay's spawn default so coordinator
171
+ // spawns still get the bundled Relay context/affordances. Explicit profile always wins.
172
+ const profileName = input.profile ?? DEFAULT_SPAWN_PROFILE_NAME;
173
+ const agentProfile = withResolvedProvisioning(getAgentProfile(profileName)?.value, input.provider);
174
+ if (!agentProfile) throw new McpNotFoundError(input.profile ? "agent profile not found" : `default agent profile ${DEFAULT_SPAWN_PROFILE_NAME} not found`);
174
175
 
175
176
  // --- THE GATE (uniform across transports) ---------------------------------
176
177
  // Coarse capability: spawning requires command:spawn. The MCP dispatcher already enforced
@@ -247,7 +248,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
247
248
  // dropped this when it unified the spawn service, silently un-spawning every default-spawner
248
249
  // (P0 regression #364). For an agent caller, agentInitiated still forces a non-spawn-capable
249
250
  // child regardless of profile (no grandchildren).
250
- profile: input.profile || undefined,
251
+ profile: profileName,
251
252
  ...(callerId ? { agentInitiated: true, spawnedBy: callerId } : {}),
252
253
  ...(callerTeam ? { teamId: callerTeam.id } : {}),
253
254
  ...(callerProject ? { projectId: callerProject.id } : {}),
@@ -270,7 +271,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
270
271
  ...(workspaceMode ? { workspaceMode } : {}),
271
272
  lifecycle,
272
273
  label: input.label,
273
- profile: input.profile || undefined,
274
+ profile: profileName,
274
275
  agentProfile,
275
276
  tags: input.tags ?? [],
276
277
  capabilities: input.capabilities ?? [],
@@ -1,5 +1,5 @@
1
1
  import { countLiveSpawnedAgents, getAgent, getOrchestrator, listOrchestrators, ValidationError } from "./db";
2
- import { listAgentProfiles } from "./config-store";
2
+ import { DEFAULT_SPAWN_PROFILE_NAME, listAgentProfiles } from "./config-store";
3
3
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
4
4
  import { isPathWithinBase } from "./utils";
5
5
  import { McpNotFoundError } from "./mcp-errors";
@@ -39,7 +39,7 @@ export function buildSpawnTargets(input: { callerId?: string; quota: number }):
39
39
  })),
40
40
  profiles: spawnProfilesSummary(),
41
41
  guidance:
42
- "Default to your own host (isSelf:true); only set orchestratorId to spawn onto another host that runs a provider/model yours doesn't. Omitting model/effort uses the provider default (marked below). Prefer a named profile over assembling raw knobs; set spawnRequestId for idempotency. After spawning you'll be pushed agent.ready / agent.exited / agent.spawn_failed — don't block-poll.",
42
+ `Default to your own host (isSelf:true); only set orchestratorId to spawn onto another host that runs a provider/model yours doesn't. Omitting profile applies ${DEFAULT_SPAWN_PROFILE_NAME}; omitting model/effort uses the provider default (marked below). Prefer a named profile over assembling raw knobs; set spawnRequestId for idempotency. After spawning you'll be pushed agent.ready / agent.exited / agent.spawn_failed — don't block-poll.`,
43
43
  };
44
44
  }
45
45
 
@@ -109,7 +109,7 @@ export function spawnCapablePrimer(input: { canSpawn: boolean; quota?: number })
109
109
  `You can spawn long-living child agents${quotaLabel}. Shut your own down with relay_shutdown_agent.`,
110
110
  "- BEFORE spawning, call relay_spawn_targets: it returns the LIVE matrix of hosts × providers × models × per-model effort, your remaining quota, and named profiles. Don't guess — that tool is the discovery path.",
111
111
  "- Default to your OWN host. Only set orchestratorId to spawn onto another host when it runs a provider/model yours doesn't, or the work must run there.",
112
- "- Prefer a named profile (profile: \"<name>\") over hand-assembling provider+model+effort+approvalMode. Omitting model/effort uses the provider default. Lean to the cheapest provider/model/effort that fits; escalate only for complex work.",
112
+ `- Prefer a named profile (profile: "<name>") over hand-assembling provider+model+effort+approvalMode. Omitting profile applies ${DEFAULT_SPAWN_PROFILE_NAME}; omitting model/effort uses the provider default. Lean to the cheapest provider/model/effort that fits; escalate only for complex work.`,
113
113
  "- Set spawnRequestId for idempotency so a retried spawn doesn't double-create.",
114
114
  "- Spawn is fire-and-forget: pass waitForRegistrationMs:0 and keep working — you'll be PUSHED agent.ready when the child is genuinely ready (past onboarding), or agent.spawn_failed / agent.exited otherwise. Don't block-poll.",
115
115
  "- Spawned children cannot themselves spawn (no grandchildren).",