mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ export function createOpenClawTools(options?: {
|
||||
agentGroupSpace: options?.agentGroupSpace,
|
||||
sandboxed: options?.sandboxed,
|
||||
requesterAgentIdOverride: options?.requesterAgentIdOverride,
|
||||
workspaceDir,
|
||||
}),
|
||||
createSubagentsTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"> & {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user