github.com/anthropics/anthropic-sdk-go@v1.70.1/betasessiontoolrunner.go (about)

     1  package anthropic
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"iter"
     9  	"log/slog"
    10  	"math/rand/v2"
    11  	"sync"
    12  	"time"
    13  
    14  	"github.com/anthropics/anthropic-sdk-go/internal/sendwindow"
    15  	"github.com/anthropics/anthropic-sdk-go/internal/stainlessheader"
    16  	"github.com/anthropics/anthropic-sdk-go/option"
    17  	"github.com/anthropics/anthropic-sdk-go/packages/param"
    18  	"golang.org/x/sync/errgroup"
    19  )
    20  
    21  // DefaultMaxIdle is used for [SessionToolRunnerOptions.MaxIdle] when it is nil:
    22  // once the session goes idle with stop_reason "end_turn", the runner keeps
    23  // running for this long before stopping (any new event resets the countdown).
    24  const DefaultMaxIdle = 60 * time.Second
    25  
    26  const (
    27  	sessionRunnerStreamBackoffStart = 500 * time.Millisecond
    28  	sessionRunnerStreamBackoffCap   = 10 * time.Second
    29  	// sessionRunnerStreamHealthyAfter is how long a stream must stay open
    30  	// before the reconnect backoff resets.
    31  	sessionRunnerStreamHealthyAfter = 5 * time.Second
    32  	sessionRunnerResultsBuffer      = 32 // enough to absorb a brief consumer pause without back-pressuring dispatch
    33  	sessionRunnerToolUseQueueBuffer = 32
    34  )
    35  
    36  // Backoff between tool-result send retries; vars so tests can shrink them.
    37  var (
    38  	sessionRunnerSendBackoffStart = 1 * time.Second
    39  	sessionRunnerSendBackoffCap   = 30 * time.Second
    40  )
    41  
    42  // Defaults for the tunable per-runner timeouts. Each is overridable via the
    43  // matching [SessionToolRunnerOptions] field (ToolTimeout / SendTimeout /
    44  // DrainTimeout); a zero option falls back to the default here, so the
    45  // out-of-the-box behavior is identical when nothing is set.
    46  const (
    47  	defaultSessionRunnerToolTimeout  = 120 * time.Second
    48  	defaultSessionRunnerSendTimeout  = 15 * time.Second
    49  	defaultSessionRunnerDrainTimeout = 30 * time.Second
    50  )
    51  
    52  // defaultSessionRunnerSendRetryWindow bounds how long a transiently failing
    53  // tool-result send keeps retrying when the runner is used on its own: the
    54  // server's default work-item lease TTL. lib/environments.EnvironmentWorker
    55  // overrides it with the live TTL from each lease heartbeat (internal/sendwindow),
    56  // so a send is only abandoned once the lease can no longer be ours.
    57  const defaultSessionRunnerSendRetryWindow = 5 * time.Minute
    58  
    59  // Sentinel errors returned via (*SessionToolRunner).Err so consumers can
    60  // distinguish normal end-of-session from idle bailout.
    61  var (
    62  	// ErrSessionTerminated means the server emitted session.status_terminated
    63  	// or session.deleted. Iteration ends normally; the consumer's outer loop
    64  	// can move on.
    65  	ErrSessionTerminated = errors.New("session terminated")
    66  
    67  	// ErrIdleTimeout means the session went idle with stop_reason "end_turn"
    68  	// and stayed quiet for MaxIdle. The consumer chose this timeout, so treat
    69  	// it as expected.
    70  	ErrIdleTimeout = errors.New("session idle after end_turn")
    71  )
    72  
    73  // SessionToolRunnerOptions configures a [SessionToolRunner]. The
    74  // managed-agents session id is not part of this struct — it is a positional
    75  // argument to NewToolRunner, matching list/send/stream on the events resource.
    76  type SessionToolRunnerOptions struct {
    77  	// Tools is the registry of locally-executable tools, in the same
    78  	// [BetaTool] shape (*BetaMessageService).NewToolRunner accepts. The runner
    79  	// looks up each agent.tool_use and agent.custom_tool_use event's Name
    80  	// against this slice and routes to the matching tool. Use
    81  	// agenttoolset.BetaAgentToolset20260401(env) (from
    82  	// github.com/anthropics/anthropic-sdk-go/tools/agenttoolset) for the
    83  	// standard agent_toolset_20260401 set; filter or extend the slice to
    84  	// customise. Tool lifetime — including Close on tools that implement
    85  	// io.Closer — is the caller's responsibility: the runner never closes
    86  	// tools, so the same slice can be reused across multiple sessions.
    87  	Tools []BetaTool
    88  
    89  	// MaxIdle is how long the runner keeps running after the session goes idle
    90  	// with stop_reason "end_turn" before it stops; any new event resets the
    91  	// countdown and it re-arms on the next "end_turn" idle. The countdown is
    92  	// deferred while a confirmation-gated call is held or still dispatching,
    93  	// and starts fresh once the last such call resolves. nil uses
    94  	// [DefaultMaxIdle] (60s). A non-nil value <= 0 disables it — the runner
    95  	// then only stops on session termination, the consumer breaking out, or
    96  	// ctx cancellation.
    97  	MaxIdle *time.Duration
    98  
    99  	// Logger receives non-fatal warnings (transient stream errors, send
   100  	// retries). Defaults to slog.Default().
   101  	Logger *slog.Logger
   102  
   103  	// RequestOptions are applied to every request the runner issues — the
   104  	// event stream, the reconcile list, and the tool-result send. The runner
   105  	// additionally tags each request with an x-stainless-helper header of
   106  	// its own (appended last, so it always wins on a collision with a
   107  	// caller-supplied tag).
   108  	//
   109  	// Self-hosted-environment callers must pass the environment key here.
   110  	// option.WithAuthToken alone only ADDS an Authorization header — the
   111  	// parent client's WithAPIKey middleware still emits X-Api-Key on every
   112  	// request, so both creds would land on the wire and the server rejects
   113  	// the dual auth on the events stream. Pair the bearer with an explicit
   114  	// X-Api-Key delete:
   115  	//
   116  	//	RequestOptions: []option.RequestOption{
   117  	//		option.WithHeaderDel("X-Api-Key"),
   118  	//		option.WithAuthToken(environmentKey),
   119  	//	}
   120  	RequestOptions []option.RequestOption
   121  
   122  	// ToolTimeout bounds a single tool's Execute call; a tool that runs longer
   123  	// is cancelled and reported as an error result. Zero uses the default of
   124  	// 120s.
   125  	ToolTimeout time.Duration
   126  
   127  	// SendTimeout bounds a single attempt to post a tool-result event back to
   128  	// the session; transient failures are retried with exponential backoff
   129  	// for a bounded window (5m by default). Zero uses the default of 15s.
   130  	SendTimeout time.Duration
   131  
   132  	// DrainTimeout bounds how long Close waits for in-flight tool executions
   133  	// to finish before it logs a warning and returns. Zero uses the default of
   134  	// 30s.
   135  	DrainTimeout time.Duration
   136  }
   137  
   138  // DispatchedToolCall describes a single tool-use event that the runner has
   139  // executed locally and (attempted to) post a result for. The consumer observes
   140  // one of these per iteration of (*SessionToolRunner).Next.
   141  //
   142  // The runner dispatches both kinds of tool-call event the session API emits:
   143  // agent.tool_use (the builtin agent_toolset_20260401 tools, answered with
   144  // user.tool_result) and agent.custom_tool_use (user-defined function tools,
   145  // answered with user.custom_tool_result). The Custom field reports which kind
   146  // this dispatch came from and selects which embedded-event pair is populated —
   147  // ToolUse/Result for a builtin call, CustomToolUse/CustomResult for a custom
   148  // one. The flat top-level convenience fields (ToolUseID / Name / IsError /
   149  // Posted) are populated for both kinds; read whichever is more convenient,
   150  // they describe the same dispatch. The raw tool input lives on the triggering
   151  // event (ToolUse.Input / CustomToolUse.Input) and the tool's output blocks on
   152  // the posted result (Result.Content / CustomResult.Content).
   153  //
   154  // IsError is true if the tool reported failure or exceeded its per-call
   155  // timeout. Posted is orthogonal to IsError: it reports whether a result event
   156  // was successfully sent back to the session. Posted=false means the agent will
   157  // not see a result from this runner, regardless of IsError — either because
   158  // the send failed or because the tool name is not one this runner owns and the
   159  // runner deliberately posted nothing and left the id pending for its owner.
   160  type DispatchedToolCall struct {
   161  	// Custom reports whether this dispatch was triggered by an
   162  	// agent.custom_tool_use event (a user-defined function tool) rather than an
   163  	// agent.tool_use event (a builtin agent_toolset_20260401 tool). It selects
   164  	// which of the ToolUse/CustomToolUse and Result/CustomResult pairs below is
   165  	// populated; the other pair is left zero.
   166  	Custom bool
   167  
   168  	// Confirmation is the verdict that gated this call, if any. "allow"
   169  	// means the call required user confirmation and was approved before it
   170  	// ran; "deny" means the user denied it or the server evaluated its
   171  	// permission to "deny" — the tool was never executed and nothing was
   172  	// posted (Posted=false, IsError=false, no Result). Empty means the call
   173  	// needed no confirmation.
   174  	Confirmation string
   175  
   176  	// ToolUse is the full agent.tool_use event that triggered this dispatch,
   177  	// exactly as it arrived on the stream (or via reconcile). Populated only
   178  	// when Custom is false.
   179  	ToolUse BetaManagedAgentsAgentToolUseEvent
   180  
   181  	// CustomToolUse is the full agent.custom_tool_use event that triggered this
   182  	// dispatch, exactly as it arrived on the stream (or via reconcile).
   183  	// Populated only when Custom is true.
   184  	CustomToolUse BetaManagedAgentsAgentCustomToolUseEvent
   185  
   186  	// Result is the user.tool_result event the runner built and posted back to
   187  	// the session for a builtin agent.tool_use. Populated only when Custom is
   188  	// false. Read Result.Content for the tool's output blocks; the flat
   189  	// IsError mirrors Result.IsError and Posted reports whether the send
   190  	// succeeded.
   191  	Result BetaManagedAgentsUserToolResultEventParams
   192  
   193  	// CustomResult is the user.custom_tool_result event the runner built and
   194  	// posted back to the session for an agent.custom_tool_use. Populated only
   195  	// when Custom is true. Read CustomResult.Content for the tool's output
   196  	// blocks; the flat IsError mirrors CustomResult.IsError and Posted
   197  	// reports whether the send succeeded.
   198  	CustomResult BetaManagedAgentsUserCustomToolResultEventParams
   199  
   200  	// ToolUseID is the id of the tool-use event that triggered this dispatch
   201  	// (flat convenience copy of ToolUse.ID / CustomToolUse.ID). It is the id
   202  	// the posted result event references — tool_use_id for a builtin call,
   203  	// custom_tool_use_id for a custom one. Use it to correlate logs with the
   204  	// session history.
   205  	ToolUseID string
   206  
   207  	// Name is the tool name from the tool-use event (flat convenience copy of
   208  	// ToolUse.Name / CustomToolUse.Name).
   209  	Name string
   210  
   211  	// IsError captures whether the tool's outcome should be surfaced to the
   212  	// agent as an error.
   213  	IsError bool
   214  
   215  	// Posted reports whether a result event for this call reached the session.
   216  	// False on a permanent 4xx or exhausted retries, and also false — with no
   217  	// result event ever built — for a tool name this runner does not own when
   218  	// it deliberately posts nothing and leaves the id pending for its owner.
   219  	Posted bool
   220  }
   221  
   222  // SessionToolRunner attaches to a managed-agents session, executes incoming
   223  // agent.tool_use and agent.custom_tool_use events via a local tool registry,
   224  // and posts the matching user.tool_result / user.custom_tool_result events
   225  // back. agent.mcp_tool_use events are left alone — MCP tools are server-side.
   226  // It is the sessions-side counterpart to (*BetaMessageService).NewToolRunner:
   227  // it does ONLY the tool-execution loop — attach to the event stream, reconcile
   228  // via the events list endpoint, dispatch the registered tools, post results,
   229  // and the idle-after-end_turn timeout.
   230  // Lease heartbeating, work claiming, and skill download are not its concern —
   231  // see [github.com/anthropics/anthropic-sdk-go/lib/environments.EnvironmentWorker]
   232  // for the full self-hosted runner composition.
   233  //
   234  // A SessionToolRunner is NOT safe for concurrent use. All methods must be
   235  // called from a single goroutine. Tool BetaTool.Execute handlers ARE called
   236  // from a background goroutine, but only one at a time per runner (serial
   237  // execution per the agent.tool_use contract).
   238  //
   239  // A call the server gated with evaluated_permission "ask" (the always_ask
   240  // policy — or any unrecognized value, failing closed) is held until its
   241  // user.tool_confirmation arrives: only an explicit "allow" runs it. A "deny"
   242  // verdict — or a call the server already evaluated to "deny" — is never
   243  // executed and posts nothing, but IS yielded (Confirmation="deny",
   244  // Posted=false, no Result) so the caller can observe it. While a call is held
   245  // the server parks the session at stop_reason "requires_action" and the
   246  // runner waits until the verdict arrives, the session terminates, or ctx is
   247  // cancelled — wrap in [environments.EnvironmentWorker] for lease-driven
   248  // detach, or pass a context.WithTimeout for a wall-clock bound.
   249  //
   250  // Tool lifetime is the caller's responsibility; the runner never closes tools,
   251  // so the same Tools slice can be reused across multiple sessions.
   252  //
   253  // Typical usage:
   254  //
   255  //	r := client.Beta.Sessions.Events.NewToolRunner(ctx, sessionID, anthropic.SessionToolRunnerOptions{
   256  //	    Tools: agenttoolset.BetaAgentToolset20260401(&agenttoolset.AgentToolContext{Workdir: "."}),
   257  //	})
   258  //	defer r.Close()
   259  //	for r.Next() {
   260  //	    call := r.Current()
   261  //	    // observe call.Name, call.IsError, call.Posted
   262  //	}
   263  //	if err := r.Err(); err != nil && !errors.Is(err, anthropic.ErrSessionTerminated) {
   264  //	    log.Print(err)
   265  //	}
   266  type SessionToolRunner struct {
   267  	eventService *BetaSessionEventService
   268  	sessionID    string
   269  	opts         SessionToolRunnerOptions
   270  	log          *slog.Logger
   271  	byName       map[string]BetaTool
   272  
   273  	// reqOpts are applied to every request the runner issues: the caller's
   274  	// SessionToolRunnerOptions.RequestOptions plus the runner's own
   275  	// x-stainless-helper telemetry header. Immutable after construction.
   276  	reqOpts []option.RequestOption
   277  
   278  	// ctx is the runner's internal context — a child of the caller's ctx —
   279  	// used by all background loops. cancel terminates them.
   280  	ctx    context.Context
   281  	cancel context.CancelFunc
   282  
   283  	// started/closed are plain bools; Next/Close/All are documented as
   284  	// single-goroutine.
   285  	started bool
   286  	closed  bool
   287  
   288  	// Set on start(); read-only after.
   289  	group   *errgroup.Group
   290  	results chan DispatchedToolCall
   291  
   292  	// Tracks in-flight tool executions for the drain phase.
   293  	inFlight sync.WaitGroup
   294  
   295  	// Dedup sets — touched by the stream goroutine, the reconcile pass before
   296  	// each stream reconnect, and the dispatch goroutine. `seen` prevents
   297  	// re-dispatching the same tool_use across reconcile+stream overlaps;
   298  	// `answered` prevents re-executing a tool whose result the server already
   299  	// has. One mutex covers both — they're always touched together at low
   300  	// frequency.
   301  	mu       sync.Mutex
   302  	seen     map[string]struct{}
   303  	answered map[string]struct{}
   304  	// Confirmation gating (always_ask tools). `confirmationVerdicts` records
   305  	// every user.tool_confirmation verdict by tool_use_id (persistent for the
   306  	// run, like seen/answered). `awaitingConfirmation` holds the ask-gated tool
   307  	// calls whose verdict has not yet arrived. Both are only touched from the
   308  	// stream goroutine (streamLoop / reconcile), so no lock is needed for them.
   309  	confirmationVerdicts map[string]string
   310  	awaitingConfirmation map[string]pendingToolUse
   311  
   312  	// idle owns the single stop-countdown, armed off the server's
   313  	// session.status_idle stop_reason, including its deferral while
   314  	// confirmation-gated tool work is outstanding.
   315  	idle *idleClock
   316  
   317  	// sendWindow overrides defaultSessionRunnerSendRetryWindow when carried on
   318  	// the construction ctx (EnvironmentWorker's live lease TTL); nil otherwise.
   319  	sendWindow *sendwindow.Window
   320  
   321  	// constructErr is set only at construction and is immutable thereafter;
   322  	// safe to read without a lock. Used to bail out of Next/start when required
   323  	// options are missing.
   324  	constructErr error
   325  
   326  	// terminalErr is the first sentinel/wrapped error any loop produced.
   327  	// Surfaced via Err() after the results channel drains.
   328  	terminalErrMu sync.Mutex
   329  	terminalErr   error
   330  
   331  	// Yielded value most recently read from results.
   332  	current DispatchedToolCall
   333  }
   334  
   335  // NewToolRunner returns a [SessionToolRunner] bound to ctx and this event
   336  // service for the given managed-agents session. It is the sessions-side
   337  // counterpart to (*BetaMessageService).NewToolRunner. sessionID is a leading
   338  // positional argument, matching list/send/stream on the events resource. The
   339  // first call to Next launches background goroutines (stream, dispatch).
   340  // Logger defaults to slog.Default().
   341  //
   342  // sessionID is validated here: if it is empty, Next returns false on the first
   343  // call and Err returns the corresponding error. The returned pointer is never
   344  // nil so `defer r.Close()` is always safe.
   345  func (r *BetaSessionEventService) NewToolRunner(ctx context.Context, sessionID string, opts SessionToolRunnerOptions) *SessionToolRunner {
   346  	log := opts.Logger
   347  	if log == nil {
   348  		log = slog.Default()
   349  	}
   350  	log = log.With(
   351  		slog.String("component", "session-tool-runner"),
   352  		slog.String("session_id", sessionID),
   353  	)
   354  	byName := make(map[string]BetaTool, len(opts.Tools))
   355  	for _, t := range opts.Tools {
   356  		byName[t.Name()] = t
   357  	}
   358  	internalCtx, cancel := context.WithCancel(ctx)
   359  	reqOpts := make([]option.RequestOption, 0, len(opts.RequestOptions)+1)
   360  	reqOpts = append(reqOpts, opts.RequestOptions...)
   361  	reqOpts = append(reqOpts, stainlessheader.With(stainlessheader.SessionToolRunner))
   362  	rn := &SessionToolRunner{
   363  		eventService: r,
   364  		sessionID:    sessionID,
   365  		opts:         opts,
   366  		log:          log,
   367  		byName:       byName,
   368  		reqOpts:      reqOpts,
   369  		ctx:          internalCtx,
   370  		cancel:       cancel,
   371  		sendWindow:   sendwindow.FromContext(ctx),
   372  		seen:         map[string]struct{}{},
   373  		answered:     map[string]struct{}{},
   374  	}
   375  	rn.confirmationVerdicts = map[string]string{}
   376  	rn.awaitingConfirmation = map[string]pendingToolUse{}
   377  	rn.idle = newIdleClock(rn.maxIdle())
   378  	if sessionID == "" {
   379  		rn.constructErr = errors.New("anthropic: NewToolRunner requires a non-empty session id")
   380  	}
   381  	return rn
   382  }
   383  
   384  // NewSessionToolRunner is the package-level equivalent of
   385  // (*BetaSessionEventService).NewToolRunner — useful when you have a [Client]
   386  // value. Prefer client.Beta.Sessions.Events.NewToolRunner in new code.
   387  func NewSessionToolRunner(ctx context.Context, client Client, sessionID string, opts SessionToolRunnerOptions) *SessionToolRunner {
   388  	return client.Beta.Sessions.Events.NewToolRunner(ctx, sessionID, opts)
   389  }
   390  
   391  // Next advances the runner. Returns true if a new DispatchedToolCall is
   392  // available via Current. Returns false when the bound context is cancelled,
   393  // Close has been called, the session terminated, or MaxIdle elapsed (check Err
   394  // to distinguish).
   395  func (r *SessionToolRunner) Next() bool {
   396  	if r.constructErr != nil {
   397  		return false
   398  	}
   399  	r.start()
   400  	select {
   401  	case <-r.ctx.Done():
   402  		return false
   403  	case call, ok := <-r.results:
   404  		if !ok {
   405  			return false
   406  		}
   407  		r.current = call
   408  		return true
   409  	}
   410  }
   411  
   412  // Current returns the most recent DispatchedToolCall. Only valid after Next
   413  // returned true.
   414  func (r *SessionToolRunner) Current() DispatchedToolCall {
   415  	return r.current
   416  }
   417  
   418  // Err returns the first non-recoverable error, or nil if iteration ended via
   419  // the consumer's ctx cancellation or Close. Distinguish causes with errors.Is
   420  // against [ErrSessionTerminated] and [ErrIdleTimeout]. Construction-time option
   421  // errors are surfaced here as well.
   422  func (r *SessionToolRunner) Err() error {
   423  	if r.constructErr != nil {
   424  		return r.constructErr
   425  	}
   426  	r.terminalErrMu.Lock()
   427  	defer r.terminalErrMu.Unlock()
   428  	return r.terminalErr
   429  }
   430  
   431  // Close stops all background goroutines and waits for the cleanup goroutine
   432  // (which drains in-flight tools) to finish. Safe to call multiple times. Always
   433  // returns nil — the signature satisfies io.Closer so callers can `defer
   434  // r.Close()` uniformly. The runner never closes the tools it was given.
   435  func (r *SessionToolRunner) Close() error {
   436  	if r.closed {
   437  		return nil
   438  	}
   439  	r.closed = true
   440  	// Mark started so a late Next becomes a no-op.
   441  	r.started = true
   442  	r.cancel()
   443  	if r.results == nil {
   444  		return nil
   445  	}
   446  	for range r.results { //nolint:revive // drain channel; body intentionally empty
   447  	}
   448  	return nil
   449  }
   450  
   451  // All returns a Go 1.23 range-over-func iterator yielding DispatchedToolCall
   452  // values. On the final iteration err carries Err.
   453  func (r *SessionToolRunner) All() iter.Seq2[DispatchedToolCall, error] {
   454  	return func(yield func(DispatchedToolCall, error) bool) {
   455  		for r.Next() {
   456  			if !yield(r.Current(), nil) {
   457  				return
   458  			}
   459  		}
   460  		if err := r.Err(); err != nil {
   461  			yield(DispatchedToolCall{}, err)
   462  		}
   463  	}
   464  }
   465  
   466  // start lazily launches the background goroutines on the first Next call.
   467  // Idempotent via the started flag.
   468  func (r *SessionToolRunner) start() {
   469  	if r.started {
   470  		return
   471  	}
   472  	r.started = true
   473  	if r.constructErr != nil {
   474  		return
   475  	}
   476  	r.results = make(chan DispatchedToolCall, sessionRunnerResultsBuffer)
   477  	toolUseQ := make(chan pendingToolUse, sessionRunnerToolUseQueueBuffer)
   478  	g, gctx := errgroup.WithContext(r.ctx)
   479  	r.group = g
   480  
   481  	g.Go(func() error { return r.streamLoop(gctx, toolUseQ) })
   482  	g.Go(func() error { return r.dispatchLoop(gctx, toolUseQ) })
   483  	g.Go(func() error { return r.idleWatchdog(gctx) })
   484  
   485  	go r.coordinate(g)
   486  }
   487  
   488  func (r *SessionToolRunner) coordinate(g *errgroup.Group) {
   489  	groupErr := g.Wait()
   490  	r.setTerminalErr(groupErr)
   491  	r.drainInFlight()
   492  	close(r.results)
   493  }
   494  
   495  // drainInFlight waits up to the configured drain timeout (opts.DrainTimeout, or
   496  // defaultSessionRunnerDrainTimeout) for any in-flight tool executions to
   497  // complete, then logs a warning and returns. On timeout the waiter goroutine
   498  // below stays blocked on r.inFlight.Wait() — and pins the runner state it closes
   499  // over — until each tool actually returns; that is bounded by the per-tool
   500  // timeout (opts.ToolTimeout, or defaultSessionRunnerToolTimeout), not unbounded,
   501  // but it does mean a slow tool can outlive Close.
   502  func (r *SessionToolRunner) drainInFlight() {
   503  	drainTimeout := r.drainTimeout()
   504  	done := make(chan struct{})
   505  	go func() {
   506  		r.inFlight.Wait()
   507  		close(done)
   508  	}()
   509  	t := time.NewTimer(drainTimeout)
   510  	defer t.Stop()
   511  	select {
   512  	case <-done:
   513  	case <-t.C:
   514  		r.log.Warn("drain timeout exceeded; in-flight tools may still be running",
   515  			slog.Duration("drain_timeout", drainTimeout))
   516  	}
   517  }
   518  
   519  func (r *SessionToolRunner) setTerminalErr(err error) {
   520  	if err == nil {
   521  		return
   522  	}
   523  	r.terminalErrMu.Lock()
   524  	defer r.terminalErrMu.Unlock()
   525  	if r.terminalErr == nil {
   526  		r.terminalErr = err
   527  	}
   528  }
   529  
   530  // pendingToolUse is a tool-call event the stream loop (or the reconcile pass)
   531  // has picked up and queued for dispatch. Exactly one of toolUse / customToolUse
   532  // is populated, selected by custom: an agent.tool_use is answered with a
   533  // user.tool_result, an agent.custom_tool_use with a user.custom_tool_result.
   534  // confirmation is the verdict that released it onto the queue — "allow" for an
   535  // ask-gated call the user approved, "" for a call that needed no confirmation.
   536  // Denied calls never reach the queue.
   537  type pendingToolUse struct {
   538  	custom        bool
   539  	toolUse       BetaManagedAgentsAgentToolUseEvent
   540  	customToolUse BetaManagedAgentsAgentCustomToolUseEvent
   541  	confirmation  string
   542  }
   543  
   544  // id returns the tool-use event id — the key for the seen/answered dedup sets
   545  // and the id the posted result event references.
   546  func (p pendingToolUse) id() string {
   547  	if p.custom {
   548  		return p.customToolUse.ID
   549  	}
   550  	return p.toolUse.ID
   551  }
   552  
   553  // name returns the tool name to look up in the local registry.
   554  func (p pendingToolUse) name() string {
   555  	if p.custom {
   556  		return p.customToolUse.Name
   557  	}
   558  	return p.toolUse.Name
   559  }
   560  
   561  // input returns the raw tool input the agent supplied.
   562  func (p pendingToolUse) input() map[string]any {
   563  	if p.custom {
   564  		return p.customToolUse.Input
   565  	}
   566  	return p.toolUse.Input
   567  }
   568  
   569  // idleClock is the idle stop-countdown state. idleWatchdog reads it and
   570  // stops the runner once armedAt + maxIdle has elapsed.
   571  //
   572  // It also owns the deferral of that countdown: gated tool work registered via
   573  // block — a call held for user confirmation, or a user-approved call still
   574  // dispatching — keeps an arm pending until unblock retires the last blocker.
   575  // The server can report an end_turn idle while a call is held (a legacy
   576  // v1beta idle is up-converted with stop_reason "end_turn" unconditionally), so
   577  // the countdown must never run over gated work: stopping then would drop the
   578  // held call when its verdict later arrives, or cut the runner off before a
   579  // released call's result can drive the next turn.
   580  //
   581  // Every transition runs under mu, so a disarm racing the pending-arm apply
   582  // cannot be overwritten and resurrect a cancelled countdown.
   583  type idleClock struct {
   584  	maxIdle time.Duration
   585  
   586  	wake chan struct{} // cap-1; nudges idleWatchdog to re-read the state
   587  
   588  	mu      sync.Mutex
   589  	armedAt time.Time // zero when disarmed
   590  	// armPending is set when an arm found blockers outstanding; the unblock
   591  	// retiring the last blocker applies it. Cleared by any disarm. While it is
   592  	// set — and, more generally, while blockers is non-empty — armedAt is zero.
   593  	armPending bool
   594  	blockers   map[string]struct{}
   595  }
   596  
   597  func newIdleClock(maxIdle time.Duration) *idleClock {
   598  	return &idleClock{maxIdle: maxIdle, wake: make(chan struct{}, 1), blockers: map[string]struct{}{}}
   599  }
   600  
   601  // noteEvent arms on session.status_idle with stop_reason "end_turn";
   602  // disarms on anything else.
   603  //
   604  // user.tool_confirmation is neutral: it signals neither agent activity nor an
   605  // idle, and its effect on the clock flows through block/unblock instead —
   606  // disarming here would discard the pending arm the verdict is about to settle.
   607  func (c *idleClock) noteEvent(eventType, stopReason string) {
   608  	if eventType == "user.tool_confirmation" {
   609  		return
   610  	}
   611  	if eventType == string(BetaManagedAgentsSessionStatusIdleEventTypeSessionStatusIdle) &&
   612  		stopReason == string(BetaManagedAgentsSessionEndTurnTypeEndTurn) {
   613  		c.arm()
   614  	} else {
   615  		c.disarm()
   616  	}
   617  }
   618  
   619  // arm (re)starts the countdown from now and wakes the watchdog — or, while
   620  // blockers are outstanding, holds the arm pending instead.
   621  func (c *idleClock) arm() {
   622  	if c.maxIdle <= 0 {
   623  		return
   624  	}
   625  	c.mu.Lock()
   626  	if len(c.blockers) > 0 {
   627  		// Any running countdown is stale: the arm defers because gated work is
   628  		// outstanding, so the session is not idly waiting to stop.
   629  		c.armPending = true
   630  		was := !c.armedAt.IsZero()
   631  		c.armedAt = time.Time{}
   632  		c.mu.Unlock()
   633  		if was {
   634  			c.signal()
   635  		}
   636  		return
   637  	}
   638  	c.armPending = false
   639  	c.armedAt = time.Now()
   640  	c.mu.Unlock()
   641  	c.signal()
   642  }
   643  
   644  // disarm cancels the countdown and any pending arm. Blockers persist — they
   645  // track real outstanding work, retired only by unblock.
   646  func (c *idleClock) disarm() {
   647  	c.mu.Lock()
   648  	c.armPending = false
   649  	was := !c.armedAt.IsZero()
   650  	c.armedAt = time.Time{}
   651  	c.mu.Unlock()
   652  	if was {
   653  		c.signal()
   654  	}
   655  }
   656  
   657  // block registers gated work that must finish before an idle countdown starts.
   658  func (c *idleClock) block(id string) {
   659  	c.mu.Lock()
   660  	c.blockers[id] = struct{}{}
   661  	// Defensive: a blocker taken while the countdown runs converts it into a
   662  	// pending arm, so a stale end_turn cannot stop the runner mid-gate.
   663  	was := !c.armedAt.IsZero()
   664  	if was {
   665  		c.armPending = true
   666  		c.armedAt = time.Time{}
   667  	}
   668  	c.mu.Unlock()
   669  	if was {
   670  		c.signal()
   671  	}
   672  }
   673  
   674  // unblock retires gated work (a no-op for ids never blocked) and applies a
   675  // pending arm once the last blocker retires — the countdown then runs a full
   676  // fresh window from now. The apply happens inside the critical section:
   677  // deciding under the lock but stamping after releasing it would let a
   678  // concurrent disarm (a new stream event — the session is not idle) land in the
   679  // gap and be overwritten, resurrecting a cancelled countdown.
   680  func (c *idleClock) unblock(id string) {
   681  	c.mu.Lock()
   682  	delete(c.blockers, id)
   683  	fire := len(c.blockers) == 0 && c.armPending
   684  	if fire {
   685  		c.armPending = false
   686  		c.armedAt = time.Now()
   687  	}
   688  	c.mu.Unlock()
   689  	if fire {
   690  		c.signal()
   691  	}
   692  }
   693  
   694  func (c *idleClock) signal() {
   695  	select {
   696  	case c.wake <- struct{}{}:
   697  	default:
   698  	}
   699  }
   700  
   701  // snapshot returns the current arm timestamp for the watchdog.
   702  func (c *idleClock) snapshot() time.Time {
   703  	c.mu.Lock()
   704  	defer c.mu.Unlock()
   705  	return c.armedAt
   706  }
   707  
   708  // streamLoop tails the live SSE stream with reconnect backoff. On each
   709  // (re)connect it opens the stream FIRST and only then reconciles full history,
   710  // so a tool_use the server emits during the reconcile window lands on the
   711  // stream rather than in the gap between the two; the per-event seen/answered
   712  // sets dedup events both passes observe. Both agent.tool_use and
   713  // agent.custom_tool_use are dispatched; agent.mcp_tool_use is ignored (MCP
   714  // tools run server-side). Closes toolUseQ on exit so dispatchLoop can return.
   715  func (r *SessionToolRunner) streamLoop(ctx context.Context, out chan<- pendingToolUse) error {
   716  	defer close(out)
   717  
   718  	backoff := sessionRunnerStreamBackoffStart
   719  	for {
   720  		if ctx.Err() != nil {
   721  			return nil
   722  		}
   723  
   724  		// Open the live stream before reconciling history: StreamEvents makes
   725  		// its HTTP request eagerly, so once it returns the connection is
   726  		// established and no event emitted during reconcile can slip through
   727  		// the gap. seen/answered dedup whatever both passes pick up.
   728  		stream := r.eventService.StreamEvents(
   729  			ctx,
   730  			r.sessionID,
   731  			BetaSessionEventStreamParams{},
   732  			r.reqOpts...,
   733  		)
   734  		if err := r.reconcile(ctx, out); err != nil {
   735  			_ = stream.Close()
   736  			if errors.Is(err, ErrSessionTerminated) {
   737  				return err
   738  			}
   739  			if ctx.Err() != nil {
   740  				return nil
   741  			}
   742  			return err
   743  		}
   744  		if ctx.Err() != nil {
   745  			_ = stream.Close()
   746  			return nil
   747  		}
   748  
   749  		// Reset backoff only once the connection has been healthy.
   750  		connectedAt := time.Now()
   751  		var sentinel error
   752  		for stream.Next() {
   753  			if time.Since(connectedAt) > sessionRunnerStreamHealthyAfter {
   754  				backoff = sessionRunnerStreamBackoffStart
   755  			}
   756  			ev := stream.Current()
   757  			r.idle.noteEvent(ev.Type, ev.StopReason.Type)
   758  			switch ev.Type {
   759  			case string(BetaManagedAgentsAgentToolUseEventTypeAgentToolUse):
   760  				if r.markSeen(ev.ID) {
   761  					r.routeToolEvent(ctx, out, pendingToolUse{toolUse: ev.AsAgentToolUse()})
   762  				}
   763  			case string(BetaManagedAgentsAgentCustomToolUseEventTypeAgentCustomToolUse):
   764  				if r.markSeen(ev.ID) {
   765  					r.routeToolEvent(ctx, out, pendingToolUse{custom: true, customToolUse: ev.AsAgentCustomToolUse()})
   766  				}
   767  			case "user.tool_confirmation":
   768  				r.noteConfirmation(ctx, out, ev.ToolUseID, string(ev.AsUserToolConfirmation().Result))
   769  			case string(BetaManagedAgentsUserToolResultEventTypeUserToolResult):
   770  				r.markAnswered(ev.ToolUseID)
   771  			case string(BetaManagedAgentsUserCustomToolResultEventTypeUserCustomToolResult):
   772  				r.markAnswered(ev.CustomToolUseID)
   773  			case string(BetaManagedAgentsSessionStatusTerminatedEventTypeSessionStatusTerminated),
   774  				string(BetaManagedAgentsSessionDeletedEventTypeSessionDeleted):
   775  				r.log.Info("session terminated", slog.String("type", ev.Type))
   776  				sentinel = ErrSessionTerminated
   777  			}
   778  			if sentinel != nil {
   779  				break
   780  			}
   781  		}
   782  		_ = stream.Close()
   783  		if sentinel != nil {
   784  			return sentinel
   785  		}
   786  		if err := stream.Err(); err != nil {
   787  			if ctx.Err() != nil {
   788  				return nil
   789  			}
   790  			if isFatal4xxStatus(err) {
   791  				r.log.Error("permanent stream failure", slog.Any("error", err))
   792  				return fmt.Errorf("stream: %w", err)
   793  			}
   794  			r.log.Warn("stream disconnected; reconnecting",
   795  				slog.Any("error", err), slog.Duration("backoff", backoff))
   796  		}
   797  		if ctx.Err() != nil {
   798  			return nil
   799  		}
   800  		// Jittered backoff so a fleet of runners reconnecting after the same
   801  		// blip don't synchronise into a thundering herd.
   802  		sleepCtx(ctx, jitterDuration(backoff))
   803  		backoff = min(backoff*2, sessionRunnerStreamBackoffCap)
   804  	}
   805  }
   806  
   807  // jitterDuration returns a random duration in [d/2, d) so concurrent runners
   808  // spread their retries instead of synchronising. d <= 1 is returned unchanged.
   809  func jitterDuration(d time.Duration) time.Duration {
   810  	if d <= 1 {
   811  		return d
   812  	}
   813  	half := d / 2
   814  	return half + time.Duration(rand.Int64N(int64(d-half)))
   815  }
   816  
   817  // reconcile lists full event history and emits any tool_use the runner has not
   818  // already posted a result for. It pairs agent.tool_use with user.tool_result
   819  // and agent.custom_tool_use with user.custom_tool_result when deciding what is
   820  // still unanswered.
   821  //
   822  // Every tool-use event is collected — and marked seen so the concurrently
   823  // running stream loop does not also enqueue it — but the enqueue decision is
   824  // gated on `answered`, NOT `seen`: a tool_use whose result post previously
   825  // FAILED was never marked answered, so it is re-dispatched on the next
   826  // reconcile instead of being silently dropped.
   827  //
   828  // Returns ErrSessionTerminated if the listed history contains a
   829  // session.status_terminated or session.deleted event — the live stream
   830  // will never replay a terminate that fired before we attached, so without
   831  // this check streamLoop would reconnect forever against a dead session.
   832  func (r *SessionToolRunner) reconcile(ctx context.Context, out chan<- pendingToolUse) error {
   833  	var pending []pendingToolUse
   834  	lastWasEndTurn := false
   835  	pager := r.eventService.ListAutoPaging(ctx, r.sessionID,
   836  		BetaSessionEventListParams{
   837  			Limit: param.NewOpt(int64(1000)),
   838  			// Reconcile assumes oldest-to-newest event order.
   839  			Order: BetaSessionEventListParamsOrderAsc,
   840  		},
   841  		r.reqOpts...)
   842  	for pager.Next() {
   843  		ev := pager.Current()
   844  		switch ev.Type {
   845  		case string(BetaManagedAgentsAgentToolUseEventTypeAgentToolUse):
   846  			r.markSeen(ev.ID)
   847  			pending = append(pending, pendingToolUse{toolUse: ev.AsAgentToolUse()})
   848  		case string(BetaManagedAgentsAgentCustomToolUseEventTypeAgentCustomToolUse):
   849  			r.markSeen(ev.ID)
   850  			pending = append(pending, pendingToolUse{custom: true, customToolUse: ev.AsAgentCustomToolUse()})
   851  		case "user.tool_confirmation":
   852  			// Record the verdict only, before the routing pass below, so a
   853  			// tool call whose confirmation appears later in the same history
   854  			// is routed with its verdict already known. Skip already-answered
   855  			// calls so we don't re-record their verdict on every reconcile.
   856  			if !r.isAnswered(ev.ToolUseID) {
   857  				r.confirmationVerdicts[ev.ToolUseID] = string(ev.AsUserToolConfirmation().Result)
   858  			}
   859  		case string(BetaManagedAgentsUserToolResultEventTypeUserToolResult):
   860  			r.markAnswered(ev.ToolUseID)
   861  		case string(BetaManagedAgentsUserCustomToolResultEventTypeUserCustomToolResult):
   862  			r.markAnswered(ev.CustomToolUseID)
   863  		case string(BetaManagedAgentsSessionStatusTerminatedEventTypeSessionStatusTerminated),
   864  			string(BetaManagedAgentsSessionDeletedEventTypeSessionDeleted):
   865  			// The session is already over. Any pending tool_use we
   866  			// collected above can't be answered (the server won't accept
   867  			// results against a terminated session) and the live stream
   868  			// will never replay this event, so streamLoop must shut down.
   869  			r.log.Info("reconcile: session already terminated", slog.String("type", ev.Type))
   870  			return ErrSessionTerminated
   871  		}
   872  		lastWasEndTurn = ev.Type == string(BetaManagedAgentsSessionStatusIdleEventTypeSessionStatusIdle) &&
   873  			ev.StopReason.Type == string(BetaManagedAgentsSessionEndTurnTypeEndTurn)
   874  	}
   875  	if err := pager.Err(); err != nil {
   876  		r.log.Warn("reconcile list failed", slog.Any("error", err))
   877  		return nil
   878  	}
   879  	var unanswered []pendingToolUse
   880  	for _, p := range pending {
   881  		if !r.isAnswered(p.id()) {
   882  			unanswered = append(unanswered, p)
   883  		}
   884  	}
   885  	// Disarm before routing: the routing pass can block on a full work queue
   886  	// while the clock is still armed from before the reconnect.
   887  	r.idle.disarm()
   888  	for _, p := range unanswered {
   889  		r.routeToolEvent(ctx, out, p)
   890  	}
   891  	// A held call's verdict is normally applied by the routing pass above; if
   892  	// its tool_use fell outside the listed window the pass never saw it, so
   893  	// apply the recorded verdict to the held copy here.
   894  	for id, held := range r.awaitingConfirmation {
   895  		if v, ok := r.confirmationVerdicts[id]; ok {
   896  			r.applyVerdict(ctx, out, held, v)
   897  		}
   898  	}
   899  	// Routing resolved denied calls in place (marking them answered) and held
   900  	// ask-gated calls for their verdict. If the last event in history is an
   901  	// end_turn idle and no tool work is outstanding, the session is done — arm
   902  	// the stop-countdown so the runner stops even if that end_turn arrived
   903  	// during a disconnect. A still-held call doesn't count as outstanding: the
   904  	// clock blocks on it, so the arm is held pending until its verdict lands.
   905  	if lastWasEndTurn && len(r.outstanding(unanswered)) == 0 {
   906  		r.idle.arm()
   907  	}
   908  	return nil
   909  }
   910  
   911  // outstanding returns the reconciled tool calls that still owe the session a
   912  // result: neither answered (a denial resolves a call in place) nor held
   913  // awaiting a user confirmation.
   914  func (r *SessionToolRunner) outstanding(unanswered []pendingToolUse) []pendingToolUse {
   915  	var out []pendingToolUse
   916  	for _, p := range unanswered {
   917  		id := p.id()
   918  		if _, held := r.awaitingConfirmation[id]; held {
   919  			continue
   920  		}
   921  		if r.isAnswered(id) {
   922  			continue
   923  		}
   924  		out = append(out, p)
   925  	}
   926  	return out
   927  }
   928  
   929  // dispatchLoop reads tool-use events (agent.tool_use and agent.custom_tool_use)
   930  // serially and executes each. The session contract guarantees one outstanding
   931  // tool-use per session at a time, so serial execution is correct. Pushes the
   932  // resulting DispatchedToolCall to r.results.
   933  //
   934  // Each call is unblocked once it is fully disposed of — executed, or moot
   935  // because it was answered elsewhere — retiring the idle-clock blocker
   936  // applyVerdict took on a user-approved call. unblock is a no-op for a call
   937  // that was never gated.
   938  func (r *SessionToolRunner) dispatchLoop(ctx context.Context, in <-chan pendingToolUse) error {
   939  	for {
   940  		select {
   941  		case <-ctx.Done():
   942  			return nil
   943  		case p, ok := <-in:
   944  			if !ok {
   945  				return nil
   946  			}
   947  			// Retire the blocker only once the call is fully disposed of —
   948  			// executed and surfaced, or moot because it was answered
   949  			// elsewhere. Surfacing can block on a full results buffer, and
   950  			// the countdown must not start while the call is still queued.
   951  			func() {
   952  				defer r.idle.unblock(p.id())
   953  				if r.isAnswered(p.id()) {
   954  					return
   955  				}
   956  				r.surfaceCall(r.execute(ctx, p))
   957  			}()
   958  		}
   959  	}
   960  }
   961  
   962  // maxIdle returns the configured idle-grace duration: DefaultMaxIdle when
   963  // opts.MaxIdle is nil, otherwise the pointed-to value (<= 0 means disabled).
   964  func (r *SessionToolRunner) maxIdle() time.Duration {
   965  	if r.opts.MaxIdle == nil {
   966  		return DefaultMaxIdle
   967  	}
   968  	return *r.opts.MaxIdle
   969  }
   970  
   971  // toolTimeout returns the per-tool Execute deadline: opts.ToolTimeout when set
   972  // to a positive value, otherwise defaultSessionRunnerToolTimeout.
   973  func (r *SessionToolRunner) toolTimeout() time.Duration {
   974  	if r.opts.ToolTimeout > 0 {
   975  		return r.opts.ToolTimeout
   976  	}
   977  	return defaultSessionRunnerToolTimeout
   978  }
   979  
   980  // sendTimeout returns the per-attempt tool-result send deadline:
   981  // opts.SendTimeout when set to a positive value, otherwise
   982  // defaultSessionRunnerSendTimeout.
   983  func (r *SessionToolRunner) sendTimeout() time.Duration {
   984  	if r.opts.SendTimeout > 0 {
   985  		return r.opts.SendTimeout
   986  	}
   987  	return defaultSessionRunnerSendTimeout
   988  }
   989  
   990  // sendRetryWindow returns how long a transiently failing tool-result send keeps
   991  // retrying: the ctx-carried window when set, else defaultSessionRunnerSendRetryWindow.
   992  func (r *SessionToolRunner) sendRetryWindow() time.Duration {
   993  	if d := r.sendWindow.Get(); d > 0 {
   994  		return d
   995  	}
   996  	return defaultSessionRunnerSendRetryWindow
   997  }
   998  
   999  // drainTimeout returns how long Close waits for in-flight tools: opts.DrainTimeout
  1000  // when set to a positive value, otherwise defaultSessionRunnerDrainTimeout.
  1001  func (r *SessionToolRunner) drainTimeout() time.Duration {
  1002  	if r.opts.DrainTimeout > 0 {
  1003  		return r.opts.DrainTimeout
  1004  	}
  1005  	return defaultSessionRunnerDrainTimeout
  1006  }
  1007  
  1008  // idleWatchdog returns ErrIdleTimeout once the session has been idle past
  1009  // the bound matching its stop_reason. It is event-driven: the stream
  1010  // goroutine nudges idle.wake whenever it updates the clock, and the
  1011  // watchdog re-arms a single timer off that authoritative stamp — no
  1012  // polling ticker.
  1013  func (r *SessionToolRunner) idleWatchdog(ctx context.Context) error {
  1014  	timer := time.NewTimer(time.Hour)
  1015  	if !timer.Stop() {
  1016  		<-timer.C
  1017  	}
  1018  	defer timer.Stop()
  1019  
  1020  	rearm := func() {
  1021  		if !timer.Stop() {
  1022  			select {
  1023  			case <-timer.C:
  1024  			default:
  1025  			}
  1026  		}
  1027  		if at := r.idle.snapshot(); !at.IsZero() {
  1028  			remaining := r.idle.maxIdle - time.Since(at)
  1029  			if remaining < time.Millisecond {
  1030  				remaining = time.Millisecond
  1031  			}
  1032  			timer.Reset(remaining)
  1033  		}
  1034  	}
  1035  
  1036  	for {
  1037  		select {
  1038  		case <-ctx.Done():
  1039  			return nil
  1040  		case <-r.idle.wake:
  1041  			rearm()
  1042  		case <-timer.C:
  1043  			at := r.idle.snapshot()
  1044  			if !at.IsZero() && time.Since(at) >= r.idle.maxIdle {
  1045  				r.log.Info("session idle after end_turn; stopping",
  1046  					slog.Duration("max_idle", r.idle.maxIdle))
  1047  				return ErrIdleTimeout
  1048  			}
  1049  			rearm()
  1050  		}
  1051  	}
  1052  }
  1053  
  1054  // execute looks up the tool by name, runs it under a per-tool timeout, posts
  1055  // the matching result event (user.tool_result for an agent.tool_use,
  1056  // user.custom_tool_result for an agent.custom_tool_use), and returns the
  1057  // DispatchedToolCall to be yielded.
  1058  func (r *SessionToolRunner) execute(ctx context.Context, p pendingToolUse) DispatchedToolCall {
  1059  	id, name := p.id(), p.name()
  1060  	log := r.log.With(
  1061  		slog.String("tool", name),
  1062  		slog.String("tool_use_id", id),
  1063  		slog.Bool("custom", p.custom),
  1064  	)
  1065  	log.Info("executing tool")
  1066  
  1067  	r.inFlight.Add(1)
  1068  	defer r.inFlight.Done()
  1069  
  1070  	call := DispatchedToolCall{
  1071  		Custom:        p.custom,
  1072  		ToolUse:       p.toolUse,
  1073  		CustomToolUse: p.customToolUse,
  1074  		ToolUseID:     id,
  1075  		Name:          name,
  1076  		Confirmation:  p.confirmation,
  1077  	}
  1078  
  1079  	rawInput, err := json.Marshal(p.input())
  1080  	if err != nil {
  1081  		log.Warn("re-encoding tool input failed", slog.Any("error", err))
  1082  		call.IsError = true
  1083  		return r.postCall(ctx, call, textOnlyResult(fmt.Sprintf("tool input could not be re-encoded: %v", err)))
  1084  	}
  1085  
  1086  	var blocks []BetaToolResultBlockParamContentUnion
  1087  	tool, ok := r.byName[name]
  1088  	if !ok {
  1089  		// Skip (split-client partial fulfilment): a name this runner is not
  1090  		// registered for belongs to the other client servicing this session
  1091  		// (typically the customer's app backend handling custom tools). Post
  1092  		// NO result, do not mark it answered, and leave the tool_use_id
  1093  		// pending for its owner — claiming it would corrupt the conversation.
  1094  		// Still yield the call so the caller can observe the unowned
  1095  		// dispatch; nothing was sent, so Posted and IsError stay false and no
  1096  		// result event is populated. The id remains unanswered, so reconcile
  1097  		// keeps it out of the idle/end-turn accounting and re-surfaces it
  1098  		// after a reconnect until its owner answers it.
  1099  		log.Info("tool not owned by this runner; leaving the tool_use_id pending for its owner")
  1100  		return call
  1101  	} else {
  1102  		// Derive the per-tool timeout from the runner ctx (not
  1103  		// context.WithoutCancel) so cancelling the runner also aborts an
  1104  		// in-flight tool instead of leaving it to run out the full timeout
  1105  		// while teardown blocks on the drain.
  1106  		toolTimeout := r.toolTimeout()
  1107  		toolCtx, cancel := context.WithTimeout(ctx, toolTimeout)
  1108  		out, runErr := tool.Execute(toolCtx, rawInput)
  1109  		cancel()
  1110  		switch {
  1111  		case errors.Is(toolCtx.Err(), context.DeadlineExceeded):
  1112  			call.IsError = true
  1113  			blocks = textOnlyResult(fmt.Sprintf("tool %q timed out after %s", name, toolTimeout))
  1114  		case runErr != nil:
  1115  			call.IsError = true
  1116  			blocks = textOnlyResult(runErr.Error())
  1117  		default:
  1118  			blocks = out
  1119  			call.IsError = false
  1120  		}
  1121  	}
  1122  	if len(blocks) == 0 {
  1123  		blocks = textOnlyResult("(no output)")
  1124  	}
  1125  	return r.postCall(ctx, call, blocks)
  1126  }
  1127  
  1128  // textOnlyResult wraps s as a single text tool-result block.
  1129  func textOnlyResult(s string) []BetaToolResultBlockParamContentUnion {
  1130  	return []BetaToolResultBlockParamContentUnion{{OfText: &BetaTextBlockParam{Text: s}}}
  1131  }
  1132  
  1133  // toToolResultContent converts tool result blocks for a user.tool_result event
  1134  // by JSON round-tripping each block into the event content union, falling back
  1135  // to a text block holding the raw JSON when the round-trip is incomplete. Empty
  1136  // text blocks are posted as "(no output)".
  1137  func toToolResultContent(blocks []BetaToolResultBlockParamContentUnion) []BetaManagedAgentsUserToolResultEventParamsContentUnion {
  1138  	out := make([]BetaManagedAgentsUserToolResultEventParamsContentUnion, 0, len(blocks))
  1139  	for _, b := range blocks {
  1140  		raw, err := json.Marshal(b)
  1141  		if err != nil || len(raw) == 0 || string(raw) == "null" {
  1142  			continue
  1143  		}
  1144  		var dst BetaManagedAgentsUserToolResultEventParamsContentUnion
  1145  		if json.Unmarshal(raw, &dst) == nil && roundTripComplete(&dst) {
  1146  			// The Sessions API rejects empty text blocks; a tool that
  1147  			// succeeds silently must still produce a postable result.
  1148  			if dst.OfText != nil && dst.OfText.Text == "" {
  1149  				dst.OfText.Text = "(no output)"
  1150  			}
  1151  			out = append(out, dst)
  1152  			continue
  1153  		}
  1154  		out = append(out, BetaManagedAgentsUserToolResultEventParamsContentUnion{
  1155  			OfText: &BetaManagedAgentsTextBlockParam{Text: string(raw)},
  1156  		})
  1157  	}
  1158  	return out
  1159  }
  1160  
  1161  // roundTripComplete reports whether dst decoded into a variant whose required
  1162  // fields survived the JSON round-trip.
  1163  func roundTripComplete(dst *BetaManagedAgentsUserToolResultEventParamsContentUnion) bool {
  1164  	switch {
  1165  	case dst.OfText != nil:
  1166  		return true
  1167  	case dst.OfImage != nil:
  1168  		return dst.OfImage.Source.asAny() != nil
  1169  	case dst.OfDocument != nil:
  1170  		return dst.OfDocument.Source.asAny() != nil
  1171  	case dst.OfSearchResult != nil:
  1172  		return dst.OfSearchResult.Source != "" && dst.OfSearchResult.Title != ""
  1173  	default:
  1174  		return false
  1175  	}
  1176  }
  1177  
  1178  // toCustomToolResultContent converts tool result blocks for a
  1179  // user.custom_tool_result event using the same round-trip as toToolResultContent.
  1180  func toCustomToolResultContent(blocks []BetaToolResultBlockParamContentUnion) []BetaManagedAgentsUserCustomToolResultEventParamsContentUnion {
  1181  	src := toToolResultContent(blocks)
  1182  	out := make([]BetaManagedAgentsUserCustomToolResultEventParamsContentUnion, 0, len(src))
  1183  	for _, b := range src {
  1184  		out = append(out, BetaManagedAgentsUserCustomToolResultEventParamsContentUnion{
  1185  			OfText:         b.OfText,
  1186  			OfImage:        b.OfImage,
  1187  			OfDocument:     b.OfDocument,
  1188  			OfSearchResult: b.OfSearchResult,
  1189  		})
  1190  	}
  1191  	return out
  1192  }
  1193  
  1194  // postCall builds the matching result event for call — user.custom_tool_result
  1195  // when call.Custom, otherwise user.tool_result — from the tool's result blocks,
  1196  // sends it, and records the event on call.Result / call.CustomResult and the
  1197  // send outcome on call.Posted. The tool-use id is marked answered ONLY when
  1198  // the result post actually succeeds: a failed post leaves the call unanswered
  1199  // so the next reconcile re-dispatches it instead of silently dropping it.
  1200  func (r *SessionToolRunner) postCall(ctx context.Context, call DispatchedToolCall, blocks []BetaToolResultBlockParamContentUnion) DispatchedToolCall {
  1201  	var event BetaManagedAgentsEventParamsUnion
  1202  	if call.Custom {
  1203  		call.CustomResult = BetaManagedAgentsUserCustomToolResultEventParams{
  1204  			CustomToolUseID: call.ToolUseID,
  1205  			Type:            BetaManagedAgentsUserCustomToolResultEventParamsTypeUserCustomToolResult,
  1206  			IsError:         param.NewOpt(call.IsError),
  1207  			Content:         toCustomToolResultContent(blocks),
  1208  		}
  1209  		event = BetaManagedAgentsEventParamsUnion{OfUserCustomToolResult: &call.CustomResult}
  1210  	} else {
  1211  		call.Result = BetaManagedAgentsUserToolResultEventParams{
  1212  			ToolUseID: call.ToolUseID,
  1213  			Type:      BetaManagedAgentsUserToolResultEventParamsTypeUserToolResult,
  1214  			IsError:   param.NewOpt(call.IsError),
  1215  			Content:   toToolResultContent(blocks),
  1216  		}
  1217  		event = BetaManagedAgentsEventParamsUnion{OfUserToolResult: &call.Result}
  1218  	}
  1219  	call.Posted = r.sendResult(ctx, event, call.ToolUseID)
  1220  	if call.Posted {
  1221  		r.markAnswered(call.ToolUseID)
  1222  	}
  1223  	return call
  1224  }
  1225  
  1226  // sendResult posts a tool-result event (user.tool_result or
  1227  // user.custom_tool_result). Returns true on success. Transient failures are
  1228  // retried with jittered exponential backoff until sendRetryWindow (re-read on
  1229  // each failure, so a lease TTL learned mid-retry applies) has elapsed since the
  1230  // first attempt; a permanent 4xx or ctx cancellation short-circuits and returns
  1231  // false.
  1232  func (r *SessionToolRunner) sendResult(ctx context.Context, event BetaManagedAgentsEventParamsUnion, toolUseID string) bool {
  1233  	params := BetaSessionEventSendParams{
  1234  		Events: []BetaManagedAgentsEventParamsUnion{event},
  1235  	}
  1236  	start := time.Now()
  1237  	backoff := sessionRunnerSendBackoffStart
  1238  	for attempt := 1; ; attempt++ {
  1239  		sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.sendTimeout())
  1240  		_, err := r.eventService.Send(sendCtx, r.sessionID, params, r.reqOpts...)
  1241  		cancel()
  1242  		if err == nil {
  1243  			return true
  1244  		}
  1245  		if isFatal4xxStatus(err) {
  1246  			r.log.Error("tool result send hit permanent 4xx; not retrying",
  1247  				slog.String("tool_use_id", toolUseID), slog.Any("error", err))
  1248  			return false
  1249  		}
  1250  		if ctx.Err() != nil {
  1251  			r.log.Debug("tool result send abandoned: ctx cancelled",
  1252  				slog.String("tool_use_id", toolUseID))
  1253  			return false
  1254  		}
  1255  		remaining := r.sendRetryWindow() - time.Since(start)
  1256  		if remaining <= 0 {
  1257  			r.log.Error("tool result send failed; retry window exhausted",
  1258  				slog.String("tool_use_id", toolUseID),
  1259  				slog.Int("attempts", attempt),
  1260  				slog.Any("error", err))
  1261  			return false
  1262  		}
  1263  		wait := min(jitterDuration(backoff), remaining)
  1264  		r.log.Warn("tool result send failed; retrying",
  1265  			slog.String("tool_use_id", toolUseID),
  1266  			slog.Int("attempt", attempt),
  1267  			slog.Duration("backoff", wait),
  1268  			slog.Any("error", err))
  1269  		sleepCtx(ctx, wait)
  1270  		backoff = min(backoff*2, sessionRunnerSendBackoffCap)
  1271  	}
  1272  }
  1273  
  1274  func (r *SessionToolRunner) markSeen(id string) bool {
  1275  	r.mu.Lock()
  1276  	defer r.mu.Unlock()
  1277  	if _, ok := r.seen[id]; ok {
  1278  		return false
  1279  	}
  1280  	r.seen[id] = struct{}{}
  1281  	return true
  1282  }
  1283  
  1284  func (r *SessionToolRunner) markAnswered(id string) {
  1285  	r.mu.Lock()
  1286  	r.answered[id] = struct{}{}
  1287  	r.mu.Unlock()
  1288  }
  1289  
  1290  func (r *SessionToolRunner) isAnswered(id string) bool {
  1291  	r.mu.Lock()
  1292  	defer r.mu.Unlock()
  1293  	_, ok := r.answered[id]
  1294  	return ok
  1295  }
  1296  
  1297  // routeToolEvent enqueues p for dispatch, honoring its evaluated permission.
  1298  // A call the server gated with "ask" (or any unrecognized permission — fail
  1299  // closed) is held until its user.tool_confirmation arrives; a call already
  1300  // evaluated to "deny" is resolved as denied regardless of any recorded verdict.
  1301  func (r *SessionToolRunner) routeToolEvent(ctx context.Context, out chan<- pendingToolUse, p pendingToolUse) {
  1302  	id := p.id()
  1303  	// Only builtin agent.tool_use carries evaluated_permission today; if the
  1304  	// field ever lands on agent.custom_tool_use the gate must keep failing
  1305  	// closed rather than dispatch a gated call by event type.
  1306  	perm := ""
  1307  	if !p.custom {
  1308  		perm = string(p.toolUse.EvaluatedPermission)
  1309  	}
  1310  	// Comma-ok, not the zero value: a verdict this SDK doesn't recognise —
  1311  	// including an empty one — must reach applyVerdict and fail closed, not
  1312  	// read as "no verdict yet" and dispatch.
  1313  	verdict, hasVerdict := r.confirmationVerdicts[id]
  1314  	if perm == "deny" {
  1315  		// A server-side deny overrides any (stray) recorded verdict.
  1316  		verdict, hasVerdict = "deny", true
  1317  	}
  1318  	if !hasVerdict {
  1319  		if perm == "" || perm == "allow" {
  1320  			select {
  1321  			case <-ctx.Done():
  1322  			case out <- p:
  1323  			}
  1324  		} else if _, held := r.awaitingConfirmation[id]; !held {
  1325  			// "ask" — or a permission this SDK doesn't recognise — waits for
  1326  			// the user's verdict. (Already held: a reconcile after a reconnect
  1327  			// re-routes the call; keep the existing hold.)
  1328  			r.log.Info("tool call awaiting confirmation; holding",
  1329  				slog.String("tool", p.name()), slog.String("tool_use_id", id))
  1330  			r.awaitingConfirmation[id] = p
  1331  			r.idle.block(id)
  1332  		}
  1333  		return
  1334  	}
  1335  	r.applyVerdict(ctx, out, p, verdict)
  1336  }
  1337  
  1338  // noteConfirmation records an allow/deny verdict and releases the held call it
  1339  // gates, if any. A verdict for a call this runner has not seen yet (or one it
  1340  // never gates, e.g. an agent.mcp_tool_use) is kept in confirmationVerdicts so
  1341  // a later route of that call resolves instantly.
  1342  func (r *SessionToolRunner) noteConfirmation(ctx context.Context, out chan<- pendingToolUse, toolUseID, result string) {
  1343  	r.confirmationVerdicts[toolUseID] = result
  1344  	if held, ok := r.awaitingConfirmation[toolUseID]; ok {
  1345  		r.applyVerdict(ctx, out, held, result)
  1346  	}
  1347  }
  1348  
  1349  // applyVerdict dispatches or resolves a gated call according to its verdict.
  1350  // Only an explicit "allow" releases the call to execute; anything else fails
  1351  // closed as a denial. A denied call is marked answered and yielded directly
  1352  // (nothing ran, nothing posted) — it never reaches the dispatch queue.
  1353  //
  1354  // The idle-clock blocker accounting lives here: a denial retires the held
  1355  // call's blocker, while an allow keeps one on the call — taking it now if the
  1356  // verdict was already known when the call was routed, so no hold was ever
  1357  // taken — until the dispatch loop has finished with it. The countdown must not
  1358  // run over gated work that is still in flight.
  1359  func (r *SessionToolRunner) applyVerdict(ctx context.Context, out chan<- pendingToolUse, p pendingToolUse, verdict string) {
  1360  	id := p.id()
  1361  	_, wasHeld := r.awaitingConfirmation[id]
  1362  	delete(r.awaitingConfirmation, id)
  1363  	if verdict == "allow" {
  1364  		r.log.Info("tool call confirmed", slog.String("tool", p.name()), slog.String("tool_use_id", id))
  1365  		if !wasHeld {
  1366  			r.idle.block(id)
  1367  		}
  1368  		p.confirmation = "allow"
  1369  		select {
  1370  		case <-ctx.Done():
  1371  		case out <- p:
  1372  		}
  1373  		return
  1374  	}
  1375  	if wasHeld {
  1376  		r.idle.unblock(id)
  1377  	}
  1378  	r.markAnswered(id)
  1379  	r.log.Info("tool call denied; not executing", slog.String("tool", p.name()), slog.String("tool_use_id", id))
  1380  	r.surfaceCall(DispatchedToolCall{
  1381  		Custom:        p.custom,
  1382  		ToolUse:       p.toolUse,
  1383  		CustomToolUse: p.customToolUse,
  1384  		ToolUseID:     id,
  1385  		Name:          p.name(),
  1386  		IsError:       false,
  1387  		Posted:        false,
  1388  		Confirmation:  "deny",
  1389  	})
  1390  }
  1391  
  1392  // surfaceCall yields call to the consumer, tolerating a consumer that left
  1393  // early (Close, or the caller's ctx was cancelled). The work already happened;
  1394  // only the surfaced event is lost.
  1395  func (r *SessionToolRunner) surfaceCall(call DispatchedToolCall) {
  1396  	select {
  1397  	case <-r.ctx.Done():
  1398  	case r.results <- call:
  1399  	}
  1400  }
  1401  
  1402  // sleepCtx sleeps for d or until ctx is done, whichever comes first.
  1403  func sleepCtx(ctx context.Context, d time.Duration) {
  1404  	t := time.NewTimer(d)
  1405  	defer t.Stop()
  1406  	select {
  1407  	case <-ctx.Done():
  1408  	case <-t.C:
  1409  	}
  1410  }
  1411  
  1412  // isFatal4xxStatus reports whether err is a client error that will not succeed
  1413  // on retry. 408 (timeout), 409 (conflict) and 429 (rate-limited) are excluded
  1414  // so callers back off rather than tear down, mirroring the statuses the core
  1415  // client itself retries (internal/requestconfig). Keep in step with
  1416  // lib/environments.isFatal4xx, the environment worker's copy.
  1417  func isFatal4xxStatus(err error) bool {
  1418  	var apiErr *Error
  1419  	if !errors.As(err, &apiErr) {
  1420  		return false
  1421  	}
  1422  	c := apiErr.StatusCode
  1423  	return c >= 400 && c < 500 && c != 408 && c != 409 && c != 429
  1424  }