real tool calls
This commit is contained in:
parent
d33517c6b5
commit
4f455f1869
5 changed files with 242 additions and 3 deletions
|
|
@ -129,6 +129,12 @@ const LlmScriptActionSchema = z.discriminatedUnion("type", [
|
||||||
z.object({ type: z.literal("text"), content: z.string() }),
|
z.object({ type: z.literal("text"), content: z.string() }),
|
||||||
z.object({ type: z.literal("thinking"), content: z.string() }),
|
z.object({ type: z.literal("thinking"), content: z.string() }),
|
||||||
z.object({ type: z.literal("error"), message: z.string() }),
|
z.object({ type: z.literal("error"), message: z.string() }),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("tool-call"),
|
||||||
|
toolCallId: z.string(),
|
||||||
|
toolName: z.string(),
|
||||||
|
input: z.any(),
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const LlmScriptSchema = z.object({
|
const LlmScriptSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3FinishReason, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
import type {
|
||||||
|
LanguageModelV3,
|
||||||
|
LanguageModelV3CallOptions,
|
||||||
|
LanguageModelV3Content,
|
||||||
|
LanguageModelV3FinishReason,
|
||||||
|
LanguageModelV3StreamPart,
|
||||||
|
} from "@ai-sdk/provider"
|
||||||
import { simulateReadableStream } from "ai"
|
import { simulateReadableStream } from "ai"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
|
|
@ -18,7 +24,7 @@ const model: Provider.Model = {
|
||||||
temperature: true,
|
temperature: true,
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
attachment: false,
|
attachment: false,
|
||||||
toolcall: false,
|
toolcall: true,
|
||||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
interleaved: false,
|
interleaved: false,
|
||||||
|
|
@ -75,6 +81,16 @@ function stream(script: LLMScript) {
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (item.type === "tool-call") {
|
||||||
|
const input = JSON.stringify(item.input)
|
||||||
|
chunks.push(
|
||||||
|
{ type: "tool-input-start", id: item.toolCallId, toolName: item.toolName },
|
||||||
|
{ type: "tool-input-delta", id: item.toolCallId, delta: input },
|
||||||
|
{ type: "tool-input-end", id: item.toolCallId },
|
||||||
|
{ type: "tool-call", toolCallId: item.toolCallId, toolName: item.toolName, input },
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
chunks.push(
|
chunks.push(
|
||||||
{ type: "text-start", id },
|
{ type: "text-start", id },
|
||||||
{ type: "text-delta", id, delta: item.content },
|
{ type: "text-delta", id, delta: item.content },
|
||||||
|
|
@ -122,8 +138,20 @@ function language(simulation: Simulation.Interface): LanguageModelV3 {
|
||||||
const script = await nextScript(simulation)
|
const script = await nextScript(simulation)
|
||||||
const err = error(script)
|
const err = error(script)
|
||||||
if (err?.type === "error") throw new Error(err.message)
|
if (err?.type === "error") throw new Error(err.message)
|
||||||
|
const content: LanguageModelV3Content[] = []
|
||||||
|
const textValue = text(script)
|
||||||
|
if (textValue) content.push({ type: "text", text: textValue })
|
||||||
|
for (const item of script.steps[0] ?? []) {
|
||||||
|
if (item.type !== "tool-call") continue
|
||||||
|
content.push({
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: item.toolCallId,
|
||||||
|
toolName: item.toolName,
|
||||||
|
input: JSON.stringify(item.input),
|
||||||
|
})
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: text(script) }],
|
content,
|
||||||
finishReason: finishReason(script),
|
finishReason: finishReason(script),
|
||||||
usage: usage(script),
|
usage: usage(script),
|
||||||
warnings: [],
|
warnings: [],
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@ export const LLMScriptAction = Schema.Union([
|
||||||
Schema.Struct({ type: Schema.Literal("text"), content: Schema.String }),
|
Schema.Struct({ type: Schema.Literal("text"), content: Schema.String }),
|
||||||
Schema.Struct({ type: Schema.Literal("thinking"), content: Schema.String }),
|
Schema.Struct({ type: Schema.Literal("thinking"), content: Schema.String }),
|
||||||
Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }),
|
Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }),
|
||||||
|
Schema.Struct({
|
||||||
|
type: Schema.Literal("tool-call"),
|
||||||
|
toolCallId: Schema.String,
|
||||||
|
toolName: Schema.String,
|
||||||
|
input: Schema.Json,
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
export type LLMScriptAction = typeof LLMScriptAction.Type
|
export type LLMScriptAction = typeof LLMScriptAction.Type
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||||
|
{ "type": "pressKey", "key": "b" },
|
||||||
|
{ "type": "writeFile", "path": "src/greeting.ts", "content": "export function greet(name: string) {\n return `Hi, ${name}`\n}\n" },
|
||||||
|
{
|
||||||
|
"type": "enqueueLLM",
|
||||||
|
"scripts": [
|
||||||
|
{
|
||||||
|
"steps": [
|
||||||
|
[
|
||||||
|
{ "type": "text", "content": "Looking at `src/greeting.ts`, the function currently returns `\"Hi, …\"`. " },
|
||||||
|
{ "type": "text", "content": "You asked for a friendlier greeting, so I'll change the prefix from `Hi` to `Hello`. " },
|
||||||
|
{ "type": "text", "content": "I'll keep the template literal and the `name` interpolation untouched. " },
|
||||||
|
{ "type": "text", "content": "Here's the plan: I'll use the `edit` tool to replace the single return line. " },
|
||||||
|
{ "type": "text", "content": "Patching `src/greeting.ts` now." },
|
||||||
|
{
|
||||||
|
"type": "tool-call",
|
||||||
|
"toolCallId": "patch-greeting-1",
|
||||||
|
"toolName": "edit",
|
||||||
|
"input": {
|
||||||
|
"filePath": "/opencode/src/greeting.ts",
|
||||||
|
"oldString": "return `Hi, ${name}`",
|
||||||
|
"newString": "return `Hello, ${name}`"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"usage": { "inputTokens": 320, "outputTokens": 90, "totalTokens": 410 },
|
||||||
|
"finish": "tool-calls"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"steps": [
|
||||||
|
[
|
||||||
|
{ "type": "text", "content": "Done — `src/greeting.ts` now returns `Hello, ${name}`." }
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"usage": { "inputTokens": 140, "outputTokens": 16, "totalTokens": 156 },
|
||||||
|
"finish": "stop"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "type": "typeText", "text": "Make the greeting friendlier — use \"Hello\" instead of \"Hi\" in src/greeting.ts." },
|
||||||
|
{ "type": "pressEnter" },
|
||||||
|
{ "type": "wait", "ms": 1500 },
|
||||||
|
{ "type": "typeText", "text": "!cat src/greeting.ts" },
|
||||||
|
{ "type": "pressEnter" },
|
||||||
|
{ "type": "wait", "ms": 800 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { streamText, tool, jsonSchema } from "ai"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Provider } from "../../../src/provider/provider"
|
import { Provider } from "../../../src/provider/provider"
|
||||||
|
|
@ -189,4 +190,152 @@ describe("Simulation", () => {
|
||||||
expect((yield* simulation.snapshot()).llmConsumed).toBe(1)
|
expect((yield* simulation.snapshot()).llmConsumed).toBe(1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("simulation provider streams queued tool-call actions", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const simulation = yield* Simulation.Service
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||||
|
const language = yield* provider.getLanguage(model)
|
||||||
|
|
||||||
|
yield* simulation.enqueueLLM({
|
||||||
|
scripts: [
|
||||||
|
{
|
||||||
|
steps: [
|
||||||
|
[
|
||||||
|
{ type: "text", content: "I'll write that file for you" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "write",
|
||||||
|
input: { filePath: "/opencode/hello.txt", content: "hi" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
finish: "tool-calls",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = yield* Effect.promise(() => language.doStream({ prompt: [], abortSignal: undefined }))
|
||||||
|
const reader = result.stream.getReader()
|
||||||
|
const parts: unknown[] = []
|
||||||
|
while (true) {
|
||||||
|
const next = yield* Effect.promise(() => reader.read())
|
||||||
|
if (next.done) break
|
||||||
|
parts.push(next.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedInput = JSON.stringify({ filePath: "/opencode/hello.txt", content: "hi" })
|
||||||
|
expect(parts).toContainEqual({ type: "tool-input-start", id: "call-1", toolName: "write" })
|
||||||
|
expect(parts).toContainEqual({ type: "tool-input-delta", id: "call-1", delta: expectedInput })
|
||||||
|
expect(parts).toContainEqual({ type: "tool-input-end", id: "call-1" })
|
||||||
|
expect(parts).toContainEqual({ type: "tool-call", toolCallId: "call-1", toolName: "write", input: expectedInput })
|
||||||
|
const finish = parts.find((p: any) => p?.type === "finish") as any
|
||||||
|
expect(finish?.finishReason).toEqual({ unified: "tool-calls", raw: "tool-calls" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("simulation provider doGenerate returns tool-call content", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const simulation = yield* Simulation.Service
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||||
|
const language = yield* provider.getLanguage(model)
|
||||||
|
|
||||||
|
yield* simulation.enqueueLLM({
|
||||||
|
scripts: [
|
||||||
|
{
|
||||||
|
steps: [
|
||||||
|
[
|
||||||
|
{ type: "text", content: "writing now" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-9",
|
||||||
|
toolName: "write",
|
||||||
|
input: { filePath: "/opencode/a.txt", content: "x" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
finish: "tool-calls",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = yield* Effect.promise(() => language.doGenerate({ prompt: [], abortSignal: undefined }))
|
||||||
|
expect(result.content).toEqual([
|
||||||
|
{ type: "text", text: "writing now" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-9",
|
||||||
|
toolName: "write",
|
||||||
|
input: JSON.stringify({ filePath: "/opencode/a.txt", content: "x" }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(result.finishReason).toEqual({ unified: "tool-calls", raw: "tool-calls" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("simulation model advertises toolcall capability", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||||
|
expect(model.capabilities.toolcall).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("AI SDK streamText invokes tool.execute when the simulated provider emits a tool-call", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const simulation = yield* Simulation.Service
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||||
|
const language = yield* provider.getLanguage(model)
|
||||||
|
|
||||||
|
yield* simulation.enqueueLLM({
|
||||||
|
scripts: [
|
||||||
|
{
|
||||||
|
steps: [
|
||||||
|
[
|
||||||
|
{ type: "text", content: "writing now" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-execute-1",
|
||||||
|
toolName: "write",
|
||||||
|
input: { filePath: "/opencode/from-tool.txt", content: "hello from tool" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
finish: "tool-calls",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const executed: Array<{ filePath: string; content: string }> = []
|
||||||
|
const result = streamText({
|
||||||
|
model: language,
|
||||||
|
prompt: "please write the file",
|
||||||
|
tools: {
|
||||||
|
write: tool({
|
||||||
|
description: "Write a file",
|
||||||
|
inputSchema: jsonSchema<{ filePath: string; content: string }>({
|
||||||
|
type: "object",
|
||||||
|
properties: { filePath: { type: "string" }, content: { type: "string" } },
|
||||||
|
required: ["filePath", "content"],
|
||||||
|
}),
|
||||||
|
execute(args) {
|
||||||
|
executed.push(args)
|
||||||
|
return Promise.resolve({ ok: true, path: args.filePath })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drain the stream so streamText runs to completion.
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
for await (const _ of result.fullStream) void _
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executed).toEqual([{ filePath: "/opencode/from-tool.txt", content: "hello from tool" }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue