Skip to content

Commit 1fef20c

Browse files
committed
fix(tasks): preserve requester agent attribution
1 parent 77d6ad6 commit 1fef20c

22 files changed

Lines changed: 216 additions & 23 deletions

docs/automation/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ A sweeper runs every **60 seconds** and handles four things:
360360

361361
</Accordion>
362362
<Accordion title="Tasks and sessions">
363-
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Sessions are conversation context; tasks are activity tracking on top of that.
363+
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Its `agentId` identifies the agent executing the work, while the requester and owner fields preserve launch and control context. Sessions are conversation context; tasks are activity tracking on top of that.
364364
</Accordion>
365365
<Accordion title="Tasks and agent runs">
366366
A task's `runId` links to the agent run doing the work. Agent lifecycle events (start, end, error) automatically update the task status - you do not need to manage the lifecycle manually.

docs/gateway/protocol.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,9 @@ runtime state.
542542
`TaskSummary` includes `id`, `status`, and optional metadata such as `kind`,
543543
`runtime`, `title`, `agentId`, `sessionKey`, `childSessionKey`, `ownerKey`,
544544
`runId`, `taskId`, `flowId`, `parentTaskId`, `sourceId`, timestamps, progress,
545-
terminal summary, and sanitized error text.
545+
terminal summary, and sanitized error text. `agentId` identifies the agent
546+
executing the task; `sessionKey` and `ownerKey` preserve requester and control
547+
context.
546548

547549
### Operator helper methods
548550

src/agents/acp-spawn.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2787,6 +2787,37 @@ describe("spawnAcpDirect", () => {
27872787
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
27882788
});
27892789

2790+
it("persists separate requester and executor agents for global cross-agent tasks", async () => {
2791+
replaceSpawnConfig({
2792+
...hoisted.state.cfg,
2793+
session: {
2794+
...hoisted.state.cfg.session,
2795+
scope: "global",
2796+
},
2797+
});
2798+
2799+
const result = await spawnAcpDirect(
2800+
{
2801+
task: "Investigate flaky tests",
2802+
agentId: "codex",
2803+
},
2804+
{
2805+
agentSessionKey: "global",
2806+
requesterAgentIdOverride: "research",
2807+
},
2808+
);
2809+
2810+
expectAcceptedSpawn(result);
2811+
expect(hoisted.createRunningTaskRunMock).toHaveBeenCalledWith(
2812+
expect.objectContaining({
2813+
ownerKey: "global",
2814+
childSessionKey: expect.stringMatching(/^agent:codex:acp:/),
2815+
agentId: "codex",
2816+
requesterAgentId: "research",
2817+
}),
2818+
);
2819+
});
2820+
27902821
it("does not implicitly stream for subagent requester sessions when heartbeat is disabled", async () => {
27912822
replaceSpawnConfig({
27922823
...hoisted.state.cfg,

src/agents/acp-spawn.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ function toGatewayImageAttachments(
162162

163163
export type SpawnAcpContext = {
164164
agentSessionKey?: string;
165+
requesterAgentIdOverride?: string;
165166
agentChannel?: string;
166167
agentAccountId?: string;
167168
agentTo?: string;
@@ -766,6 +767,7 @@ function prepareAcpThreadBinding(params: {
766767
function resolveAcpSpawnRequesterState(params: {
767768
cfg: OpenClawConfig;
768769
parentSessionKey?: string;
770+
requesterAgentId: string;
769771
targetAgentId: string;
770772
ctx: SpawnAcpContext;
771773
subagentStore?: SessionCapabilityStore;
@@ -784,8 +786,6 @@ function resolveAcpSpawnRequesterState(params: {
784786
typeof params.ctx.agentThreadId === "string"
785787
? Boolean(normalizeOptionalString(params.ctx.agentThreadId))
786788
: params.ctx.agentThreadId != null;
787-
const requesterAgentId = requesterParsedSession?.agentId;
788-
789789
return {
790790
parentSessionKey: params.parentSessionKey,
791791
isSubagentSession,
@@ -796,17 +796,17 @@ function resolveAcpSpawnRequesterState(params: {
796796
sessionKey: params.parentSessionKey,
797797
}),
798798
heartbeatRelayRouteUsable:
799-
params.parentSessionKey && requesterAgentId
799+
params.parentSessionKey && params.requesterAgentId
800800
? hasSessionLocalHeartbeatRelayRoute({
801801
cfg: params.cfg,
802802
parentSessionKey: params.parentSessionKey,
803-
requesterAgentId,
803+
requesterAgentId: params.requesterAgentId,
804804
})
805805
: false,
806806
origin: resolveRequesterOriginForChild({
807807
cfg: params.cfg,
808808
targetAgentId: params.targetAgentId,
809-
requesterAgentId: normalizeAgentId(requesterAgentId),
809+
requesterAgentId: params.requesterAgentId,
810810
requesterChannel: params.ctx.agentChannel,
811811
requesterAccountId: params.ctx.agentAccountId,
812812
requesterTo: params.ctx.agentTo,
@@ -820,6 +820,7 @@ function resolveAcpSpawnRequesterState(params: {
820820
function resolveAcpSubagentEnvelopeState(params: {
821821
cfg: OpenClawConfig;
822822
requesterSessionKey?: string;
823+
requesterAgentId: string;
823824
targetAgentId: string;
824825
requestedAgentId?: string;
825826
subagentStore?: SessionCapabilityStore;
@@ -860,9 +861,8 @@ function resolveAcpSubagentEnvelopeState(params: {
860861
};
861862
}
862863

863-
const requesterAgentId = normalizeAgentId(parseAgentSessionKey(requesterSessionKey)?.agentId);
864864
const requireAgentId =
865-
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.requireAgentId ??
865+
resolveAgentConfig(params.cfg, params.requesterAgentId)?.subagents?.requireAgentId ??
866866
params.cfg.agents?.defaults?.subagents?.requireAgentId ??
867867
false;
868868
if (requireAgentId && !params.requestedAgentId?.trim()) {
@@ -873,11 +873,11 @@ function resolveAcpSubagentEnvelopeState(params: {
873873
}
874874

875875
const targetPolicy = resolveSubagentTargetPolicy({
876-
requesterAgentId,
876+
requesterAgentId: params.requesterAgentId,
877877
targetAgentId: params.targetAgentId,
878878
requestedAgentId: params.requestedAgentId,
879879
allowAgents:
880-
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.allowAgents ??
880+
resolveAgentConfig(params.cfg, params.requesterAgentId)?.subagents?.allowAgents ??
881881
params.cfg.agents?.defaults?.subagents?.allowAgents,
882882
configuredAgentIds: resolveConfiguredAcpSubagentTargetIds(params.cfg),
883883
});
@@ -1275,6 +1275,9 @@ export async function spawnAcpDirect(
12751275
cfg,
12761276
requesterSessionKey: ctx.agentSessionKey,
12771277
});
1278+
const requesterAgentId = normalizeAgentId(
1279+
ctx.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId,
1280+
);
12781281
if (!isAcpEnabledByPolicy(cfg)) {
12791282
return createAcpSpawnFailure({
12801283
status: "forbidden",
@@ -1370,13 +1373,15 @@ export async function spawnAcpDirect(
13701373
const requesterState = resolveAcpSpawnRequesterState({
13711374
cfg,
13721375
parentSessionKey,
1376+
requesterAgentId,
13731377
targetAgentId,
13741378
ctx,
13751379
subagentStore,
13761380
});
13771381
const subagentEnvelopeState = resolveAcpSubagentEnvelopeState({
13781382
cfg,
13791383
requesterSessionKey: requesterInternalKey,
1384+
requesterAgentId,
13801385
targetAgentId,
13811386
requestedAgentId: params.agentId,
13821387
subagentStore,
@@ -1636,6 +1641,8 @@ export async function spawnAcpDirect(
16361641
scopeKind: "session",
16371642
requesterOrigin: requesterState.origin,
16381643
childSessionKey: sessionKey,
1644+
agentId: targetAgentId,
1645+
requesterAgentId,
16391646
runId: childRunId,
16401647
label: params.label,
16411648
task: params.task,
@@ -1675,6 +1682,8 @@ export async function spawnAcpDirect(
16751682
scopeKind: "session",
16761683
requesterOrigin: requesterState.origin,
16771684
childSessionKey: sessionKey,
1685+
agentId: targetAgentId,
1686+
requesterAgentId,
16781687
runId: childRunId,
16791688
label: params.label,
16801689
task: params.task,

src/agents/subagent-registry-run-manager.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export type RegisterSubagentRunParams = {
148148
task: string;
149149
taskName?: string;
150150
agentId?: string;
151+
requesterAgentId?: string;
151152
cleanup: "delete" | "keep";
152153
label?: string;
153154
model?: string;
@@ -692,6 +693,7 @@ export function createSubagentRunManager(params: {
692693
label: registerParams.label,
693694
task: registerParams.task,
694695
agentId: registerParams.agentId,
696+
requesterAgentId: registerParams.requesterAgentId,
695697
deliveryStatus:
696698
registerParams.expectsCompletionMessage === false ? "not_applicable" : "pending",
697699
startedAt: now,

src/agents/subagent-spawn.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ describe("spawnSubagentDirect seam flow", () => {
189189

190190
it("registers the target agent id for cross-agent task attribution", async () => {
191191
hoisted.configOverride = createConfigOverride({
192+
session: {
193+
scope: "global",
194+
},
192195
agents: {
193196
defaults: {
194197
workspace: os.tmpdir(),
@@ -215,7 +218,8 @@ describe("spawnSubagentDirect seam flow", () => {
215218
agentId: "worker",
216219
},
217220
{
218-
agentSessionKey: "agent:main:main",
221+
agentSessionKey: "global",
222+
requesterAgentIdOverride: "main",
219223
},
220224
);
221225

@@ -224,6 +228,8 @@ describe("spawnSubagentDirect seam flow", () => {
224228
const registerInput = firstRegisteredSubagentRun();
225229
expect(registerInput.childSessionKey).toBe(result.childSessionKey);
226230
expect(registerInput.agentId).toBe("worker");
231+
expect(registerInput.requesterSessionKey).toBe("global");
232+
expect(registerInput.requesterAgentId).toBe("main");
227233
});
228234

229235
it("accepts a spawned run across session patching, runtime-model persistence, registry registration, and lifecycle emission", async () => {

src/agents/subagent-spawn.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1671,6 +1671,7 @@ export async function spawnSubagentDirect(
16711671
task,
16721672
taskName,
16731673
agentId: targetAgentId,
1674+
requesterAgentId,
16741675
cleanup,
16751676
label: label || undefined,
16761677
model: resolvedModel,

src/agents/tools/sessions-spawn-tool.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@ describe("sessions_spawn tool", () => {
556556
registerAcpBackendForTest();
557557
const tool = createSessionsSpawnTool({
558558
agentSessionKey: "agent:main:main",
559+
requesterAgentIdOverride: "main",
559560
agentChannel: "quietchat",
560561
agentAccountId: "default",
561562
agentTo: "channel:123",
@@ -587,11 +588,13 @@ describe("sessions_spawn tool", () => {
587588
expect(spawnArgs.streamTo).toBe("parent");
588589
const spawnContext = mockCallArg(hoisted.spawnAcpDirectMock, 0, 1, "spawnAcpDirect");
589590
expect(spawnContext.agentSessionKey).toBe("agent:main:main");
591+
expect(spawnContext.requesterAgentIdOverride).toBe("main");
590592
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
591593
const registration = mockCallArg(hoisted.registerSubagentRunMock, 0, 0, "registerSubagentRun");
592594
expect(registration.runId).toBe("run-acp");
593595
expect(registration.childSessionKey).toBe("agent:codex:acp:1");
594596
expect(registration.requesterSessionKey).toBe("agent:main:main");
597+
expect(registration.requesterAgentId).toBe("main");
595598
expect(registration.task).toBe("investigate the failing CI run");
596599
expect(registration.cleanup).toBe("keep");
597600
expect(registration.spawnMode).toBe("session");

src/agents/tools/sessions-spawn-tool.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ export function createSessionsSpawnTool(
400400
},
401401
{
402402
agentSessionKey: opts?.agentSessionKey,
403+
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
403404
agentChannel: opts?.agentChannel,
404405
agentAccountId: opts?.agentAccountId,
405406
agentTo: opts?.agentTo,
@@ -447,6 +448,7 @@ export function createSessionsSpawnTool(
447448
requesterDisplayKey: ownership.completionRequesterDisplayKey,
448449
task,
449450
taskName,
451+
requesterAgentId: opts?.requesterAgentIdOverride,
450452
cleanup: trackedCleanup,
451453
label: label || undefined,
452454
runTimeoutSeconds: result.runTimeoutSeconds,

src/gateway/server-methods/artifacts.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ describe("artifacts RPC handlers", () => {
295295
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
296296
agentId: "work",
297297
requesterSessionKey: "global",
298+
ownerKey: "global",
298299
});
299300
mockedMessages([assistantFileMessage({ title: "task.txt", taskId: "task-global" })]);
300301

@@ -547,6 +548,61 @@ describe("artifacts RPC handlers", () => {
547548
});
548549
});
549550

551+
it("keeps cross-agent task artifacts scoped to the requester transcript", async () => {
552+
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
553+
requesterSessionKey: "agent:main:main",
554+
ownerKey: "agent:main:main",
555+
runId: "run-for-task-1",
556+
agentId: "worker",
557+
requesterAgentId: "main",
558+
});
559+
mockedMessages([
560+
assistantImageMessage({ alt: "task-result.png", data: "dGFyZ2V0", taskId: "task-1" }),
561+
]);
562+
563+
const { calls } = await listArtifacts(
564+
{ taskId: "task-1", agentId: "worker" },
565+
{ id: "task-cross-agent-requester-session" },
566+
);
567+
568+
expect(hoisted.resolveSessionKeyForRun).not.toHaveBeenCalled();
569+
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:main:main");
570+
expectFields(expectFirstArtifact(calls), {
571+
taskId: "task-1",
572+
sessionKey: "agent:main:main",
573+
});
574+
});
575+
576+
it("uses the requester agent store for cross-agent global task artifacts", async () => {
577+
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
578+
requesterSessionKey: "global",
579+
ownerKey: "global",
580+
runId: "run-for-task-1",
581+
agentId: "worker",
582+
requesterAgentId: "main",
583+
});
584+
mockedMessages([
585+
assistantImageMessage({ alt: "task-result.png", data: "dGFyZ2V0", taskId: "task-1" }),
586+
]);
587+
588+
const { calls } = await listArtifacts(
589+
{ taskId: "task-1", agentId: "worker" },
590+
{
591+
id: "task-cross-agent-global-requester",
592+
context: runtimeContext({
593+
session: { scope: "global" },
594+
agents: { list: [{ id: "main", default: true }, { id: "worker" }] },
595+
}),
596+
},
597+
);
598+
599+
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "main" });
600+
expectFields(expectFirstArtifact(calls), {
601+
taskId: "task-1",
602+
sessionKey: "global",
603+
});
604+
});
605+
550606
it("derives taskId artifact scope from requesterSessionKey when task agentId is absent", async () => {
551607
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
552608
requesterSessionKey: "agent:work:main",

0 commit comments

Comments
 (0)