Compare commits

...

3 Commits

Author SHA1 Message Date
Aiden Cline 18c83be6b9 refactor(ai): simplify freeform tool lowering and parsing
Reuse the function-call stream handlers for custom tool items now that
normalized output items always carry an id, gate freeform support once at
the request boundary, validate freeform input shapes with schema decoding,
and trim custom wire schemas to the fields we emit.
2026-09-04 13:05:52 -05:00
Aiden Cline 5c8906fe46 fix(ai): align custom tool wire schemas 2026-09-04 12:27:10 -05:00
Aiden Cline 8aec89744a feat(ai): support freeform tool representations 2026-09-04 12:24:46 -05:00
16 changed files with 760 additions and 86 deletions
+20
View File
@@ -195,6 +195,26 @@ const program = Effect.gen(function* () {
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
## Freeform Tools
A tool may declare an alternate string-input representation while retaining one canonical object schema and executor:
```ts
const patch = Tool.make({
description: "Apply a patch",
parameters: Schema.Struct({ patchText: Schema.String }),
success: Schema.String,
freeform: {
input: "patchText",
name: "apply_patch",
grammars: [{ dialect: "oai-lark", definition: patchGrammar }],
},
execute: ({ patchText }) => applyPatch(patchText),
})
```
First-party GPT-5 Responses models lower this to an OpenAI `custom` tool and normalize the returned raw string back to `{ patchText }`. Models and protocols without freeform support receive the normal JSON-schema function tool. Grammars are optional; omit `grammars` for unconstrained text. The supported dialects are the OpenAI grammar formats `oai-lark` and `oai-regex`.
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
+144 -73
View File
@@ -153,10 +153,8 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
OpenResponsesInputVideo,
])
const OpenResponsesFunctionCallOutput = Schema.Union([
Schema.String,
Schema.Array(OpenResponsesFunctionCallOutputContent),
])
export const FunctionCallOutput = Schema.Union([Schema.String, Schema.Array(OpenResponsesFunctionCallOutputContent)])
export type FunctionCallOutput = Schema.Schema.Type<typeof FunctionCallOutput>
export const CompactionItem = Schema.Struct({
type: Schema.Literal("compaction"),
@@ -195,7 +193,7 @@ export const InputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("function_call_output"),
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
output: FunctionCallOutput,
}),
HostedToolItem,
])
@@ -208,6 +206,14 @@ export type HostedToolReplayItem = {
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| {
readonly type: "custom_tool_call"
readonly id?: string
readonly call_id: string
readonly name: string
readonly input: string
}
| { readonly type: "custom_tool_call_output"; readonly call_id: string; readonly output: FunctionCallOutput }
| {
readonly type: "message"
readonly id?: string
@@ -318,6 +324,7 @@ export const StreamItem = Schema.StructWithRest(
name: Schema.optional(Schema.String),
namespace: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
input: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
@@ -372,6 +379,7 @@ export const Event = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
input: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
@@ -414,6 +422,13 @@ export interface ProviderAdapter {
readonly request: LLMRequest
}) => MediaInput | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
readonly freeformTools?: (request: LLMRequest) => ReadonlyArray<FreeformTool>
}
export interface FreeformTool {
readonly name: string
readonly wireName: string
readonly input: string
}
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
@@ -425,6 +440,7 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
readonly freeformTools: ReadonlyArray<FreeformTool>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
@@ -483,8 +499,29 @@ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadata
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const freeformTool = (request: LLMRequest, adapter: ProviderAdapter, name: string) =>
adapter.freeformTools?.(request).find((tool) => tool.name === name)
const lowerToolCall = Effect.fnUntraced(function* (
part: ToolCallPart,
providerMetadataKey: string,
request: LLMRequest,
adapter: ProviderAdapter,
) {
const id = itemID(part.providerMetadata, providerMetadataKey)
const freeform = freeformTool(request, adapter, part.name)
if (freeform) {
const input = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.Struct({ [freeform.input]: Schema.String })),
)(part.input)
return {
type: "custom_tool_call",
...(id === undefined ? {} : { id }),
call_id: part.id,
name: freeform.wireName,
input: input[freeform.input],
} satisfies LoweredInputItem
}
return {
type: "function_call",
...(id === undefined ? {} : { id }),
@@ -492,8 +529,8 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
}
}
} satisfies LoweredInputItem
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
@@ -691,7 +728,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey))
input.push(yield* lowerToolCall(part, providerMetadataKey, request, adapter))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
@@ -734,6 +771,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
if (freeformTool(request, adapter, part.name)) {
input.push({
type: "custom_tool_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, adapter),
})
continue
}
input.push({
type: "function_call_output",
call_id: part.id,
@@ -796,13 +841,18 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
return disabled === undefined ? undefined : !disabled
}
export const allowedToolChoice = (request: LLMRequest) => {
export const allowedToolChoice = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
type: "allowed_tools" as const,
mode: allowed.mode,
tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })),
tools: allowed.toolNames.map((name) => {
const freeform = freeformTool(request, adapter, name)
return freeform
? ({ type: "custom" as const, name: freeform.wireName } as const)
: ({ type: "function" as const, name } as const)
}),
}
}
@@ -826,7 +876,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
),
),
tool_choice:
allowedToolChoice(request) ??
allowedToolChoice(projected.request, adapter) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
}
})
@@ -930,6 +980,7 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
message: "msg",
reasoning: "rs",
function_call: "fc",
custom_tool_call: "ctc",
compaction: "cmp",
}
@@ -1090,35 +1141,37 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
events,
]
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
if (item.type !== "function_call" && item.type !== "custom_tool_call") return [state, NO_EVENTS]
if (!item.call_id || state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const freeform = freeformByWireName(state, item)
if (item.type === "custom_tool_call" && !freeform) return [state, NO_EVENTS]
const tool = {
id: item.call_id,
name: freeform?.name ?? item.name ?? "",
namespace: freeform ? undefined : item.namespace,
providerMetadata: providerMetadata(state, { itemId: item.id }),
}
const started = ToolStream.start(state.tools, item.id, { ...tool, input: freeform ? "" : (item.arguments ?? "") })
// Custom input streams as a bare string, so open the canonical JSON object
// here and let later deltas append escaped string content.
const seeded = freeform
? ToolStream.append(
started,
item.id,
`{${JSON.stringify(freeform.input)}:${JSON.stringify(item.input ?? "").slice(0, -1)}`,
)
: undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name ?? "",
namespace: item.namespace,
input: item.arguments ?? "",
providerMetadata: metadata,
}),
},
[
...events,
LLMEvent.toolInputStart({
id: item.call_id,
name: item.name ?? "",
namespace: item.namespace,
providerMetadata: metadata,
}),
],
{ ...state, lifecycle, tools: seeded?.tools ?? started },
[...events, LLMEvent.toolInputStart(tool), ...(seeded?.events ?? [])],
]
}
const freeformByWireName = (state: ParserState, item: { readonly type: string; readonly name?: string }) =>
item.type === "custom_tool_call" ? state.freeformTools.find((tool) => tool.wireName === item.name) : undefined
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
@@ -1147,27 +1200,26 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
]
}
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
// A `final` value replaces accumulated arguments unless it merely extends them,
// in which case only the missing suffix is emitted as a delta.
const onToolArgumentsUpdate = Effect.fn("OpenResponses.onToolArgumentsUpdate")(function* (
state: ParserState,
event: Event,
itemId: string,
update: { readonly delta?: string; readonly final?: string },
) {
if (event.item_id === undefined) return [state, NO_EVENTS] satisfies StepResult
const tool = state.tools[event.item_id]
const tool = state.tools[itemId]
if (!tool) return [state, NO_EVENTS] satisfies StepResult
const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined
if (event.type === "response.function_call_arguments.done" && final === undefined)
return [state, NO_EVENTS] satisfies StepResult
if (final !== undefined && !final.startsWith(tool.input))
if (update.final !== undefined && !update.final.startsWith(tool.input))
return [
{ ...state, tools: ToolStream.start(state.tools, event.item_id, { ...tool, input: final }) },
{ ...state, tools: ToolStream.start(state.tools, itemId, { ...tool, input: update.final }) },
NO_EVENTS,
] satisfies StepResult
const delta = final === undefined ? event.delta : final.slice(tool.input.length)
const delta = update.final === undefined ? update.delta : update.final.slice(tool.input.length)
if (!delta) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
state.id,
state.tools,
event.item_id,
itemId,
delta,
`${state.name} tool argument delta is missing its tool call`,
)
@@ -1178,6 +1230,25 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onFunctionCallArgumentsDelta = (state: ParserState, event: Event, itemId: string) => {
if (event.type !== "response.function_call_arguments.done")
return onToolArgumentsUpdate(state, itemId, { delta: event.delta })
if (event.arguments === undefined) return Effect.succeed<StepResult>([state, NO_EVENTS])
return onToolArgumentsUpdate(state, itemId, { final: event.arguments })
}
// Custom input arrives as raw string content; re-encode it as the canonical
// JSON object so the shared argument accumulator can parse it.
const onCustomToolInputDelta = (state: ParserState, event: Event, itemId: string) => {
if (event.type !== "response.custom_tool_call_input.done")
return onToolArgumentsUpdate(state, itemId, {
delta: event.delta === undefined ? undefined : JSON.stringify(event.delta).slice(1, -1),
})
const freeform = state.freeformTools.find((entry) => entry.name === state.tools[itemId]?.name)
if (!freeform || event.input === undefined) return Effect.succeed<StepResult>([state, NO_EVENTS])
return onToolArgumentsUpdate(state, itemId, { final: JSON.stringify({ [freeform.input]: event.input }) })
}
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: NormalizedEvent["item"],
@@ -1228,37 +1299,31 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
if (item.type === "function_call" || item.type === "custom_tool_call") {
const freeform = freeformByWireName(state, item)
if (!item.call_id || !item.name || (item.type === "custom_tool_call" && !freeform))
return [state, NO_EVENTS] satisfies StepResult
const tool = {
id: item.call_id,
name: freeform?.name ?? item.name,
namespace: freeform ? undefined : item.namespace,
providerMetadata: providerMetadata(state, { itemId: item.id }),
}
const final = freeform
? item.input === undefined
? undefined
: JSON.stringify({ [freeform.input]: item.input })
: item.arguments
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
namespace: item.namespace,
providerMetadata: metadata,
})
const tools = registered ? state.tools : ToolStream.start(state.tools, item.id, tool)
const result =
item.arguments === undefined
final === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, final)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered || finished.length === 0
? finished
: [
LLMEvent.toolInputStart({
id: item.call_id,
name: item.name,
namespace: item.namespace,
providerMetadata: metadata,
}),
...finished,
]
const resultEvents = registered || finished.length === 0 ? finished : [LLMEvent.toolInputStart(tool), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1327,7 +1392,8 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
"Cannot recover a compaction checkpoint after output has been emitted",
)
const recoverable =
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
item.type === "compaction" ||
((item.type === "function_call" || item.type === "custom_tool_call") && current.tools[item.id] !== undefined)
if (!recoverable) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
@@ -1454,7 +1520,11 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
? onFunctionCallArgumentsDelta(state, event, event.item_id)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.custom_tool_call_input.delta" || event.type === "response.custom_tool_call_input.done")
return event.item_id !== undefined
? onCustomToolInputDelta(state, event, event.item_id)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
@@ -1489,6 +1559,7 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
providerMetadataKey: metadataKey(request.model),
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
freeformTools: adapter.freeformTools?.(request) ?? [],
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
+123 -10
View File
@@ -1,11 +1,11 @@
import { Effect, Encoding, Schema } from "effect"
import { Effect, Encoding, Option, Schema } from "effect"
import { Headers } from "effect/unstable/http"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { LLMRequest, mergeJsonRecords, type JsonSchema, ToolDefinition, type ToolEntry } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
@@ -42,6 +42,37 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesCustomTool = Schema.Struct({
type: Schema.tag("custom"),
name: Schema.String,
description: Schema.optional(Schema.String),
format: Schema.optional(
Schema.Union([
Schema.Struct({ type: Schema.tag("text") }),
Schema.Struct({
type: Schema.tag("grammar"),
syntax: Schema.Literals(["lark", "regex"]),
definition: Schema.String,
}),
]),
),
})
const OpenAIResponsesCustomToolItem = Schema.Union([
Schema.Struct({
type: Schema.tag("custom_tool_call"),
id: Schema.optional(Schema.String),
call_id: Schema.String,
name: Schema.String,
input: Schema.String,
}),
Schema.Struct({
type: Schema.tag("custom_tool_call_output"),
call_id: Schema.String,
output: OpenResponses.FunctionCallOutput,
}),
])
const OpenAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
@@ -86,17 +117,31 @@ const OpenAIResponsesNamespace = Schema.Struct({
const OpenAIResponsesTools = Schema.Union([
OpenResponses.Tool,
OpenAIResponsesNamespace,
OpenAIResponsesCustomTool,
OpenAIResponsesImageGenerationTool,
])
const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({
type: Schema.tag("allowed_tools"),
mode: Schema.Literals(["auto", "none", "required"]),
tools: Schema.Array(
Schema.Union([
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({ type: Schema.tag("custom"), name: Schema.String }),
]),
),
}),
OpenResponses.ToolChoice,
Schema.Struct({ type: Schema.tag("custom"), name: Schema.String }),
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
input: Schema.Array(
Schema.Union([OpenResponses.InputItem, OpenAIResponsesCustomToolItem, OpenAIResponsesHostedToolItem]),
),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
@@ -119,7 +164,14 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
input: Schema.Array(
Schema.Union([
OpenResponses.InputItem,
OpenAIResponsesCustomToolItem,
OpenAIResponsesHostedToolItem,
CompactionTrigger,
]),
),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
@@ -131,8 +183,37 @@ const adapter = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
freeformTools: (request: LLMRequest) =>
withFreeformSupport(request).tools.flatMap((tool) =>
tool.type === "tool" && tool.freeform
? [{ name: tool.name, wireName: tool.freeform.name ?? tool.name, input: tool.freeform.input }]
: [],
),
} satisfies OpenResponses.ProviderAdapter
// Custom tools are an OpenAI extension gated per model. Strip the facet once
// for unsupported models so lowering keys off `tool.freeform` alone and those
// models see the canonical function tool.
const withFreeformSupport = (request: LLMRequest) => {
if (request.model.compatibility?.supportsFreeformTools === true) return request
return LLMRequest.update(request, {
tools: request.tools.map((tool) =>
tool.type === "tool" && tool.freeform ? new ToolDefinition({ ...tool, freeform: undefined }) : tool,
),
})
}
// The wrapper object exists only to match the canonical JSON tool contract, so
// its schema must be exactly the one required string property.
const FreeformInputSchema = (input: string) =>
Schema.Struct({
type: Schema.Literal("object"),
properties: Schema.Struct({ [input]: Schema.Struct({ type: Schema.Literal("string") }) }).annotate({
parseOptions: { onExcessProperty: "error" },
}),
required: Schema.Tuple([Schema.Literal(input)]),
})
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
@@ -149,6 +230,27 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
return yield* ProviderShared.invalidRequest("OpenAI Responses image generation tool options are invalid")
}
if (tool.freeform) {
if (Option.isNone(Schema.decodeUnknownOption(FreeformInputSchema(tool.freeform.input))(tool.inputSchema)))
return yield* ProviderShared.invalidRequest(
`OpenAI Responses freeform tool ${tool.name} requires exactly one required string input property named ${tool.freeform.input}`,
)
const grammar = tool.freeform.grammars?.[0]
return {
type: "custom" as const,
name: tool.freeform.name ?? tool.name,
description: tool.description,
...(grammar
? {
format: {
type: "grammar" as const,
syntax: grammar.dialect === "oai-lark" ? ("lark" as const) : ("regex" as const),
definition: grammar.definition,
},
}
: {}),
}
}
return yield* OpenResponses.lowerTool(NAME, tool, inputSchema)
})
@@ -177,19 +279,30 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "required" as const,
tool: (name) =>
tools.some((tool) => tool.type === "tool" && tool.name === name && nativeImageTool(tool) !== undefined)
? ({ type: "image_generation" } as const)
: { type: "function" as const, name },
tool: (name) => {
const tool = tools.find((tool): tool is ToolDefinition => tool.type === "tool" && tool.name === name)
if (tool && nativeImageTool(tool) !== undefined) return { type: "image_generation" as const }
if (tool?.freeform) return { type: "custom" as const, name: tool.freeform.name ?? name }
return { type: "function" as const, name }
},
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (input: LLMRequest) {
const request = withFreeformSupport(input)
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const names = request.tools.flatMap((tool) =>
tool.type === "tool" && tool.freeform ? [tool.freeform.name ?? tool.name] : [],
)
const duplicate = names.find((name, index) => names.indexOf(name) !== index)
if (duplicate)
return yield* ProviderShared.invalidRequest(
`OpenAI Responses has multiple tools with model-facing name ${duplicate}`,
)
return yield* decodeBody({
...(yield* OpenResponses.lowerConversation(request, adapter)),
...OpenResponses.lowerGeneration(request),
@@ -199,7 +312,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
? undefined
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)),
tool_choice:
OpenResponses.allowedToolChoice(request) ??
OpenResponses.allowedToolChoice(request, adapter) ??
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
})
})
+12 -1
View File
@@ -82,6 +82,14 @@ const configuredRoute = <Body, Prepared, Compact extends CompactionOperations |
endpoint: { baseURL: input.baseURL, query: input.queryParams },
})
const supportsFreeformTools = (input: Config, modelID: string) => {
if (!modelID.toLowerCase().includes("gpt-5")) return false
if (input.baseURL === undefined) return true
return [OpenAIResponses.DEFAULT_BASE_URL, "https://chatgpt.com/backend-api/codex"].includes(
input.baseURL.replace(/\/+$/, ""),
)
}
export const configure = (input: Config = {}) => {
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const chatRoute = configuredRoute(OpenAIChat.route, input)
@@ -89,7 +97,10 @@ export const configure = (input: Config = {}) => {
const responses = (id: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
.model<OpenAIProviderOptionsInput>({
id,
compatibility: supportsFreeformTools(input, id) ? { supportsFreeformTools: true } : undefined,
})
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
const image = (modelID: string | ModelID) =>
+8
View File
@@ -269,11 +269,19 @@ export namespace Message {
make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
}
export const ToolFreeformGrammar = Tool.FreeformGrammar
export type ToolFreeformGrammar = Schema.Schema.Type<typeof ToolFreeformGrammar>
export const ToolFreeform = Tool.Freeform
export type ToolFreeform = Schema.Schema.Type<typeof ToolFreeform>
const toolDefinitionFields = {
name: Schema.String,
description: Schema.String,
inputSchema: JsonSchema,
outputSchema: Schema.optional(JsonSchema),
/** Optional string-input representation for protocols and models that support freeform tools. */
freeform: Schema.optional(ToolFreeform),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+1
View File
@@ -161,6 +161,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
supportsFreeformTools: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
}) {}
+20
View File
@@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type {
ToolCallPart,
ToolDefinition as ToolDefinitionClass,
ToolFreeform,
ToolOutput as ToolOutputType,
} from "./schema/index.js"
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema/index.js"
@@ -13,6 +14,13 @@ import { ToolDefinition, ToolFailure, ToolOutput } from "./schema/index.js"
* beyond pure data conversion belongs in the handler closure.
*/
export type ToolSchema<T> = Schema.Codec<T, any, never, never>
type StringKeys<Value> = Extract<
{ [Key in keyof Value]-?: Value[Key] extends string ? Key : never }[keyof Value],
string
>
export type Freeform<Parameters extends ToolSchema<any>> = Omit<ToolFreeform, "input"> & {
readonly input: unknown extends Schema.Schema.Type<Parameters> ? string : StringKeys<Schema.Schema.Type<Parameters>>
}
export interface ToolExecuteContext {
readonly id: ToolCallPart["id"]
readonly name: ToolCallPart["name"]
@@ -50,6 +58,7 @@ export interface Definition<Parameters extends ToolSchema<any>, Success extends
readonly description: string
readonly parameters: Parameters
readonly success: Success
readonly freeform?: Freeform<Parameters>
readonly execute?: ToolExecute<Parameters, Success>
readonly toModelOutput?: ToolToModelOutput<Parameters, Success>
readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown
@@ -86,6 +95,7 @@ type TypedToolConfig = {
readonly description: string
readonly parameters: ToolSchema<any>
readonly success: ToolSchema<any>
readonly freeform?: ToolFreeform
readonly execute?: ToolExecute<ToolSchema<any>, ToolSchema<any>>
readonly toModelOutput?: ToolToModelOutput<ToolSchema<any>, ToolSchema<any>>
readonly toStructuredOutput?: (output: unknown) => unknown
@@ -95,6 +105,7 @@ type DynamicToolConfig = {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly freeform?: ToolFreeform
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
readonly toStructuredOutput?: (output: unknown) => unknown
@@ -135,6 +146,7 @@ export function make<Parameters extends ToolSchema<any>, Success extends ToolSch
readonly description: string
readonly parameters: Parameters
readonly success: Success
readonly freeform?: Freeform<Parameters>
readonly execute: ToolExecute<Parameters, Success>
readonly toModelOutput?: ToolToModelOutput<Parameters, Success>
readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown
@@ -143,6 +155,7 @@ export function make<Parameters extends ToolSchema<any>, Success extends ToolSch
readonly description: string
readonly parameters: Parameters
readonly success: Success
readonly freeform?: Freeform<Parameters>
readonly execute?: undefined
readonly toModelOutput?: ToolToModelOutput<Parameters, Success>
readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown
@@ -151,6 +164,7 @@ export function make(config: {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly freeform?: ToolFreeform
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
readonly toStructuredOutput?: (output: unknown) => unknown
@@ -159,6 +173,7 @@ export function make(config: {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly freeform?: ToolFreeform
readonly execute?: undefined
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
readonly toStructuredOutput?: (output: unknown) => unknown
@@ -169,6 +184,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
description: config.description,
parameters: Schema.Unknown as ToolSchema<unknown>,
success: Schema.Unknown as ToolSchema<unknown>,
freeform: config.freeform,
execute: config.execute,
toModelOutput: config.toModelOutput,
toStructuredOutput: config.toStructuredOutput,
@@ -182,6 +198,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
description: config.description,
inputSchema: config.jsonSchema,
outputSchema: config.outputSchema,
freeform: config.freeform,
}),
}
}
@@ -189,6 +206,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
description: config.description,
parameters: config.parameters,
success: config.success,
freeform: config.freeform,
execute: config.execute,
toModelOutput: config.toModelOutput,
toStructuredOutput: config.toStructuredOutput,
@@ -202,6 +220,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
description: config.description,
inputSchema: toJsonSchema(config.parameters),
outputSchema: toJsonSchema(config.success),
freeform: config.freeform,
}),
}
}
@@ -227,6 +246,7 @@ export const toDefinitions = (tools: Tools): ReadonlyArray<ToolDefinitionClass>
description: item._definition.description,
inputSchema: item._definition.inputSchema,
outputSchema: item._definition.outputSchema,
freeform: item._definition.freeform,
}),
)
@@ -93,6 +93,23 @@ const request = LLM.request({
generation: { maxTokens: 20, temperature: 0 },
})
const freeformModel = LanguageModel.update(model, { compatibility: { supportsFreeformTools: true } })
const patchTool = ToolDefinition.make({
name: "patch",
description: "Apply a patch",
inputSchema: {
type: "object",
properties: { patchText: { type: "string" } },
required: ["patchText"],
additionalProperties: false,
},
freeform: {
input: "patchText",
name: "apply_patch",
grammars: [{ dialect: "oai-lark", definition: 'start: "*** Begin Patch" /(.|\\n)+/' }],
},
})
const configEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
type OpenAIToolOutput = Extract<
@@ -109,6 +126,212 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool
}
describe("OpenAI Responses route", () => {
it.effect("lowers supported freeform tools to OpenAI custom tools", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: freeformModel,
prompt: "Patch the file.",
tools: [patchTool],
toolChoice: "patch",
}),
)
expect(prepared.body.tools).toEqual([
{
type: "custom",
name: "apply_patch",
description: "Apply a patch",
format: {
type: "grammar",
syntax: "lark",
definition: 'start: "*** Begin Patch" /(.|\\n)+/',
},
},
])
expect(prepared.body.tool_choice).toEqual({ type: "custom", name: "apply_patch" })
}),
)
it.effect("enables freeform tools for first-party GPT-5 Responses models", () =>
Effect.gen(function* () {
const toolType = (model: LanguageModel) =>
compileRequest(LLM.request({ model, prompt: "Patch the file.", tools: [patchTool] })).pipe(
Effect.map((prepared) => prepared.body.tools?.[0]?.type),
)
expect(yield* toolType(OpenAI.configure({ apiKey: "test" }).responses("gpt-5.2"))).toBe("custom")
expect(
yield* toolType(OpenAI.configure({ apiKey: "test", baseURL: "https://proxy.test/v1" }).responses("gpt-5.2")),
).toBe("function")
expect(
yield* toolType(
OpenAI.configure({ apiKey: "test", baseURL: "https://chatgpt.com/backend-api/codex" }).responses(
"gpt-5.2-codex",
),
),
).toBe("custom")
}),
)
it.effect("falls back to the canonical function tool when freeform tools are unsupported", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "Patch the file.", tools: [patchTool], toolChoice: "patch" }),
)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "patch",
description: "Apply a patch",
parameters: patchTool.inputSchema,
strict: false,
},
])
expect(prepared.body.tool_choice).toEqual({ type: "function", name: "patch" })
}),
)
it.effect("uses custom wire names in allowed tool choices", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: freeformModel,
prompt: "Patch the file.",
tools: [patchTool],
providerOptions: { allowedTools: { toolNames: ["patch"], mode: "required" } },
}),
)
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
mode: "required",
tools: [{ type: "custom", name: "apply_patch" }],
})
}),
)
it.effect("rejects freeform representations that cannot encode the canonical input", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: freeformModel,
prompt: "Patch the file.",
tools: [
ToolDefinition.make({
...patchTool,
inputSchema: {
type: "object",
properties: { patchText: { type: "string" }, dryRun: { type: "boolean" } },
required: ["patchText"],
},
}),
],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("requires exactly one required string input property named patchText")
}),
)
it.effect("rejects duplicate model-facing freeform tool names", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: freeformModel,
prompt: "Patch the file.",
tools: [
patchTool,
ToolDefinition.make({
...patchTool,
name: "other_patch",
freeform: { input: "patchText", name: "apply_patch" },
}),
],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("multiple tools with model-facing name apply_patch")
}),
)
it.effect("replays canonical calls using the selected freeform representation", () =>
Effect.gen(function* () {
const messages = [
Message.assistant([
ToolCallPart.make({ id: "call_patch", name: "patch", input: { patchText: "*** Begin Patch" } }),
]),
Message.tool(ToolResultPart.make({ id: "call_patch", name: "patch", result: "Success", resultType: "text" })),
]
const prepared = yield* compileRequest(LLM.request({ model: freeformModel, tools: [patchTool], messages }))
expect(prepared.body.input).toEqual([
{
type: "custom_tool_call",
call_id: "call_patch",
name: "apply_patch",
input: "*** Begin Patch",
},
{ type: "custom_tool_call_output", call_id: "call_patch", output: "Success" },
])
const fallback = yield* compileRequest(LLM.request({ model, tools: [patchTool], messages }))
expect(fallback.body.input).toEqual([
{ type: "function_call", call_id: "call_patch", name: "patch", arguments: '{"patchText":"*** Begin Patch"}' },
{ type: "function_call_output", call_id: "call_patch", output: "Success" },
])
}),
)
it.effect("rejects malformed canonical input when replaying a freeform call", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: freeformModel,
tools: [patchTool],
messages: [
Message.assistant([ToolCallPart.make({ id: "call_patch", name: "patch", input: { patchText: 1 } })]),
],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain('Expected string\n at ["patchText"]')
}),
)
it.effect("preserves media in custom tool outputs", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: freeformModel,
tools: [patchTool],
messages: [
Message.tool(
ToolResultPart.make({
id: "call_patch",
name: "patch",
resultType: "content",
result: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
}),
),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "custom_tool_call_output",
call_id: "call_patch",
output: [{ type: "input_image", image_url: "data:image/png;base64,AAAA" }],
},
])
}),
)
it.effect("prepares OpenAI Responses target", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
@@ -3888,6 +4111,144 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("normalizes streamed custom tool input to the canonical object", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "custom_tool_call", id: "ctc_1", call_id: "call_patch", name: "apply_patch", input: "" },
},
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "*** Begin Patch\n" },
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "*** End Patch" },
{
type: "response.custom_tool_call_input.done",
item_id: "ctc_1",
input: "*** Begin Patch\n*** End Patch",
},
{
type: "response.output_item.done",
item: {
type: "custom_tool_call",
id: "ctc_1",
call_id: "call_patch",
name: "apply_patch",
input: "*** Begin Patch\n*** End Patch",
},
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(
LLM.request({ model: freeformModel, prompt: "Patch the file.", tools: [patchTool] }),
).pipe(Effect.provide(fixedResponse(body)))
const deltas = response.events.filter(LLMEvent.is.toolInputDelta)
const call = response.events.find(LLMEvent.is.toolCall)
expect(deltas.map((event) => event.text).join("")).toBe(
JSON.stringify({ patchText: "*** Begin Patch\n*** End Patch" }),
)
expect(call).toMatchObject({
type: "tool-call",
id: "call_patch",
name: "patch",
input: { patchText: "*** Begin Patch\n*** End Patch" },
providerMetadata: { openai: { itemId: "ctc_1" } },
})
expect(response.finishReason?.normalized).toBe("tool-calls")
}),
)
it.effect("finalizes custom input at each completion boundary", () =>
Effect.gen(function* () {
const added = {
type: "response.output_item.added",
item: { type: "custom_tool_call", id: "ctc_1", call_id: "call_patch", name: "apply_patch", input: "" },
}
const scenarios = [
{
expected: "*** Begin Patch",
events: [
added,
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "*** Begin" },
{ type: "response.custom_tool_call_input.done", item_id: "ctc_1", input: "*** Begin Patch" },
{ type: "response.completed", response: {} },
],
},
{
expected: "authoritative",
events: [
added,
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "partial" },
{ type: "response.completed", response: { output: [{ ...added.item, input: "authoritative" }] } },
],
},
{
expected: "done only",
events: [
{ type: "response.output_item.done", item: { ...added.item, id: undefined, input: "done only" } },
{ type: "response.completed", response: {} },
],
},
{ expected: "", events: [added, { type: "response.completed", response: {} }] },
]
for (const scenario of scenarios) {
const response = yield* LLMClient.generate(
LLM.request({ model: freeformModel, prompt: "Patch the file.", tools: [patchTool] }),
).pipe(Effect.provide(fixedResponse(sseEvents(...scenario.events))))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
name: "patch",
input: { patchText: scenario.expected },
})
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}
}),
)
it.effect("mints and replays an item id for a completed function call without one", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toEqual({
openai: { itemId: expect.stringMatching(/^fc_/) },
})
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: expect.stringMatching(/^fc_/),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
+21
View File
@@ -399,6 +399,27 @@ describe("LLMClient tools", () => {
expect(dynamic?.outputSchema).toEqual(schema)
})
test("preserves freeform representations in generated definitions", () => {
const freeform = {
input: "patchText",
name: "apply_patch",
grammars: [{ dialect: "oai-regex" as const, definition: "\\*\\*\\* Begin Patch(.|\\n)+" }],
}
const [definition] = toDefinitions({
patch: Tool.make({
description: "Apply a patch.",
jsonSchema: {
type: "object",
properties: { patchText: { type: "string" } },
required: ["patchText"],
},
freeform,
}),
})
expect(definition?.freeform).toEqual(freeform)
})
it.effect("preserves content tool results from dynamic tools", () =>
Effect.gen(function* () {
const screenshot = Tool.make({
@@ -1377,6 +1377,7 @@ export type ModelCompatibility = {
maxTokensField?: ModelMaxTokensField
requireFinishReason?: boolean
requireAssistantAfterTool?: boolean
supportsFreeformTools?: boolean
}
export type ModelCost = {
+2 -2
View File
@@ -213,9 +213,9 @@ function runtime(
const child = definition(registration)
getNode(root, qualifiedName(registration)).tool = Tool.make({
description: child.description,
input: child.inputSchema,
input: child.freeform ? Schema.String : child.inputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
execute: (input) => executeTool(name, registration, child.freeform ? { [child.freeform.input]: input } : input),
})
}
const tools = renderTools(root)
+1
View File
@@ -81,6 +81,7 @@ export const Plugin = {
description: DESCRIPTION,
input: Input,
output: Output,
freeform: { input: "patchText", name: "apply_patch" },
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const parsed = Patch.parse(input.patchText)
+1
View File
@@ -23,6 +23,7 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
description: tool.description,
inputSchema: inputJsonSchema(tool.input),
...(tool.output === undefined ? {} : { outputSchema: outputJsonSchema(tool.output) }),
...(tool.freeform === undefined ? {} : { freeform: tool.freeform }),
})
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
+30
View File
@@ -3,6 +3,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Tool } from "@opencode-ai/core/tool"
import { Agent } from "@opencode-ai/core/agent"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Effect, Schema } from "effect"
import { it } from "./lib/effect"
@@ -20,6 +23,14 @@ describe("CodeMode", () => {
options: { pinned: true },
execute: ({ text }) => Effect.succeed({ output: text }),
})
editor.add({
name: "script",
description: "Run script text",
input: Schema.Struct({ source: Schema.String }),
output: Schema.String,
freeform: { input: "source" },
execute: ({ source }) => Effect.succeed({ output: source }),
})
})
const snapshot = yield* tools.snapshot()
@@ -39,8 +50,27 @@ describe("CodeMode", () => {
description: "No tools registered yet",
tools: [],
},
{
type: "tool",
name: "script",
description: "Run script text",
signature: "tools.script(input: string): Promise<string>",
pinned: false,
},
],
})
const result = yield* snapshot.execute({
sessionID: Session.ID.make("ses_codemode_freeform"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_codemode_freeform"),
call: {
type: "tool-call",
id: "call-codemode-freeform",
name: "execute",
input: { code: 'return await tools.script("hello")' },
},
})
expect(result.output).toMatchObject({ output: "hello" })
}).pipe(
Effect.scoped,
Effect.provide(
+1
View File
@@ -60,6 +60,7 @@ export const Compatibility = Schema.Struct({
maxTokensField: MaxTokensField.pipe(optional),
requireFinishReason: Schema.Boolean.pipe(optional),
requireAssistantAfterTool: Schema.Boolean.pipe(optional),
supportsFreeformTools: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Compatibility" })
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
+14
View File
@@ -43,6 +43,19 @@ export type Options = BaseOptions &
export type ValueSchema<A = unknown> = Schema.Codec<A, any> | StandardSchemaV1<any, A> | JsonSchema.JsonSchema
export const FreeformGrammar = Schema.Struct({
dialect: Schema.Literals(["oai-lark", "oai-regex"]),
definition: Schema.String,
})
export type FreeformGrammar = typeof FreeformGrammar.Type
export const Freeform = Schema.Struct({
input: Schema.String,
name: Schema.optional(Schema.String),
grammars: Schema.optional(Schema.Array(FreeformGrammar)),
})
export type Freeform = typeof Freeform.Type
type InputValue<S> = 0 extends 1 & S
? any
: S extends Schema.Codec<infer A, any>
@@ -98,5 +111,6 @@ export type Info<
readonly description: string
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Result<Output>, Error>
readonly output?: Output
readonly freeform?: Freeform
readonly options?: Options
}