Skip to content

Commit d17f73b

Browse files
committed
🤖 fix: keep agent inheritance scoped in SSH workspaces
Anchor agent frontmatter/body resolution to the known definition scope so host-global agents in SSH workspaces don't widen back into unnecessary project-scope SSH probes. This preserves host-global agent discovery while avoiding the SSH stat hangs we reproduced in integration tests. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$60.79`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=60.79 -->
1 parent 82ac47e commit d17f73b

File tree

7 files changed

+111
-11
lines changed

7 files changed

+111
-11
lines changed

src/node/orpc/router.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
} from "@/node/services/agentSkills/agentSkillsService";
6464
import {
6565
discoverAgentDefinitions,
66+
getSkipScopesAboveForKnownScope,
6667
readAgentDefinition,
6768
resolveAgentFrontmatter,
6869
} from "@/node/services/agentDefinitions/agentDefinitionsService";
@@ -1175,7 +1176,10 @@ export const router = (authToken?: string) => {
11751176
const resolvedFrontmatter = await resolveAgentFrontmatter(
11761177
runtime,
11771178
discoveryPath,
1178-
descriptor.id
1179+
descriptor.id,
1180+
{
1181+
skipScopesAbove: getSkipScopesAboveForKnownScope(descriptor.scope),
1182+
}
11791183
);
11801184

11811185
const effectivelyDisabled = isAgentEffectivelyDisabled({

src/node/services/agentDefinitions/agentDefinitionsService.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { RemoteRuntime, type SpawnResult } from "@/node/runtime/RemoteRuntime";
99
import { DisposableTempDir } from "@/node/services/tempDir";
1010
import {
1111
discoverAgentDefinitions,
12+
getSkipScopesAboveForKnownScope,
1213
readAgentDefinition,
1314
resolveAgentBody,
1415
resolveAgentFrontmatter,
@@ -163,6 +164,18 @@ class RemotePathMappedRuntime extends RemoteRuntime {
163164
}
164165
}
165166

167+
class TrackingRemotePathMappedRuntime extends RemotePathMappedRuntime {
168+
readonly statCalls: string[] = [];
169+
170+
override stat(
171+
filePath: string,
172+
abortSignal?: AbortSignal
173+
): ReturnType<RemotePathMappedRuntime["stat"]> {
174+
this.statCalls.push(filePath);
175+
return super.stat(filePath, abortSignal);
176+
}
177+
}
178+
166179
describe("agentDefinitionsService", () => {
167180
test("project agents override global agents", async () => {
168181
using project = new DisposableTempDir("agent-defs-project");
@@ -279,6 +292,44 @@ describe("agentDefinitionsService", () => {
279292
expect(body).toContain("Project instructions.");
280293
});
281294

295+
test("known global-scope resolution skips remote project probes during inheritance", async () => {
296+
using project = new DisposableTempDir("agent-defs-ssh-frontmatter-project");
297+
using global = new DisposableTempDir("agent-defs-ssh-frontmatter-global");
298+
299+
const remoteWorkspacePath = "/remote/workspace";
300+
const projectAgentsRoot = path.join(project.path, ".mux", "agents");
301+
const globalAgentsRoot = path.join(global.path, "agents");
302+
await fs.mkdir(projectAgentsRoot, { recursive: true });
303+
await fs.mkdir(globalAgentsRoot, { recursive: true });
304+
305+
await fs.writeFile(
306+
path.join(globalAgentsRoot, "asklike.md"),
307+
`---\nname: Ask Like\nbase: exec\n---\nAsk-like body.\n`,
308+
"utf-8"
309+
);
310+
311+
const roots = {
312+
projectRoot: path.posix.join(remoteWorkspacePath, ".mux", "agents"),
313+
globalRoot: globalAgentsRoot,
314+
};
315+
const runtime = new TrackingRemotePathMappedRuntime(project.path, remoteWorkspacePath);
316+
317+
const descriptors = await discoverAgentDefinitions(runtime, remoteWorkspacePath, { roots });
318+
const askLike = descriptors.find((descriptor) => descriptor.id === "asklike");
319+
expect(askLike).toBeDefined();
320+
expect(askLike?.scope).toBe("global");
321+
322+
const frontmatter = await resolveAgentFrontmatter(runtime, remoteWorkspacePath, "asklike", {
323+
roots,
324+
skipScopesAbove: getSkipScopesAboveForKnownScope(askLike!.scope),
325+
});
326+
327+
expect(frontmatter.name).toBe("Ask Like");
328+
expect(runtime.statCalls.some((filePath) => filePath.endsWith("/.mux/agents/exec.md"))).toBe(
329+
false
330+
);
331+
});
332+
282333
test("resolveAgentBody appends by default (new default), replaces when prompt.append is false", async () => {
283334
using tempDir = new DisposableTempDir("agent-body-test");
284335
const agentsRoot = path.join(tempDir.path, ".mux", "agents");

src/node/services/agentDefinitions/agentDefinitionsService.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,44 @@ export function agentVisitKey(id: AgentId, scope: AgentDefinitionScope): string
3939
return `${id}:${scope}`;
4040
}
4141

42+
/**
43+
* When the caller already knows which scope supplied an agent definition, skip any higher-priority
44+
* scopes so resolution stays anchored to that package instead of re-probing more specific roots.
45+
*
46+
* Examples:
47+
* - Known global agent → skip project scope
48+
* - Known built-in agent → skip project + global scopes
49+
*/
50+
export function getSkipScopesAboveForKnownScope(
51+
scope: AgentDefinitionScope
52+
): AgentDefinitionScope | undefined {
53+
switch (scope) {
54+
case "project":
55+
return undefined;
56+
case "global":
57+
return "project";
58+
case "built-in":
59+
return "global";
60+
}
61+
}
62+
4263
/**
4364
* Compute the skipScopesAbove value when resolving a base agent.
44-
* If the base has the same ID as the current agent, skip the current scope
45-
* to allow project/global agents to extend built-ins of the same name.
65+
*
66+
* Same-name inheritance (for example project/exec -> global|built-in exec) still skips the current
67+
* scope entirely. Otherwise, keep the lookup anchored to the current package's scope so a known
68+
* global or built-in agent does not widen back into project/global overrides during inheritance.
4669
*/
4770
export function computeBaseSkipScope(
4871
baseId: AgentId,
4972
currentId: AgentId,
5073
currentScope: AgentDefinitionScope
5174
): AgentDefinitionScope | undefined {
52-
return baseId === currentId ? currentScope : undefined;
75+
if (baseId === currentId) {
76+
return currentScope;
77+
}
78+
79+
return getSkipScopesAboveForKnownScope(currentScope);
5380
}
5481

5582
const GLOBAL_AGENTS_ROOT = "~/.mux/agents";

src/node/services/agentResolution.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { getErrorMessage } from "@/common/utils/errors";
2525
import { type ToolPolicy } from "@/common/utils/tools/toolPolicy";
2626
import type { Runtime } from "@/node/runtime/Runtime";
2727
import {
28+
getSkipScopesAboveForKnownScope,
2829
readAgentDefinition,
2930
resolveAgentFrontmatter,
3031
} from "@/node/services/agentDefinitions/agentDefinitionsService";
@@ -147,7 +148,10 @@ export async function resolveAgentForStream(
147148
const resolvedFrontmatter = await resolveAgentFrontmatter(
148149
runtime,
149150
agentDiscoveryPath,
150-
agentDefinition.id
151+
agentDefinition.id,
152+
{
153+
skipScopesAbove: getSkipScopesAboveForKnownScope(agentDefinition.scope),
154+
}
151155
);
152156

153157
const effectivelyDisabled = isAgentEffectivelyDisabled({

src/node/services/streamContextBuilder.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ async function buildSystemContextForTest(args: {
8787
metadata: args.metadata,
8888
workspacePath: args.workspacePath,
8989
workspaceId: args.metadata.id,
90-
agentDefinition: { id: "exec" },
90+
agentDefinition: { id: "exec", scope: "built-in" },
9191
agentDiscoveryPath: args.workspacePath,
9292
isSubagentWorkspace: args.isSubagentWorkspace,
9393
effectiveAdditionalInstructions: args.effectiveAdditionalInstructions,

src/node/services/streamContextBuilder.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { MuxMessage } from "@/common/types/message";
2020
import type { DesktopCapability } from "@/common/types/desktop";
2121
import type { ProjectsConfig } from "@/common/types/project";
2222
import type { MuxToolScope } from "@/common/types/toolScope";
23+
import type { AgentDefinitionScope } from "@/common/types/agentDefinition";
2324
import type { WorkspaceMetadata } from "@/common/types/workspace";
2425
import type { ProvidersConfigMap } from "@/common/orpc/types";
2526
import type { TaskSettings } from "@/common/types/tasks";
@@ -34,6 +35,7 @@ import {
3435
resolveAgentBody,
3536
resolveAgentFrontmatter,
3637
discoverAgentDefinitions,
38+
getSkipScopesAboveForKnownScope,
3739
type AgentDefinitionsRoots,
3840
} from "@/node/services/agentDefinitions/agentDefinitionsService";
3941
import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/agentEnablement";
@@ -222,7 +224,7 @@ export interface BuildStreamSystemContextOptions {
222224
workspacePath: string;
223225
workspaceId: string;
224226
/** Agent definition (may have fallen back to exec). Use `.id` for resolution. */
225-
agentDefinition: { id: string };
227+
agentDefinition: { id: string; scope: AgentDefinitionScope };
226228
agentDiscoveryPath: string;
227229
isSubagentWorkspace: boolean;
228230
effectiveAdditionalInstructions: string | undefined;
@@ -453,15 +455,20 @@ export async function buildStreamSystemContext(
453455

454456
// Resolve the body with inheritance (prompt.append merges with base).
455457
// Use agentDefinition.id (may have fallen back to exec) instead of effectiveAgentId.
456-
const resolvedBody = await resolveAgentBody(runtime, agentDiscoveryPath, agentDefinition.id);
458+
const resolvedBody = await resolveAgentBody(runtime, agentDiscoveryPath, agentDefinition.id, {
459+
skipScopesAbove: getSkipScopesAboveForKnownScope(agentDefinition.scope),
460+
});
457461

458462
let subagentAppendPrompt: string | undefined;
459463
if (isSubagentWorkspace) {
460464
try {
461465
const resolvedFrontmatter = await resolveAgentFrontmatter(
462466
runtime,
463467
agentDiscoveryPath,
464-
agentDefinition.id
468+
agentDefinition.id,
469+
{
470+
skipScopesAbove: getSkipScopesAboveForKnownScope(agentDefinition.scope),
471+
}
465472
);
466473
subagentAppendPrompt = resolvedFrontmatter.subagent?.append_prompt;
467474
} catch (error: unknown) {
@@ -605,7 +612,10 @@ export async function discoverAvailableSubagentsForToolContext(args: {
605612
args.runtime,
606613
args.workspacePath,
607614
descriptor.id,
608-
{ roots: args.roots }
615+
{
616+
roots: args.roots,
617+
skipScopesAbove: getSkipScopesAboveForKnownScope(descriptor.scope),
618+
}
609619
);
610620

611621
const effectivelyDisabled = isAgentEffectivelyDisabled({

src/node/services/taskService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { InitStateManager } from "@/node/services/initStateManager";
1313
import { log } from "@/node/services/log";
1414
import {
1515
discoverAgentDefinitions,
16+
getSkipScopesAboveForKnownScope,
1617
readAgentDefinition,
1718
resolveAgentFrontmatter,
1819
} from "@/node/services/agentDefinitions/agentDefinitionsService";
@@ -1195,7 +1196,10 @@ export class TaskService {
11951196
const frontmatter = await resolveAgentFrontmatter(
11961197
runtime,
11971198
parentWorkspacePath,
1198-
agent.id
1199+
agent.id,
1200+
{
1201+
skipScopesAbove: getSkipScopesAboveForKnownScope(agent.scope),
1202+
}
11991203
);
12001204
if (frontmatter.subagent?.runnable !== true) {
12011205
return null;

0 commit comments

Comments
 (0)