Conversation
…h typed `Part` interrupt state Promotes the experimental tool surface into ai and genkit, ports InterruptibleToolAction with V2 verbs, narrows ai.Tool to a tool that runs, gives Part typed interrupt and restart state with ToToolRestart and ToToolResponse, and makes tool.Interrupt the one way to raise an interrupt. Deletes the ai/exp and genkit/exp tool copies.
There was a problem hiding this comment.
Code Review
This pull request stabilizes the experimental tools API by moving streaming, multipart, and interruptible tool support into the stable packages, deprecating the experimental implementations. It introduces typed fields for interrupts and restarts on the Part struct to natively support human-in-the-loop (HITL) workflows, and adds stable runtime helpers in the ai/tool package. The review feedback highlights three key areas for improvement: preventing a nil pointer dereference in runToolFunc when a tool returns a nil response but has attached parts, ensuring Part.Clone() performs a deep copy of nested maps or slices within the Interrupt and Restart payloads to avoid shared-state mutation, and defensively filtering out nil parts in AttachParts to prevent downstream panics.
Every tool is one struct, InterruptibleToolAction[In, Out, Res], with ToolAction[In, Out] a generic alias that fixes Res to a map. Interrupted claims a part for its tool and returns an InterruptedCall whose Restart, RestartWithInput, and Respond are typed and cannot fail; Res is checked at definition to serialize as a JSON object. WithResume takes restart and response parts in one list, and the Part verbs take the resume data and the new input directly. The ai.Tool interface keeps its erased verbs, deprecated, so nothing on main stops compiling. Also hardens three spots the review flagged: Part.Clone copies the top-level container of every interrupt and restart payload, a multipart function that returns a nil response still receives its attached parts, and tool.AttachParts ignores a nil part.
ai/tool to stable, with typed Part interrupt stateai/tool to stable, resolved through claimed calls
…r pair Part.interruptState and Part.restartState return the typed field, or the state a tool request hand-assembled with the JS metadata keys describes, so IsInterrupt, IsRestart, InterruptAs, the claim, the part verbs, and the resume loop agree on what counts as an interrupt or a restart without lifting copies inside the verbs. The loop lifts the keys onto the fields when it copies a part into history (typedClone behind interruptedPart and resolvedPart), buildRestartPart strips them from the metadata it carries over, and the wire keys become unexported constants beside the fold and lift code instead of living in internal/base. handleResumeOption validates each resume part, the two restart verbs on Part share one body, the deprecated RespondWith and RestartWith share one guard, and buildRestartPart takes the replacement input in place of a flag.
…rupts in the loop objectPayload is the one conversion of an interrupt or resume payload to the JSON object the wire contract requires; IsToolInterruptError, the restart verbs, and the resume branch of the loop all call it. The interrupt payload is checked in interruptedPart, where every interrupt lands whether a tool function or a WrapTool hook raised it, so runToolFunc only installs the part sink and folds attached parts. interruptedPart also normalizes a typed nil payload to a bare interrupt, the way buildRestartPart already does for a restart, so both markers reach the wire as true rather than null; bareIfNil uses base.IsNil for that.
…om the action applyToolOptions applies the tool options and runs the In check for every constructor, newTool records the output schema with base.SchemaMapFor, and the multipart flag has one source: the "tool" map the constructors write into the action metadata, which IsMultipart, Definition, and Register read through toolMetaOf. LookupTool no longer parses it back into a field.
TestPartToRestart_Flow asserts that the part verbs build the same parts as the typed verbs of a claimed call instead of running the loop a second time, TestInterruptibleTool_OutputSchemaOptions keeps only the check that names the constructor (the option itself is covered on NewTool, and both constructors share newTool), and the wire round-trip test reads the wire metadata through the wireMetadataOf helper the package already has.
…Option A tool accepts six options: the three input options, the two output schema options, and WithStrictSchema. DefineTool, DefineInterruptibleTool, and DefineMultipartTool listed only the two input schema options. The full list now lives on ToolOption, which NewTool and NewInterruptibleTool point at, and on the genkit constructors, with the any-constraint an explicit schema puts on the type parameter stated once above each list.
tool.Interrupt(ctx, data) takes the tool's context like the other verbs of the package, and like the JS and Python counterparts, which are methods on the tool context. Nothing reads it today; the parameter is in the signature so that the verb can use the context later without changing shape.
… its interrupt A restart part may carry the interrupt it resolves: that is the shape the JS runtime's restartTool builds, so Validate no longer rejects it and the loop resumes it. The wire keys are read the way JS reads them, by truthiness: null and false mean no state, true is a bare marker, and a resumed value that is not a JSON object, which Go cannot deliver to a tool, resumes it with an empty payload. The deprecated Restart carries its resume data as given instead of returning nil for a non-object, and a nil part in the resume list is reported as nil rather than as the wrong kind.
…rrupt verb A part of kind tool request with a nil ToolRequest names no tool, so it is neither an interrupt nor a restart: Interrupted reports false, ToToolRestart, ToToolRestartWithInput and ToToolResponse return an error, and the deprecated Respond, Restart, RespondWith and RestartWith decline it as their contracts say, instead of dereferencing the nil request.
tool.Interrupt converts its payload to the JSON object it serializes to and returns a plain error, naming the constraint, for a value that is not one, so a bad payload fails the tool call at the raise site instead of reaching IsToolInterruptError as a bare interrupt while the loop rejects it. The loop records the same object on the part, so Part.Interrupt.Data has one shape in process and after a wire hop and InterruptAs decodes it into any type with the same JSON shape either way. The verbs that only need to know a resume payload is an object check its kind instead of round-tripping it through JSON, and the helpers live in internal/base, where both ai and ai/tool reach them.
The resume payload rides the context as the caller gave it, a map after a wire hop or from a map restart and the caller's struct from a typed restart, and each reader converts it to what it returns with ConvertTo: the resume parameter of a NewInterruptibleTool tool, ToolContext.Resumed, ResumedValue and tool.ResumeData all hand a map over untouched, so an int restarted in process stays an int in every one of them instead of widening to float64 in the parameter alone, and a typed restart reaches its tool without a JSON round trip.
The generate loop installs the sink tool.AttachParts writes to before it runs the WrapTool chain and folds it when the call returns, so a hook can attach parts before or after running the tool and they land on the response in call order; a direct run installs and folds its own. The sink is one struct in internal/base rather than a closure over a mutex and a slice, and AttachParts documents that a part attached after the call returned, by a goroutine the tool did not wait for, attaches to nothing.
…art verbs buildRestartPart replaces the tool's input when the verb says so, not when the value it was handed is non-nil: RestartWithInput replaces with whatever it is given, a nil pointer included, since a positional parameter has no "not set"; ToToolRestartWithInput rejects a nil input, since with no type to say what the tool expects a nil is a mistake rather than a JSON null; and the deprecated option-driven verbs, where replacing is optional, treat a nil of any type as not set instead of sending null.
…gent ValidateResumeAgainstHistory holds a restart accountable for the input it preserves as the original when it replaced the input, on the typed state or under the replacedInput wire key, so a part built with RestartWithInput or ToToolRestartWithInput resumes an agent the way it resumes Generate instead of being rejected as a forgery, while a restart whose original does not match history is still refused.
…Request The Respond and Restart directives are matched by one helper and end in one place: the tool lookup, the resolved copy of the request and the output shape are written once, with the respond and restart specifics in two helpers. The caller's response is validated with base.ValidateValue like every other schema check in the framework, and a completed tool request is recorded with the same shallow typed copy the interrupt branch uses, since the model message was already deep-cloned for the call.
…unresolved The banker's handler checks that the interrupt data decodes before acting on it: a payload it cannot read is left unresolved for the CLI to report, rather than read as an empty reason and answered with a decline nobody was asked for.
Interrupted still reports false when the tool's own interrupt carries an input that no longer decodes as In, as documented, but now logs the decode error at debug level with the tool's name, since the unresolved tool request the next Generate reports cannot name the type mismatch behind it.
ToolInterrupt and ToolRestart say that in process the state lives on the typed fields alone, that the loop lifts the wire keys off Metadata, and that a part assembled with the keys reads as the same state through the predicates while its fields stay nil. InterruptedCall.Restart no longer promises a bare restart for a zero struct, which is sent as an object with zero fields, and newResponsePart describes the interruptResponse marker as the wire contract's mark of a caller-provided response rather than as what drives the loop, which matches by name and ref.
The external-package tests for tools.go need their own file, since ai/tool imports ai and an internal test file importing it would close the cycle, but the file is named for the source it tests rather than for a topic.
IsToolResumed, ResumedValue and OriginalInputAs read the same context values as tool.ResumeData and tool.OriginalInput, which work from any context, a ToolContext included, so the ai copies carry a Deprecated marker naming their replacement like the other verbs this surface retires. The ToolApproval middleware reads its approval flag through tool.ResumeData with a typed payload.
wireMetadata drops the key of the other resolution state when it folds a typed interrupt into the wire keys, so a part assembled with the raw "interrupt" key and then resolved on the field marshals as resolved only and does not read back as pending after a wire hop. A restart part no longer carries the loop's record of a completed sibling (pendingOutput and its companions) off the interrupted part it is built from.
toolMetadata is the only writer of originalOutputSchema: newTool passes it the explicit schema when one was given, otherwise the schema inferred from Out, and NewMultipartTool passes the explicit one or none, so the precedence is stated at the call rather than implied by two writers running in order.
…s against it A tool records the JSON schema inferred from its resume type in its action metadata and surfaces it as the definition's "resumeSchema" metadata, next to the multipart and strict flags. The generate loop validates a restart's payload against it before the tool re-executes, where it already validates a supplied response against the output schema, so a payload with a missing or mistyped field fails the resume with INVALID_ARGUMENT naming the field instead of decoding into a zero value. A bare restart is validated as an empty object, so a resume type with a required field rejects it; a field the caller may leave unset needs an omitempty tag, as for In. A tool without a resume type advertises the object schema, which is what the loop delivers its payload as; a tool behind a foreign action advertises none and is not checked.
A WrapTool hook that holds a call with tool.Interrupt raises its own interrupt, and the restart that answers it must reach the hook alone: the tool never asked, and decoding the hook's answer into its resume parameter made it run as if its own question had been answered. tool.Interrupt now reads the stage it is called from off the context, the interrupted part records it as ToolInterrupt.RaisedBy (wire key "interruptedBy", Go-only), and on restart the hook chain delivers the payload to that stage only: the hook reads it with tool.ResumeData, the tool runs as a fresh call that may interrupt in turn, a hook before the raising stage sees tool.Released and lets the restart through, and a hook after it, which never ran, sees a fresh call. The tool's resume schema applies to the tool's own restarts only, and Interrupted declines a hook's hold, which Part.ToToolRestart answers. ToolApproval passes a released call, so an approved interruptible tool can ask its own question and get the answer through the tool.
Promotes the experimental tool surface (the
ai/exptool types, theai/exp/toolverbs, and thegenkit/exptool constructors) intoaiandgenkit, porting it from the V2 branch (#5814), and deletes the experimental copies. Every tool is now one struct,InterruptibleToolAction[In, Out, Res], withToolAction[In, Out]a generic alias that fixesResto a map, and an interrupt is resolved by claiming the part for its tool:Interruptedreturns anInterruptedCallwhose verbs are typed and cannot fail.WithResumetakes restart and response parts in one list, andtool.Interruptis the one way to raise an interrupt. Every older verb and option is deprecated with its replacement named; the one removal is an accidental export. The wire format is unchanged: a tool requestPartcarries its interrupt and restart state in typed fields that fold into the metadata keys the JS runtime reads (interrupt,resolvedInterrupt,resumed,replacedInput) on marshal and lift back out on unmarshal, read the way JS reads them, and pinned by a round-trip test over every shape.An interrupt is resolved by claiming the part for its tool
Before: the application matches parts to tools by name, reads the input as an untyped map, and sends the answer through an option whose type parameter enforces nothing. The verb re-checks the match at runtime and returns an error, and the tool reads the answer back a key at a time.
After: the tool claims the part. The claim is the match and the type check, so the input is
In, the answer isReson both ends, and nothing after the claim has an error to return.Interruptedreports false for a nil part, a part of another kind or with no request, a resolved interrupt, another tool's interrupt, or an input that no longer decodes asIn(logged at debug level, since the unresolved request the nextGeneratereports cannot name the mismatch).Resis checked at definition to be a struct or a map with string keys, soRestartalways builds a JSON object, and the loop validatesOut. The resume value reaches the tool as the caller built it: it rides the context as given and every reader converts it to what it returns, so a struct needs no conversion and a map keeps its Go types in the resume parameter,ToolContext.Resumedandtool.ResumeDataalike.call.Respond(out)answers without re-running the tool, andcall.RestartWithInput(in, res)re-runs it with revised arguments, whatever their value;Part.ToToolRestartWithInputrejects a nil input, having no type to say what the tool expects. The data a tool sent when it paused stays on the part, read withai.InterruptAs, because Go has no generic methods to put it on the call.One struct behind every tool
ToolActionis a generic alias (Go 1.24 and later; the module is at 1.25), so*ai.ToolAction[In, Out]in existing code names the same type it always did, and aDefineTooltool gets the claim too, withRestarttaking the map itsToolContext.Resumedreads. Both constructors return the one struct,Hooks.Tools []ai.Toolaccepts it, andai.Toolkeeps its erasedRespondandRestart, deprecated on the interface, so aLookupToolresult compiles as before.The
ai/toolverbs work in every toolThe generate loop installs the part sink
AttachPartswrites to around the whole tool call,WrapToolhooks included, and folds the attached parts into the response when the call returns, so a hook can attach parts before or after running the tool; a direct run (RunRaw, the Dev UI) installs and folds its own.NewMultipartToolandDefineMultipartToolare deprecated in favor ofAttachParts, which keeps the output type and therefore the advertised schema. The interrupt is an internal error type ininternal/base;tool.Interruptraises it, from a tool function or from aWrapToolhook that holds a call without running it (theToolApprovalmiddleware does this), andai.IsToolInterruptErrordetects it outside the loop.tool.Interruptconverts its payload to the JSON object the wire carries when it raises the interrupt, and returns a plain error for a value that is not one, so a bad payload fails the call at the line that raised it and the data has one shape in process and after a wire hop.Partcarries interrupt state in typed fieldsDatais the JSON object the tool's payload serializes to, in process as after a wire hop, soInterruptAsdecodes it into any type with that shape.Resumeis the value the caller restarted with, a struct from a typed restart and a map from the wire, and every reader converts it. A tool request hand-assembled with the JS keys instead of the fields reads as the same state everywhere:IsInterrupt,InterruptAs, the claim, the part verbs, and the loop read the typed field or the key behind it, and the loop lifts the keys onto the fields when it copies a part into history. The keys are read by truthiness, as JS reads them:nullandfalseare no state,trueis a bare marker, and aresumedvalue that is not an object (the JSrestartToolpasses any value through) resumes the tool with an empty payload. A restart part may still carry the interrupt it resolves, which is the shaperestartToolbuilds.Validatechecks a part's fields against its kind, and the loop runs it on every resume part.Tools advertise their output schema, never the envelope
Every tool function is wrapped in the multipart envelope internally, so the action's own output schema describes that envelope.
Definition()advertises only the schema recorded from the output type orWithOutputSchema, and nothing when there is none: an unconstrained output is described by no schema, not by the envelope's.Public API
Compatibility
No stable signature changes.
ToolActionis an alias of the new struct, so every mention of the type, every method on it, and everyai.Toolvalue compile as before, and the erased verbs stay on the interface. The one removal isai.NewToolInterruptError, an accidental export that existed for middleware holding a tool call without running it;tool.Interrupt(ctx, map[string]any{...})is the drop-in replacement, and theToolApprovalmiddleware in this repo made that switch. It was never documented on the docs site.In process,
Part.Metadatano longer carries the interrupt and restart keys: the loop lifts them onto the typed fields when it records the state, sopart.Metadata["interrupt"]reads nil on a part the loop produced, where it held the interrupt data before.IsInterruptandInterruptAsare the reads. The wire bytes are the same.The deletions are all under
exp: an exp user changes the import path fromai/exp/tooltoai/tool,genkitx.DefineInterruptibleTooltogenkit.DefineInterruptibleTool,tool.InterruptAstoai.InterruptAs,*aix.InterruptibleToolto*ai.InterruptibleToolAction, andmyTool.Resume(part, res)ormyTool.Respond(part, out)to the claim,call, ok := myTool.Interrupted(part)followed bycall.Restart(res)orcall.Respond(out). Agenkitx.DefineTooltool becomesgenkit.DefineToolwith*ai.ToolContextas its first parameter.What was deliberately left out
InterruptAs[T]stays a function on the part: Go has no generic methods, and a fourth type parameter for the payload is too heavy for the common case, where the input already says what to approve.Part.Metadata. The typed fields are the one in-process representation; a second one drifts, and the wire is unchanged.ToolApprovalpolicy from the same V2 section are separate concerns and are not here.