Documentation
¶
Overview ¶
Package pool provides SandboxPool, an agentloop.SandboxBuilder that reuses one long-lived *sandbox.Sandbox per session across every Run, instead of paying goja.New() + pack-Register cost (which for some packs includes compiling JS module wrappers — e.g. require('http'), require('secret')) on every single message.
The stale-context problem this solves ¶
A Capability's Build closes over BuildContext.Ctx once (see agentloop.Capability's doc comment) — DefaultCapabilities' ai capability and every ext pack do this. That ctx is the *per-Run* context Loop.Run received, typically derived from a request (an HTTP handler's context, say) and cancelled once that Run returns. A naively cached Sandbox — built once, its packs' closures holding the first Run's ctx forever — would use an already-cancelled context for every fetch()/ai()/secret()/etc. call on every subsequent Run.
SandboxPool solves this by giving the delegate builder a swappable context.Context (swapCtx, in context.go) in place of the real one: a value that implements context.Context but forwards every call to whichever context was most recently swapped in. Every Build call — cache hit or miss — swaps in the current Run's ctx before returning, so a pack that captured the swappable value at session-creation time still observes the *current* Run's cancellation and deadline.
This only fixes Ctx. BuildContext has no other field that legitimately varies per Run for one session: Session.MessageID is documented as set once at session creation, and Scope is the tenant boundary, not expected to change mid-session. A capability that somehow captures per-Run state some other way won't be covered by this fix.
What doesn't need fixing ¶
sb.SetOnEvent and sb.SetPolicy are plain setters on the returned Sandbox, called fresh on every Run by SandboxPool.Build (SetOnEvent) and by Loop.Run itself (SetPolicy) — both already rebind correctly on a reused sandbox with no extra work.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Options ¶
type Options struct {
// IdleTimeout is how long a session's cached sandbox may sit unused
// before the background reaper evicts it (running its real cleanup
// func). Zero uses a 30-minute default.
IdleTimeout time.Duration
// ReapInterval is how often the background reaper sweeps for idle
// entries. Zero uses a 1-minute default.
ReapInterval time.Duration
}
Options configures a SandboxPool. The zero value is a usable default.
type SandboxPool ¶
type SandboxPool struct {
// contains filtered or unexported fields
}
SandboxPool is an agentloop.SandboxBuilder that reuses one long-lived *sandbox.Sandbox per session, built once via a delegate SandboxBuilder and returned again — with its context swapped to the current Run's, see the package doc comment — on every later Build call for the same session. A background goroutine evicts sandboxes idle longer than Options.IdleTimeout.
Concurrent Runs for one session serialize ¶
A goja.Runtime is not safe for concurrent use from multiple goroutines — this is a goja-level rule, not one SandboxPool invented. A fresh-per-Run SandboxBuilder never had to think about it, because two concurrent Runs for the same session got two separate Runtimes. SandboxPool's whole point is to hand out the *same* Runtime across Runs, which means it also has to guarantee only one Run is ever actually using it at a time.
It does this with a per-session mutex, not a comment: Build blocks until any Run currently using that session's sandbox has returned its cleanup func, and the cleanup func Build returns is that mutex's Unlock. Loop.Run already calls `defer cleanup()` right after Build succeeds (see run.go), so this requires no change on the caller's side — but it does mean a second Run for a session already in flight now *waits* for the first to finish, rather than running in parallel the way two fresh sandboxes would have. For a typical single-threaded chat session (no second message before the first one's response) this is invisible; an application that intentionally runs concurrent Runs for one session will see them serialize.
func New ¶
func New(delegate agentloop.SandboxBuilder, opts Options) *SandboxPool
New wraps delegate in a SandboxPool and starts its background reaper. Call Close to stop the reaper and clean up every cached sandbox.
func (*SandboxPool) Build ¶
func (p *SandboxPool) Build(ctx context.Context, sess agentloop.Session, scope agentloop.Scope, onEvent sandbox.OnEvent) (*sandbox.Sandbox, func(), error)
Build implements agentloop.SandboxBuilder. A cache hit checks out the existing sandbox for sess.ID — swapping in ctx and rebinding onEvent, blocking first if another Run for this session is still in flight — and returns it with a cleanup that checks it back in. A cache miss builds via the delegate, with the delegate seeing a swappable context in place of ctx, and caches the result under sess.ID.
func (*SandboxPool) Close ¶
func (p *SandboxPool) Close()
Close stops the background reaper and cleans up every cached sandbox, waiting out any Run still in flight for each. The pool remains usable afterward — Build simply starts caching fresh entries again, same as a newly-constructed pool — but a stopped reaper never restarts, so idle entries added after Close only get cleaned up via Evict or a later Close.
func (*SandboxPool) Evict ¶
func (p *SandboxPool) Evict(sessionID string)
Evict removes and cleans up one session's cached sandbox, if any — for an application-level session end (logout, explicit close) rather than idle eviction. Blocks until any Run currently using it finishes.
func (*SandboxPool) EvictIdle ¶
func (p *SandboxPool) EvictIdle()
EvictIdle runs one eviction sweep immediately, removing and cleaning up every session idle longer than Options.IdleTimeout. The background reaper calls this on ReapInterval; exported so callers can drive eviction on their own schedule (or deterministically in tests) instead of waiting on the timer.
A session whose Run is taking longer than IdleTimeout looks idle by this sweep's clock (lastUsed is stamped at checkout, not continuously) and gets queued for eviction — but evict() blocks on the entry's lock, so the sweep waits out that Run rather than tearing down its sandbox. The session simply gets a fresh sandbox on its next Build.
func (*SandboxPool) Len ¶
func (p *SandboxPool) Len() int
Len returns the number of sessions currently cached.