agentloop

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 17 Imported by: 0

README

agentloop

A small Go 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) { ... }. agentloop executes it in a sandboxed goja runtime and threads its return value into the next turn as args — full fidelity, server-side, never re-serialised 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 return-threading design 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.

Packages

  • agentloop — the reasoning loop itself: Loop.Run drives the rehydrate → prompt → execute → thread cycle for one user message.
  • sandbox — the goja-based JS executor and its built-in packs: require, http, fetch + htmlToMarkdown, markdown (structured parsing), ai/aiJSON, help, and skill discovery. A PolicyChecker seam gates side-effecting primitives.
  • llm — a small Client interface plus an OpenAI-wire-compatible implementation that works with OpenAI itself, OpenRouter, EdenAI, Groq, together.ai, a local vLLM/Ollama server, or any other gateway that speaks the same /chat/completions protocol.
  • agentloopmem — in-memory SessionStore / StepStore for local dev, one-shot CLIs, and eval suites. Not for production traffic (no durability, no cross-process visibility).
  • agentlooptestStepStoreContract, a reusable conformance test harness. Point it at your own StepStore implementation (Postgres, SQLite, whatever) to hold it to the same behavioural guarantees the loop relies on — see agentloopmem's own contract_test.go for the worked example.
  • ext — optional sandbox.Packs that are generically useful but don't belong in the core sandbox package: EmailPack (sendEmail), SecretPack (secret), SearchPack (documentSearch), StoresPack (stores.list/stores.read), and OpenAPIPack, which generates a require()-able skill — one JS function per operation — from an OpenAPI 3 document. Each takes a callback, small interface, or (for OpenAPIPack) a parsed spec, same decoupling the core packs use, so this package stays free of any mail/secrets/search-backend/HTTP-client dependency of its own.
  • poolSandboxPool, a SandboxBuilder that reuses one long-lived sandbox per session across every Run instead of paying goja.New() + pack-registration cost on every message. Wraps any other SandboxBuilder; see Extending for the correctness issue it has to solve to do that safely.
  • eval — an LLM-judged eval harness: a Suite of Cases (input + judge criteria), each dispatched through an agentloop.Loop and scored 0–10 by a judge llm.Client. agentloop.Loop and agentloop.RunRequest/RunResult already have exactly the shape the harness needs, so Service takes a Loop directly — no adapter interface required.
  • evalmem — in-memory eval.Store for local dev and CI, same role agentloopmem plays for SessionStore/StepStore. Not for production traffic.
  • redactRedactor, a small ordered (value, placeholder) list that strips known secret values out of text or bytes; a nil *Redactor is a safe pass-through everywhere. Config.Redactor and eval.NewService's redactor parameter both take one — see Extending for what it's wired into.

Quickstart

client, err := llm.NewOpenAI(llm.ConfigFromEnv())
if err != nil {
    log.Fatal(err)
}

caps := agentloop.DefaultCapabilities(client, "")
loop := agentloop.New(agentloop.Config{
    LLM:            client,
    Sessions:       mySessionStore, // implements agentloop.SessionStore
    Steps:          myStepStore,    // implements agentloop.StepStore
    SandboxBuilder: &agentloop.DefaultSandboxBuilder{Capabilities: caps},
})

result, err := loop.Run(ctx, agentloop.RunRequest{
    SessionID: "session-1",
    Message:   "What's 12 * 7? Reply with just the number.",
})

See examples/cli for a complete runnable program with in-memory stores:

export OPENAI_API_KEY=sk-...
go run ./examples/cli "What's 12 * 7? Reply with just the number."

To point at a different OpenAI-wire-compatible gateway, set OPENAI_BASE_URL and OPENAI_CHAT_MODEL to match it, e.g.:

# OpenRouter
export OPENAI_BASE_URL=https://openrouter.ai/api/v1
export OPENAI_CHAT_MODEL=openai/gpt-4o-mini

# EdenAI
export OPENAI_BASE_URL=https://api.edenai.run/v3
export OPENAI_CHAT_MODEL=google/gemini-2.5-flash

Extending

  • Custom capabilities — a Capability is a name plus a Build func returning []sandbox.Pack. Application-specific dependencies (a database handle, an accumulator, a broadcaster) should be closed over when the Capability is constructed rather than threaded through BuildContext — see DefaultCapabilities in defaults.go for the pattern.

  • Custom sandbox composition — implement agentloop.SandboxBuilder yourself (its one method, Build) when your capability set varies per scope or session, e.g. pulled from a database per tenant.

  • Custom storesSessionStore and StepStore are small interfaces; back them with whatever persistence you already have. agentloopmem ships in-memory implementations to start from, and agentlooptest.StepStoreContract is a conformance harness to run against your own implementation as an acceptance gate.

  • Policy — implement sandbox.PolicyChecker to gate side-effecting primitives (fetch, ai, or your own) per call. sandbox.DefaultPolicy is a conservative default (deny side effects, block private-network fetches); sandbox.AllowAll is the explicit fail-open escape hatch.

  • Optional extension packsext.EmailPack, ext.SecretPack, ext.SearchPack, and ext.StoresPack are common but not universal, so they live outside sandbox and aren't in DefaultCapabilities. Wrap one in a Capability that closes over your own backend and add it to the slice passed to DefaultSandboxBuilder. sendEmail and secret are already in DefaultPolicy's side-effect list, so they're denied until granted via DefaultPolicy.AllowTools.

  • Generate a skill from an OpenAPI specext.OpenAPIPack(spec, cfg) turns an OpenAPI 3 document into a require()-able skill: one JS function per operation, named after its operationId (or derived from the method + path when it has none), taking a single params object (params.<name> per path/query parameter, params.body for the request body) and returning {status, body, headers} — the same shape fetch()/require('http') already return. Generated functions call the sandbox's own internal HTTP primitive, not a new client, so every call is still policy-gated exactly like fetch() already is, and require(<skill name>) is gated by that name like any other skill. cfg.Headers is the whole auth story (a static header map — bearer token, API key — applied to every call); OpenAPI securitySchemes aren't interpreted. The generated Pack's Prompt is one short line (API name, operation count, a pointer to skillGet/require) rather than the full surface — a spec can have far more operations than are worth inlining into every turn. The full docs, returned by skillGet(<skill name>) on demand, are TypeScript ambient declarations — declare function getPetById(params: { petId: string; verbose?: boolean }): { status: number; body: string; headers: Record<string, string> }; — in the same style the core packs' own Prompt fields already use, so a generated skill's API reads the same way a built-in one does:

    spec, err := openapi3.NewLoader().LoadFromFile("petstore.yaml")
    pack, err := ext.OpenAPIPack(spec, ext.OpenAPIConfig{
        Headers: map[string]string{"Authorization": "Bearer " + apiKey},
    })
    caps = append(caps, agentloop.Capability{
        Name: pack.Name,
        Build: func(agentloop.BuildContext) ([]sandbox.Pack, error) { return []sandbox.Pack{pack}, nil },
    })
    
  • Session-scoped sandbox reuseagentloop.New's default is a fresh sandbox per Run (sandbox.New() builds a whole goja.Runtime and re-registers every pack from scratch every time). Wrap your SandboxBuilder in pool.New to reuse one sandbox per session across every Run instead:

    builder := pool.New(&agentloop.DefaultSandboxBuilder{Capabilities: caps}, pool.Options{
        IdleTimeout: 30 * time.Minute, // evict a session's sandbox after this much idle time
    })
    defer builder.Close()
    
    loop := agentloop.New(agentloop.Config{
        // ...
        SandboxBuilder: builder,
    })
    

    This isn't just a cache wrapper — a Capability's Build closes over BuildContext.Ctx once (see DefaultCapabilities in defaults.go), so a naively cached sandbox would keep using the first Run's context — including its cancellation — forever. pool.SandboxPool gives the delegate builder a swappable context instead, and swaps in each Run's real context before handing the sandbox back. It also serializes concurrent Runs for one session: a goja.Runtime isn't safe for concurrent use, so a second Run for a session already in flight blocks until the first releases the sandbox, rather than racing on it. See the pool package doc comment for both mechanisms in detail.

  • Distributed tracing (OpenTelemetry)Config.TracerProvider takes a trace.TracerProvider. Left unset, Run produces no spans (a no-op tracer, a few allocations and nothing else); set it to an OTel SDK TracerProvider — e.g. configured with an OTLP exporter pointed at a Jaeger collector — and every Run produces a span tree: one root agentloop.run span, with agentloop.sandbox_build and one agentloop.turn per LLM round-trip as children, each turn's own agentloop.llm_call and (when the model emits JS) agentloop.execute_js nested under it. Only go.opentelemetry.io/otel/trace (the stable, SDK-free API package) is an agentloop dependency — the SDK, exporter, and Jaeger wiring are entirely the application's to choose:

    loop := agentloop.New(agentloop.Config{
        // ...
        TracerProvider: myOTelSDKTracerProvider, // e.g. wired to an OTLP/Jaeger exporter
    })
    

A capability whose Build fails is logged and skipped (a warning sandbox event, not an aborted session) — one flaky capability shouldn't deny the user their turn.

Evaluating agent quality

eval.Service runs a Suite of Cases — each an input plus judge criteria — through an agentloop.Loop, has a judge llm.Client score every response 0–10, and persists the run:

store := evalmem.New() // or your own eval.Store
svc := eval.NewService(store, loop, judgeClient, nil) // last arg: optional *redact.Redactor

suite, _ := svc.CreateSuite(ctx, "arithmetic", "" /* judge model, empty = client default */)
svc.AddCase(ctx, suite.ID, "sums", "What's 2+2?", "must say 4", 7, nil)

run, err := svc.RunSuite(ctx, suite.ID)
// run.Summary.Passed / .Failed; run.Results[i].{Response,Score,Rationale,Passed}

agentloop.Loop's Run(ctx, RunRequest) (RunResult, error) already has exactly the shape an eval-harness runner needs (RunResult.FinalText is the response to judge), so Service takes a Loop directly rather than some separate runner interface. Each case gets its own fresh, never reused session ID — agentloop.SessionStore.Get is documented to auto-create a shell for an unknown ID, so RunSuite needs no separate session-provisioning step.

A Case can carry either a single Criteria string + PassThreshold (the legacy path), or a per-criterion CriteriaItems rubric — when set, the judge scores each item separately and the case passes only when every item clears its own MinScore, with the overall Score reported as the average. A case that errors (agent failure or judge failure) records the error inline on that case's result rather than aborting the suite — RunSuite always finishes and returns a Run.

evalmem.InMemoryStore implements eval.Store for local dev and CI; back a real deployment with whatever persistence you already have.

Redacting secrets from observability

redact.Redactor holds a small ordered list of (secret value, placeholder) pairs and strips every occurrence out of text or bytes — build one with redact.FromSecrets(map[string]string{"api_key": key, ...}) over whatever values a session's capabilities can return. A nil *Redactor is a safe pass-through everywhere it's used, so this is entirely opt-in.

Two places take one:

  • Config.Redactor — applied to every RunEvent (Content and Args, before OnEvent sees it), RunResult.FinalText, persisted RunStep.Content, and error messages recorded on a span. The defense-in-depth case: a script does log(secret("API_KEY")), or a fetch() response happens to echo a credential back, and that value would otherwise land in whatever OnEvent forwards to, the persisted trace, or a trace backend. One trade-off: setting this suppresses "response_chunk" events (live token-by-token streaming), because a secret can split across two chunk boundaries with neither chunk containing the whole value to redact against — the complete, redacted text still arrives via the terminal "response" event.
  • eval.NewService's 4th argument — applied to the agent's response before it reaches the judge's prompt (a third-party LLM call) or gets persisted in CaseResult.Response. An eval case exercises the same capabilities production traffic does, so without this a case that happens to trigger a credential-bearing response would send it on to the judge and store it in the run.

Known limitations

This is a synchronous, single-process design, not a durable workflow engine:

  • No durable execution. A Run call lives in one goroutine; a process restart mid-run kills it (steps already persisted are fine, but nothing resumes automatically).
  • Everything inside a turn is synchronous, including I/O — no Promise/async/.then() in the sandbox (goja parses them but has no event loop, so continuations silently never run; the system prompt warns the model off this).
  • Process-level isolation only. goja is memory-safe and interruptible, but sandboxes share the host process's heap/CPU — no per-run resource quota.
  • Unbounded args carry. Nothing caps the size of the server-side state threaded between turns (RunResult.DataBytesCarried gives you the observability to notice, not a limit).
  • No built-in cost ceiling. MaxIterations bounds turns and token usage is tracked, but there's no per-run or per-tenant token budget.

None of these are architectural dead ends — they're the natural next layer (suspend/resume, batched I/O primitives, resource quotas, budget enforcement) to add on top if/when you need them.

License

MIT — see LICENSE.

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

View Source
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.

View Source
const MaxIterations = 20

MaxIterations is the default cap on LLM round-trips a single Run will make (override via Config.MaxIterations).

View Source
const RunTimeout = 5 * time.Minute

RunTimeout is the default wall-clock cap on a single Run call (override via Config.RunTimeout).

Variables

View Source
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

func ComposeSystemPrompt(persona, sandboxAPI string) string

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

func ExtractDoneMarker(s string) (done bool, final string)

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

func ExtractJSBlock(s string) string

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

type CallTokens struct {
	Prompt     int32 `json:"prompt"`
	Completion int32 `json:"completion"`
}

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).

func New

func New(cfg Config) Loop

New constructs the default Loop from Config. Required fields: LLM, Sessions, Steps, SandboxBuilder. Missing fields panic at construction so misconfigurations surface at boot, not on the first request.

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

type Scope struct {
	WorkspaceID string
	ProjectID   string // empty = not narrowed to one project
}

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

type TokenUsage struct {
	Prompt     int32
	Completion int32
}

TokenUsage is the per-Run aggregate.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL