From ab54532c8f420cf2e79e7d4d3ca5c232bef51898 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 23:55:51 +0000 Subject: [PATCH] fix(agents): land #39247 from @jasonQin6 (subagent workspace inheritance) Propagate parent workspace directories into spawned subagent runs, keep workspace override internal-only, and add regression tests for forwarding boundaries. Co-authored-by: jasonQin6 <991262382@qq.com> --- CHANGELOG.md | 1 + src/agents/openclaw-tools.ts | 1 + src/agents/subagent-spawn.ts | 13 ++++++++ src/agents/tools/sessions-spawn-tool.test.ts | 19 +++++++++++ src/agents/tools/sessions-spawn-tool.ts | 5 ++- src/commands/agent.ts | 4 ++- src/commands/agent/types.ts | 2 ++ src/gateway/protocol/schema/agent.ts | 1 + src/gateway/server-methods/agent.test.ts | 33 ++++++++++++++++++++ src/gateway/server-methods/agent.ts | 3 ++ 10 files changed, 80 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df7aa68ba1a..54e8b319cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -309,6 +309,7 @@ Docs: https://docs.openclaw.ai - Docker/token persistence on reconfigure: reuse the existing `.env` gateway token during `docker-setup.sh` reruns and align compose token env defaults, so Docker installs stop silently rotating tokens and breaking existing dashboard sessions. Landed from contributor PR #33097 by @chengzhichao-xydt. Thanks @chengzhichao-xydt. - Agents/strict OpenAI turn ordering: apply assistant-first transcript bootstrap sanitization to strict OpenAI-compatible providers (for example vLLM/Gemma via `openai-completions`) without adding Google-specific session markers, preventing assistant-first history rejections. (#39252) Thanks @scoootscooob. - Discord/exec approvals gateway auth: pass resolved shared gateway credentials into the Discord exec-approvals gateway client so token-auth installs stop failing approvals with `gateway token mismatch`. Related to #38179. Thanks @0riginal-claw for the adjacent PR #35147 investigation. +- Subagents/workspace inheritance: propagate parent workspace directory to spawned subagent runs so child sessions reliably inherit workspace-scoped instructions (`AGENTS.md`, `SOUL.md`, etc.) without exposing workspace override through tool-call arguments. (#39247) Thanks @jasonQin6. ## 2026.3.2 diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 6dc694c6350..1fd53069fbf 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -182,6 +182,7 @@ export function createOpenClawTools(options?: { agentGroupSpace: options?.agentGroupSpace, sandboxed: options?.sandboxed, requesterAgentIdOverride: options?.requesterAgentIdOverride, + workspaceDir, }), createSubagentsTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index bf6e2724ecc..08f78d6fa98 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -85,6 +85,8 @@ export type SpawnSubagentContext = { agentGroupChannel?: string | null; agentGroupSpace?: string | null; requesterAgentIdOverride?: string; + /** Explicit workspace directory for subagent to inherit (optional). */ + workspaceDir?: string; }; export const SUBAGENT_SPAWN_ACCEPTED_NOTE = @@ -697,6 +699,16 @@ export async function spawnSubagentDirect( .filter((line): line is string => Boolean(line)) .join("\n\n"); + // Resolve workspace directory for subagent to inherit from requester. + const requesterWorkspaceAgentId = requesterInternalKey + ? parseAgentSessionKey(requesterInternalKey)?.agentId + : undefined; + const workspaceDir = + ctx.workspaceDir?.trim() ?? + (requesterWorkspaceAgentId + ? resolveAgentWorkspaceDir(cfg, normalizeAgentId(requesterWorkspaceAgentId)) + : undefined); + const childIdem = crypto.randomUUID(); let childRunId: string = childIdem; try { @@ -720,6 +732,7 @@ export async function spawnSubagentDirect( groupId: ctx.agentGroupId ?? undefined, groupChannel: ctx.agentGroupChannel ?? undefined, groupSpace: ctx.agentGroupSpace ?? undefined, + workspaceDir, }, timeoutMs: 10_000, }); diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index a000000f1ee..01568462912 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -79,6 +79,25 @@ describe("sessions_spawn tool", () => { expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled(); }); + it("passes inherited workspaceDir from tool context, not from tool args", async () => { + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + workspaceDir: "/parent/workspace", + }); + + await tool.execute("call-ws", { + task: "inspect AGENTS", + workspaceDir: "/tmp/attempted-override", + }); + + expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + workspaceDir: "/parent/workspace", + }), + ); + }); + it("routes to ACP runtime when runtime=acp", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 03a138e8a0f..5d84dcfae48 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -70,12 +70,14 @@ export function createSessionsSpawnTool(opts?: { sandboxed?: boolean; /** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */ requesterAgentIdOverride?: string; + /** Internal-only workspace inheritance path for spawned subagents. */ + workspaceDir?: string; }): AnyAgentTool { return { label: "Sessions", name: "sessions_spawn", description: - 'Spawn an isolated session (runtime="subagent" or runtime="acp"). mode="run" is one-shot and mode="session" is persistent/thread-bound.', + 'Spawn an isolated session (runtime="subagent" or runtime="acp"). mode="run" is one-shot and mode="session" is persistent/thread-bound. Subagents inherit the parent workspace directory automatically.', parameters: SessionsSpawnToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -187,6 +189,7 @@ export function createSessionsSpawnTool(opts?: { agentGroupChannel: opts?.agentGroupChannel, agentGroupSpace: opts?.agentGroupSpace, requesterAgentIdOverride: opts?.requesterAgentIdOverride, + workspaceDir: opts?.workspaceDir, }, ); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index cd760d9eba2..88c54c4bfac 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -539,7 +539,9 @@ async function agentCommandInternal( agentId: sessionAgentId, sessionKey, }); - const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId); + // Internal callers (for example subagent spawns) may pin workspace inheritance. + const workspaceDirRaw = + opts.workspaceDir?.trim() ?? resolveAgentWorkspaceDir(cfg, sessionAgentId); const agentDir = resolveAgentDir(cfg, sessionAgentId); const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index b92f22dad8e..87a8e0a7cb5 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -80,6 +80,8 @@ export type AgentCommandOpts = { inputProvenance?: InputProvenance; /** Per-call stream param overrides (best-effort). */ streamParams?: AgentStreamParams; + /** Explicit workspace directory override (for subagents to inherit parent workspace). */ + workspaceDir?: string; }; export type AgentCommandIngressOpts = Omit & { diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 63660a1de62..68b3fb0b83c 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -110,6 +110,7 @@ export const AgentParamsSchema = Type.Object( idempotencyKey: NonEmptyString, label: Type.Optional(SessionLabelString), spawnedBy: Type.Optional(Type.String()), + workspaceDir: Type.Optional(Type.String()), }, { additionalProperties: false }, ); diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index d00da68b255..d5a30f7bb6f 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -409,6 +409,39 @@ describe("gateway agent handler", () => { expect(callArgs.bestEffortDeliver).toBe(false); }); + it("only forwards workspaceDir for spawned subagent runs", async () => { + primeMainAgentRun(); + mocks.agentCommand.mockClear(); + + await invokeAgent( + { + message: "normal run", + sessionKey: "agent:main:main", + workspaceDir: "/tmp/ignored", + idempotencyKey: "workspace-ignored", + }, + { reqId: "workspace-ignored-1" }, + ); + await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); + const normalCall = mocks.agentCommand.mock.calls.at(-1)?.[0] as { workspaceDir?: string }; + expect(normalCall.workspaceDir).toBeUndefined(); + mocks.agentCommand.mockClear(); + + await invokeAgent( + { + message: "spawned run", + sessionKey: "agent:main:main", + spawnedBy: "agent:main:subagent:parent", + workspaceDir: "/tmp/inherited", + idempotencyKey: "workspace-forwarded", + }, + { reqId: "workspace-forwarded-1" }, + ); + await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); + const spawnedCall = mocks.agentCommand.mock.calls.at(-1)?.[0] as { workspaceDir?: string }; + expect(spawnedCall.workspaceDir).toBe("/tmp/inherited"); + }); + it("keeps origin messageChannel as webchat while delivery channel uses last session channel", async () => { mockMainSessionEntry({ sessionId: "existing-session-id", diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index aa56b857aca..2b166f1ecff 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -211,6 +211,7 @@ export const agentHandlers: GatewayRequestHandlers = { label?: string; spawnedBy?: string; inputProvenance?: InputProvenance; + workspaceDir?: string; }; const senderIsOwner = resolveSenderIsOwnerFromClient(client); const cfg = loadConfig(); @@ -645,6 +646,8 @@ export const agentHandlers: GatewayRequestHandlers = { extraSystemPrompt: request.extraSystemPrompt, internalEvents: request.internalEvents, inputProvenance, + // Internal-only: allow workspace override for spawned subagent runs. + workspaceDir: spawnedByValue ? request.workspaceDir : undefined, senderIsOwner, }, defaultRuntime,