fix: resolve target agent workspace for cross-agent subagent spawns (#40176)

Merged via squash.

Prepared head SHA: 2378e40383
Co-authored-by: moshehbenavraham <17122072+moshehbenavraham@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
This commit is contained in:
Max aka Mosheh 2026-03-13 17:09:51 +02:00 committed by GitHub
parent ca414735b9
commit 55e79adf69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 234 additions and 10 deletions

View File

@ -333,6 +333,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
## 2026.3.7

View File

@ -44,18 +44,44 @@ describe("mapToolContextToSpawnedRunMetadata", () => {
});
describe("resolveSpawnedWorkspaceInheritance", () => {
const config = {
agents: {
list: [
{ id: "main", workspace: "/tmp/workspace-main" },
{ id: "ops", workspace: "/tmp/workspace-ops" },
],
},
};
it("prefers explicit workspaceDir when provided", () => {
const resolved = resolveSpawnedWorkspaceInheritance({
config: {},
config,
requesterSessionKey: "agent:main:subagent:parent",
explicitWorkspaceDir: " /tmp/explicit ",
});
expect(resolved).toBe("/tmp/explicit");
});
it("prefers targetAgentId over requester session agent for cross-agent spawns", () => {
const resolved = resolveSpawnedWorkspaceInheritance({
config,
targetAgentId: "ops",
requesterSessionKey: "agent:main:subagent:parent",
});
expect(resolved).toBe("/tmp/workspace-ops");
});
it("falls back to requester session agent when targetAgentId is missing", () => {
const resolved = resolveSpawnedWorkspaceInheritance({
config,
requesterSessionKey: "agent:main:subagent:parent",
});
expect(resolved).toBe("/tmp/workspace-main");
});
it("returns undefined for missing requester context", () => {
const resolved = resolveSpawnedWorkspaceInheritance({
config: {},
config,
requesterSessionKey: undefined,
explicitWorkspaceDir: undefined,
});

View File

@ -58,6 +58,7 @@ export function mapToolContextToSpawnedRunMetadata(
export function resolveSpawnedWorkspaceInheritance(params: {
config: OpenClawConfig;
targetAgentId?: string;
requesterSessionKey?: string;
explicitWorkspaceDir?: string | null;
}): string | undefined {
@ -65,12 +66,13 @@ export function resolveSpawnedWorkspaceInheritance(params: {
if (explicit) {
return explicit;
}
const requesterAgentId = params.requesterSessionKey
? parseAgentSessionKey(params.requesterSessionKey)?.agentId
: undefined;
return requesterAgentId
? resolveAgentWorkspaceDir(params.config, normalizeAgentId(requesterAgentId))
: undefined;
// For cross-agent spawns, use the target agent's workspace instead of the requester's.
const agentId =
params.targetAgentId ??
(params.requesterSessionKey
? parseAgentSessionKey(params.requesterSessionKey)?.agentId
: undefined);
return agentId ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(agentId)) : undefined;
}
export function resolveIngressWorkspaceOverrideForSpawnedRun(

View File

@ -576,8 +576,11 @@ export async function spawnSubagentDirect(
...toolSpawnMetadata,
workspaceDir: resolveSpawnedWorkspaceInheritance({
config: cfg,
requesterSessionKey: requesterInternalKey,
explicitWorkspaceDir: toolSpawnMetadata.workspaceDir,
targetAgentId,
// For cross-agent spawns, ignore the caller's inherited workspace;
// let targetAgentId resolve the correct workspace instead.
explicitWorkspaceDir:
targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir,
}),
});
const spawnLineagePatchError = await patchChildSession({

View File

@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { spawnSubagentDirect } from "./subagent-spawn.js";
type TestAgentConfig = {
id?: string;
workspace?: string;
subagents?: {
allowAgents?: string[];
};
};
type TestConfig = {
agents?: {
list?: TestAgentConfig[];
};
};
const hoisted = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
configOverride: {} as Record<string, unknown>,
registerSubagentRunMock: vi.fn(),
}));
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => hoisted.configOverride,
};
});
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: () => "",
getOAuthProviders: () => [],
}));
vi.mock("./subagent-registry.js", () => ({
countActiveRunsForSession: () => 0,
registerSubagentRun: (args: unknown) => hoisted.registerSubagentRunMock(args),
}));
vi.mock("./subagent-announce.js", () => ({
buildSubagentSystemPrompt: () => "system-prompt",
}));
vi.mock("./subagent-depth.js", () => ({
getSubagentDepthFromSessionStore: () => 0,
}));
vi.mock("./model-selection.js", () => ({
resolveSubagentSpawnModelSelection: () => undefined,
}));
vi.mock("./sandbox/runtime-status.js", () => ({
resolveSandboxRuntimeStatus: () => ({ sandboxed: false }),
}));
vi.mock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => ({ hasHooks: () => false }),
}));
vi.mock("../utils/delivery-context.js", () => ({
normalizeDeliveryContext: (value: unknown) => value,
}));
vi.mock("./tools/sessions-helpers.js", () => ({
resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }),
resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main",
resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main",
}));
vi.mock("./agent-scope.js", () => ({
resolveAgentConfig: (cfg: TestConfig, agentId: string) =>
cfg.agents?.list?.find((entry) => entry.id === agentId),
resolveAgentWorkspaceDir: (cfg: TestConfig, agentId: string) =>
cfg.agents?.list?.find((entry) => entry.id === agentId)?.workspace ??
`/tmp/workspace-${agentId}`,
}));
function createConfigOverride(overrides?: Record<string, unknown>) {
return {
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
list: [
{
id: "main",
workspace: "/tmp/workspace-main",
},
],
},
...overrides,
};
}
function setupGatewayMock() {
hoisted.callGatewayMock.mockImplementation(
async (opts: { method?: string; params?: Record<string, unknown> }) => {
if (opts.method === "sessions.patch") {
return { ok: true };
}
if (opts.method === "sessions.delete") {
return { ok: true };
}
if (opts.method === "agent") {
return { runId: "run-1" };
}
return {};
},
);
}
function getRegisteredRun() {
return hoisted.registerSubagentRunMock.mock.calls.at(0)?.[0] as
| Record<string, unknown>
| undefined;
}
describe("spawnSubagentDirect workspace inheritance", () => {
beforeEach(() => {
hoisted.callGatewayMock.mockClear();
hoisted.registerSubagentRunMock.mockClear();
hoisted.configOverride = createConfigOverride();
setupGatewayMock();
});
it("uses the target agent workspace for cross-agent spawns", async () => {
hoisted.configOverride = createConfigOverride({
agents: {
list: [
{
id: "main",
workspace: "/tmp/workspace-main",
subagents: {
allowAgents: ["ops"],
},
},
{
id: "ops",
workspace: "/tmp/workspace-ops",
},
],
},
});
const result = await spawnSubagentDirect(
{
task: "inspect workspace",
agentId: "ops",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "telegram",
agentAccountId: "123",
agentTo: "456",
workspaceDir: "/tmp/requester-workspace",
},
);
expect(result.status).toBe("accepted");
expect(getRegisteredRun()).toMatchObject({
workspaceDir: "/tmp/workspace-ops",
});
});
it("preserves the inherited workspace for same-agent spawns", async () => {
const result = await spawnSubagentDirect(
{
task: "inspect workspace",
agentId: "main",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "telegram",
agentAccountId: "123",
agentTo: "456",
workspaceDir: "/tmp/requester-workspace",
},
);
expect(result.status).toBe("accepted");
expect(getRegisteredRun()).toMatchObject({
workspaceDir: "/tmp/requester-workspace",
});
});
});