mirror of https://github.com/openclaw/openclaw.git
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:
parent
ca414735b9
commit
55e79adf69
|
|
@ -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.
|
- 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/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/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
|
## 2026.3.7
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,18 +44,44 @@ describe("mapToolContextToSpawnedRunMetadata", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveSpawnedWorkspaceInheritance", () => {
|
describe("resolveSpawnedWorkspaceInheritance", () => {
|
||||||
|
const config = {
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{ id: "main", workspace: "/tmp/workspace-main" },
|
||||||
|
{ id: "ops", workspace: "/tmp/workspace-ops" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
it("prefers explicit workspaceDir when provided", () => {
|
it("prefers explicit workspaceDir when provided", () => {
|
||||||
const resolved = resolveSpawnedWorkspaceInheritance({
|
const resolved = resolveSpawnedWorkspaceInheritance({
|
||||||
config: {},
|
config,
|
||||||
requesterSessionKey: "agent:main:subagent:parent",
|
requesterSessionKey: "agent:main:subagent:parent",
|
||||||
explicitWorkspaceDir: " /tmp/explicit ",
|
explicitWorkspaceDir: " /tmp/explicit ",
|
||||||
});
|
});
|
||||||
expect(resolved).toBe("/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", () => {
|
it("returns undefined for missing requester context", () => {
|
||||||
const resolved = resolveSpawnedWorkspaceInheritance({
|
const resolved = resolveSpawnedWorkspaceInheritance({
|
||||||
config: {},
|
config,
|
||||||
requesterSessionKey: undefined,
|
requesterSessionKey: undefined,
|
||||||
explicitWorkspaceDir: undefined,
|
explicitWorkspaceDir: undefined,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export function mapToolContextToSpawnedRunMetadata(
|
||||||
|
|
||||||
export function resolveSpawnedWorkspaceInheritance(params: {
|
export function resolveSpawnedWorkspaceInheritance(params: {
|
||||||
config: OpenClawConfig;
|
config: OpenClawConfig;
|
||||||
|
targetAgentId?: string;
|
||||||
requesterSessionKey?: string;
|
requesterSessionKey?: string;
|
||||||
explicitWorkspaceDir?: string | null;
|
explicitWorkspaceDir?: string | null;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
|
|
@ -65,12 +66,13 @@ export function resolveSpawnedWorkspaceInheritance(params: {
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
return explicit;
|
return explicit;
|
||||||
}
|
}
|
||||||
const requesterAgentId = params.requesterSessionKey
|
// 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
|
? parseAgentSessionKey(params.requesterSessionKey)?.agentId
|
||||||
: undefined;
|
: undefined);
|
||||||
return requesterAgentId
|
return agentId ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(agentId)) : undefined;
|
||||||
? resolveAgentWorkspaceDir(params.config, normalizeAgentId(requesterAgentId))
|
|
||||||
: undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveIngressWorkspaceOverrideForSpawnedRun(
|
export function resolveIngressWorkspaceOverrideForSpawnedRun(
|
||||||
|
|
|
||||||
|
|
@ -576,8 +576,11 @@ export async function spawnSubagentDirect(
|
||||||
...toolSpawnMetadata,
|
...toolSpawnMetadata,
|
||||||
workspaceDir: resolveSpawnedWorkspaceInheritance({
|
workspaceDir: resolveSpawnedWorkspaceInheritance({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
requesterSessionKey: requesterInternalKey,
|
targetAgentId,
|
||||||
explicitWorkspaceDir: toolSpawnMetadata.workspaceDir,
|
// 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({
|
const spawnLineagePatchError = await patchChildSession({
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue