Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 85 additions & 5 deletions packages/ai/ai/src/LanguageModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,10 +775,15 @@ export const make: (params: ConstructorParams) => Effect.Effect<Service> = Effec
const ResponseSchema = Schema.mutable(Schema.Array(Response.Part(toolkit)))

// If tool call resolution is disabled, return the response without
// resolving the tool calls that were generated
// resolving the tool calls that were generated.
// Use the raw schema (GenericToolCallPart) to avoid decoding tool
// call parameters through the tool's parametersSchema, so that
// params remain as raw JSON values. This prevents double-decoding
// when the user later calls toolkit.handle() which also decodes
// via parametersSchema.
if (options.disableToolCallResolution === true) {
const rawContent = yield* params.generateText(providerOptions)
const content = yield* Schema.decodeUnknown(ResponseSchema)(rawContent)
const content = yield* Schema.decodeUnknown(RawPartSchema)(rawContent)
return content as Array<Response.Part<Tools>>
}

Expand Down Expand Up @@ -837,10 +842,11 @@ export const make: (params: ConstructorParams) => Effect.Effect<Service> = Effec
providerOptions.toolChoice = toolChoice

// If tool call resolution is disabled, return the response without
// resolving the tool calls that were generated
// resolving the tool calls that were generated.
// Use the raw schema to avoid decoding tool call parameters, keeping
// params as raw JSON for the same reason as in generateContent above.
if (options.disableToolCallResolution === true) {
const schema = Schema.ChunkFromSelf(Response.StreamPart(toolkit))
const decode = Schema.decode(schema)
const decode = Schema.decode(RawStreamPartSchema)
return params.streamText(providerOptions).pipe(
Stream.mapChunksEffect(decode)
) as Stream.Stream<Response.StreamPart<Tools>, AiError.AiError | ParseResult.ParseError, IdGenerator>
Expand Down Expand Up @@ -985,6 +991,80 @@ export const streamText = <
LanguageModel | ExtractContext<Options>
> => Stream.unwrap(LanguageModel.pipe(Effect.map((model) => model.streamText(options))))

// =============================================================================
// Raw Response Schemas (for disableToolCallResolution)
// =============================================================================

// A generic tool-call schema that passes params through as unknown,
// avoiding parameter decoding that would cause double-decode when
// toolkit.handle() is later called manually.
const GenericToolCallPart = Schema.Struct({
type: Schema.Literal("tool-call"),
id: Schema.String,
name: Schema.String,
params: Schema.Unknown,
providerName: Schema.optional(Schema.String),
providerExecuted: Schema.optionalWith(Schema.Boolean, { default: () => false }),
metadata: Schema.optionalWith(Response.ProviderMetadata, { default: () => ({}) })
}).pipe(
Schema.attachPropertySignature(Response.PartTypeId, Response.PartTypeId),
Schema.annotations({ identifier: "GenericToolCallPart" })
)

// A generic tool-result schema that passes result through as unknown.
// Included for type-completeness (the provider stream type includes
// ToolResultPartEncoded even though results shouldn't appear when
// tool call resolution is disabled).
const GenericToolResultPart = Schema.Struct({
type: Schema.Literal("tool-result"),
id: Schema.String,
name: Schema.String,
result: Schema.Unknown,
isFailure: Schema.Boolean,
providerName: Schema.optional(Schema.String),
providerExecuted: Schema.optionalWith(Schema.Boolean, { default: () => false }),
metadata: Schema.optionalWith(Response.ProviderMetadata, { default: () => ({}) })
}).pipe(
Schema.attachPropertySignature(Response.PartTypeId, Response.PartTypeId),
Schema.annotations({ identifier: "GenericToolResultPart" })
)

const RawPartSchema = Schema.mutable(Schema.Array(
Schema.Union(
Response.TextPart,
Response.ReasoningPart,
Response.FilePart,
Response.DocumentSourcePart,
Response.UrlSourcePart,
Response.ResponseMetadataPart,
Response.FinishPart,
GenericToolCallPart,
GenericToolResultPart
)
))

const RawStreamPartSchema = Schema.ChunkFromSelf(
Schema.Union(
Response.TextStartPart,
Response.TextDeltaPart,
Response.TextEndPart,
Response.ReasoningStartPart,
Response.ReasoningDeltaPart,
Response.ReasoningEndPart,
Response.ToolParamsStartPart,
Response.ToolParamsDeltaPart,
Response.ToolParamsEndPart,
Response.FilePart,
Response.DocumentSourcePart,
Response.UrlSourcePart,
Response.ResponseMetadataPart,
Response.FinishPart,
Response.ErrorPart,
GenericToolCallPart,
GenericToolResultPart
)
)

// =============================================================================
// Tool Call Resolution
// =============================================================================
Expand Down
111 changes: 111 additions & 0 deletions packages/ai/ai/test/LanguageModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,65 @@ const MyToolkitLayer = MyToolkit.toLayer({
)
})

// Tool with a transforming schema (NumberFromString: string -> number)
// to verify that disableToolCallResolution keeps params as raw JSON
const TransformTool = Tool.make("TransformTool", {
description: "A tool with a transforming parameter schema",
parameters: { count: Schema.NumberFromString },
success: Schema.Struct({ doubled: Schema.Number })
})

const TransformToolkit = Toolkit.make(TransformTool)

const TransformToolkitLayer = TransformToolkit.toLayer({
TransformTool: ({ count }) =>
Effect.succeed({ doubled: count * 2 })
})

describe("LanguageModel", () => {
describe("generateText", () => {
it.effect(
"disableToolCallResolution should not double-decode transforming params",
() =>
Effect.gen(function*() {
const toolCallId = "tool-transform-1"
const toolName = "TransformTool"
// The LLM returns params as raw JSON (encoded form: string "42")
const toolParams = { count: "42" }

const response = yield* LanguageModel.generateText({
prompt: "test",
toolkit: TransformToolkit,
disableToolCallResolution: true
}).pipe(
TestUtils.withLanguageModel({
generateText: [
{
type: "tool-call",
id: toolCallId,
name: toolName,
params: toolParams
}
]
}),
Effect.provide(TransformToolkitLayer)
)

// Params should remain as raw JSON (string "42"), not decoded to number 42
assert.strictEqual(response.toolCalls.length, 1)
const toolCall = response.toolCalls[0]!
assert.strictEqual(toolCall.name, toolName)

// Now manually call toolkit.handle with the raw params — should succeed
const toolkit = yield* TransformToolkit.pipe(
Effect.provide(TransformToolkitLayer)
)
const result = yield* toolkit.handle(toolCall.name, toolCall.params as any)
assert.deepStrictEqual(result.result, { doubled: 84 })
})
)
})

describe("streamText", () => {
it.effect("should emit tool calls before executing tool handlers", () =>
Effect.gen(function*() {
Expand Down Expand Up @@ -82,5 +140,58 @@ describe("LanguageModel", () => {

assert.deepStrictEqual(parts, [toolCallPart, toolResultPart])
}))

it.effect(
"disableToolCallResolution should not double-decode transforming params in stream",
() =>
Effect.gen(function*() {
const parts: Array<Response.StreamPart<Toolkit.Tools<typeof TransformToolkit>>> = []
const latch = yield* Effect.makeLatch()

const toolCallId = "tool-transform-stream-1"
const toolName = "TransformTool"
const toolParams = { count: "42" }

yield* LanguageModel.streamText({
prompt: "test",
toolkit: TransformToolkit,
disableToolCallResolution: true
}).pipe(
Stream.runForEach((part) =>
Effect.andThen(latch.open, () => {
parts.push(part)
})
),
TestUtils.withLanguageModel({
streamText: [
{
type: "tool-call",
id: toolCallId,
name: toolName,
params: toolParams
}
]
}),
Effect.provide(TransformToolkitLayer),
Effect.fork
)

yield* latch.await

// Verify tool call params remain as raw JSON
const toolCallParts = parts.filter((p) => p.type === "tool-call")
assert.strictEqual(toolCallParts.length, 1)

// Manually call toolkit.handle — should succeed without double-decode
const toolkit = yield* TransformToolkit.pipe(
Effect.provide(TransformToolkitLayer)
)
const result = yield* toolkit.handle(
toolCallParts[0]!.name as any,
toolCallParts[0]!.params as any
)
assert.deepStrictEqual(result.result, { doubled: 84 })
})
)
})
})