refactor(question): tool-arg errors at the boundary, drop redundant inner decode (#28570)

This commit is contained in:
Kit Langton
2026-05-20 23:06:22 -04:00
committed by GitHub
parent ddf18a7f9c
commit 8fc02b0130
4 changed files with 114 additions and 93 deletions

View File

@@ -8,16 +8,20 @@ import { QuestionID } from "./schema"
const log = Log.create({ service: "question" })
// Schemas
// Schemas — these are pure data; nothing checks class identity (see PR
// description) so they're plain `Schema.Struct` + type alias. That lets
// `Question.ask` and other internal sites trust the type contract without a
// re-decode to coerce nested class instances.
export class Option extends Schema.Class<Option>("QuestionOption")({
export const Option = Schema.Struct({
label: Schema.String.annotate({
description: "Display text (1-5 words, concise)",
}),
description: Schema.String.annotate({
description: "Explanation of choice",
}),
}) {}
}).annotate({ identifier: "QuestionOption" })
export type Option = Schema.Schema.Type<typeof Option>
const base = {
question: Schema.String.annotate({
@@ -34,48 +38,53 @@ const base = {
}),
}
export class Info extends Schema.Class<Info>("QuestionInfo")({
export const Info = Schema.Struct({
...base,
custom: Schema.optional(Schema.Boolean).annotate({
description: "Allow typing a custom answer (default: true)",
}),
}) {}
}).annotate({ identifier: "QuestionInfo" })
export type Info = Schema.Schema.Type<typeof Info>
export class Prompt extends Schema.Class<Prompt>("QuestionPrompt")(base) {}
export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" })
export type Prompt = Schema.Schema.Type<typeof Prompt>
export class Tool extends Schema.Class<Tool>("QuestionTool")({
export const Tool = Schema.Struct({
messageID: MessageID,
callID: Schema.String,
}) {}
}).annotate({ identifier: "QuestionTool" })
export type Tool = Schema.Schema.Type<typeof Tool>
export class Request extends Schema.Class<Request>("QuestionRequest")({
export const Request = Schema.Struct({
id: QuestionID,
sessionID: SessionID,
questions: Schema.Array(Info).annotate({
description: "Questions to ask",
}),
tool: Schema.optional(Tool),
}) {}
}).annotate({ identifier: "QuestionRequest" })
export type Request = Schema.Schema.Type<typeof Request>
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" })
export type Answer = Schema.Schema.Type<typeof Answer>
export class Reply extends Schema.Class<Reply>("QuestionReply")({
export const Reply = Schema.Struct({
answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}) {}
}).annotate({ identifier: "QuestionReply" })
export type Reply = Schema.Schema.Type<typeof Reply>
class Replied extends Schema.Class<Replied>("QuestionReplied")({
const Replied = Schema.Struct({
sessionID: SessionID,
requestID: QuestionID,
answers: Schema.Array(Answer),
}) {}
}).annotate({ identifier: "QuestionReplied" })
class Rejected extends Schema.Class<Rejected>("QuestionRejected")({
const Rejected = Schema.Struct({
sessionID: SessionID,
requestID: QuestionID,
}) {}
}).annotate({ identifier: "QuestionRejected" })
export const Event = {
Asked: BusEvent.define("question.asked", Request),
@@ -146,26 +155,12 @@ export const layer = Layer.effect(
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
// Use the Effect-returning decode so a schema failure surfaces as a
// typed error the tool wrap can turn into a "rewrite the input" tool
// result. The previous `decodeUnknownSync` would throw uncaught, which
// crashed the assistant turn for any payload that slipped past the
// wrap-level validation (#28438).
const info = yield* Schema.decodeUnknownEffect(Request)({
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}).pipe(
Effect.mapError(
(error) =>
new Error(
`The question tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
),
),
Effect.orDie,
)
}
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)

View File

@@ -13,6 +13,24 @@ interface Metadata {
// TODO: remove this hack
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
/**
* Raised when the LLM calls a tool with arguments that fail the parameter
* schema. This is the canonical "rewrite the input" tool error: the typed
* error class makes it matchable upstream, and its `message` getter produces
* the model-facing prose that the AI SDK feeds back as the tool result.
*/
export class InvalidArgumentsError extends Schema.TaggedErrorClass<InvalidArgumentsError>()(
"ToolInvalidArgumentsError",
{
tool: Schema.String,
detail: Schema.String,
},
) {
override get message() {
return `The ${this.tool} tool was called with invalid arguments: ${this.detail}.\nPlease rewrite the input so it satisfies the expected schema.`
}
}
export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
messageID: MessageID
@@ -99,13 +117,12 @@ function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadat
}
return Effect.gen(function* () {
const decoded = yield* decode(args).pipe(
Effect.mapError((error) =>
toolInfo.formatValidationError
? new Error(toolInfo.formatValidationError(error), { cause: error })
: new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
),
Effect.mapError(
(error) =>
new InvalidArgumentsError({
tool: id,
detail: toolInfo.formatValidationError ? toolInfo.formatValidationError(error) : String(error),
}),
),
)
const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)