Skip to content

Commit 4620929

Browse files
authored
fix(agent): continue after source message tool replies (#92343)
1 parent da4671e commit 4620929

17 files changed

Lines changed: 750 additions & 212 deletions

packages/agent-core/src/agent-loop.test.ts

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
// Agent Core tests cover agent loop behavior.
2+
import { Type } from "typebox";
23
import { describe, expect, it } from "vitest";
34
import { agentLoop, agentLoopContinue } from "./agent-loop.js";
45
import { createAssistantMessageEventStream } from "./llm.js";
56
import type { AssistantMessage, Message, Model } from "./llm.js";
6-
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js";
7+
import type {
8+
AgentContext,
9+
AgentEvent,
10+
AgentLoopConfig,
11+
AgentMessage,
12+
AgentTool,
13+
StreamFn,
14+
} from "./types.js";
715

816
const model: Model = {
917
id: "test-model",
@@ -143,6 +151,146 @@ describe("agentLoop streaming updates", () => {
143151
});
144152
});
145153

154+
describe("agentLoop tool termination", () => {
155+
function makeAssistantMessage(content: AssistantMessage["content"]): AssistantMessage {
156+
return {
157+
role: "assistant",
158+
content,
159+
api: model.api,
160+
provider: model.provider,
161+
model: model.id,
162+
usage: {
163+
input: 0,
164+
output: 0,
165+
cacheRead: 0,
166+
cacheWrite: 0,
167+
totalTokens: 0,
168+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
169+
},
170+
stopReason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop",
171+
timestamp: 1,
172+
};
173+
}
174+
175+
function makeTool(name: string, executed: string[]): AgentTool {
176+
return {
177+
name,
178+
label: name,
179+
description: name,
180+
parameters: Type.Object({}, { additionalProperties: false }),
181+
execute: async () => {
182+
executed.push(name);
183+
return {
184+
content: [{ type: "text", text: `${name} result` }],
185+
details: { name },
186+
};
187+
},
188+
};
189+
}
190+
191+
it("continues after a side-effect tool result when afterToolCall records it without terminate", async () => {
192+
const executed: string[] = [];
193+
let turn = 0;
194+
const streamFn: StreamFn = () => {
195+
turn += 1;
196+
const stream = createAssistantMessageEventStream();
197+
queueMicrotask(() => {
198+
const message =
199+
turn === 1
200+
? makeAssistantMessage([
201+
{ type: "toolCall", id: "call-message", name: "message", arguments: {} },
202+
])
203+
: turn === 2
204+
? makeAssistantMessage([
205+
{ type: "toolCall", id: "call-exec", name: "exec", arguments: {} },
206+
])
207+
: makeAssistantMessage([{ type: "text", text: "done" }]);
208+
stream.push({
209+
type: "done",
210+
reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
211+
message,
212+
});
213+
stream.end();
214+
});
215+
return stream;
216+
};
217+
let recordedSideEffect = false;
218+
219+
const stream = agentLoop(
220+
[{ role: "user", content: "hello", timestamp: 1 }],
221+
{
222+
systemPrompt: "",
223+
messages: [],
224+
tools: [makeTool("message", executed), makeTool("exec", executed)],
225+
},
226+
{
227+
...config,
228+
afterToolCall: async ({ toolCall }) => {
229+
if (toolCall.name === "message") {
230+
recordedSideEffect = true;
231+
}
232+
return undefined;
233+
},
234+
},
235+
undefined,
236+
streamFn,
237+
);
238+
239+
const events = await collectEvents(stream);
240+
241+
expect(recordedSideEffect).toBe(true);
242+
expect(turn).toBe(3);
243+
expect(executed).toEqual(["message", "exec"]);
244+
expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(2);
245+
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
246+
});
247+
248+
it("stops after a tool result only when the finalized result explicitly terminates", async () => {
249+
const executed: string[] = [];
250+
let turn = 0;
251+
const streamFn: StreamFn = () => {
252+
turn += 1;
253+
const stream = createAssistantMessageEventStream();
254+
queueMicrotask(() => {
255+
const message =
256+
turn === 1
257+
? makeAssistantMessage([
258+
{ type: "toolCall", id: "call-message", name: "message", arguments: {} },
259+
])
260+
: makeAssistantMessage([
261+
{ type: "toolCall", id: "call-exec", name: "exec", arguments: {} },
262+
]);
263+
stream.push({ type: "done", reason: "toolUse", message });
264+
stream.end();
265+
});
266+
return stream;
267+
};
268+
269+
const stream = agentLoop(
270+
[{ role: "user", content: "hello", timestamp: 1 }],
271+
{
272+
systemPrompt: "",
273+
messages: [],
274+
tools: [makeTool("message", executed), makeTool("exec", executed)],
275+
},
276+
{
277+
...config,
278+
afterToolCall: async ({ toolCall }) =>
279+
toolCall.name === "message" ? { terminate: true } : undefined,
280+
},
281+
undefined,
282+
streamFn,
283+
);
284+
285+
const events = await collectEvents(stream);
286+
287+
expect(turn).toBe(1);
288+
expect(executed).toEqual(["message"]);
289+
expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(1);
290+
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
291+
});
292+
});
293+
146294
describe("agentLoop thinking state", () => {
147295
function makeAssistantMessage(
148296
activeModel: Model,

src/agents/agent-tool-handler-state.test-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function createBaseToolHandlerState() {
2929
messagingToolSentTextsNormalized: [] as string[],
3030
messagingToolSentMediaUrls: [] as string[],
3131
messagingToolSourceReplyPayloads: [],
32+
messageToolOnlySourceReplyDelivered: false,
3233
messagingToolSentTargets: [] as unknown[],
3334
deterministicApprovalPromptSent: false,
3435
blockBuffer: "",
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* Detects message-tool sends that delivered a visible reply to the current source.
3+
*/
4+
import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js";
5+
import { isMessageToolSendActionName } from "./embedded-agent-messaging.js";
6+
import { isToolResultError } from "./embedded-agent-subscribe.tools.js";
7+
import { normalizeToolName } from "./tool-policy.js";
8+
9+
const MESSAGE_TOOL_NAME = "message";
10+
const EXPLICIT_MESSAGE_ROUTE_KEYS = ["channel", "target", "to", "channelId", "provider"];
11+
const DRY_RUN_DELIVERY_STATUS = "dry_run";
12+
const SENT_DELIVERY_STATUS = "sent";
13+
const RESULT_ENVELOPE_KEYS = [
14+
"details",
15+
"payload",
16+
"result",
17+
"results",
18+
"sendResult",
19+
"toolResult",
20+
];
21+
22+
function asRecord(value: unknown): Record<string, unknown> {
23+
return value && typeof value === "object" && !Array.isArray(value)
24+
? (value as Record<string, unknown>)
25+
: {};
26+
}
27+
28+
function hasStringValue(value: unknown): boolean {
29+
return typeof value === "string" && value.trim().length > 0;
30+
}
31+
32+
function hasExplicitMessageRoute(args: Record<string, unknown>): boolean {
33+
if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasStringValue(args[key]))) {
34+
return true;
35+
}
36+
return Array.isArray(args.targets) && args.targets.some((value) => hasStringValue(value));
37+
}
38+
39+
function normalizeStatus(value: unknown): string | undefined {
40+
return typeof value === "string" ? value.trim().toLowerCase() : undefined;
41+
}
42+
43+
function parseJsonRecord(value: string): Record<string, unknown> | undefined {
44+
try {
45+
const parsed = JSON.parse(value);
46+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
47+
? (parsed as Record<string, unknown>)
48+
: undefined;
49+
} catch {
50+
return undefined;
51+
}
52+
}
53+
54+
function recordHasDeliveredMessageId(record: Record<string, unknown>): boolean {
55+
if (hasStringValue(record.messageId)) {
56+
return true;
57+
}
58+
const receipt = record.receipt;
59+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) {
60+
return false;
61+
}
62+
const receiptRecord = receipt as Record<string, unknown>;
63+
return (
64+
hasStringValue(receiptRecord.primaryPlatformMessageId) ||
65+
(Array.isArray(receiptRecord.platformMessageIds) &&
66+
receiptRecord.platformMessageIds.some((value) => hasStringValue(value)))
67+
);
68+
}
69+
70+
function deliveryEnvelopeIndicatesDryRun(value: unknown, depth = 0): boolean {
71+
if (!value || typeof value !== "object" || depth > 4) {
72+
return false;
73+
}
74+
if (Array.isArray(value)) {
75+
return value.some((item) => deliveryEnvelopeIndicatesDryRun(item, depth + 1));
76+
}
77+
78+
const record = value as Record<string, unknown>;
79+
if (
80+
record.dryRun === true ||
81+
normalizeStatus(record.deliveryStatus) === DRY_RUN_DELIVERY_STATUS
82+
) {
83+
return true;
84+
}
85+
86+
const content = record.content;
87+
if (Array.isArray(content)) {
88+
for (const item of content) {
89+
if (deliveryEnvelopeIndicatesDryRun(item, depth + 1)) {
90+
return true;
91+
}
92+
if (item && typeof item === "object" && !Array.isArray(item)) {
93+
const text = (item as Record<string, unknown>).text;
94+
if (typeof text === "string") {
95+
const parsed = parseJsonRecord(text);
96+
if (parsed && deliveryEnvelopeIndicatesDryRun(parsed, depth + 1)) {
97+
return true;
98+
}
99+
}
100+
}
101+
}
102+
}
103+
104+
return RESULT_ENVELOPE_KEYS.some((key) =>
105+
deliveryEnvelopeIndicatesDryRun(record[key], depth + 1),
106+
);
107+
}
108+
109+
function deliveryEnvelopeIndicatesDelivered(value: unknown, depth = 0): boolean {
110+
if (!value || typeof value !== "object" || depth > 4) {
111+
return false;
112+
}
113+
if (Array.isArray(value)) {
114+
return value.some((item) => deliveryEnvelopeIndicatesDelivered(item, depth + 1));
115+
}
116+
117+
const record = value as Record<string, unknown>;
118+
if (
119+
normalizeStatus(record.deliveryStatus) === SENT_DELIVERY_STATUS ||
120+
recordHasDeliveredMessageId(record)
121+
) {
122+
return true;
123+
}
124+
125+
const content = record.content;
126+
if (Array.isArray(content)) {
127+
for (const item of content) {
128+
if (deliveryEnvelopeIndicatesDelivered(item, depth + 1)) {
129+
return true;
130+
}
131+
if (item && typeof item === "object" && !Array.isArray(item)) {
132+
const text = (item as Record<string, unknown>).text;
133+
if (typeof text === "string") {
134+
const parsed = parseJsonRecord(text);
135+
if (parsed && deliveryEnvelopeIndicatesDelivered(parsed, depth + 1)) {
136+
return true;
137+
}
138+
}
139+
}
140+
}
141+
}
142+
143+
return RESULT_ENVELOPE_KEYS.some((key) =>
144+
deliveryEnvelopeIndicatesDelivered(record[key], depth + 1),
145+
);
146+
}
147+
148+
/**
149+
* Only implicit-route, non-dry-run, delivered `message.send` calls qualify.
150+
* Explicit routes and other messaging tools are outbound side effects, not source replies.
151+
*/
152+
export function isDeliveredMessageToolOnlySourceReplyResult(params: {
153+
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
154+
toolName: string;
155+
args?: unknown;
156+
result?: unknown;
157+
hookResult?: unknown;
158+
isError?: boolean;
159+
}): boolean {
160+
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
161+
return false;
162+
}
163+
if (normalizeToolName(params.toolName) !== MESSAGE_TOOL_NAME) {
164+
return false;
165+
}
166+
const args = asRecord(params.args);
167+
if (!isMessageToolSendActionName(args.action) || hasExplicitMessageRoute(args)) {
168+
return false;
169+
}
170+
if (params.isError || isToolResultError(params.result) || isToolResultError(params.hookResult)) {
171+
return false;
172+
}
173+
if (
174+
args.dryRun === true ||
175+
deliveryEnvelopeIndicatesDryRun(params.result) ||
176+
deliveryEnvelopeIndicatesDryRun(params.hookResult)
177+
) {
178+
return false;
179+
}
180+
return (
181+
deliveryEnvelopeIndicatesDelivered(params.result) ||
182+
deliveryEnvelopeIndicatesDelivered(params.hookResult)
183+
);
184+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3030,6 +3030,8 @@ export async function runEmbeddedAgent(
30303030
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
30313031
inlineToolResultsAllowed: false,
30323032
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
3033+
didDeliverSourceReplyViaMessageTool:
3034+
attempt.didDeliverSourceReplyViaMessageTool === true,
30333035
messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads,
30343036
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
30353037
agentId: params.agentId,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3283,6 +3283,7 @@ export async function runEmbeddedAttempt(
32833283
shouldEmitToolResult: params.shouldEmitToolResult,
32843284
shouldEmitToolOutput: params.shouldEmitToolOutput,
32853285
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
3286+
hasDeliveredMessageToolOnlySourceReply: () => didDeliverSourceReplyViaMessageTool,
32863287
onToolResult: params.onToolResult,
32873288
onReasoningStream: params.onReasoningStream,
32883289
onReasoningEnd: params.onReasoningEnd,

0 commit comments

Comments
 (0)