?

rousseau as a library

rousseau-agent is a library as much as it is a daemon. The agent loop, tool registry, and provider abstractions have no CLI dependency. You can compose them into your own binary without importing internal/cli or any transport package.

Every exported identifier has a godoc comment. pkg.go.dev/github.com/sebastienrousseau/rousseau-agent renders the full reference.

Anatomy of a Turn

The Agent.Turn function is defined in internal/agent/agent.go. In prose, one turn does this:

Turn(ctx, session)
  │
  ├── 1. Session guard: empty session → ErrEmptySession
  │
  ├── 2. Compressor.Compress(ctx, session)
  │     • If enabled and len(messages) > TriggerMessages, summarise older
  │       messages in place. Sets CacheableMessages on next Request.
  │
  ├── 3. registry.Definitions() → toolDefs
  │
  └── loop up to MaxIterations (default 32) times:
        │
        ├── a. Build Request{
        │       SessionID:         session.ID,
        │       System:            systemPrompt(session),
        │       Messages:          session.Messages,
        │       Tools:             toolDefs,
        │       CacheableMessages: <hint from compressor>,
        │     }
        │
        ├── b. resp = provider.Complete(ctx, req)
        │
        ├── c. session.Append(resp.Message)
        │
        ├── d. Switch on resp.StopReason:
        │       • StopEndTurn → return resp.Message (success)
        │       • StopMaxTokens / StopOther → return resp.Message
        │       • StopToolUse → continue to (e)
        │
        ├── e. runTools(ctx, resp.Message, sessionID):
        │       For each tool_use block:
        │         • registry.Get(name) → tool or ErrToolNotFound
        │         • approver.Approve(...)
        │             DecisionDeny → tool_result with is_error=true and reason
        │             DecisionAllow → tool.Execute(ctx, input)
        │               err → tool_result with is_error=true and err.Error()
        │               ok  → tool_result with output
        │
        └── f. session.Append(Message{Role: user, Content: []tool_result})
              Loop.

  MaxIterations exhausted → ErrMaxIterations

Backpressure and cancellation

The ctx passed to Turn propagates through everything: Compressor.Compress, every Provider.Complete, every Tool.Execute, and every Approver.Approve. Cancel the context to abort mid-turn — the current iteration's provider call returns context.Canceled, the session is left with the model's last complete message plus the outstanding tool call, and callers can decide whether to retry.

The built-in BashTool wraps each command in its own context.WithTimeout (default 60s, configurable) so a runaway command cannot exceed the outer context.

System-prompt composition

systemPrompt(ctx, session) in agent.go line 138 assembles up to three parts:

<Options.SystemPrompt>

<SkillsProvider.SystemAppendix(session)>

<RecallProvider.SystemAppendix(ctx, session)>

Any part that returns empty is omitted. The result is strings.Join(parts, "\n\n"). Composition happens once per iteration (not per turn) so skills and recall react to whichever message is most recent — including intermediate tool results, when relevant.

Context-window management

Large sessions eventually exceed the model's context window. Rousseau does not truncate on its own — that is Compressor's job. The default NoopCompressor never rewrites, so embedders who want an unbounded transcript in a small window must either provide their own compressor or accept the model-side error when the window fills.

LLMCompressor (see below) collapses messages older than KeepRecent into a single summary block once the count exceeds TriggerMessages. The summary is generated by the same provider that runs the turn, so it costs one extra completion per compression cycle.

The Provider interface

internal/agent/provider.go:

type Provider interface {
    Name() string
    Complete(ctx context.Context, req Request) (Response, error)
}

type StreamingProvider interface {
    Provider
    CompleteStream(ctx context.Context, req Request, out chan<- StreamEvent) error
}

Complete runs a single non-streaming turn. Request carries SessionID, System, Messages, Tools, and CacheableMessages (an ephemeral-cache hint). Response returns a single assistant Message, a StopReason (end_turn, tool_use, max_tokens, other), and Usage token counts.

Every shipped provider (Anthropic, Bedrock, Vertex, OpenAI-compatible, claudecli) implements Provider. Every one except claudecli implements StreamingProvider.

Session, Message, Turn

internal/agent/session.go and internal/agent/message.go:

type Session struct {
    ID        string
    Title     string
    Messages  []Message
    CreatedAt time.Time
    UpdatedAt time.Time
}

type Message struct {
    Role      Role     // "user", "assistant", "system"
    Content   []Content
    CreatedAt time.Time
}

type Content struct {
    Kind       ContentKind  // "text", "tool_use", "tool_result"
    Text       string
    ToolUse    *ToolUse
    ToolResult *ToolResult
}

A Session is append-only. Every user message is a call to Agent.Turn(ctx, session); the agent loop mutates the session in place and returns the final assistant Message.

Registering tools

internal/tools:

registry := tools.NewRegistry()
registry.MustRegister(builtin.NewReadTool())
registry.MustRegister(builtin.NewGrepTool(0, 0))
registry.MustRegister(builtin.NewEditTool())

Every tool declares a strict JSON schema. Adding your own is a Tool implementation:

type Tool interface {
    Name() string
    Description() string
    InputSchema() json.RawMessage
    Execute(ctx context.Context, input json.RawMessage) (string, error)
}

MustRegister panics on duplicate names; use Register and check the error if you build the registry dynamically.

Approval policies

internal/agent/approver.go. Three built-in policies:

  • AllowAllApprover — every call runs.
  • DenyAllApprover{Reason: "…"} — every call is blocked with the given reason.
  • PatternApprover{Allow: []PatternRule, Deny: []PatternRule, Default: Decision} — regex allow / deny per tool. Deny wins; unmatched requests use Default (empty → DecisionDeny).

Pattern rules are lazily compiled once. Compile errors surface as a DecisionDeny with the error string as the reason, so a malformed regex fails safe.

Custom approvers implement:

type Approver interface {
    Approve(ctx context.Context, req ApprovalRequest) (Decision, string)
}

ApprovalRequest carries ToolName, raw Input JSON, and SessionID. Return DecisionAllow or DecisionDeny plus a reason string (surfaced back to the model as a tool_result error).

Compression

internal/agent/compressor.go. LLMCompressor calls the same provider to summarise older messages once the session crosses a threshold:

compressor, err := agent.NewLLMCompressor(agent.LLMCompressorConfig{
    Provider:        provider,
    TriggerMessages: 60,
    KeepRecent:      8,
})

The most recent KeepRecent messages survive verbatim; everything older collapses into a single summary block. The Compressor sets CacheableMessages on the next request so the summary is cache-hot on the very next turn.

NoopCompressor is the default when Compressor is nil.

FTS5 cross-session recall

internal/agent/recall.go + internal/state/sqlite/. The session store's FTS5 index covers every message. SQLiteRecall queries against the current user message and returns the top-K most relevant snippets as a system-prompt appendix:

recall := recall.NewSQLiteRecall(store, 5)

Enable by setting Options.RecallProvider = recall. Empty results are safe — the loop proceeds normally.

Complete embed example

package main

import (
    "context"
    "fmt"
    "log/slog"
    "os"

    "github.com/sebastienrousseau/rousseau-agent/internal/agent"
    "github.com/sebastienrousseau/rousseau-agent/internal/llm/claudecli"
    "github.com/sebastienrousseau/rousseau-agent/internal/tools"
    "github.com/sebastienrousseau/rousseau-agent/internal/tools/builtin"
)

func main() {
    provider := claudecli.New(claudecli.Config{
        PermissionMode: "bypassPermissions",
    })

    registry := tools.NewRegistry()
    registry.MustRegister(builtin.NewReadTool())
    registry.MustRegister(builtin.NewGrepTool(0, 0))

    ag := agent.New(provider, registry,
        slog.New(slog.NewJSONHandler(os.Stdout, nil)),
        agent.Options{
            SystemPrompt: "You are a careful, concise coding assistant.",
            Approver: &agent.PatternApprover{
                Allow: []agent.PatternRule{
                    {ToolName: "read", Match: ".*"},
                    {ToolName: "grep", Match: ".*"},
                },
                Default: agent.DecisionDeny,
            },
        })

    session := agent.NewSession("hello")
    session.Append(agent.NewUserText("What does main.go do?"))

    reply, err := ag.Turn(context.Background(), session)
    if err != nil {
        fmt.Fprintf(os.Stderr, "turn: %v\n", err)
        os.Exit(1)
    }
    fmt.Println(reply.Content[0].Text)
}

A runnable copy lives in examples/embed-agent in the source tree.

Troubleshooting

agent: max iterations exceeded

The model kept requesting tool calls without ever emitting end_turn. Common causes: a tool that always errors (the model keeps retrying with variations), or a MaxIterations value that is too low for a genuinely complex task. Default is 32 — bump to 64 for large refactors. Set MaxIterations: 0 to use the default.

agent: tool not found: <name>

The model emitted a tool_use block naming a tool that is not in the registry. Usually indicates a stale system prompt (the tool was removed but the model still remembers it), or a hallucinated tool. Rousseau surfaces this as an error to the caller; the model is not given a chance to adapt. If you want graceful degradation, wrap the registry lookup in your own tool dispatcher.

Provider returned end_turn with an empty message

Some providers return stop_reason=end_turn with no content blocks — for example, when the model chose to remain silent. Rousseau returns the empty Message; the caller decides whether "empty" is a valid outcome for their UI. The chat transport handlers log whatsapp.empty_reply, slack.empty_reply, etc.

Tool result is truncated

Content.ToolResult.Output is a plain Go string. Some tool implementations (notably read on a huge file) return output larger than the model can absorb. Cap the output in the tool itself — the built-in read tool truncates at 200 KB.

Compression fires but the summary is nonsensical

The default compression prompt asks for a bullet-list summary. If the model's summaries are missing key facts, either raise KeepRecent so more messages survive verbatim, or override CompressionConfig.Prompt with a task-specific instruction. The instruction is the operator's lever — the compressor does not otherwise steer the model.

Related pages

Further reading

  • internal/agent/agent.goTurn, runTools, systemPrompt.
  • internal/agent/approver.goPatternApprover, AllowAllApprover, DenyAllApprover.
  • internal/agent/compressor.goLLMCompressor and NoopCompressor.
  • internal/agent/recall.goSQLiteRecall and the FTS5 query shape.
  • internal/agent/stream_turn.go — streaming variant that surfaces token-by-token progress.
  • internal/tools/tool.go — the Tool interface.
  • examples/embed-agent/main.go — runnable embedding example.

Type to search 150+ pages. Ranking: BM25 with title/description boost.