Skip to content

Commit 11ba938

Browse files
fix: prevent A2A target from reverse-calling sessions_send to requester (#39476)
Co-authored-by: Cursor <[email protected]>
1 parent 66b91d7 commit 11ba938

7 files changed

Lines changed: 134 additions & 1 deletion

File tree

src/agents/agent-tools.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,13 @@ export function createOpenClawCodingTools(options?: {
496496
/** Visible source replies must be sent through the message tool when set to message_tool_only. */
497497
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
498498
inboundEventKind?: InboundEventKind;
499+
/**
500+
* True when this run is processing a sessions_send agent-to-agent message. The
501+
* target's reply already returns through the sessions_send tool result, so the
502+
* routed turn omits sessions_send to stop the target reverse-calling the
503+
* requester and duplicating content (issue #39476).
504+
*/
505+
interAgentSendTurn?: boolean;
499506
/** If true, omit the message tool from the tool list. */
500507
disableMessageTool?: boolean;
501508
/** Keep the message tool available even when the selected profile omits it. */
@@ -1024,6 +1031,7 @@ export function createOpenClawCodingTools(options?: {
10241031
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
10251032
sourceReplyDeliveryMode: options?.sourceReplyDeliveryMode,
10261033
inboundEventKind: options?.inboundEventKind,
1034+
interAgentSendTurn: options?.interAgentSendTurn,
10271035
disableMessageTool: options?.disableMessageTool,
10281036
enableHeartbeatTool,
10291037
disablePluginTools: !includePluginTools,

src/agents/embedded-agent-runner/run/attempt.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,33 @@ describe("buildEmbeddedAttemptToolRunContext", () => {
126126
expect(context.memoryFlushWritePath).toBe("memory/log.md");
127127
expect(context.runtimeToolAllowlist).toEqual(["memory_search", "memory_get"]);
128128
});
129+
130+
it("flags sessions_send A2A turns so tool construction can drop sessions_send", () => {
131+
const context = buildEmbeddedAttemptToolRunContext({
132+
trigger: "manual",
133+
inputProvenance: {
134+
kind: "inter_session",
135+
sourceSessionKey: "agent:requester:discord:source",
136+
sourceTool: "sessions_send",
137+
},
138+
});
139+
expect(context.interAgentSendTurn).toBe(true);
140+
});
141+
142+
it("does not flag normal user turns or non-send inter-session handoffs", () => {
143+
expect(
144+
buildEmbeddedAttemptToolRunContext({
145+
trigger: "manual",
146+
inputProvenance: { kind: "external_user", sourceChannel: "discord" },
147+
}).interAgentSendTurn,
148+
).toBeUndefined();
149+
expect(
150+
buildEmbeddedAttemptToolRunContext({
151+
trigger: "manual",
152+
inputProvenance: { kind: "inter_session", sourceTool: "subagent_announce" },
153+
}).interAgentSendTurn,
154+
).toBeUndefined();
155+
});
129156
});
130157

131158
describe("resolvePromptBuildHookResult", () => {

src/agents/embedded-agent-runner/run/attempt.tool-run-context.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
freezeDiagnosticTraceContext,
66
type DiagnosticTraceContext,
77
} from "../../../infra/diagnostic-trace-context.js";
8+
import {
9+
type InputProvenance,
10+
isAgentToAgentSendInputProvenance,
11+
} from "../../../sessions/input-provenance.js";
812
import type { EmbeddedRunTrigger } from "./params.js";
913

1014
/**
@@ -15,19 +19,27 @@ export function buildEmbeddedAttemptToolRunContext(params: {
1519
jobId?: string;
1620
memoryFlushWritePath?: string;
1721
toolsAllow?: string[];
22+
inputProvenance?: InputProvenance;
1823
trace?: DiagnosticTraceContext;
1924
}): {
2025
trigger?: EmbeddedRunTrigger;
2126
jobId?: string;
2227
memoryFlushWritePath?: string;
2328
runtimeToolAllowlist?: string[];
29+
interAgentSendTurn?: boolean;
2430
trace?: DiagnosticTraceContext;
2531
} {
2632
return {
2733
trigger: params.trigger,
2834
jobId: params.jobId,
2935
memoryFlushWritePath: params.memoryFlushWritePath,
3036
...(params.toolsAllow ? { runtimeToolAllowlist: params.toolsAllow } : {}),
37+
// A sessions_send A2A turn already returns its reply through the tool result.
38+
// Flag it so tool construction drops sessions_send and the target cannot
39+
// reverse-call the requester (issue #39476).
40+
...(isAgentToAgentSendInputProvenance(params.inputProvenance)
41+
? { interAgentSendTurn: true }
42+
: {}),
3143
// Freeze trace metadata at the attempt boundary so later mutable diagnostic updates do not
3244
// rewrite the facts attached to tool calls already in flight.
3345
...(params.trace ? { trace: freezeDiagnosticTraceContext(params.trace) } : {}),
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Verifies createOpenClawTools drops sessions_send on a sessions_send A2A turn so
2+
// the target cannot reverse-call the requester and duplicate content (issue #39476).
3+
import { beforeEach, describe, expect, it } from "vitest";
4+
import { setActivePluginRegistry } from "../plugins/runtime.js";
5+
import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js";
6+
import { createOpenClawTools } from "./openclaw-tools.js";
7+
8+
const BASE_OPTIONS = {
9+
agentSessionKey: "agent:main:discord:channel:target-room",
10+
agentChannel: "discord",
11+
// Keep construction to shipped core tools so the assertion stays focused.
12+
disableMessageTool: true,
13+
disablePluginTools: true,
14+
} as const;
15+
16+
function toolNames(options: Parameters<typeof createOpenClawTools>[0]): string[] {
17+
return createOpenClawTools(options).map((tool) => tool.name);
18+
}
19+
20+
describe("createOpenClawTools sessions_send A2A gate", () => {
21+
beforeEach(() => {
22+
setActivePluginRegistry(createSessionConversationTestRegistry());
23+
});
24+
25+
it("exposes sessions_send on a normal turn", () => {
26+
expect(toolNames(BASE_OPTIONS)).toContain("sessions_send");
27+
});
28+
29+
it("omits sessions_send when the turn is itself a sessions_send A2A turn", () => {
30+
const names = toolNames({ ...BASE_OPTIONS, interAgentSendTurn: true });
31+
expect(names).not.toContain("sessions_send");
32+
// Only the reverse-call vector is removed; other session tools remain so the
33+
// target can still read context during the routed turn.
34+
expect(names).toContain("sessions_list");
35+
expect(names).toContain("sessions_history");
36+
});
37+
});

src/agents/openclaw-tools.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ export function createOpenClawTools(
142142
/** Visible source replies must be sent through the message tool when set to message_tool_only. */
143143
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
144144
inboundEventKind?: InboundEventKind;
145+
/**
146+
* True when this turn is processing a sessions_send agent-to-agent message.
147+
* The reply already returns through the sessions_send tool result, so the
148+
* tool is omitted here to stop the target reverse-calling the requester and
149+
* posting duplicate content (issue #39476).
150+
*/
151+
interAgentSendTurn?: boolean;
145152
/** If true, omit the message tool from the tool list. */
146153
disableMessageTool?: boolean;
147154
/** If true, include the heartbeat response tool for structured heartbeat outcomes. */
@@ -491,7 +498,10 @@ export function createOpenClawTools(
491498
config: resolvedConfig,
492499
callGateway: effectiveCallGateway,
493500
}),
494-
...(embedded
501+
// A sessions_send A2A turn returns the target's reply via the tool result, so
502+
// the routed turn must not re-expose sessions_send: otherwise the target can
503+
// reverse-call the requester and duplicate the content (issue #39476).
504+
...(embedded || options?.interAgentSendTurn
495505
? []
496506
: [
497507
createSessionsSendTool({

src/sessions/input-provenance.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33
import {
44
annotateInterSessionPromptText,
55
isAgentMediatedCompletionSourceTool,
6+
isAgentToAgentSendInputProvenance,
67
shouldPreserveUserFacingSessionStateForInputProvenance,
78
stripInterSessionPromptPrefixForDisplay,
89
} from "./input-provenance.js";
@@ -97,6 +98,32 @@ describe("isAgentMediatedCompletionSourceTool", () => {
9798
);
9899
});
99100

101+
describe("isAgentToAgentSendInputProvenance", () => {
102+
it("identifies an inter-session sessions_send turn", () => {
103+
expect(
104+
isAgentToAgentSendInputProvenance({
105+
kind: "inter_session",
106+
sourceSessionKey: "agent:other:discord:source",
107+
sourceTool: "sessions_send",
108+
}),
109+
).toBe(true);
110+
});
111+
112+
it.each(["subagent_announce", "agent_harness_task", "image_generate"])(
113+
"does not flag inter-session %s turns",
114+
(sourceTool) => {
115+
expect(isAgentToAgentSendInputProvenance({ kind: "inter_session", sourceTool })).toBe(false);
116+
},
117+
);
118+
119+
it("does not flag external-user sessions_send provenance or missing provenance", () => {
120+
expect(
121+
isAgentToAgentSendInputProvenance({ kind: "external_user", sourceTool: "sessions_send" }),
122+
).toBe(false);
123+
expect(isAgentToAgentSendInputProvenance(undefined)).toBe(false);
124+
});
125+
});
126+
100127
describe("shouldPreserveUserFacingSessionStateForInputProvenance", () => {
101128
it.each([
102129
"agent_harness_task",

src/sessions/input-provenance.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ export function shouldPreserveUserFacingSessionStateForInputProvenance(value: un
103103
return sourceTool ? USER_FACING_SESSION_STATE_PRESERVING_SOURCE_TOOLS.has(sourceTool) : false;
104104
}
105105

106+
// True when this turn is processing a sessions_send agent-to-agent message. The
107+
// target's reply already flows back through the sessions_send tool result, so the
108+
// routed turn must not expose sessions_send again: otherwise the target can
109+
// reverse-call the requester and post the same content twice (issue #39476).
110+
export function isAgentToAgentSendInputProvenance(value: unknown): boolean {
111+
const provenance = normalizeInputProvenance(value);
112+
if (provenance?.kind !== "inter_session") {
113+
return false;
114+
}
115+
return provenance.sourceTool === "sessions_send";
116+
}
117+
106118
export function hasInterSessionUserProvenance(
107119
message: { role?: unknown; provenance?: unknown } | undefined,
108120
): boolean {

0 commit comments

Comments
 (0)