Skip to content

feat(go/ai): promote interruptible tools and ai/tool to stable, resolved through claimed calls - #6325

Draft
apascal07 wants to merge 28 commits into
mainfrom
ap/go-typed-part-interrupts
Draft

apascal07 wants to merge 28 commits into
mainfrom
ap/go-typed-part-interrupts

Conversation

@apascal07

@apascal07 apascal07 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Promotes the experimental tool surface (the ai/exp tool types, the ai/exp/tool verbs, and the genkit/exp tool constructors) into ai and genkit, porting it from the V2 branch (#5814), and deletes the experimental copies. Every tool is now one struct, InterruptibleToolAction[In, Out, Res], with ToolAction[In, Out] a generic alias that fixes Res to a map, and an interrupt is resolved by claiming the part for its tool: Interrupted returns an InterruptedCall whose verbs are typed and cannot fail. WithResume takes restart and response parts in one list, and tool.Interrupt is 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 request Part carries 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.

transferMoney := genkit.DefineTool(g, "transferMoney", "...",
    func(tc *ai.ToolContext, in TransferInput) (*TransferOutput, error) {
        if !tc.IsResumed() && in.Amount > approvalLimit {
            return nil, ai.InterruptWith(tc, TransferInterrupt{Reason: "over_limit"})
        }
        if approved, ok := ai.ResumedValue[bool](tc, "approved"); ok && !approved {
            return &TransferOutput{Status: "declined"}, nil
        }
        ...
    })

var restarts []*ai.Part
for _, part := range resp.Interrupts() {
    switch part.ToolRequest.Name {
    case transferMoney.Name():
        in := part.ToolRequest.Input.(map[string]any) // untyped on the part
        approved := askHuman(in["amount"].(float64), in["toAccount"].(string))
        restart, err := transferMoney.RestartWith(part,
            ai.WithResumedMetadata[TransferInput](map[string]any{"approved": approved}))
        if err != nil {
            return nil, err
        }
        restarts = append(restarts, restart)
    // case anotherTool.Name():
    //     ...
    }
}
resp, err = genkit.Generate(ctx, g,
    ai.WithMessages(resp.History()...), ai.WithTools(transferMoney), ai.WithToolRestarts(restarts...))

After: the tool claims the part. The claim is the match and the type check, so the input is In, the answer is Res on both ends, and nothing after the claim has an error to return.

transferMoney := genkit.DefineInterruptibleTool(g, "transferMoney", "...",
    func(ctx context.Context, in TransferInput, resume *Confirmation) (*TransferOutput, error) {
        if resume == nil && in.Amount > approvalLimit {
            return nil, tool.Interrupt(ctx, TransferInterrupt{Reason: "over_limit"})
        }
        if resume != nil && !resume.Approved {
            return &TransferOutput{Status: "declined"}, nil
        }
        ...
    })

var parts []*ai.Part
for _, part := range resp.Interrupts() {
    if call, ok := transferMoney.Interrupted(part); ok {
        approved := askHuman(call.Input.Amount, call.Input.ToAccount) // In, decoded from the part
        parts = append(parts, call.Restart(Confirmation{Approved: approved}))
    }
    // if call, ok := anotherTool.Interrupted(part); ok {
    //     parts = append(parts, call.Respond(AnotherOutput{...}))
    // }
}
resp, err = genkit.Generate(ctx, g,
    ai.WithMessages(resp.History()...), ai.WithTools(transferMoney), ai.WithResume(parts...))

Interrupted reports 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 as In (logged at debug level, since the unresolved request the next Generate reports cannot name the mismatch). Res is checked at definition to be a struct or a map with string keys, so Restart always builds a JSON object, and the loop validates Out. 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.Resumed and tool.ResumeData alike. call.Respond(out) answers without re-running the tool, and call.RestartWithInput(in, res) re-runs it with revised arguments, whatever their value; Part.ToToolRestartWithInput rejects 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 with ai.InterruptAs, because Go has no generic methods to put it on the call.

One struct behind every tool

type InterruptibleToolAction[In, Out, Res any] struct { action api.Action; multipart bool; registry api.Registry }
type ToolAction[In, Out any] = InterruptibleToolAction[In, Out, map[string]any]

ToolAction is 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 a DefineTool tool gets the claim too, with Restart taking the map its ToolContext.Resumed reads. Both constructors return the one struct, Hooks.Tools []ai.Tool accepts it, and ai.Tool keeps its erased Respond and Restart, deprecated on the interface, so a LookupTool result compiles as before.

The ai/tool verbs work in every tool

weather := genkit.DefineTool(g, "getWeather", "...",
    func(ctx *ai.ToolContext, in WeatherInput) (Forecast, error) {
        tool.SendPartial(ctx, map[string]any{"step": "fetching"})
        tool.AttachParts(ctx, ai.NewMediaPart("image/png", chart))
        if _, ok := tool.ResumeData[Approval](ctx); !ok {
            return Forecast{}, tool.Interrupt(ctx, NeedsApproval{City: in.City})
        }
        return forecast, nil
    })

The generate loop installs the part sink AttachParts writes to around the whole tool call, WrapTool hooks 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. NewMultipartTool and DefineMultipartTool are deprecated in favor of AttachParts, which keeps the output type and therefore the advertised schema. The interrupt is an internal error type in internal/base; tool.Interrupt raises it, from a tool function or from a WrapTool hook that holds a call without running it (the ToolApproval middleware does this), and ai.IsToolInterruptError detects it outside the loop. tool.Interrupt converts 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.

Part carries interrupt state in typed fields

type ToolInterrupt struct { Data any; Resolved bool }
type ToolRestart struct { Resume any; OriginalInput any } // OriginalInput is "replacedInput" on the wire

part.Interrupt.Data      // ai.InterruptAs[T](part) decodes it
part.Interrupt.Resolved  // flipped when a restart or a response resolves it

Data is the JSON object the tool's payload serializes to, in process as after a wire hop, so InterruptAs decodes it into any type with that shape. Resume is 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: null and false are no state, true is a bare marker, and a resumed value that is not an object (the JS restartTool passes any value through) resumes the tool with an empty payload. A restart part may still carry the interrupt it resolves, which is the shape restartTool builds. Validate checks 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 or WithOutputSchema, and nothing when there is none: an unconstrained output is described by no schema, not by the envelope's.

Public API

// Added (ai)
type InterruptibleToolAction[In, Out, Res any] struct{ ... }   // the one tool type
type ToolAction[In, Out any] = InterruptibleToolAction[In, Out, map[string]any]
type InterruptibleToolFunc[In, Out, Res any] = func(ctx context.Context, input In, resume *Res) (Out, error)
func NewInterruptibleTool[In, Out, Res any](name, description string, fn InterruptibleToolFunc[In, Out, Res], opts ...ToolOption) *InterruptibleToolAction[In, Out, Res]

func (t *InterruptibleToolAction[In, Out, Res]) Interrupted(part *Part) (*InterruptedCall[In, Out, Res], bool)
type InterruptedCall[In, Out, Res any] struct{ Part *Part; Input In }
func (c *InterruptedCall[In, Out, Res]) Restart(resume Res) *Part
func (c *InterruptedCall[In, Out, Res]) RestartWithInput(input In, resume Res) *Part
func (c *InterruptedCall[In, Out, Res]) Respond(output Out) *Part
func (p *Part) ToToolRestart(resume any) (*Part, error)
func (p *Part) ToToolRestartWithInput(input, resume any) (*Part, error)
func (p *Part) ToToolResponse(output any) (*Part, error)
func WithResume(parts ...*Part) GenerateOption
type ToolInterrupt struct{ Data any; Resolved bool }
type ToolRestart struct{ Resume, OriginalInput any }
Part.Interrupt *ToolInterrupt; Part.Restart *ToolRestart
func (p *Part) IsRestart() bool; func (p *Part) Validate() error; func (k PartKind) String() string

// Added (genkit, ai/tool)
func genkit.DefineInterruptibleTool[In, Out, Res any](g *Genkit, name, description string, fn ai.InterruptibleToolFunc[In, Out, Res], opts ...ai.ToolOption) *ai.InterruptibleToolAction[In, Out, Res]

package ai/tool: Interrupt, AttachParts, SendPartial, SendChunk, OriginalInput[In], ResumeData[T]   // moved from ai/exp/tool

// Changed (ai, ai/exp)
InterruptAs[T]                  // decodes the JSON object on Part.Interrupt.Data
(*ToolAction).Definition()      // no output schema when the output type has none (was the envelope)
IsToolInterruptError, ToolContext.Interrupt, InterruptWith   // read or build the internal interrupt error
aix.ValidateResumeAgainstHistory                             // accepts a restart whose preserved original input matches history

// Deprecated (ai, genkit), each note naming its replacement
Tool.Respond, Tool.Restart, ToolAction.Respond, .Restart, .RespondWith, .RestartWith
RestartOptions, RespondOptions, RestartWithOption, RespondWithOption, WithNewInput, WithResumedMetadata, WithResponseMetadata
WithToolRestarts, WithToolResponses                      // use WithResume
ToolContext.Interrupt, InterruptWith, InterruptOptions   // use tool.Interrupt
IsToolResumed, ResumedValue, OriginalInputAs             // use tool.ResumeData, tool.OriginalInput
ai.NewMultipartTool, genkit.DefineMultipartTool          // use tool.AttachParts

// Removed (ai)
NewToolInterruptError           // use tool.Interrupt

// Deleted (pre-stability)
ai/exp: ToolFunc, Tool, InterruptibleTool, NewTool, NewInterruptibleTool, InterruptError
genkit/exp: DefineTool, DefineInterruptibleTool
ai/exp/tool (moved to ai/tool; Resume, Respond, InterruptAs, SetOriginalInput, NewPartsContext dropped)

Compatibility

No stable signature changes. ToolAction is an alias of the new struct, so every mention of the type, every method on it, and every ai.Tool value compile as before, and the erased verbs stay on the interface. The one removal is ai.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 the ToolApproval middleware in this repo made that switch. It was never documented on the docs site.

In process, Part.Metadata no longer carries the interrupt and restart keys: the loop lifts them onto the typed fields when it records the state, so part.Metadata["interrupt"] reads nil on a part the loop produced, where it held the interrupt data before. IsInterrupt and InterruptAs are the reads. The wire bytes are the same.

The deletions are all under exp: an exp user changes the import path from ai/exp/tool to ai/tool, genkitx.DefineInterruptibleTool to genkit.DefineInterruptibleTool, tool.InterruptAs to ai.InterruptAs, *aix.InterruptibleTool to *ai.InterruptibleToolAction, and myTool.Resume(part, res) or myTool.Respond(part, out) to the claim, call, ok := myTool.Interrupted(part) followed by call.Restart(res) or call.Respond(out). A genkitx.DefineTool tool becomes genkit.DefineTool with *ai.ToolContext as its first parameter.

What was deliberately left out

  • A typed interrupt payload. 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.
  • A resolver that dispatches interrupts to handlers. The claim loop is the dispatch. A handler registry would be an in-process resume model, and interrupts are durable JSON.
  • A compatibility copy of the wire keys on in-process Part.Metadata. The typed fields are the one in-process representation; a second one drifts, and the wire is unchanged.
  • Thought signatures and ToolApproval policy from the same V2 section are separate concerns and are not here.

…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.
@github-actions github-actions Bot added docs Improvements or additions to documentation go labels Sep 9, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread go/ai/tools.go Outdated
Comment thread go/ai/document.go
Comment thread go/ai/tool/tool.go
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.
@apascal07 apascal07 changed the title feat(go/ai): promote interruptible tools and ai/tool to stable, with typed Part interrupt state feat(go/ai): promote interruptible tools and ai/tool to stable, resolved through claimed calls Sep 10, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation go

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant