agentloop

package module
v0.4.1 Latest Latest
Warning

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

Go to latest
Published: Aug 29, 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. Rate limits (429) and upstream failures (5xx) are retried with exponential backoff plus jitter, honouring a Retry-After header when the upstream sends one — see Config.MaxRetries.
  • agentloopmem — in-memory SessionStore / StepStore for local dev, one-shot CLIs, and eval suites. Not for production traffic (no durability, no cross-process visibility).
  • agentloopsql — the durable counterpart: one SQLite-backed *Store implementing both interfaces, plus the listing and deletion a session manager needs. Uses modernc.org/sqlite, a pure-Go driver, so a binary embedding it still cross-compiles with nothing but GOOS and GOARCH. Several processes can share one database.
  • agentlooptestStepStoreContract and SessionStoreContract, reusable conformance test harnesses. Point them at your own store implementations (Postgres, SQLite, whatever) to hold them to the same behavioural guarantees the loop relies on — see agentloopmem's own contract_test.go for the worked example, and agentloopsql for a second implementation held to exactly the same bar.
  • 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), WorkspacePack (readFile, writeFile, editFile, listDir, glob, grep — confined to one directory by os.Root, so .. and symlinks cannot escape it), ExecPack (exec — argv only, allowlisted environment, capped output, and a timeout that reclaims the whole process group), 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.
  • browser — a browser global that drives a real browser: goto / click / type / text against CSS selectors, plus Set-of-Marks, where mark() numbers every visible interactive element and returns {id, tag, role, name, x, y, w, h} for each so the model acts by id (clickMark(3)) rather than by inventing a selector or a pixel coordinate. Optional vision (ask / askMarks) arrives as a callback, same decoupling ext uses. Every primitive goes through a ten-method Driver; the chromedp implementation is a separate module, github.com/mind-vm/agentloop/browser/chrome, so chromedp and cdproto stay off the dependency list of every deployment that never opens a browser.
  • 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.
  • projectctx — discovers project instruction files (AGENTS.md) from a checkout on disk and renders them into a prompt section. A layered capability: nothing in the core imports it, and an application opts in by passing projectctx.Render(docs) as RunRequest.Context.
  • skills — discovers SKILL.md files in a project and turns each into a Capability the model can find with skillList() and read with skillGet(name). Bodies stay out of the prompt until fetched, so a project can carry many detailed skills at the cost of one catalog line each.
  • 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

A failed request is retried up to three times by default — set OPENAI_MAX_RETRIES (or llm.Config.MaxRetries) to tune that, or to 0 to turn retrying off entirely.

The CLI

cmd/agentloop is the engine as a command. It discovers AGENTS.md and SKILL.md from a workspace directory, composes the default capability bundle, and runs turns:

go install github.com/mind-vm/agentloop/cmd/agentloop@latest

agentloop "What's 12 * 7? Reply with just the number."
agentloop chat
agentloop doctor

There are two output modes. By default the loop's activity streams to stderr and stdout carries only the agent's answer, so answer=$(agentloop run "...") captures what you would expect and nothing else — no banner, no framing. With --json, stdout becomes a newline-delimited stream of every run event followed by a terminal result object, which is the mode another program should drive:

agentloop run --json "summarise this repo" | jq -c 'select(.type=="result")'

The exit code distinguishes how the run ended, so a script never has to parse output to find out: 0 completed, 1 error, 2 max iterations reached, 3 usage. agentloop help lists the flags — they map directly onto agentloop.Config, so --max-steps, --timeout, and --history-window mean exactly what MaxIterations, RunTimeout, and HistoryWindow mean in Go.

--context-window is the exception: it maps onto no single field but onto ProfileFor, deriving the token budget, the compaction thresholds, the per-turn log cap, and how much of an AGENTS.md rides in the prompt from the model's window. It also does two things a profile cannot express as a number — installs a compactor, and switches project instructions to retrieval mode (excerpt in the prompt, the rest behind projectGet()). Set it for a locally hosted model; left unset every one of those settings keeps a default that assumes a large window. An explicit --history-window overrides the derived one.

agentloop --context-window 8192 --timeout 30m "..."

agentloop doctor reports the resolved endpoint and model, makes one real request to confirm the endpoint answers (--offline skips it), and lists the project instructions and skills it found in the workspace.

Sessions are durable. Every run is recorded in a SQLite database, so --session <id> extends a conversation across separate invocations and --continue picks up the most recent one:

agentloop run --session review "read the diff and tell me what changed"
agentloop run --continue "now check the tests cover it"

agentloop sessions ls
agentloop sessions show review
agentloop sessions rm review

The database lives at $AGENTLOOP_DB, else $XDG_DATA_HOME/agentloop/, else the platform's per-user application directory — --db overrides all three. --ephemeral runs against in-memory stores instead and writes nothing, for a turn that should leave no trace.

The workspace

--cwd (default: the current directory) is the project the agent reads and edits. It gets readFile, writeFile, editFile, listDir, glob, and grep, all resolved through an os.Root handle — so .., an absolute path, and a symlink pointing outside are refused by the runtime rather than by path arithmetic that has to be got right. .git is never walked: not source, frequently enormous, and its config can hold credentials.

Reads are ungated. Writes and edits are not: they are denied by the default policy and become a permission prompt naming the file. A read confined to the workspace cannot reach anything you did not point the agent at, while a prompt per file would train you to approve without reading — the mutations are where the consequence is.

editFile requires its target text to appear exactly once, and refuses the edit otherwise rather than guessing. --no-network removes fetch and require('http') entirely, for a tree whose contents should not be able to leave the machine.

Running commands

exec(argv) runs a command and returns {stdout, stderr, code, timedOut}. A non-zero exit is a result, not an error — a failing build is the information the agent asked for:

const r = exec(["go", "test", "./..."]);
if (r.code !== 0) log(r.stderr);

Every call is asked about, showing the full command, because the arguments are most of what there is to judge. --allow-exec go,git pre-approves by command name, which is the difference between "this agent may run the build" and "this agent may run anything". --no-exec removes the primitive entirely.

There is no shell: argv is an array, so a model-authored string never becomes a command line. --dangerously-allow-shell opts back in. Note that this is a convenience, not a boundary — an agent can still ask to run ["sh", "-c", "…"], and the approval prompt showing the whole command is what actually stands between that and running.

The child gets an allowlisted environment — PATH, HOME, locale and little else — so the agent's own API key is absent by construction rather than by remembering to strip it. Output is capped per stream and truncated with a marker. A command that outlives its timeout is killed along with everything it spawned, so a build tool cannot leave its compiler running.

Unlike the workspace primitives, exec is not confined to --cwd. Once another program is running it has the full privileges of the user running the agent, and os.Root has no say in it. What bounds this is the permission prompt and your judgement about which commands to allow.

Permission prompts

Capabilities that need permission — a fetch to a domain the policy has not already allowed, or a change to a file — ask at the terminal:

Allow the agent to access example.com? [y/N]
Allow the agent to modify src/main.go? [y/N]

--approve decides how those are answered: prompt asks, auto approves without asking, deny refuses without asking. The default depends on whether anyone is there to answer — prompt at a terminal, deny otherwise — so an unattended run fails closed rather than blocking forever on an answer nobody will give. Asking for --approve prompt with stdin redirected is an error rather than a silent downgrade.

An approval is remembered for the rest of the session, including across invocations, so resuming does not re-ask. A refusal holds for the rest of that invocation — the loop re-runs a turn that threw, and re-asking on every retry is how you get trained to hit y without reading — but is never persisted, so a later run is free to allow what an earlier one declined. agentloop sessions show <id> lists what a session has approved and agentloop sessions revoke <id> forgets it.

examples/cli stays as the minimal library demo — one Run call in 100 lines, which is the thing to read when embedding the engine rather than running it.

Contributing

AGENTS.md holds the conventions this codebase is written to — what belongs in sandbox versus ext, which seams to reach for, and the handful of behaviours that are load-bearing in ways the types do not show. It is written for an agent working here, and is the fastest orientation for a person too.

CI runs gofmt, go vet, and go test -race on Linux and macOS, and cross-compiles for every target the CLI is released for — which is what catches a build-tag mistake in the platform-specific files.

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.

  • History compaction — by default a session's history is truncated to Config.HistoryWindow steps and the overflow is gone. Set Config.Compactor to fold that older stretch into a summary instead. agentloop.SummarizingCompactor is the batteries-included implementation: past Trigger messages it summarizes everything except the most recent Keep via an llm.Client (point it at a small, cheap model), and it never splits a run() block from the execution result it produced. A session long enough to need compaction can be long enough to overflow the context of the call meant to fix that, so the transcript is summarized in chunks that each fit one call and the partial summaries are folded together until one remains — SummarizingCompactor.Budget (a ContextBudget, sized to the summarizer's window, not the loop's) is what sizes a chunk, and MergePrompt overrides the fold instruction. Nothing is discarded to make the transcript fit: a stretch a single-call summarizer would have elided is summarized like any other, and the only surviving elision is for one individual message too large for a whole call on its own. A 242 KB transcript takes one call against a 128k window and 21 against a 4k one, where before it was a single oversized call the small model would reject. Raising HistoryWindow alongside it is how a session gets long-term memory — the prompt stays bounded by the compactor rather than by the window. Each compaction is persisted as a summary checkpoint step, so the next Run rehydrates from it rather than summarizing the same turns again — a long session pays for a summarization only when it has grown past Trigger since the last one, not on every Run. The covered steps stay in the trace; only what is replayed to the model changes. Compaction is best-effort — a failure emits a warning event and the run proceeds on the uncompacted history.

  • Token budgetHistoryWindow and the compactor both count messages, which says nothing about how many tokens those messages occupy: the same 60-turn history is trivial against a frontier model and a hard overflow against a locally hosted one. Set Config.ContextBudget to bound each turn's prompt in tokens instead. MaxTokens is the window the model is actually served with (for a local runtime, the server's configured context size — llama.cpp's --ctx-size, Ollama's num_ctx — not the figure on the model card), and Reserve is what's held back for the completion (default MaxTokens/8, clamped to [512, 4096]); the reserve is also sent as the request's output cap, so a long answer can't overrun the room made for it. The zero value is disabled, so adding a budget changes nothing until MaxTokens is set.

    The trim runs immediately before each request — after compaction, after stale-code elision, and after this run's own turns have grown the history, which is the only point where the real size is knowable. The system prompt and the current turn are never dropped; the oldest conversational turns go first, an execution result is never stranded from the run() block that produced it, and a marker tells the model how many turns are gone so it doesn't quietly assume what they contained. Every trim emits a budget_trimmed event and every turn span carries agentloop.budget.estimated_tokens against agentloop.budget.allowance, so headroom is observable before a session starts losing history. Sizing uses a crude bytes/4 estimate by default — supply ContextBudget.Estimate to plug in a real tokenizer and fill the window tighter.

    This is a backstop, not a replacement for compaction: the compactor preserves meaning, the budget only guarantees the request is sendable. Configure both.

  • Small-model profile — a model's context window sets four limits this package exposes as separate knobs in three different units: ContextBudget in tokens, HistoryWindow in steps, the compactor's Trigger/Keep in messages, log output in bytes. Every default assumes a frontier model, so against an 8k local model they are wrong by an order of magnitude — and fixing that by hand means four unit conversions from one number you already know. agentloop.ProfileFor(window) is that number expanded:

    p := agentloop.ProfileFor(8192)
    docs, _ := projectctx.Loader{InlineBytes: p.ProjectInlineBytes}.Load(cwd)
    cfg := agentloop.Config{
        LLM: client, Sessions: sessions, Steps: steps,
        SandboxBuilder: &agentloop.DefaultSandboxBuilder{
            Capabilities: caps,
            MaxLogBytes:  p.MaxLogBytes,
        },
        Compactor: &agentloop.SummarizingCompactor{LLM: client},
    }
    p.Apply(&cfg) // ContextBudget, HistoryWindow, and the compactor's knobs
    
    window history trigger/keep log bytes AGENTS.md inline
    4k 42 14/4 1,792 1,792
    8k 84 28/9 3,584 3,584
    32k 180 60/20 14,336 4,096
    128k+ 180 60/20 16,384 4,096

    At a large window it lands on the package defaults, so adopting it changes nothing for a hosted model. ProfileFor(0) returns a zero Profile whose Apply is a no-op — "I don't know the window" degrades to the defaults rather than to a guess. It is a starting point, not a constraint: read the fields and override what your workload justifies. Apply leaves a custom Compactor alone (its knobs are its own) and never touches the sandbox builder or the project-instruction loader, which are separate objects you construct.

    The remaining lever it can't pull for you is how many packs you register: DefaultCapabilities costs ~1,300 prompt tokens of declare lines on every turn, and the ext/ packs (especially OpenAPI-generated ones) add more. On a small window, dropping capabilities a given agent never uses is often the largest single saving available — DefaultSandboxBuilder.EnabledCapabilities is the per-session allowlist for that.

  • Project instructionsprojectctx.Load(cwd) walks from the repository root down to cwd collecting AGENTS.md files (general first, most specific last), and projectctx.Render(docs) turns them into a ## Project instructions section to pass as RunRequest.Context — see examples/cli. Repository files must resolve inside the root even after symlinks and are read through an os.Root, so a symlinked AGENTS.md can't pull in a file from outside the project. Load returns any docs that read cleanly alongside its error, so one unreadable file warns rather than denying the run its remaining context. Unlike the CLI convention this came from, no user-global file is read unless you ask for one (Loader{GlobalDir: ...}) — a library shouldn't reach into $HOME on its own.

    Render inlines every file in full, which is a standing cost — these ride in the prompt of every turn of every run, whether or not the turn touches anything they cover. projectctx.RenderCatalog(docs) plus projectctx.Capabilities(docs) is the retrieval alternative: the prompt carries each file's opening section (Loader.InlineBytes, 4 KB by default, cut at a paragraph boundary and never left dangling on a heading), and the model pulls the rest with projectGet(name) when it needs it — projectList() reports each file's size and whether the prompt already has all of it. A typical page-or-two AGENTS.md rides along whole and nothing changes; a 36 KB one drops from ~9,300 to ~1,200 prompt tokens per turn, with nothing lost — unlike MaxBytes truncation, the tail stays reachable. The two are alternatives, not a pair: RenderCatalog writes pointers to projectGet, so wire the capabilities alongside it. See examples/cli.

  • Project skillsskills.Load(cwd) reads <root>/.agentloop/skills/<name>/SKILL.md (directory configurable) and skills.Capabilities(sk) turns them into capabilities to append to the slice a DefaultSandboxBuilder gets — see examples/cli. Each skill contributes ONE line to the system prompt (name, description, and how to fetch it); the instructions themselves arrive only when the model calls skillGet(name), which is what keeps a big skill library cheap. One capability per skill, so DefaultSandboxBuilder.EnabledCapabilities can switch a skill on per session. Names that would shadow a built-in (fetch, http, require, …) are rejected: pack help entries merge by name, so such a skill would otherwise replace that primitive's own documentation.

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

Design decisions

The load-bearing choices here — code-as-action, return-threading, stale-code elision, the synchronous goja runtime, the context-economy layers — are recorded with their reasoning and their price in docs/adr. Start with the index; the What would change our mind section of each record is where it says what would make it wrong.

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 DefaultCompactPrompt = `` /* 902-byte string literal not displayed */

DefaultCompactPrompt is the instruction SummarizingCompactor sends when Prompt is empty. It is exported so an application can extend it rather than rewrite it from scratch.

View Source
const DefaultMergePrompt = `` /* 972-byte string literal not displayed */

DefaultMergePrompt is the instruction sent when several partial summaries have to be folded into one, i.e. when the transcript did not fit a single summarization call. Overridable via SummarizingCompactor.MergePrompt.

It is a separate instruction from DefaultCompactPrompt because the input is a different kind of thing: already-summarized prose rather than a transcript of turns, where the risk is concatenating superseded facts rather than losing specifics.

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

View Source
const StepTypeSummary = "summary"

StepTypeSummary marks a compaction checkpoint in a session's step trace: a RunStep whose Content is the summary to replay in place of the steps it covers, and whose ToolArgs carries a CompactionCheckpoint saying where the retained history resumes.

The steps a checkpoint covers stay in the trace — this changes only what is replayed to the model, never what a human reviewing the session can see.

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 EstimateTokens added in v0.4.0

func EstimateTokens(s string) int

EstimateTokens approximates how many tokens s occupies, without a tokenizer: byte length over a fixed divisor.

It is intentionally crude. A real tokenizer is per-model, pulls in a vocabulary, and would have to be kept in step with providers this package deliberately knows nothing about — while the decision it feeds ("does one more message fit?") tolerates a wide margin, since the reserve absorbs the error. Supply ContextBudget.Estimate when you have the real thing and want the context filled tighter.

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 CompactResult added in v0.4.0

type CompactResult struct {
	// Messages is the history to send, chronological.
	Messages []llm.Message

	// Tokens is what producing it cost, zero for an implementation that
	// makes no provider call.
	Tokens TokenUsage

	// Summary, when non-empty, is the exact message content to replay
	// in place of the folded-away history on FUTURE runs. Setting it
	// (together with RetainedFrom) is what makes a compaction durable:
	// the loop persists it as a StepTypeSummary checkpoint, and the
	// next Run rehydrates from that instead of summarizing again.
	//
	// Leave it empty to compact for this run only. That costs a
	// summarization per Run, so an implementation that can express its
	// result as "one message replacing a prefix" should set it.
	Summary string

	// RetainedFrom is the index, in the history passed to Compact, of
	// the first message kept verbatim — everything before it is what
	// Summary stands for. Ignored when Summary is empty, and a value
	// outside (0, len(history)] disables persistence: a compaction that
	// folded nothing away has no checkpoint worth writing.
	RetainedFrom int
}

CompactResult is one compaction attempt's output.

type CompactionCheckpoint added in v0.4.0

type CompactionCheckpoint struct {
	// RetainFromStep is the StepIndex the retained history resumes at.
	// Every earlier step is represented by the summary instead of being
	// replayed, so a session that has been compacted once does not pay
	// to summarize the same turns again on the next Run.
	RetainFromStep int32 `json:"retain_from_step"`
}

CompactionCheckpoint is the ToolArgs payload of a StepTypeSummary step.

type Compactor added in v0.4.0

type Compactor interface {
	Compact(ctx context.Context, history []llm.Message) (CompactResult, error)
}

Compactor shrinks a session's rehydrated history before the loop starts its turns, so a long conversation survives as a summary instead of being silently truncated.

Compact receives the prior conversation in chronological order, WITHOUT the current user message (which the loop appends afterwards and always sends verbatim). It returns the history to actually send. Returning the input unchanged is always valid and is what an implementation should do when there is nothing worth compacting — the loop makes no assumption that the result is shorter.

Tokens must report what the implementation spent, and must be populated even when Compact returns an error: a provider call that produced an unusable summary is still billable, and a Run's reported usage would otherwise understate what the session cost.

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.
	//
	// With a Compactor configured this becomes how much of the past the
	// loop CONSIDERS rather than how much it sends: everything loaded
	// is handed to the Compactor, which decides what survives verbatim.
	// Raising it is how a session gets long-term memory — the prompt
	// stays bounded by the compactor, not by this.
	HistoryWindow int

	// Compactor folds the older part of a long session's history into a
	// summary before the run's turns begin, so early context survives
	// in compressed form instead of falling off the end of
	// HistoryWindow unnoticed.
	//
	// A compaction that reports a CompactResult.Summary is persisted as
	// a StepTypeSummary checkpoint, and later runs rehydrate from that
	// rather than summarizing the same turns again — see
	// rehydrateHistory. The covered steps remain in the trace; only
	// what is replayed to the model changes.
	//
	// Optional; nil keeps the default behaviour — history is truncated
	// to HistoryWindow and the overflow is simply gone. Compaction is
	// best-effort: a Compactor that fails warns and the run proceeds on
	// the uncompacted history.
	Compactor Compactor

	// ContextBudget bounds each turn's prompt in TOKENS, as a hard
	// backstop under HistoryWindow and Compactor — both of which count
	// MESSAGES and so cannot tell a forty-token turn from a
	// four-thousand-token one. Optional; the zero value is disabled and
	// preserves the prior behaviour exactly.
	//
	// Enabling it is what turns a context overflow from a provider
	// error (or a silent server-side truncation) into a deliberate,
	// observable trim: the oldest turns are dropped, the model is told
	// how many, and a "budget_trimmed" event reports it. Set
	// MaxTokens to the window the model is actually SERVED with — for a
	// local runtime that is the server's configured context size, not
	// the figure on the model card.
	ContextBudget ContextBudget

	// 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 ContextBudget added in v0.4.0

type ContextBudget struct {
	// MaxTokens is the model's context window. Zero disables budgeting
	// entirely — the loop then behaves exactly as it did before, with
	// the prompt bounded only by HistoryWindow and the Compactor.
	//
	// Set it to the SERVED window, which for a locally hosted model is
	// what the server was started with (llama.cpp's --ctx-size, Ollama's
	// num_ctx), not what the model card advertises.
	MaxTokens int

	// Reserve is how much of MaxTokens is held back for the completion.
	// Zero uses MaxTokens/8, clamped to [512, 4096] and never more than
	// half the window.
	//
	// It doubles as the request's output cap: when a budget is enabled
	// and CompletionRequest.MaxTokens would otherwise be unset, the
	// loop sends Reserve. Otherwise the reserve is a wish rather than a
	// guarantee — nothing would stop the model from generating past the
	// room left for it.
	Reserve int

	// Estimate overrides EstimateTokens. Supply a real tokenizer to
	// fill the window tighter; the default's error is absorbed by
	// Reserve.
	Estimate TokenEstimator
}

ContextBudget bounds a turn's prompt in TOKENS rather than messages. The zero value is disabled, which is why adding one to an existing Config changes nothing until MaxTokens is set.

When enabled, the loop trims each turn's request immediately before sending it — after compaction, after stale-code elision, and after this run's own turns have grown the history. That placement is the point: history grows DURING a Run (every run() block and execution result appends to it), so a check performed once at rehydrate time would pass and then overflow three turns later.

The system prompt and the most recent message are never dropped; the oldest conversational turns go first, and a marker records how many.

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

	// MaxLogBytes caps one turn's log() output. Zero uses
	// sandbox.DefaultMaxLogBytes; negative removes the cap.
	//
	// Worth lowering for a small context window: a turn's logs are
	// replayed in every later prompt of the session, so this is a
	// per-turn cost that compounds. Profile.MaxLogBytes derives a value
	// from a model's window.
	MaxLogBytes int
}

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 Profile added in v0.4.0

type Profile struct {
	// ContextBudget bounds each turn's prompt. Goes in Config.
	ContextBudget ContextBudget

	// HistoryWindow is how many steps the loop rehydrates. Goes in
	// Config.
	HistoryWindow int

	// CompactTrigger and CompactKeep configure a SummarizingCompactor,
	// in messages.
	CompactTrigger int
	CompactKeep    int

	// SummarizeBudget bounds one summarization call. Set it on
	// SummarizingCompactor.Budget when the summarizer runs on the same
	// model — the common case for a local setup. Point the compactor at
	// a larger hosted model and this is the field to override.
	SummarizeBudget ContextBudget

	// MaxLogBytes caps one turn's log() output. Goes in
	// DefaultSandboxBuilder.MaxLogBytes.
	MaxLogBytes int

	// ProjectInlineBytes is how much of each instruction file belongs in
	// the prompt. Goes in projectctx.Loader.InlineBytes — which this
	// package cannot set for you, since nothing in the core imports
	// projectctx.
	ProjectInlineBytes int
}

Profile is a coherent set of context settings derived from one model's context window.

It is a starting point, not a constraint: read the fields, change what your workload justifies. Every value it produces is one you could have written by hand.

func ProfileFor added in v0.4.0

func ProfileFor(contextWindow int) Profile

ProfileFor derives context settings for a model with the given context window, in tokens.

Use the window the model is actually SERVED with: for a local runtime that is the server's configured context size (llama.cpp's --ctx-size, Ollama's num_ctx), not the figure on the model card. A window the server will not honour produces a profile that fits nothing.

A non-positive window returns the zero Profile, whose Apply is a no-op — so "I don't know the window" degrades to today's defaults rather than to a guess.

func (Profile) Apply added in v0.4.0

func (p Profile) Apply(cfg *Config)

Apply writes the profile's loop-level settings into cfg: everything Config itself owns, plus the compactor's message counts and budget when cfg.Compactor is a *SummarizingCompactor.

It does NOT touch the sandbox builder or the project-instruction loader, which are separate objects the application constructs — pass Profile.MaxLogBytes and Profile.ProjectInlineBytes to those yourself:

p := agentloop.ProfileFor(8192)
docs, _ := projectctx.Loader{InlineBytes: p.ProjectInlineBytes}.Load(cwd)
cfg := agentloop.Config{
    LLM: client, Sessions: sessions, Steps: steps,
    SandboxBuilder: &agentloop.DefaultSandboxBuilder{
        Capabilities: caps,
        MaxLogBytes:  p.MaxLogBytes,
    },
    Compactor: &agentloop.SummarizingCompactor{LLM: client},
}
p.Apply(&cfg)

Apply overwrites rather than merges: it is the profile's settings that end up in cfg, not a blend. Call it BEFORE any hand-tuning you want to survive. A zero Profile (from ProfileFor(0)) writes nothing.

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
compacted         history was summarized before the run's turns;
                  Args = {"messages_before", "messages_after"}
budget_trimmed    a turn's prompt exceeded Config.ContextBudget and
                  the oldest turns were dropped to fit; Args =
                  {"messages_dropped", "estimated_tokens",
                  "allowance"}
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, summary. 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.

A StepTypeSummary row is a compaction checkpoint rather than a turn: its Content is replayed in place of the steps it covers, and its ToolArgs holds a CompactionCheckpoint naming where retained history resumes. Store implementations need do nothing special for it — it is an ordinary append — but they must preserve ToolArgs verbatim, since losing that marker turns the checkpoint into an unusable row.

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 SummarizingCompactor added in v0.4.0

type SummarizingCompactor struct {
	// LLM performs the summarization. Required; a nil client makes
	// Compact a no-op that returns the history unchanged, so a missing
	// provider degrades to today's truncation rather than failing runs.
	//
	// Pointing this at a small, cheap model is usually right — the work
	// is extractive, and it keeps the per-Run cost noted above low.
	LLM llm.Client

	// Model overrides the client's default for the summarization call.
	Model string

	// Trigger is the history length, in messages, past which compaction
	// runs at all. Zero uses the package default (60).
	Trigger int

	// Keep is how many of the most recent messages stay verbatim. Zero
	// uses the package default (20). Clamped to half of Trigger when it
	// would otherwise leave nothing to summarize.
	Keep int

	// Prompt overrides DefaultCompactPrompt.
	Prompt string

	// MergePrompt overrides DefaultMergePrompt, the instruction used
	// when partial summaries have to be folded together. Unused when
	// the transcript fits a single call.
	MergePrompt string

	// Budget bounds ONE summarization call against the window of the
	// model doing the summarizing — which is not the loop's model, and
	// is often deliberately smaller and cheaper. Its MaxTokens is that
	// window and its Reserve the room left for the summary itself.
	//
	// Zero uses an internal default sized to the bound this replaced,
	// which suits a hosted summarizer. Set it when the summarizer is
	// locally hosted: it is the difference between chunking to fit and
	// sending one call the server will reject.
	Budget ContextBudget
}

SummarizingCompactor folds the older part of a long history into one summary message, keeping the most recent turns verbatim.

A session long enough to need compaction can be long enough to overflow the context of the call meant to fix that, so the transcript is summarized in CHUNKS that each fit one call and the partial summaries are then folded together — repeatedly, until one remains. Nothing is dropped to make the transcript fit: a stretch of the session that a single-call summarizer would have elided is summarized like any other. The only surviving elision is for one individual message too large for a whole call on its own.

Budget is what sizes a chunk. Left unset it defaults to roughly the old single-call bound, which is the right answer against a hosted model; point it at a small local model's window and the same transcript is simply summarized in more, smaller pieces.

Cost: a summarization happens when the rehydrated history passes Trigger. Because the loop persists each result as a checkpoint (see CompactResult.Summary), that is once per Trigger-worth of NEW conversation rather than once per Run — a session compacted at turn 60 does not pay again until it has grown back past Trigger. Trigger is the knob: raise it to pay less often and send more verbatim history, lower it for the reverse. Chunking multiplies the calls one compaction makes, so a small Budget against a large Trigger is the combination that costs most.

func (*SummarizingCompactor) Compact added in v0.4.0

func (c *SummarizingCompactor) Compact(ctx context.Context, history []llm.Message) (CompactResult, error)

Compact implements Compactor.

type TokenEstimator added in v0.4.0

type TokenEstimator func(string) int

TokenEstimator reports how many tokens a string occupies.

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 agentloopsql is a SQLite-backed agentloop.SessionStore and agentloop.StepStore: the durable counterpart to agentloopmem, for a CLI or a single-host service that wants sessions to outlive the process.
Package agentloopsql is a SQLite-backed agentloop.SessionStore and agentloop.StepStore: the durable counterpart to agentloopmem, for a CLI or a single-host service that wants sessions to outlive the process.
Package agentlooptest provides reusable conformance harnesses for the agentloop store contracts.
Package agentlooptest provides reusable conformance harnesses for the agentloop store contracts.
Package browser gives an agentloop sandbox a `browser` global that drives a real browser: navigate, click, type, read text, and — via Set-of-Marks — enumerate the page's interactive elements as numbered ids the model can act on without ever producing a CSS selector or a pixel coordinate.
Package browser gives an agentloop sandbox a `browser` global that drives a real browser: navigate, click, type, read text, and — via Set-of-Marks — enumerate the page's interactive elements as numbered ids the model can act on without ever producing a CSS selector or a pixel coordinate.
chrome module
cmd
agentloop command
Command agentloop drives an agentloop.Loop from the terminal.
Command agentloop drives an agentloop.Loop from the terminal.
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 projectctx discovers project-level instruction files — AGENTS.md and friends — from a checkout on disk, and renders them into a section an application can fold into an agentloop run's system prompt.
Package projectctx discovers project-level instruction files — AGENTS.md and friends — from a checkout on disk, and renders them into a section an application can fold into an agentloop run's system prompt.
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.
Package skills discovers SKILL.md files in a project and exposes each one to an agentloop run as a retrievable document.
Package skills discovers SKILL.md files in a project and exposes each one to an agentloop run as a retrievable document.

Jump to

Keyboard shortcuts

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