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>
This commit is contained in:
Peter Steinberger
2026-03-07 23:55:51 +00:00
parent eeba93d63d
commit ab54532c8f
10 changed files with 80 additions and 2 deletions

View File

@@ -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

View File

@@ -182,6 +182,7 @@ export function createOpenClawTools(options?: {
agentGroupSpace: options?.agentGroupSpace,
sandboxed: options?.sandboxed,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
workspaceDir,
}),
createSubagentsTool({
agentSessionKey: options?.agentSessionKey,

View File

@@ -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,
});

View File

@@ -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",

View File

@@ -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<string, unknown>;
@@ -187,6 +189,7 @@ export function createSessionsSpawnTool(opts?: {
agentGroupChannel: opts?.agentGroupChannel,
agentGroupSpace: opts?.agentGroupSpace,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
workspaceDir: opts?.workspaceDir,
},
);

View File

@@ -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,

View File

@@ -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<AgentCommandOpts, "senderIsOwner"> & {

View File

@@ -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 },
);

View File

@@ -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",

View File

@@ -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,