Documentation
¶
Overview ¶
Package agentloop is a small reasoning-loop engine for LLM agents that think by writing JavaScript instead of calling named tools.
Each turn the model emits one fenced ```javascript block defining `function run(args) { ... }`. The loop executes it in a sandboxed goja runtime (package sandbox) and threads its return value into the next turn as `args` — full fidelity, server-side, never serialised back into the prompt. The model sees only its own log() output plus a compact structural digest of what it returned. A run finishes when the script calls answer(result).
This design — return-threading instead of a growing tool-call transcript — keeps the prompt small even for many-step runs: stale code is elided from history (only the most recent turn's script is kept verbatim) and large data never appears in the context window twice.
A minimal wiring looks like:
client, _ := llm.NewOpenAI(llm.ConfigFromEnv())
caps := agentloop.DefaultCapabilities(client, "")
loop := agentloop.New(agentloop.Config{
LLM: client,
Sessions: mySessionStore,
Steps: myStepStore,
SandboxBuilder: &agentloop.DefaultSandboxBuilder{Capabilities: caps},
})
result, err := loop.Run(ctx, agentloop.RunRequest{
SessionID: "session-1",
Message: "What's 2+2, and say it back as markdown?",
})
See examples/cli for a complete runnable program with in-memory stores.
Index ¶
- Constants
- Variables
- func ComposeSystemPrompt(persona, sandboxAPI string) string
- func ExtractDoneMarker(s string) (done bool, final string)
- func ExtractJSBlock(s string) string
- func TextEmissionSystemPrompt() string
- type BuildContext
- type CallTokens
- type Capability
- type Config
- type DefaultSandboxBuilder
- type FinalizeSummary
- type Loop
- type RunEvent
- type RunRequest
- type RunResult
- type RunStep
- type RunSummary
- type SandboxBuilder
- type Scope
- type Session
- type SessionStore
- type StepStore
- type TokenUsage
Constants ¶
const HistoryWindow = 80
HistoryWindow is the default cap on how many prior steps the loop replays into the LLM's context window (override via Config.HistoryWindow). Roughly the last several user turns of full reasoning trails before the oldest start to drop — beyond this the prompt gets expensive and the model loses the user's actual question in the noise.
const MaxIterations = 20
MaxIterations is the default cap on LLM round-trips a single Run will make (override via Config.MaxIterations).
const RunTimeout = 5 * time.Minute
RunTimeout is the default wall-clock cap on a single Run call (override via Config.RunTimeout).
Variables ¶
var ErrEmptyResponse = errors.New("agentloop: model returned an empty response")
ErrEmptyResponse is returned when the model yields no content across the allowed retries. Distinct from a normal completion so callers can treat it as a retryable failure instead of silently finishing with an empty answer.
Functions ¶
func ComposeSystemPrompt ¶
ComposeSystemPrompt builds the full system prompt the loop sends to the LLM for a session: the text-emission contract, then the optional persona, then the sandbox's primitive documentation. Persona AFTER protocol, sandbox API LAST so declarations sit close to the user message.
func ExtractDoneMarker ¶
ExtractDoneMarker reports whether s contains a terminating DONE marker, and if so returns the answer that follows it. The marker must be on its own line — an inline "DONE" inside prose doesn't prematurely terminate the run. answer() is the documented way to finish; this is a defensive fallback for a model that emits the legacy marker instead.
func ExtractJSBlock ¶
ExtractJSBlock returns the contents of the first ```javascript or ```js fenced block, or "" if none is present. Whitespace inside the block is trimmed — some models add a blank line right after the fence and the intent is unaffected.
func TextEmissionSystemPrompt ¶
func TextEmissionSystemPrompt() string
TextEmissionSystemPrompt is the workflow contract the loop layers on top of the sandbox's primitive documentation. It defines the run(args)→return protocol: each turn the model emits one fenced ```javascript block defining `function run(args)`, whose return value the loop threads into the next turn as `args` (full fidelity, server-side, never serialised into the prompt — the model sees only a structural shape digest of it plus its own log() output). The run finishes when the script calls answer(result).
Kept free of primitive listings — those come from the registered packs via sandbox.Sandbox.SystemPrompt() and are appended by the loop under "## Sandbox API".
The runtime notes are empirically grounded: goja executes modern JS syntax (arrows, const/let, template literals, destructuring, spread, optional chaining), but has NO event loop — Promise/async code parses and then its continuations silently never run, which is why the prompt bans them outright rather than saying "unsupported".
Types ¶
type BuildContext ¶
type BuildContext struct {
// Ctx is the per-run context. Capabilities should honour
// cancellation — a long-running primitive (fetch, ai()) must abort
// when the run's deadline fires.
Ctx context.Context
// Scope is the tenant boundary. Capabilities that touch
// application data must filter on it.
Scope Scope
// SessionID identifies the session this run extends.
SessionID string
// MessageID is the inbound message that started this session, if
// any (mirrors Session.MessageID — carried here too so a capability
// doesn't need the Session value itself).
MessageID string
// UserID is the invoking user, empty for system-initiated runs.
UserID string
// EnabledCapabilities is the session's capability allowlist. nil
// means "default-all"; a non-nil slice (possibly empty) means "only
// load capabilities whose Name appears here." AlwaysOn capabilities
// load regardless.
EnabledCapabilities *[]string
}
BuildContext is the per-run bag of dependencies each capability's Build receives. Application-specific dependencies (a database handle, an accumulator slice, …) that a capability needs should be closed over when the Capability is constructed, not threaded through here — see DefaultCapabilities for the pattern.
type CallTokens ¶
CallTokens is the per-LLM-call token count carried on execute_js_result and response events, for fine-grained reporting.
type Capability ¶
type Capability struct {
// Name is the stable identifier a per-session allowlist can
// reference (see BuildContext.EnabledCapabilities).
Name string
// Description is shown in a capability catalog / skill listing.
Description string
// AlwaysOn skips the enabled-capabilities allowlist filter — for
// capabilities nothing should be able to disable without making the
// runtime unusable (e.g. require()).
AlwaysOn bool
// Build runs at session-start with the per-run BuildContext. Empty
// returns are fine: a capability with a missing optional dependency
// (no LLM key configured, say) should return (nil, nil) so the
// session can proceed without it.
Build func(BuildContext) ([]sandbox.Pack, error)
}
Capability is the seam between the loop and application-supplied packs — a named, optionally-gated unit of sandbox functionality a SandboxBuilder composes into a session's sandbox.
func DefaultCapabilities ¶
func DefaultCapabilities(llmClient llm.Client, model string) []Capability
DefaultCapabilities is the general-purpose bundle most agents want: require() (always on), require('http'), require('markdown'), fetch() / htmlToMarkdown(), and — when llmClient is non-nil — ai() / aiJSON(). model is the model passed to every ai()/aiJSON() sub-call; empty uses the client's own default.
Passing llmClient == nil is valid: the "ai" capability's Build then returns (nil, nil) and the session simply has no ai()/aiJSON() primitive, rather than failing to start.
type Config ¶
type Config struct {
// LLM is the per-Run chat client.
LLM llm.Client
// Sessions persists session metadata. Required.
Sessions SessionStore
// Steps persists the per-turn trace. Required.
Steps StepStore
// SandboxBuilder constructs the sandbox for a Run. Required.
SandboxBuilder SandboxBuilder
// Policy gates side-effecting primitives. Optional; nil installs
// sandbox.DefaultPolicy (conservative: deny by default).
Policy sandbox.PolicyChecker
// Model is the default chat model when the session has none pinned.
// Optional; falls back to the LLM client's own default when empty.
Model string
// MaxIterations caps LLM round-trips per Run. Zero uses the package
// default.
MaxIterations int
// RunTimeout is the wall-clock cap per Run. Zero uses the package
// default.
RunTimeout time.Duration
// HistoryWindow caps how many prior steps are rehydrated into the
// LLM context. Zero uses the package default.
HistoryWindow int
// Now is a clock seam for tests. Nil uses time.Now.
Now func() time.Time
// TracerProvider produces spans for each Run — one root span per
// call plus child spans for sandbox build, each turn, its LLM call,
// and its JS execution. Optional; nil installs a no-op tracer, so
// leaving this unset costs a few allocations and produces no spans.
// Wire in an OTel SDK TracerProvider (e.g. configured with an OTLP
// exporter pointed at a Jaeger collector) to observe Run calls in
// production — nothing else in this package needs to change.
TracerProvider trace.TracerProvider
// Redactor strips known secret values out of every surface this
// package writes free text to: RunEvent (Content and Args, before
// OnEvent sees it), RunResult.FinalText, persisted RunStep.Content
// (via Steps.Append), and error messages recorded on a span.
// Optional; nil is a safe no-op (see redact.Redactor) — the
// defense-in-depth case this exists for is a script that logs a
// fetched credential (log(secret("KEY")), or a fetch() response
// that echoes one back) and would otherwise carry it into
// whatever OnEvent forwards to, the persisted trace, or a trace
// backend. Build one with redact.FromSecrets over the secret
// values your capabilities can return this session.
//
// One trade-off: setting this suppresses "response_chunk" events
// (live token-by-token streaming). A secret can split across two
// chunk boundaries with neither chunk containing the whole value to
// match against, so per-chunk redaction can't be made safe — the
// complete, redacted text still arrives via the terminal "response"
// event instead.
Redactor *redact.Redactor
}
Config wires the dependencies the loop needs.
type DefaultSandboxBuilder ¶
type DefaultSandboxBuilder struct {
// Capabilities is the full set this builder can install; each
// Build call filters it down via EnabledCapabilities.
Capabilities []Capability
// EnabledCapabilities is the allowlist passed through to every
// capability's BuildContext. nil means "all enabled".
EnabledCapabilities *[]string
}
DefaultSandboxBuilder is the simplest SandboxBuilder: it composes a fixed Capabilities list into a fresh sandbox.Sandbox for every Run, filtered by EnabledCapabilities (nil = all enabled). Applications whose capability set varies per scope/session (e.g. a per-tenant allowlist pulled from a database) should implement SandboxBuilder themselves — its Build method is a good starting point to copy.
func (*DefaultSandboxBuilder) Build ¶
func (b *DefaultSandboxBuilder) Build(ctx context.Context, sess Session, scope Scope, onEvent sandbox.OnEvent) (*sandbox.Sandbox, func(), error)
Build implements SandboxBuilder. A capability whose Build fails is logged and skipped — via a "warning" sandbox.Event when onEvent is non-nil, and always via slog — rather than aborting the whole session: one flaky capability shouldn't deny the user their turn.
type FinalizeSummary ¶
type FinalizeSummary struct {
Status string
PromptTokens int32
CompletionTokens int32
StepCount int32
DurationMs int32
// DataBytesCarried sums, over every LLM call of this run, the bytes
// of threaded working state held server-side minus the shape digest
// actually sent.
DataBytesCarried int64
}
FinalizeSummary is what the loop hands to Finalize at the end of a run (success or failure).
type Loop ¶
type Loop interface {
Run(ctx context.Context, req RunRequest) (RunResult, error)
}
Loop is the reasoning-loop contract. One call to Run drives a multi-turn rehydrate-execute-respond cycle for one user message, finishing when the model calls answer() (or emits a legacy DONE marker, or answers with no code fence at all).
type RunEvent ¶
type RunEvent struct {
Type string `json:"type"`
Content string `json:"content,omitempty"`
Tool string `json:"tool,omitempty"`
Args map[string]any `json:"args,omitempty"`
Summary *RunSummary `json:"summary,omitempty"`
Tokens *CallTokens `json:"tokens,omitempty"`
}
RunEvent is one observability emission the loop streams to RunRequest.OnEvent in real time.
The Type discriminator names what fields are populated:
user user turn persisted; Content = message
execute_js agent emitted a JS block; Content = the JS source
execute_js_result a JS block finished; Content = textual result
sandbox_event a primitive emitted observability; Args carries
the underlying sandbox.Event fields
data_update the agent's carried data changed; Args = new value
response final markdown answer; Content = the answer
response_chunk streamed token from a final-text turn;
Content = the chunk (no Args)
warning non-fatal degradation; Content = human-readable detail
error a step errored; Content = human-readable error
done terminal event; Summary = aggregate RunSummary
Tokens is populated on `response` and `execute_js_result` events to attribute LLM cost back to the step that incurred it.
type RunRequest ¶
type RunRequest struct {
// SessionID identifies the agent session this run extends. The loop
// loads prior steps from StepStore using this ID; new steps are
// appended under the same ID.
SessionID string
// Scope is the tenant boundary the run executes under. Passed
// through to the PolicyChecker and each Capability's Build.
Scope Scope
// UserID is the invoking user, empty for system-initiated runs.
UserID string
// Message is the user turn that triggered the run. The loop appends
// it to history before the first LLM call.
Message string
// Context is optional per-run context (e.g. a webhook payload,
// prefetched) folded into the system prompt for this run only. Not
// persisted as a step — the user turn in the trace stays the raw
// Message.
Context string
// OnEvent receives every observability emission as it happens. Nil
// is acceptable — events still land in the step trace.
OnEvent func(RunEvent)
}
RunRequest is the input to Loop.Run.
type RunResult ¶
type RunResult struct {
// RunID is the session ID this run extended (mirrors RunRequest.SessionID).
RunID string
// FinalText is the agent's last `response` step content. Empty when
// Status != "completed".
FinalText string
// Steps is the number of steps persisted by this Run call.
Steps int
// Status is one of "completed" | "error" | "max_iterations".
Status string
// Tokens is the aggregate prompt + completion token usage across
// every LLM call this Run made.
Tokens TokenUsage
// SystemPrompt is the fully composed system prompt sent to the
// model, for an inspectable turn trace. Empty if the run failed
// before composing it.
SystemPrompt string
// DataBytesCarried is the context-economy measurement: bytes of
// threaded working state withheld from prompts, summed per LLM
// call, net of the shape digests sent.
DataBytesCarried int64
}
RunResult is the summary populated when Loop.Run returns.
type RunStep ¶
type RunStep struct {
SessionID string
StepIndex int32
StepType string
Content string
ToolArgs json.RawMessage
DurationMs int32
PromptTokens int32
CompletionTokens int32
CreatedAt time.Time
}
RunStep is one persisted row in the session's trace. StepType is the discriminator: user, execute_js, execute_js_result, response, error. rehydrateHistory (history.go) only replays user / response / execute_js / execute_js_result back to the LLM — error rows stay in the trace but don't feed back.
type RunSummary ¶
type RunSummary struct {
SessionID string `json:"session_id"`
Steps int `json:"steps"`
Tokens struct {
Prompt int32 `json:"prompt"`
Completion int32 `json:"completion"`
} `json:"tokens"`
// DataBytesCarried is the run's context-economy measurement: bytes
// of threaded working state withheld from prompts, summed per LLM
// call, net of the shape digests sent.
DataBytesCarried int64 `json:"data_bytes_carried,omitempty"`
}
RunSummary rides the terminal "done" event — the same numbers RunResult carries, for a caller that only subscribes to the event stream.
type SandboxBuilder ¶
type SandboxBuilder interface {
// Build returns the sandbox + a cleanup func the loop defers.
Build(ctx context.Context, sess Session, scope Scope, onEvent sandbox.OnEvent) (*sandbox.Sandbox, func(), error)
}
SandboxBuilder produces the sandbox for one Run. The loop calls it once per Run with the per-run scope so capabilities can resolve scoped state.
type Scope ¶
Scope is the tenant boundary a run executes under. Every capability's Build receives it and should scope its reads/writes accordingly. ProjectID is optional — leave it empty when your application has no sub-workspace narrowing.
type Session ¶
type Session struct {
ID string
Model string // optional; loop falls back to Config.Model when empty
SystemPrompt string // persona section appended to the platform prompt
Data json.RawMessage
// MessageID is the inbound message that started this session, if any
// — set once at session creation and read back here so a
// SandboxBuilder can hand it to capabilities that need it. Empty for
// a session with no originating message.
MessageID string
}
Session is the minimum the loop needs to drive a Run.
type SessionStore ¶
type SessionStore interface {
// Get returns the session for sessionID. An UNKNOWN id is NOT an
// error — implementations may auto-create a fresh session shell,
// because the loop treats "no session yet" as normal.
Get(ctx context.Context, sessionID string) (Session, error)
// Exists reports whether a session row already exists, WITHOUT
// creating one.
Exists(ctx context.Context, sessionID string) (bool, error)
UpdateData(ctx context.Context, sessionID string, snapshot json.RawMessage) error
Finalize(ctx context.Context, sessionID string, summary FinalizeSummary) error
}
SessionStore exposes the per-session metadata the loop reads and the lifecycle hooks it writes.
type StepStore ¶
type StepStore interface {
Append(ctx context.Context, step RunStep) error
LastN(ctx context.Context, sessionID string, n int) ([]RunStep, error)
}
StepStore persists the per-turn trace of a session.
LastN's ordering is load-bearing: it must return the most recent n steps in CHRONOLOGICAL order (oldest first) — the loop replays this straight into the LLM context window.
type TokenUsage ¶
TokenUsage is the per-Run aggregate.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package agentloopmem provides in-memory implementations of agentloop.StepStore and agentloop.SessionStore.
|
Package agentloopmem provides in-memory implementations of agentloop.StepStore and agentloop.SessionStore. |
|
Package agentlooptest provides reusable conformance harnesses for the agentloop store contracts.
|
Package agentlooptest provides reusable conformance harnesses for the agentloop store contracts. |
|
browser
|
|
|
chrome
module
|
|
|
Package eval is a small LLM-judged eval harness: a Suite of Cases (input + judge criteria), each dispatched through an agentloop.Loop and scored by a judge llm.Client on a 0-10 scale.
|
Package eval is a small LLM-judged eval harness: a Suite of Cases (input + judge criteria), each dispatched through an agentloop.Loop and scored by a judge llm.Client on a 0-10 scale. |
|
Package evalmem provides an in-memory eval.Store — for local dev, CI, and one-shot eval runs.
|
Package evalmem provides an in-memory eval.Store — for local dev, CI, and one-shot eval runs. |
|
examples
|
|
|
cli
command
Command cli is a minimal, runnable demo of agentloop: in-memory session/step stores, the default capability bundle (require, http, fetch, markdown, ai), and one Run call against an OpenAI-wire-compatible LLM.
|
Command cli is a minimal, runnable demo of agentloop: in-memory session/step stores, the default capability bundle (require, http, fetch, markdown, ai), and one Run call against an OpenAI-wire-compatible LLM. |
|
Package ext holds optional sandbox.Pack implementations that are generically useful but don't belong in the core sandbox package.
|
Package ext holds optional sandbox.Pack implementations that are generically useful but don't belong in the core sandbox package. |
|
Package llm is the LLM client interface agentloop's ai()/aiJSON() sandbox capability calls against, and what drives the agent loop's own turn-taking.
|
Package llm is the LLM client interface agentloop's ai()/aiJSON() sandbox capability calls against, and what drives the agent loop's own turn-taking. |
|
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.
|
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. |
|
Package redact strips known secret values from text before it leaves the runtime.
|
Package redact strips known secret values from text before it leaves the runtime. |
|
Package sandbox is agentloop's JS executor — a goja sandbox the model writes run(args) → return turns against, one capability per registered Pack.
|
Package sandbox is agentloop's JS executor — a goja sandbox the model writes run(args) → return turns against, one capability per registered Pack. |