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 }