Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 3e3910bc84 fix(ai): derive Anthropic tool finish reason 2026-08-05 01:06:17 +00:00
2 changed files with 44 additions and 3 deletions
@@ -303,6 +303,7 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly tools: ToolStream.State<number>
readonly hasLocalToolCalls: boolean
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
readonly pendingFinish?: {
@@ -668,7 +669,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// =============================================================================
// Stream Parsing
// =============================================================================
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
const mapFinishReason = (reason: string | null | undefined, hasLocalToolCalls: boolean): FinishReason => {
if (
hasLocalToolCalls &&
(reason === undefined || reason === null || reason === "end_turn" || reason === "stop_sequence")
)
return "tool-calls"
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
if (reason === "tool_use") return "tool-calls"
@@ -931,7 +937,17 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
delete reasoningSignatures[event.index]
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
return [
{
...state,
hasLocalToolCalls:
state.hasLocalToolCalls || resultEvents.some((item) => item.type === "tool-call" && !item.providerExecuted),
lifecycle,
tools: result.tools,
reasoningSignatures,
},
events,
] satisfies StepResult
})
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
@@ -942,7 +958,7 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
usage,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
normalized: mapFinishReason(event.delta?.stop_reason, state.hasLocalToolCalls),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
@@ -1013,6 +1029,7 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({
tools: ToolStream.empty<number>(),
hasLocalToolCalls: false,
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
}),
@@ -915,6 +915,30 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("maps a local tool call with end_turn as tool-calls", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } },
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
)
it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () {
const body = sseEvents(