Skip to content

Commit 1f4246e

Browse files
committed
fix(agents): extend send-loop volatility strip to provider-docked tools
1 parent 5df7747 commit 1f4246e

2 files changed

Lines changed: 37 additions & 4 deletions

File tree

src/agents/tool-loop-detection.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
// Tool loop detection tests cover repeated-call hashing, ping-pong detection,
22
// unknown-tool thresholds, and circuit-breaker escalation.
3-
import { describe, expect, it } from "vitest";
3+
import { describe, expect, it, vi } from "vitest";
44
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
55
import type { SessionState } from "../logging/diagnostic-session-state.js";
6+
7+
// Recognize a provider-docked send tool by name (only "telegram" here) so the
8+
// volatility strip applies to it without pulling in the channel-plugin registry; the
9+
// real detector is covered by embedded-agent-messaging's own tests.
10+
const isMessagingToolSendActionMock = vi.hoisted(() =>
11+
vi.fn((toolName: string): boolean => toolName === "telegram"),
12+
);
13+
vi.mock("./embedded-agent-messaging.js", () => ({
14+
isMessagingToolSendAction: isMessagingToolSendActionMock,
15+
}));
616
import {
717
CRITICAL_THRESHOLD,
818
GLOBAL_CIRCUIT_BREAKER_THRESHOLD,
@@ -1013,6 +1023,19 @@ describe("tool-loop-detection", () => {
10131023
}
10141024
});
10151025

1026+
it("escalates provider-docked send-tool loops (e.g. telegram) whose result carries fresh ids", () => {
1027+
const state = createState();
1028+
const params = { to: "telegram:123", text: "ping" };
1029+
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
1030+
recordSend(state, "telegram", params, sendPayload(i), i);
1031+
}
1032+
const loopResult = detectToolCallLoop(state, "telegram", params, enabledLoopDetectionConfig);
1033+
expect(loopResult.stuck).toBe(true);
1034+
if (loopResult.stuck) {
1035+
expect(loopResult.level).toBe("critical");
1036+
}
1037+
});
1038+
10161039
it("escalates sibling delivery actions (reply) the same as send", () => {
10171040
const state = createState();
10181041
const params = { action: "reply", target: "feishu:oc_chat", text: "ping" };

src/agents/tool-loop-detection.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
1212
import type { SessionState, ToolCallRecord } from "../logging/diagnostic-session-state.js";
1313
import { createSubsystemLogger } from "../logging/subsystem.js";
1414
import { isPlainObject } from "../utils.js";
15+
import { isMessagingToolSendAction } from "./embedded-agent-messaging.js";
1516
import { stableStringify } from "./stable-stringify.js";
1617

1718
const log = createSubsystemLogger("agents/loop-detection");
@@ -245,10 +246,15 @@ const SEND_LIKE_MESSAGE_ACTIONS = new Set([
245246
"sticker",
246247
"poll",
247248
]);
249+
// Denylist of per-call volatile delivery ids/timestamps stripped before hashing. Must
250+
// cover the id/timestamp fields a channel's delivery result can carry; a new channel
251+
// emitting a volatile field name outside this set silently regresses its loop blocking.
248252
const VOLATILE_SEND_RESULT_KEYS = new Set([
249253
"messageId",
250254
"message_id",
251255
"messageIds",
256+
"platformMessageId",
257+
"platformMessageIds",
252258
"fileId",
253259
"file_id",
254260
"fileKey",
@@ -299,10 +305,14 @@ function isVolatileSendResult(toolName: string, params: unknown): boolean {
299305
if (toolName === "sessions_send") {
300306
return true;
301307
}
302-
if (toolName !== "message" || !isPlainObject(params)) {
303-
return false;
308+
const args = isPlainObject(params) ? params : {};
309+
if (toolName === "message") {
310+
return typeof args.action === "string" && SEND_LIKE_MESSAGE_ACTIONS.has(args.action);
304311
}
305-
return typeof params.action === "string" && SEND_LIKE_MESSAGE_ACTIONS.has(params.action);
312+
// Provider-docked send tools (telegram/discord/...) return the same volatile-id shape.
313+
// SEND_LIKE_MESSAGE_ACTIONS stays broader than the terminal-send set on purpose:
314+
// broadcast/reply/sticker/poll carry volatile ids but are not terminal sends.
315+
return isMessagingToolSendAction(toolName, args);
306316
}
307317

308318
// Only the loop detector's own veto must not reset the streak; other blocked results

0 commit comments

Comments
 (0)