mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 23:32:12 +00:00
feat(ai): parse partial tool input (#44830)
This commit is contained in:
parent
9a90b94921
commit
63c23c98de
7 changed files with 92 additions and 19 deletions
|
|
@ -1,8 +1,10 @@
|
|||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
|
||||
import { parse } from "./partial-json.js"
|
||||
|
||||
type StreamKey = string | number
|
||||
const parsePartialInput = Option.liftThrowable(parse)
|
||||
|
||||
/**
|
||||
* One pending streamed tool call. Providers emit the tool identity and JSON
|
||||
|
|
@ -57,12 +59,15 @@ const inputStart = (tool: PendingTool) =>
|
|||
providerMetadata: tool.providerMetadata,
|
||||
})
|
||||
|
||||
const inputDelta = (tool: PendingTool, text: string) =>
|
||||
LLMEvent.toolInputDelta({
|
||||
const inputDelta = (tool: PendingTool, text: string) => {
|
||||
const input = parsePartialInput(tool.input)
|
||||
return LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
text,
|
||||
...(Option.isSome(input) ? { input: input.value } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
const raw = inputOverride ?? tool.input
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ export const ToolInputDelta = Schema.Struct({
|
|||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
text: Schema.String,
|
||||
/** Best-effort parse of all input fragments received through this delta. */
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
|
||||
|
||||
|
|
|
|||
|
|
@ -585,12 +585,11 @@ describe("Anthropic Messages route", () => {
|
|||
|
||||
it.effect("infers empty-signature compatibility across Kimi providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const coding = AnthropicMessages.route
|
||||
.with({
|
||||
provider: "kimi-for-coding",
|
||||
endpoint: { baseURL: "https://compatible.test/v1/" },
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
})
|
||||
const coding = AnthropicMessages.route.with({
|
||||
provider: "kimi-for-coding",
|
||||
endpoint: { baseURL: "https://compatible.test/v1/" },
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
})
|
||||
const moonshot = AnthropicMessages.route
|
||||
.with({
|
||||
provider: "moonshotai",
|
||||
|
|
@ -1129,8 +1128,14 @@ describe("Anthropic Messages route", () => {
|
|||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
|
|
|
|||
|
|
@ -475,8 +475,14 @@ describe("Bedrock Converse route", () => {
|
|||
])
|
||||
const events = response.events.filter((event) => event.type === "tool-input-delta")
|
||||
expect(events).toEqual([
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "tool_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
|
|
|
|||
|
|
@ -1164,8 +1164,14 @@ describe("OpenAI Chat route", () => {
|
|||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
|
|
@ -1265,8 +1271,14 @@ describe("OpenAI Chat route", () => {
|
|||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
|
|
|
|||
|
|
@ -2806,12 +2806,14 @@ describe("OpenAI Responses route", () => {
|
|||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query"',
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ describe("ToolStream", () => {
|
|||
|
||||
expect(first.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
])
|
||||
expect(second.events).toEqual([
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
|
||||
])
|
||||
expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }])
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
events: [
|
||||
|
|
@ -36,6 +38,45 @@ describe("ToolStream", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes cumulative partial string values", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
|
||||
expect(result.events.at(-1)).toEqual({
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"wea',
|
||||
input: { query: "wea" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits partial input when the accumulated value cannot be parsed", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: "x" },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
|
||||
expect(result.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = ToolStream.appendOrStart(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue