Skip to content

Commit 55e79ad

Browse files
fix: resolve target agent workspace for cross-agent subagent spawns (#40176)
Merged via squash. Prepared head SHA: 2378e40 Co-authored-by: moshehbenavraham <[email protected]> Co-authored-by: mcaxtr <[email protected]> Reviewed-by: @mcaxtr
1 parent ca41473 commit 55e79ad

5 files changed

Lines changed: 234 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ Docs: https://docs.openclaw.ai
333333
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
334334
- Agents/embedded runner: carry provider-observed overflow token counts into compaction so overflow retries and diagnostics use the rejected live prompt size instead of only transcript estimates. (#40357) thanks @rabsef-bicrym.
335335
- Agents/compaction transcript updates: emit a transcript-update event immediately after successful embedded compaction so downstream listeners observe the post-compact transcript without waiting for a later write. (#25558) thanks @rodrigouroz.
336+
- Agents/sessions_spawn: use the target agent workspace for cross-agent spawned runs instead of inheriting the caller workspace, so child sessions load the correct workspace-scoped instructions and persona files. (#40176) Thanks @moshehbenavraham.
336337

337338
## 2026.3.7
338339

src/agents/spawned-context.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,44 @@ describe("mapToolContextToSpawnedRunMetadata", () => {
4444
});
4545

4646
describe("resolveSpawnedWorkspaceInheritance", () => {
47+
const config = {
48+
agents: {
49+
list: [
50+
{ id: "main", workspace: "/tmp/workspace-main" },
51+
{ id: "ops", workspace: "/tmp/workspace-ops" },
52+
],
53+
},
54+
};
55+
4756
it("prefers explicit workspaceDir when provided", () => {
4857
const resolved = resolveSpawnedWorkspaceInheritance({
49-
config: {},
58+
config,
5059
requesterSessionKey: "agent:main:subagent:parent",
5160
explicitWorkspaceDir: " /tmp/explicit ",
5261
});
5362
expect(resolved).toBe("/tmp/explicit");
5463
});
5564

65+
it("prefers targetAgentId over requester session agent for cross-agent spawns", () => {
66+
const resolved = resolveSpawnedWorkspaceInheritance({
67+
config,
68+
targetAgentId: "ops",
69+
requesterSessionKey: "agent:main:subagent:parent",
70+
});
71+
expect(resolved).toBe("/tmp/workspace-ops");
72+
});
73+
74+
it("falls back to requester session agent when targetAgentId is missing", () => {
75+
const resolved = resolveSpawnedWorkspaceInheritance({
76+
config,
77+
requesterSessionKey: "agent:main:subagent:parent",
78+
});
79+
expect(resolved).toBe("/tmp/workspace-main");
80+
});
81+
5682
it("returns undefined for missing requester context", () => {
5783
const resolved = resolveSpawnedWorkspaceInheritance({
58-
config: {},
84+
config,
5985
requesterSessionKey: undefined,
6086
explicitWorkspaceDir: undefined,
6187
});

src/agents/spawned-context.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,21 @@ export function mapToolContextToSpawnedRunMetadata(
5858

5959
export function resolveSpawnedWorkspaceInheritance(params: {
6060
config: OpenClawConfig;
61+
targetAgentId?: string;
6162
requesterSessionKey?: string;
6263
explicitWorkspaceDir?: string | null;
6364
}): string | undefined {
6465
const explicit = normalizeOptionalText(params.explicitWorkspaceDir);
6566
if (explicit) {
6667
return explicit;
6768
}
68-
const requesterAgentId = params.requesterSessionKey
69-
? parseAgentSessionKey(params.requesterSessionKey)?.agentId
70-
: undefined;
71-
return requesterAgentId
72-
? resolveAgentWorkspaceDir(params.config, normalizeAgentId(requesterAgentId))
73-
: undefined;
69+
// For cross-agent spawns, use the target agent's workspace instead of the requester's.
70+
const agentId =
71+
params.targetAgentId ??
72+
(params.requesterSessionKey
73+
? parseAgentSessionKey(params.requesterSessionKey)?.agentId
74+
: undefined);
75+
return agentId ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(agentId)) : undefined;
7476
}
7577

7678
export function resolveIngressWorkspaceOverrideForSpawnedRun(

src/agents/subagent-spawn.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,8 +576,11 @@ export async function spawnSubagentDirect(
576576
...toolSpawnMetadata,
577577
workspaceDir: resolveSpawnedWorkspaceInheritance({
578578
config: cfg,
579-
requesterSessionKey: requesterInternalKey,
580-
explicitWorkspaceDir: toolSpawnMetadata.workspaceDir,
579+
targetAgentId,
580+
// For cross-agent spawns, ignore the caller's inherited workspace;
581+
// let targetAgentId resolve the correct workspace instead.
582+
explicitWorkspaceDir:
583+
targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir,
581584
}),
582585
});
583586
const spawnLineagePatchError = await patchChildSession({
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { spawnSubagentDirect } from "./subagent-spawn.js";
3+
4+
type TestAgentConfig = {
5+
id?: string;
6+
workspace?: string;
7+
subagents?: {
8+
allowAgents?: string[];
9+
};
10+
};
11+
12+
type TestConfig = {
13+
agents?: {
14+
list?: TestAgentConfig[];
15+
};
16+
};
17+
18+
const hoisted = vi.hoisted(() => ({
19+
callGatewayMock: vi.fn(),
20+
configOverride: {} as Record<string, unknown>,
21+
registerSubagentRunMock: vi.fn(),
22+
}));
23+
24+
vi.mock("../gateway/call.js", () => ({
25+
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
26+
}));
27+
28+
vi.mock("../config/config.js", async (importOriginal) => {
29+
const actual = await importOriginal<typeof import("../config/config.js")>();
30+
return {
31+
...actual,
32+
loadConfig: () => hoisted.configOverride,
33+
};
34+
});
35+
36+
vi.mock("@mariozechner/pi-ai/oauth", () => ({
37+
getOAuthApiKey: () => "",
38+
getOAuthProviders: () => [],
39+
}));
40+
41+
vi.mock("./subagent-registry.js", () => ({
42+
countActiveRunsForSession: () => 0,
43+
registerSubagentRun: (args: unknown) => hoisted.registerSubagentRunMock(args),
44+
}));
45+
46+
vi.mock("./subagent-announce.js", () => ({
47+
buildSubagentSystemPrompt: () => "system-prompt",
48+
}));
49+
50+
vi.mock("./subagent-depth.js", () => ({
51+
getSubagentDepthFromSessionStore: () => 0,
52+
}));
53+
54+
vi.mock("./model-selection.js", () => ({
55+
resolveSubagentSpawnModelSelection: () => undefined,
56+
}));
57+
58+
vi.mock("./sandbox/runtime-status.js", () => ({
59+
resolveSandboxRuntimeStatus: () => ({ sandboxed: false }),
60+
}));
61+
62+
vi.mock("../plugins/hook-runner-global.js", () => ({
63+
getGlobalHookRunner: () => ({ hasHooks: () => false }),
64+
}));
65+
66+
vi.mock("../utils/delivery-context.js", () => ({
67+
normalizeDeliveryContext: (value: unknown) => value,
68+
}));
69+
70+
vi.mock("./tools/sessions-helpers.js", () => ({
71+
resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }),
72+
resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main",
73+
resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main",
74+
}));
75+
76+
vi.mock("./agent-scope.js", () => ({
77+
resolveAgentConfig: (cfg: TestConfig, agentId: string) =>
78+
cfg.agents?.list?.find((entry) => entry.id === agentId),
79+
resolveAgentWorkspaceDir: (cfg: TestConfig, agentId: string) =>
80+
cfg.agents?.list?.find((entry) => entry.id === agentId)?.workspace ??
81+
`/tmp/workspace-${agentId}`,
82+
}));
83+
84+
function createConfigOverride(overrides?: Record<string, unknown>) {
85+
return {
86+
session: {
87+
mainKey: "main",
88+
scope: "per-sender",
89+
},
90+
agents: {
91+
list: [
92+
{
93+
id: "main",
94+
workspace: "/tmp/workspace-main",
95+
},
96+
],
97+
},
98+
...overrides,
99+
};
100+
}
101+
102+
function setupGatewayMock() {
103+
hoisted.callGatewayMock.mockImplementation(
104+
async (opts: { method?: string; params?: Record<string, unknown> }) => {
105+
if (opts.method === "sessions.patch") {
106+
return { ok: true };
107+
}
108+
if (opts.method === "sessions.delete") {
109+
return { ok: true };
110+
}
111+
if (opts.method === "agent") {
112+
return { runId: "run-1" };
113+
}
114+
return {};
115+
},
116+
);
117+
}
118+
119+
function getRegisteredRun() {
120+
return hoisted.registerSubagentRunMock.mock.calls.at(0)?.[0] as
121+
| Record<string, unknown>
122+
| undefined;
123+
}
124+
125+
describe("spawnSubagentDirect workspace inheritance", () => {
126+
beforeEach(() => {
127+
hoisted.callGatewayMock.mockClear();
128+
hoisted.registerSubagentRunMock.mockClear();
129+
hoisted.configOverride = createConfigOverride();
130+
setupGatewayMock();
131+
});
132+
133+
it("uses the target agent workspace for cross-agent spawns", async () => {
134+
hoisted.configOverride = createConfigOverride({
135+
agents: {
136+
list: [
137+
{
138+
id: "main",
139+
workspace: "/tmp/workspace-main",
140+
subagents: {
141+
allowAgents: ["ops"],
142+
},
143+
},
144+
{
145+
id: "ops",
146+
workspace: "/tmp/workspace-ops",
147+
},
148+
],
149+
},
150+
});
151+
152+
const result = await spawnSubagentDirect(
153+
{
154+
task: "inspect workspace",
155+
agentId: "ops",
156+
},
157+
{
158+
agentSessionKey: "agent:main:main",
159+
agentChannel: "telegram",
160+
agentAccountId: "123",
161+
agentTo: "456",
162+
workspaceDir: "/tmp/requester-workspace",
163+
},
164+
);
165+
166+
expect(result.status).toBe("accepted");
167+
expect(getRegisteredRun()).toMatchObject({
168+
workspaceDir: "/tmp/workspace-ops",
169+
});
170+
});
171+
172+
it("preserves the inherited workspace for same-agent spawns", async () => {
173+
const result = await spawnSubagentDirect(
174+
{
175+
task: "inspect workspace",
176+
agentId: "main",
177+
},
178+
{
179+
agentSessionKey: "agent:main:main",
180+
agentChannel: "telegram",
181+
agentAccountId: "123",
182+
agentTo: "456",
183+
workspaceDir: "/tmp/requester-workspace",
184+
},
185+
);
186+
187+
expect(result.status).toBe("accepted");
188+
expect(getRegisteredRun()).toMatchObject({
189+
workspaceDir: "/tmp/requester-workspace",
190+
});
191+
});
192+
});

0 commit comments

Comments
 (0)