Skip to content

Commit 8c7f17b

Browse files
authored
fix: count unknown-tool retries only when streamed (openclaw#66145)
Merged via squash. Prepared head SHA: b79209c Co-authored-by: Bob <dutifulbob@gmail.com> Reviewed-by: @osolmaz
1 parent 891e42b commit 8c7f17b

3 files changed

Lines changed: 285 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- Auto-reply/queue: split collect-mode followup drains into contiguous groups by per-message authorization context (sender id, owner status, exec/bash-elevated overrides), so queued items from different senders or exec configs no longer execute under the last queued run's owner-only and exec-approval context. (#66024) Thanks @eleqtrizit.
3131
- Dreaming/memory-core: require a live queued Dreaming cron event before the heartbeat hook runs the sweep, so managed Dreaming no longer replays on later heartbeats after the scheduled run was already consumed. (#66139) Thanks @mbelinky.
3232
- Control UI/Dreaming: stop Imported Insights and Memory Palace from calling optional `memory-wiki` gateway methods when the plugin is off, and refresh config before wiki reloads so the Dreaming tab stops showing misleading unknown-method failures. (#66140) Thanks @mbelinky.
33+
- Agents/tools: only mark streamed unknown-tool retries as counted when a streamed message actually classifies an unavailable tool, and keep incomplete streamed tool names from resetting the retry streak before the final assistant message arrives. (#66145) Thanks @dutifulbob.
3334

3435
## 2026.4.12
3536

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

Lines changed: 222 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,225 @@ describe("wrapStreamFnTrimToolCallNames", () => {
722722
]);
723723
});
724724

725+
it("counts the final unknown-tool retry when streamed messages omit the tool name", async () => {
726+
const baseFn = vi.fn(() =>
727+
createFakeStream({
728+
events: [
729+
{
730+
type: "toolcall_delta",
731+
message: { role: "assistant", content: [{ type: "toolCall", name: "" }] },
732+
},
733+
],
734+
resultMessage: {
735+
role: "assistant",
736+
content: [{ type: "toolCall", name: " exec ", arguments: { command: "echo retry" } }],
737+
},
738+
}),
739+
);
740+
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, new Set(["read"]), {
741+
unknownToolThreshold: 1,
742+
});
743+
744+
const firstStream = await Promise.resolve(wrappedFn({} as never, {} as never, {} as never));
745+
await firstStream.result();
746+
747+
const secondStream = await Promise.resolve(wrappedFn({} as never, {} as never, {} as never));
748+
for await (const _item of secondStream) {
749+
// drain
750+
}
751+
const secondResult = (await secondStream.result()) as {
752+
role: string;
753+
content: Array<{ type: string; text?: string; name?: string }>;
754+
};
755+
756+
expect(secondResult.role).toBe("assistant");
757+
expect(secondResult.content).toEqual([
758+
expect.objectContaining({
759+
type: "text",
760+
text: expect.stringContaining('"exec"'),
761+
}),
762+
]);
763+
});
764+
765+
it("resets a provisional streamed unknown-tool retry when later chunks resolve to an allowed tool", async () => {
766+
const baseFn = vi
767+
.fn()
768+
.mockImplementationOnce(() =>
769+
createFakeStream({
770+
events: [
771+
{
772+
type: "toolcall_delta",
773+
message: { role: "assistant", content: [{ type: "toolCall", name: " ex " }] },
774+
},
775+
{
776+
type: "toolcall_delta",
777+
message: { role: "assistant", content: [{ type: "toolCall", name: " exec " }] },
778+
},
779+
],
780+
resultMessage: {
781+
role: "assistant",
782+
content: [{ type: "toolCall", name: " exec ", arguments: { command: "echo ok" } }],
783+
},
784+
}),
785+
)
786+
.mockImplementationOnce(() =>
787+
createFakeStream({
788+
events: [],
789+
resultMessage: {
790+
role: "assistant",
791+
content: [{ type: "toolCall", name: " ex ", arguments: { command: "echo retry" } }],
792+
},
793+
}),
794+
);
795+
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, new Set(["exec"]), {
796+
unknownToolThreshold: 1,
797+
});
798+
799+
const firstStream = await Promise.resolve(wrappedFn({} as never, {} as never, {} as never));
800+
for await (const _item of firstStream) {
801+
// drain
802+
}
803+
await firstStream.result();
804+
805+
const secondStream = await Promise.resolve(wrappedFn({} as never, {} as never, {} as never));
806+
const secondResult = (await secondStream.result()) as {
807+
role: string;
808+
content: Array<{ type: string; text?: string; name?: string }>;
809+
};
810+
811+
expect(secondResult.role).toBe("assistant");
812+
expect(secondResult.content).toEqual([
813+
expect.objectContaining({
814+
type: "toolCall",
815+
name: "ex",
816+
}),
817+
]);
818+
});
819+
820+
it("keeps processing later streamed messages after one streamed unknown-tool retry was counted", async () => {
821+
const baseFn = vi
822+
.fn()
823+
.mockImplementationOnce(() =>
824+
createFakeStream({
825+
events: [
826+
{
827+
type: "toolcall_delta",
828+
message: { role: "assistant", content: [{ type: "toolCall", name: " re " }] },
829+
},
830+
{
831+
type: "toolcall_delta",
832+
message: { role: "assistant", content: [{ type: "toolCall", name: " read " }] },
833+
},
834+
],
835+
resultMessage: {
836+
role: "assistant",
837+
content: [{ type: "text", text: "resolved to allowed tool" }],
838+
},
839+
}),
840+
)
841+
.mockImplementationOnce(() =>
842+
createFakeStream({
843+
events: [],
844+
resultMessage: {
845+
role: "assistant",
846+
content: [{ type: "toolCall", name: " re ", arguments: { command: "echo retry" } }],
847+
},
848+
}),
849+
);
850+
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, new Set(["read"]), {
851+
unknownToolThreshold: 1,
852+
});
853+
854+
const firstStream = await Promise.resolve(wrappedFn({} as never, {} as never, {} as never));
855+
for await (const _item of firstStream) {
856+
// drain
857+
}
858+
await firstStream.result();
859+
860+
const secondStream = await Promise.resolve(wrappedFn({} as never, {} as never, {} as never));
861+
const secondResult = (await secondStream.result()) as {
862+
role: string;
863+
content: Array<{ type: string; text?: string; name?: string }>;
864+
};
865+
866+
expect(secondResult.role).toBe("assistant");
867+
expect(secondResult.content).toEqual([
868+
expect.objectContaining({
869+
type: "toolCall",
870+
name: "re",
871+
}),
872+
]);
873+
});
874+
875+
it("resets a stale unknown-tool streak when a streamed message mixes allowed and unknown tools", async () => {
876+
const baseFn = vi
877+
.fn()
878+
.mockImplementationOnce(() =>
879+
createFakeStream({
880+
events: [],
881+
resultMessage: {
882+
role: "assistant",
883+
content: [{ type: "toolCall", name: " ex ", arguments: { command: "echo first" } }],
884+
},
885+
}),
886+
)
887+
.mockImplementationOnce(() =>
888+
createFakeStream({
889+
events: [
890+
{
891+
type: "toolcall_delta",
892+
message: {
893+
role: "assistant",
894+
content: [
895+
{ type: "toolCall", name: " exec ", arguments: { command: "echo allowed" } },
896+
{ type: "toolCall", name: " ex ", arguments: { command: "echo provisional" } },
897+
],
898+
},
899+
},
900+
],
901+
resultMessage: {
902+
role: "assistant",
903+
content: [{ type: "toolCall", name: " exec ", arguments: { command: "echo ok" } }],
904+
},
905+
}),
906+
)
907+
.mockImplementationOnce(() =>
908+
createFakeStream({
909+
events: [],
910+
resultMessage: {
911+
role: "assistant",
912+
content: [{ type: "toolCall", name: " ex ", arguments: { command: "echo retry" } }],
913+
},
914+
}),
915+
);
916+
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, new Set(["exec"]), {
917+
unknownToolThreshold: 1,
918+
});
919+
920+
const firstStream = await Promise.resolve(wrappedFn({} as never, {} as never, {} as never));
921+
await firstStream.result();
922+
923+
const secondStream = await Promise.resolve(wrappedFn({} as never, {} as never, {} as never));
924+
for await (const _item of secondStream) {
925+
// drain
926+
}
927+
await secondStream.result();
928+
929+
const thirdStream = await Promise.resolve(wrappedFn({} as never, {} as never, {} as never));
930+
const thirdResult = (await thirdStream.result()) as {
931+
role: string;
932+
content: Array<{ type: string; text?: string; name?: string }>;
933+
};
934+
935+
expect(thirdResult.role).toBe("assistant");
936+
expect(thirdResult.content).toEqual([
937+
expect.objectContaining({
938+
type: "toolCall",
939+
name: "ex",
940+
}),
941+
]);
942+
});
943+
725944
it("infers tool names from malformed toolCallId variants when allowlist is present", async () => {
726945
const partialToolCall = { type: "toolCall", id: "functions.read:0", name: "" };
727946
const finalToolCallA = { type: "toolCall", id: "functionsread3", name: "" };
@@ -1506,11 +1725,9 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
15061725
);
15071726

15081727
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
1509-
const stream = wrapped(
1510-
{ api: "google-gemini" } as never,
1511-
{ messages } as never,
1512-
{} as never,
1513-
) as FakeWrappedStream | Promise<FakeWrappedStream>;
1728+
const stream = wrapped({ api: "google-gemini" } as never, { messages } as never, {} as never) as
1729+
| FakeWrappedStream
1730+
| Promise<FakeWrappedStream>;
15141731
await Promise.resolve(stream);
15151732

15161733
expect(baseFn).toHaveBeenCalledTimes(1);

0 commit comments

Comments
 (0)