# ๐Ÿ—๏ธ Rony LLM Agent โ€” Architecture **Version:** 1.0 **Author:** Victor Hugo Vargas **Date:** 2026-06-28 **Status:** Library architecture specification > ๐Ÿ“š **Other documents:** > - [`README.md`](./README.md) โ€” Library overview > - [`components.md`](./components.md) โ€” Detailed reference per package > - [`phase2.md`](./phase2.md) โ€” Advanced features (MCP, full RAG, Skills, etc.) > - [`../../METHODOLOGY.md`](../../METHODOLOGY.md) โ€” How we build (SDD + DDD + Hexagonal) > > ๐Ÿ“ **Methodology:** This library follows the **SDD + DDD + Hexagonal Architecture** approach. See [`METHODOLOGY.md`](../../METHODOLOGY.md) for details. --- ## ๐ŸŽฏ 1. Library Vision ### 1.1 What is go-llm-agent? A Go library that provides all the **generic and reusable** logic for building LLM-based agents. It's not a final product โ€” it's the foundation on which specific products are built. **Products that consume it:** - [`VictorVargas/rony-harness`](https://github.com/VictorVargas/rony-harness) โ€” AI agent harness for software development (TUI CLI) - [`VictorVargas/rony-chat-bot`](https://github.com/VictorVargas/rony-chat-bot) โ€” HTTP chatbot for portfolios and websites ### 1.2 Design principles - **Reusable, not opinionated.** Does not force a UI type, deployment, or use case. - **Pure Hexagonal.** Ports & adapters โ€” every external dependency is behind an interface. - **Streaming-first.** Uses `iter.Seq2` from Go 1.23+ for natural streaming without callbacks. - **Secure by default.** Filesystem path sandbox with `os.Root` (Go 1.24+). - **Zero magic.** No reflection, no codegen, no DSLs. Idiomatic and explicit Go. - **YAML configurable.** Everything that affects behavior is declarative. ### 1.3 What it is NOT - โŒ It's not a CLI โ€” that's the product's responsibility (e.g., `rony-harness`) - โŒ It's not an HTTP server โ€” that's the product's responsibility (e.g., `rony-chat-bot`) - โŒ It does not force a specific model or provider โ€” interchangeable adapters - โŒ It has no persistent state of its own โ€” each product handles that --- ## ๐Ÿ—๏ธ 2. Hexagonal Architecture (Ports & Adapters) ``` โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ Products that consume โ”‚ โ”‚ (rony-harness, rony-chat-bot, dealer-bot, etc.) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ use public interfaces โ–ผ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ go-llm-agent (pkg/ โ€” public API) โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ agent โ”‚ โ”‚ llm โ”‚ โ”‚ persona โ”‚ โ”‚ tools โ”‚ ... โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ uses ports (interfaces) โ”‚ โ”‚ โ–ผ โ–ผ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ PORTS (pure interfaces) โ”‚ โ”‚ โ”‚ โ”‚ LLMClient, VectorDB, Embedder, ToolRegistry, ... โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ implemented by adapters โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ–ผ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ Adapters (in pkg/*/internal/) โ”‚ โ”‚ โ€ข providers/anthropic, openai, ollama, llamacpp โ”‚ โ”‚ โ€ข backends/chroma, qdrant, sqlite โ”‚ โ”‚ โ€ข embeddings/ollama, onnx โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` ### 2.1 Layers **Ports (pure interfaces)** - Define WHAT the domain does, not HOW - Contracts without implementation - Allow swapping adapters without touching business logic **Domain (pkg/)** - Contains all reusable logic - Does NOT depend on anything external - Only imports interfaces from its own ports **Adapters (internals of each adapter)** - Concrete implementations of ports - External dependencies live here (HTTP clients, filesystem, DB drivers) --- ## ๐Ÿง  3. Core Interfaces ### 3.1 LLMClient (`pkg/llm/`) Multi-provider abstraction. Products never touch a provider SDK directly โ€” they always go through this interface. ```go type LLMClient interface { Generate(ctx context.Context, req CompletionRequest) (CompletionResponse, error) Stream(ctx context.Context, req CompletionRequest) iter.Seq2[StreamChunk, error] Name() string Capabilities() ProviderCapabilities } type CompletionRequest struct { Messages []Message Tools []Tool ToolChoice ToolChoice // "auto" | "required" | "none" | ToolRef Model string Temperature *float32 MaxTokens *int Stop []string Metadata map[string]string } type CompletionResponse struct { ID string Content string ToolCalls []ToolCall Usage TokenUsage StopReason string // "end_turn" | "tool_use" | "max_tokens" | "stop_sequence" } type StreamChunk struct { Delta string ToolCalls []ToolCall // streamed incremental FinishReason string Usage TokenUsage // only on final chunk } type TokenUsage struct { InputTokens int OutputTokens int TotalTokens int } type ProviderCapabilities struct { SupportsTools bool SupportsVision bool SupportsJSON bool MaxContextWindow int } ``` **Full details:** [`pkg/llm/README.md`](../pkg/llm/README.md) ### 3.2 Tool System (`pkg/tools/`) Function callable by the LLM with JSON Schema, permissions, and sandbox. ```go type Tool struct { Name string Description string InputSchema json.RawMessage // JSON Schema draft-07+ Required []string Handler ToolHandler // func(ctx, args json.RawMessage) (Result, error) Permission Permission // Allow | Ask | Deny Examples []ToolExample // few-shot for the LLM } type ToolHandler func(ctx context.Context, args json.RawMessage) (ToolResult, error) type ToolResult struct { Content string IsError bool Metadata map[string]string Artifacts []Artifact } type ToolCall struct { ID string Name string Arguments json.RawMessage Thought string // optional: chain-of-thought } type Permission int const ( Allow Permission = iota Ask Deny ) type ToolRegistry interface { Register(tool Tool) error Get(name string) (Tool, bool) List() []Tool Filter(policy PermissionPolicy) []Tool } ``` **Full details:** [`pkg/tools/README.md`](../pkg/tools/README.md) ### 3.3 Agent Loop (`pkg/agent/`) Iterative loop between LLM and tool execution. It's the "brain" that orchestrates everything. ```go type Loop interface { Run(ctx context.Context, input string) (Response, error) RunStream(ctx context.Context, input string) iter.Seq2[Chunk, error] } type Config struct { LLM llm.LLMClient Persona persona.Persona Tools tools.Registry Sandbox Sandbox MaxIters int Approver Approver // nil = auto-approve all OnIteration func(Iteration) // observability hook } type Response struct { Content string ToolCalls []tools.Call Iterations int Duration time.Duration TokenUsage llm.TokenUsage } ``` **Algorithm:** ``` function RunAgent(userMessage, session): messages = append(session.Messages, userMessage) iteration = 0 while iteration < MaxIterations: iteration++ response = llm.Generate(messages, tools) if no tool calls: return response messages.append(assistantMsg(response)) for toolCall in response.ToolCalls: tool = registry.Get(toolCall.Name) # Approval gate if tool.Permission == Ask && !session.AutoApprove: if !cli.AskApproval(tool, toolCall): messages.append(denialMessage(toolCall)) continue # Sandbox validation if err := sandbox.ValidateToolCall(tool, toolCall); err != nil { messages.append(errorMessage(toolCall, err)) continue # Execute result, err := tool.Handler(ctx, toolCall.Arguments) # Truncate if needed result.Content = truncate(result.Content, MaxToolOutputBytes) messages.append(toolResultMessage(toolCall, result)) return ErrorResponse("max_iterations_exceeded") ``` **Parameters:** | Parameter | Default | Description | |---|---|---| | `MaxIterations` | 50 | Max cycles before cutting | | `MaxTokensPerSession` | 1,000,000 | Hard cap on consumed tokens | | `MaxToolOutputBytes` | 50KB | Truncate large outputs | | `ToolTimeout` | 30s | Default, overrideable per tool | **Termination conditions:** 1. โœ… LLM returns response without `ToolCalls` (normal case) 2. ๐Ÿ›‘ `MaxIterations` reached 3. ๐Ÿ’ฐ `MaxTokensPerSession` exceeded 4. โฑ๏ธ Global timeout 5. ๐Ÿšซ User aborts (`Ctrl+C` or `/stop`) **Full details:** [`pkg/agent/README.md`](../pkg/agent/README.md) ### 3.4 Persona System (`pkg/persona/`) System prompt assembler combining base + persona YAML + AGENTS.md. ```go type Persona struct { ID string Name string Tone string Style string Language string Constraints []string FewShot []llm.Message } type Loader interface { Load(ctx context.Context, configPath string) (Persona, error) Discover(ctx context.Context, workdir string) (Persona, error) } ``` **How the final system prompt is built:** ``` 1. Base prompt (hardcoded in the library) 2. Persona YAML (configurable) 3. AGENTS.md of the project (discovered in directory tree) 4. Working memory context (if there's compaction) ``` **AGENTS.md discovery:** Searches `./AGENTS.md`, walks up to parent, etc., concatenating all. Also includes `~/.config/rony/AGENTS.md` as global default. **Full details:** [`pkg/persona/README.md`](../pkg/persona/README.md) ### 3.5 Memory (`pkg/rag/`) Retrieval-Augmented Generation: persistent memory and semantic search. ```go type Memory interface { Add(ctx context.Context, fragment Fragment) error Search(ctx context.Context, query string, topK int) ([]Fragment, error) Forget(ctx context.Context, id string) error } type Fragment struct { ID string Content string Vector []float32 Metadata map[string]string Timestamp time.Time ProjectID string } type Embedder interface { Embed(ctx context.Context, text string) ([]float32, error) Dimensions() int } ``` **Memory types:** | Type | What it stores | Persistence | |---|---|---| | **Working** | Current session messages | RAM | | **Episodic** | Past events | Vector DB | | **Semantic** | Consolidated knowledge | Vector DB (curated) | | **Procedural** | Usage patterns | Vector DB (auto-learned) | **Supported backends:** - ChromaDB embedded (default) - Qdrant embedded - SQLite + sqlite-vec **Full details:** [`pkg/rag/README.md`](../pkg/rag/README.md) ### 3.6 Sandbox (`pkg/tools/sandbox/`) Kernel-level filesystem sandbox using `os.Root` (Go 1.24+). ```go type Sandbox struct { workspace *os.Root // Go 1.24+ configDir *os.Root // ~/.config/rony/ } func (s *Sandbox) Open(path string) (*os.File, error) func (s *Sandbox) Create(path string) (*os.File, error) func (s *Sandbox) ReadFile(path string) ([]byte, error) func (s *Sandbox) WriteFile(path string, data []byte, perm os.FileMode) error ``` **Why `os.Root` (Go 1.24+) is security-critical:** | Attack vector | `strings.HasPrefix` (naive) | `os.Root` | |---|---|---| | `../../../etc/passwd` | Blocked if abs path doesn't have prefix | Blocked by kernel | | Symlink inside workspace โ†’ `/etc/passwd` | Blocked only if we resolve manually | Blocked natively | | TOCTOU race condition | Possible | Impossible (kernel-checked) | | Path encoding (`%2e%2e`) | Not detected | Detected by stdlib | | Null bytes in path | Depends on OS | Handled by stdlib | > ๐Ÿ”’ That's why the library **requires Go 1.26+** (see [README ยง16.1.3.1](https://github.com/VictorVargas/rony-harness/blob/main/docs/architecture.md#16131)). ### 3.7 Configuration (`pkg/config/`) YAML loading with hierarchical precedence. ```go type Config struct { Model string Persona string Provider ProviderConfig Tools ToolPolicy Logging LoggingConfig Sandbox SandboxConfig } type Loader interface { Load(ctx context.Context, workdir string) (Config, error) } ``` **Precedence order (highest to lowest):** ``` flags CLI > env vars > ./rony.yaml > ~/.config/rony/config.yaml > defaults ``` **Full details:** [`pkg/config/README.md`](../pkg/config/README.md) --- ## ๐Ÿ”’ 4. Security Model ### 4.1 Covered threats | Threat | Library mitigation | |---|---| | Path traversal | `os.Root` sandbox | | Command injection | Command validation (delegated to products using bash tool) | | Resource exhaustion | `MaxIterations`, `MaxTokensPerSession`, `MaxToolOutputBytes` | | Prompt injection | Wrap external content in `` tags (Phase 2) | | Secret leakage | `Redact()` function for logs (Phase 2) | | Tool misuse | Approval gates + permission policies | ### 4.2 Principle of least privilege - Tools have `Permission: Allow | Ask | Deny` - Default is `Allow` only for read-only tools - `Ask` for tools that mutate state - `Deny` is rare, used for explicitly disabled tools ### 4.3 Defense in depth ``` Layer 1: Permission policy โ†’ rejects unauthorized tools Layer 2: Approval gate (Ask) โ†’ user confirms before execution Layer 3: Sandbox validation โ†’ paths within allowed roots Layer 4: Kernel enforcement (os.Root) โ†’ guarantee at OS level Layer 5: Output truncation โ†’ doesn't return huge outputs Layer 6: Secret redaction โ†’ doesn't expose secrets in logs/outputs ``` --- ## ๐Ÿ“ 5. Technical Specifications (SDD) ### 5.1 Core Functional Requirements | ID | Requirement | Implementation | |---|---|---| | LRF-001 | LLMClient interface | `pkg/llm/` | | LRF-002 | Multi-provider (OpenAI, Anthropic, Ollama, llama.cpp) | `pkg/llm/providers/` | | LRF-003 | Native streaming | `iter.Seq2` | | LRF-004 | Tool calling with JSON Schema | `pkg/tools/` | | LRF-005 | Agent loop with guardrails | `pkg/agent/` | | LRF-006 | Persona system + AGENTS.md | `pkg/persona/` | | LRF-007 | RAG memory | `pkg/rag/` | | LRF-008 | Filesystem sandbox (kernel) | `pkg/tools/sandbox/` | | LRF-009 | Configuration with precedence | `pkg/config/` | | LRF-010 | Approval gates | `pkg/agent/` | ### 5.2 Non-Functional Requirements | ID | Category | Target | |---|---|---| | LRNF-001 | Minimum Go version | 1.26 (due to os.Root) | | LRNF-002 | Test coverage | โ‰ฅ80% | | LRNF-003 | API stability | Strict semver | | LRNF-004 | Minimal dependencies | Only stdlib + provider SDKs | | LRNF-005 | Thread safety | All public API is safe for concurrent use | | LRNF-006 | Context propagation | Every callable takes `context.Context` | --- ## ๐Ÿงช 6. Testing ### 6.1 Mock LLM Server For deterministic tests, products can use `pkg/llm/mock`: ```go import "github.com/VictorVargas/rony-llm-agent/pkg/llm/mock" mockClient := mock.New(mock.Responses{ {Match: "hello", Response: "Hi! How are you?"}, {Match: "*", Response: "default"}, }) ``` ### 6.2 Mock Memory ```go import "github.com/VictorVargas/rony-llm-agent/pkg/rag/mock" memory := mock.NewMemory([]rag.Fragment{ {Content: "doc1", ProjectID: "test"}, }) ``` ### 6.3 Critical tests table (entire library) | Test | Package | Priority | |---|---|---| | `LLMClient.Generate` returns valid response | `pkg/llm/` | High | | `LLMClient.Stream` emits chunks in order | `pkg/llm/` | High | | `ToolRegistry.Register/Get/List` | `pkg/tools/` | High | | Agent loop terminates without tool calls | `pkg/agent/` | High | | Agent loop terminates at max iterations | `pkg/agent/` | High | | Path sandbox rejects `../../../etc/passwd` | `pkg/tools/sandbox/` | High | | Persona loader from YAML | `pkg/persona/` | High | | AGENTS.md discovery (walks up directories) | `pkg/persona/` | Medium | | Config precedence (env > file > defaults) | `pkg/config/` | High | | Memory Search returns top-K by similarity | `pkg/rag/` | High | --- ## ๐Ÿ“š 7. Code conventions ### 7.1 Naming - **Packages:** lowercase, singular (`agent`, `tools`, `llm`) - **Interfaces:** end in noun or capability (`Loop`, `LLMClient`, `Embedder`) - **Errors:** `ErrXXX` for sentinels, wrapped with `%w` - **Constructors:** `New` for main constructor, `NewXxx` for variants ### 7.2 Errors ```go // Sentinel errors var ( ErrToolNotFound = errors.New("tool not found") ErrSandboxViolation = errors.New("path outside allowed roots") ) // Wrapping if err != nil { return fmt.Errorf("loading persona %s: %w", name, err) } ``` ### 7.3 Context Every function that can block takes `ctx context.Context` as first parameter: ```go func (l *Loop) Run(ctx context.Context, input string) (Response, error) func (m *Memory) Search(ctx context.Context, query string, topK int) ([]Fragment, error) ``` --- ## ๐Ÿ”„ 8. Versioning - **Strict semver** (`vMAJOR.MINOR.PATCH`) - **MAJOR:** breaking changes in `pkg/` (interfaces, signatures, public types) - **MINOR:** new features, new packages, new adapters - **PATCH:** bugfixes Private adapters (`pkg/llm/providers/openai/`) can change without a MAJOR bump if the `LLMClient` interface doesn't change. --- ## ๐Ÿ“– 9. References - **Go 1.26 release notes:** https://go.dev/doc/go1.26 - **`os.Root` documentation:** https://pkg.go.dev/os#Root - **`iter.Seq2` documentation:** https://pkg.go.dev/iter - **Hexagonal Architecture (Alistair Cockburn):** https://alistair.cockburn.us/hexagonal-architecture/ - **DDD (Eric Evans, 2003):** Domain-Driven Design - **ReAct Pattern (Yao et al., 2022):** https://arxiv.org/abs/2210.03629 --- ## ๐Ÿ”— Related documents - [`README.md`](./README.md) โ€” Library overview - [`components.md`](./components.md) โ€” Detailed reference per package - [`phase2.md`](./phase2.md) โ€” Advanced features (MCP, full RAG, Skills, etc.) - [Products that use this library](https://github.com/VictorVargas)