Skip to content

Latest commit

 

History

History
389 lines (294 loc) · 12.9 KB

usage.md

File metadata and controls

389 lines (294 loc) · 12.9 KB

ReAgent Usage Guide

This guide is for Go developers who want to build an agent runtime with ReAgent. After reading it, you should be able to wire a runtime, choose state storage, add tools, connect a model adapter, observe events, and extend the system with your own tool or policy.

The examples use the current local module path reagent. If you consume ReAgent from another module before it is published, add a local replace directive in your go.mod.

Mental model

A ReAgent application is a small composition of ports:

user input
  -> runtime
  -> context manager builds model request from state
  -> model streams assistant events
  -> runtime records assistant message
  -> runtime executes tool calls when present
  -> runtime records tool results
  -> loop continues until no tool calls or all tool results terminate

The runtime owns orchestration. Your application owns the model adapter, tool set, policy, state store, and event handling.

Assemble a minimal runtime

The smallest useful runtime needs a model, a state store, and a context manager.

modelPort := fake.New(fake.Turn{Events: []model.Event{
    {Type: model.EventMessageDelta, DeltaText: "done"},
    {Type: model.EventMessageEnd},
}})

rt := runtimeport.Runtime{
    Model:   modelPort,
    State:   memory.NewStore(),
    Context: contextport.NewMessageHistory(),
}

Run it with one or more user messages:

err := rt.Run(ctx, runtimeport.RunRequest{
    SessionID: "demo",
    Input: []agent.Message{{
        Role: agent.RoleUser,
        Content: []agent.ContentBlock{{
            Type: agent.ContentText,
            Text: "Say hello.",
        }},
    }},
}, emit)

SessionID selects the durable conversation. If it is empty, the runtime generates one. For resumable applications, pass your own stable session id.

Handle runtime events

The emitter receives ordered runtime events. A CLI can print deltas, a TUI can render tool state, and a logger can persist structured diagnostics.

emit := func(ctx context.Context, event agent.Event) error {
    switch event.Type {
    case agent.EventMessageDelta:
        fmt.Print(event.DeltaText)
    case agent.EventToolStart:
        fmt.Fprintf(os.Stderr, "tool started: %s\n", event.ToolCallID)
    case agent.EventToolEnd:
        if event.ToolResult != nil && event.ToolResult.IsError {
            fmt.Fprintf(os.Stderr, "tool failed: %s\n", event.ToolCallID)
        }
    case agent.EventError:
        if event.Error != nil {
            fmt.Fprintf(os.Stderr, "%s: %s\n", event.Error.Code, event.Error.Message)
        }
    }
    return nil
}

Return an error from the emitter only when event delivery failure should stop the run. This is appropriate when the emitter is also the persistence path or when the UI requires strict backpressure.

Choose a state store

Use the in-memory store for tests and short-lived local experiments:

store := memory.NewStore()

Use the JSONL store when sessions should survive process restarts:

store, err := jsonl.NewStore(".reagent-state")
if err != nil {
    return err
}

The JSONL store writes one owner-only directory per session. It keeps an append-only records.jsonl log and an atomic snapshot.json. On load, records are log-authoritative and snapshot metadata is used for fast resume.

To persist emitted events as records, wrap your emitter with the store's recorder:

persistEvents := store.EventRecorder("demo")

emit := func(ctx context.Context, event agent.Event) error {
    if err := persistEvents(ctx, event); err != nil {
        return err
    }
    return renderEvent(ctx, event)
}

Build model context from state

The default context manager is message-history based:

ctxManager := contextport.NewMessageHistory()

It reads persisted message records from the current state snapshot and sends those messages to the model. Event records and custom records are not exposed to the model by default. This keeps diagnostic and UI history out of the prompt unless your application intentionally adds it.

Write a custom context manager when you need summarization, custom record interpretation, prompt budgeting, system prompts, or application-specific state injection.

Register built-in tools

Filesystem tools are created for one workspace root:

fsTools, err := fs.NewTools(".")
if err != nil {
    return err
}

The bash tool is also rooted at a workspace:

bashTool, err := bash.New(bash.Config{Root: "."})
if err != nil {
    return err
}

Register tools in the order you want them exposed to the model:

tools := append([]tool.Tool{}, fsTools...)
tools = append(tools, bashTool)

registry, err := tool.NewRegistry(tools...)
if err != nil {
    return err
}

rt.Tools = registry

The static registry validates specs, rejects duplicate names, and returns defensive copies of tool specs so later caller mutation cannot change the model-facing catalog.

Built-in tool behavior

read

  • Requires path.
  • Optional expected_hash, start_line, end_line, and max_bytes.
  • Reads files inside the workspace.
  • Refuses directory listings.
  • Returns content plus metadata such as path, byte size, line count, modification time, hash, and truncation state.

write

  • Requires path and full content.
  • Optional expected_hash and create_parents.
  • Writes atomically.
  • Refuses directory writes.
  • Preserves existing file permissions where possible.
  • Returns old and new hashes when overwriting.

edit

  • Requires path and mode.
  • Supports replace, insert, and patch modes.
  • Uses atomic writes and optional expected_hash stale-edit protection.
  • replace requires exact old_text and new_text.
  • insert requires one-based line, position of before or after, and text.
  • patch accepts unified-diff-style hunks and reports hunk and change counts.

bash

  • Requires command.
  • Optional cwd, timeout_ms, and max_output_bytes.
  • Runs each command in a separate process.
  • Defaults to bash -lc.
  • Enforces workspace-contained working directories.
  • Times out and terminates the process group on deadline.
  • Returns stdout or stderr as text and metadata including exit code, duration, timeout status, and truncation flags.

Connect the OpenAI-compatible adapter

The OpenAI adapter implements the model port using streaming Chat Completions.

modelPort := openai.New(openai.Config{
    Model:   os.Getenv("OPENAI_MODEL"),
    APIKey:  os.Getenv("OPENAI_API_KEY"),
    BaseURL: os.Getenv("OPENAI_BASE_URL"),
})

BaseURL should be the versioned API root, such as https://api.openai.com/v1. If blank, it defaults to the OpenAI API root. Use another root for compatible gateways or local servers.

The adapter converts ReAgent tool specs into OpenAI function tools and translates streaming provider output into ReAgent model events. It currently supports text and JSON content blocks. File and image blocks are rejected by this adapter until a provider-specific representation is added.

Add policy decisions

Use policy.AllowAll{} when you want an explicit permissive policy:

rt.Policy = policy.AllowAll{}

Implement policy.Policy to deny risky calls or integrate your own approval flow:

type denyShellWrites struct{}

func (denyShellWrites) Decide(ctx context.Context, req policy.Request) (policy.Decision, error) {
    if req.ToolCall != nil && req.ToolCall.Name == "bash" {
        return policy.Decision{
            Action:  policy.ActionDeny,
            Message: "bash is disabled in this session",
        }, nil
    }
    return policy.Decision{Action: policy.ActionAllow}, nil
}

The runtime treats denied calls as model-visible tool errors. ActionAsk is also returned to the model as an unavailable approval unless your host application implements approval outside the runtime call.

Write a custom tool

A tool has two methods: Spec and Execute.

type echoTool struct{}

func (echoTool) Spec() agent.ToolSpec {
    return agent.ToolSpec{
        Name:        "echo",
        Description: "Return the supplied text.",
        Parameters: agent.JSONSchema{
            "type": "object",
            "properties": map[string]any{
                "text": map[string]any{"type": "string"},
            },
            "required":             []any{"text"},
            "additionalProperties": false,
        },
        ExecutionMode: agent.ExecutionParallel,
        SideEffect:    agent.SideEffectNone,
    }
}

func (echoTool) Execute(ctx context.Context, call agent.ToolCall, snapshot agent.StateSnapshot) (agent.ToolResult, error) {
    var args struct {
        Text string `json:"text"`
    }
    if err := json.Unmarshal(call.Arguments, &args); err != nil {
        return agent.ToolResult{
            ToolCallID: call.ID,
            Name:       call.Name,
            IsError:    true,
            Content: []agent.ContentBlock{{
                Type: agent.ContentText,
                Text: "invalid arguments",
            }},
        }, nil
    }

    return agent.ToolResult{
        ToolCallID: call.ID,
        Name:       call.Name,
        Content: []agent.ContentBlock{{
            Type: agent.ContentText,
            Text: args.Text,
        }},
    }, nil
}

Register it with the same static registry as built-in tools:

registry, err := tool.NewRegistry(echoTool{})

The runtime validates arguments against the tool spec before calling Execute. A custom tool should still decode arguments defensively and return model-visible tool errors for expected input failures. Reserve returned Go errors for runtime-level failures that should stop the run.

Use the example CLI

The example CLI is useful as an integration smoke test and as a wiring reference.

go run ./cmd/example-agent \
  -prompt "Summarize the runtime responsibilities." \
  -model "$OPENAI_MODEL" \
  -workspace . \
  -state .reagent-state \
  -session default

Relevant flags:

  • -prompt: required user prompt for the next turn.
  • -workspace: workspace root for filesystem and bash tools.
  • -state: durable JSONL state root.
  • -session: durable session id. It must be a single relative path segment.
  • -adapter: currently supports openai.
  • -model: model name. Defaults from OPENAI_MODEL.
  • -base-url: OpenAI-compatible API root. Defaults from OPENAI_BASE_URL, then to the adapter default.
  • -timeout: optional run timeout such as 30s or 2m.

The CLI also reads .env as a convenience for local development. It redacts the configured API key from terminal errors.

Error handling conventions

ReAgent distinguishes model-visible failures from runtime failures.

Return a tool result with IsError: true when the model should see and recover from the problem, such as invalid tool arguments, a missing file, a denied policy decision, a non-zero shell exit, or a command timeout.

Return a Go error when the runtime should fail the run, such as model adapter failure, state persistence failure, emitter failure, or caller cancellation.

Structured errors use agent.AgentError:

return agent.AgentError{
    Code:      "state_log_corrupt",
    Message:   "state record log contains invalid JSON",
    Retryable: false,
    Metadata:  map[string]any{"line": 12},
}

These errors are suitable for events, records, and UI display.

Testing patterns

Use the fake model for deterministic runtime tests. Each call to Stream consumes one scripted turn and records the model request for assertions.

modelPort := fake.New(
    fake.Turn{Events: []model.Event{
        {Type: model.EventMessageDelta, DeltaText: "first"},
        {Type: model.EventMessageEnd},
    }},
)

// run runtime...
requests := modelPort.Requests()

Use the in-memory store for tests that do not need filesystem durability. Use the JSONL store when you want to verify resume behavior, record ordering, corruption handling, or event recording.

Production notes

ReAgent is local-agent first. If you expose it to untrusted users or run it against sensitive workspaces, add policy and process boundaries around the runtime:

  • choose workspace roots deliberately,
  • restrict or omit bash when shell execution is not needed,
  • require hash guards for high-risk write flows,
  • persist event and error records for replay,
  • keep secrets out of prompts, tool results, logs, and metadata,
  • treat model output as untrusted input to tool validation and policy.

The built-in workspace checks prevent path traversal outside the configured root, but they are not a full sandbox.

Current limitations

  • The module path is local until publication.
  • The OpenAI adapter targets Chat Completions streaming.
  • The adapter does not send file or image content blocks.
  • The built-in read tool does not list directories.
  • The runtime executes tool calls sequentially even though specs can mark tools as parallel-safe.
  • Interactive approvals are a host-application responsibility.
  • Planning, MCP, browser automation, vector memory, multi-agent orchestration, and a bundled TUI are intentionally deferred.