rony-llm-agent/docs/architecture.md

590 lines
20 KiB
Markdown
Raw Normal View History

# 🏗️ Rony LLM Agent — Architecture
2026-06-29 00:25:45 +00:00
**Version:** 1.0
**Author:** Victor Hugo Vargas
**Date:** 2026-06-28
**Status:** Library architecture specification
2026-06-29 00:25:45 +00:00
> 📚 **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.
2026-06-29 00:25:45 +00:00
---
## 🎯 1. Library Vision
2026-06-29 00:25:45 +00:00
### 1.1 What is go-llm-agent?
2026-06-29 00:25:45 +00:00
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.
2026-06-29 00:25:45 +00:00
**Products that consume it:**
2026-06-29 00:25:45 +00:00
- [`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
2026-06-29 00:25:45 +00:00
### 1.2 Design principles
2026-06-29 00:25:45 +00:00
- **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.
2026-06-29 00:25:45 +00:00
### 1.3 What it is NOT
2026-06-29 00:25:45 +00:00
- ❌ 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
2026-06-29 00:25:45 +00:00
---
## 🏗️ 2. Hexagonal Architecture (Ports & Adapters)
2026-06-29 00:25:45 +00:00
```
┌─────────────────────────────────────────────────────────────────┐
│ Products that consume │
│ (rony-harness, rony-chat-bot, dealer-bot, etc.) │
2026-06-29 00:25:45 +00:00
└──────────────────────────┬──────────────────────────────────────┘
│ use public interfaces
2026-06-29 00:25:45 +00:00
┌─────────────────────────────────────────────────────────────────┐
│ go-llm-agent (pkg/ — public API) │
2026-06-29 00:25:45 +00:00
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ agent │ │ llm │ │ persona │ │ tools │ ... │
2026-06-29 00:25:45 +00:00
│ └────┬────┘ └────┬────┘ └─────────┘ └─────────┘ │
│ │ │ │
│ │ uses ports (interfaces) │
2026-06-29 00:25:45 +00:00
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ PORTS (pure interfaces) │ │
2026-06-29 00:25:45 +00:00
│ │ LLMClient, VectorDB, Embedder, ToolRegistry, ... │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ implemented by adapters │
2026-06-29 00:25:45 +00:00
└──────────────────────────┼──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Adapters (in pkg/*/internal/) │
2026-06-29 00:25:45 +00:00
│ • providers/anthropic, openai, ollama, llamacpp │
│ • backends/chroma, qdrant, sqlite │
│ • embeddings/ollama, onnx │
└─────────────────────────────────────────────────────────────────┘
```
### 2.1 Layers
2026-06-29 00:25:45 +00:00
**Ports (pure interfaces)**
- Define WHAT the domain does, not HOW
- Contracts without implementation
- Allow swapping adapters without touching business logic
2026-06-29 00:25:45 +00:00
**Domain (pkg/)**
- Contains all reusable logic
- Does NOT depend on anything external
- Only imports interfaces from its own ports
2026-06-29 00:25:45 +00:00
**Adapters (internals of each adapter)**
- Concrete implementations of ports
- External dependencies live here (HTTP clients, filesystem, DB drivers)
2026-06-29 00:25:45 +00:00
---
## 🧠 3. Core Interfaces
### 3.1 LLMClient (`pkg/llm/`)
Multi-provider abstraction. Products never touch a provider SDK directly — they always go through this interface.
2026-06-29 00:25:45 +00:00
```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
2026-06-29 00:25:45 +00:00
}
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)
2026-06-29 00:25:45 +00:00
### 3.2 Tool System (`pkg/tools/`)
Function callable by the LLM with JSON Schema, permissions, and sandbox.
2026-06-29 00:25:45 +00:00
```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
2026-06-29 00:25:45 +00:00
}
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
2026-06-29 00:25:45 +00:00
}
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)
2026-06-29 00:25:45 +00:00
### 3.3 Agent Loop (`pkg/agent/`)
Iterative loop between LLM and tool execution. It's the "brain" that orchestrates everything.
2026-06-29 00:25:45 +00:00
```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:**
2026-06-29 00:25:45 +00:00
```
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:**
2026-06-29 00:25:45 +00:00
| Parameter | Default | Description |
2026-06-29 00:25:45 +00:00
|---|---|---|
| `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 |
2026-06-29 00:25:45 +00:00
**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`)
2026-06-29 00:25:45 +00:00
**Full details:** [`pkg/agent/README.md`](../pkg/agent/README.md)
2026-06-29 00:25:45 +00:00
### 3.4 Persona System (`pkg/persona/`)
System prompt assembler combining base + persona YAML + AGENTS.md.
2026-06-29 00:25:45 +00:00
```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:**
2026-06-29 00:25:45 +00:00
```
1. Base prompt (hardcoded in the library)
2026-06-29 00:25:45 +00:00
2. Persona YAML (configurable)
3. AGENTS.md of the project (discovered in directory tree)
4. Working memory context (if there's compaction)
2026-06-29 00:25:45 +00:00
```
**AGENTS.md discovery:** Searches `./AGENTS.md`, walks up to parent, etc., concatenating all. Also includes `~/.config/rony/AGENTS.md` as global default.
2026-06-29 00:25:45 +00:00
**Full details:** [`pkg/persona/README.md`](../pkg/persona/README.md)
2026-06-29 00:25:45 +00:00
### 3.5 Memory (`pkg/rag/`)
Retrieval-Augmented Generation: persistent memory and semantic search.
2026-06-29 00:25:45 +00:00
```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:**
2026-06-29 00:25:45 +00:00
| Type | What it stores | Persistence |
2026-06-29 00:25:45 +00:00
|---|---|---|
| **Working** | Current session messages | RAM |
| **Episodic** | Past events | Vector DB |
| **Semantic** | Consolidated knowledge | Vector DB (curated) |
| **Procedural** | Usage patterns | Vector DB (auto-learned) |
2026-06-29 00:25:45 +00:00
**Supported backends:**
2026-06-29 00:25:45 +00:00
- ChromaDB embedded (default)
- Qdrant embedded
- SQLite + sqlite-vec
**Full details:** [`pkg/rag/README.md`](../pkg/rag/README.md)
2026-06-29 00:25:45 +00:00
### 3.6 Sandbox (`pkg/tools/sandbox/`)
Kernel-level filesystem sandbox using `os.Root` (Go 1.24+).
2026-06-29 00:25:45 +00:00
```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:**
2026-06-29 00:25:45 +00:00
| Attack vector | `strings.HasPrefix` (naive) | `os.Root` |
2026-06-29 00:25:45 +00:00
|---|---|---|
| `../../../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 |
2026-06-29 00:25:45 +00:00
> 🔒 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)).
2026-06-29 00:25:45 +00:00
### 3.7 Configuration (`pkg/config/`)
YAML loading with hierarchical precedence.
2026-06-29 00:25:45 +00:00
```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):**
2026-06-29 00:25:45 +00:00
```
flags CLI > env vars > ./rony.yaml > ~/.config/rony/config.yaml > defaults
```
**Full details:** [`pkg/config/README.md`](../pkg/config/README.md)
2026-06-29 00:25:45 +00:00
---
## 🔒 4. Security Model
2026-06-29 00:25:45 +00:00
### 4.1 Covered threats
2026-06-29 00:25:45 +00:00
| Threat | Library mitigation |
2026-06-29 00:25:45 +00:00
|---|---|
| Path traversal | `os.Root` sandbox |
| Command injection | Command validation (delegated to products using bash tool) |
2026-06-29 00:25:45 +00:00
| Resource exhaustion | `MaxIterations`, `MaxTokensPerSession`, `MaxToolOutputBytes` |
| Prompt injection | Wrap external content in `<untrusted_content>` tags (Phase 2) |
| Secret leakage | `Redact()` function for logs (Phase 2) |
2026-06-29 00:25:45 +00:00
| Tool misuse | Approval gates + permission policies |
### 4.2 Principle of least privilege
2026-06-29 00:25:45 +00:00
- 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
2026-06-29 00:25:45 +00:00
### 4.3 Defense in depth
2026-06-29 00:25:45 +00:00
```
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
2026-06-29 00:25:45 +00:00
```
---
## 📐 5. Technical Specifications (SDD)
2026-06-29 00:25:45 +00:00
### 5.1 Core Functional Requirements
2026-06-29 00:25:45 +00:00
| ID | Requirement | Implementation |
2026-06-29 00:25:45 +00:00
|---|---|---|
| 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/` |
2026-06-29 00:25:45 +00:00
| 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/` |
2026-06-29 00:25:45 +00:00
| LRF-010 | Approval gates | `pkg/agent/` |
### 5.2 Non-Functional Requirements
2026-06-29 00:25:45 +00:00
| ID | Category | Target |
2026-06-29 00:25:45 +00:00
|---|---|---|
| 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` |
2026-06-29 00:25:45 +00:00
---
## 🧪 6. Testing
### 6.1 Mock LLM Server
For deterministic tests, products can use `pkg/llm/mock`:
2026-06-29 00:25:45 +00:00
```go
2026-06-29 06:22:15 +00:00
import "github.com/VictorVargas/rony-llm-agent/pkg/llm/mock"
2026-06-29 00:25:45 +00:00
mockClient := mock.New(mock.Responses{
{Match: "hello", Response: "Hi! How are you?"},
2026-06-29 00:25:45 +00:00
{Match: "*", Response: "default"},
})
```
### 6.2 Mock Memory
```go
2026-06-29 06:22:15 +00:00
import "github.com/VictorVargas/rony-llm-agent/pkg/rag/mock"
2026-06-29 00:25:45 +00:00
memory := mock.NewMemory([]rag.Fragment{
{Content: "doc1", ProjectID: "test"},
})
```
### 6.3 Critical tests table (entire library)
2026-06-29 00:25:45 +00:00
| Test | Package | Priority |
2026-06-29 00:25:45 +00:00
|---|---|---|
| `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 |
2026-06-29 00:25:45 +00:00
---
## 📚 7. Code conventions
2026-06-29 00:25:45 +00:00
### 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
2026-06-29 00:25:45 +00:00
### 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:
2026-06-29 00:25:45 +00:00
```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
2026-06-29 00:25:45 +00:00
- **Strict semver** (`vMAJOR.MINOR.PATCH`)
- **MAJOR:** breaking changes in `pkg/` (interfaces, signatures, public types)
- **MINOR:** new features, new packages, new adapters
2026-06-29 00:25:45 +00:00
- **PATCH:** bugfixes
Private adapters (`pkg/llm/providers/openai/`) can change without a MAJOR bump if the `LLMClient` interface doesn't change.
2026-06-29 00:25:45 +00:00
---
## 📖 9. References
2026-06-29 00:25:45 +00:00
- **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
2026-06-29 00:25:45 +00:00
- [`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)