- Add optional history parameter to Run/RunStream for conversation context - Add Reasoning/ReasoningDelta fields to completion and stream types - Update llama.cpp adapter to propagate reasoning content from responses - Default persona language now adapts to the user's language dynamically
20 KiB
🏗️ rony-llm-agent — Architecture
Versión: 1.0
Autor: Victor Hugo Vargas
Fecha: 2026-06-28
Estado: Especificación de arquitectura de la librería
📚 Otros documentos:
README.md— Overview de la libreríacomponents.md— Referencia detallada por paquetephase2.md— Features avanzadas (MCP, RAG completo, Skills, etc.)
🎯 1. Visión de la Librería
1.1 ¿Qué es rony-llm-agent?
Una librería Go que provee toda la lógica genérica y reusable para construir agentes basados en LLMs. No es un producto final — es el cimiento sobre el cual se construyen productos específicos.
Productos que la consumen:
VictorVargas/rony-harness— AI agent harness para desarrollo de software (TUI CLI)VictorVargas/rony-chat-bot— Chatbot HTTP para portfolios y sitios web
1.2 Principios de diseño
- Reusable, no opinionated. No fuerza un tipo de UI, deployment, ni use case.
- Hexagonal puro. Ports & adapters — toda dependencia externa va detrás de una interfaz.
- Streaming-first. Usa
iter.Seq2de Go 1.23+ para streaming natural sin callbacks. - Seguridad por defecto. Sandbox de paths con
os.Root(Go 1.24+) en filesystem. - Zero magic. No reflection, no codegen, no DSLs. Go idiomático y explícito.
- Configurable por YAML. Sin surprises, todo lo que afecta comportamiento es declarativo.
1.3 Lo que NO es
- ❌ No es un CLI — eso es responsabilidad del producto (ej.
harness) - ❌ No es un servidor HTTP — eso es responsabilidad del producto (ej.
chat-bot) - ❌ No fuerza un modelo o provider específico — adapters intercambiables
- ❌ No tiene estado persistente propio — eso lo maneja cada producto
🏗️ 2. Arquitectura Hexagonal (Ports & Adapters)
┌─────────────────────────────────────────────────────────────────┐
│ Productos que consumen │
│ (harness, chat-bot, dealer-bot, etc.) │
└──────────────────────────┬──────────────────────────────────────┘
│ usan interfaces públicas
▼
┌─────────────────────────────────────────────────────────────────┐
│ rony-llm-agent (pkg/ — API pública) │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ agent │ │ llm │ │ persona │ │ tools │ ... │
│ └────┬────┘ └────┬────┘ └─────────┘ └─────────┘ │
│ │ │ │
│ │ usa ports (interfaces) │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ PORTS (interfaces puras) │ │
│ │ LLMClient, VectorDB, Embedder, ToolRegistry, ... │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ implementadas por adapters │
└──────────────────────────┼──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Adapters (en pkg/*/internal/) │
│ • providers/anthropic, openai, ollama, llamacpp │
│ • backends/chroma, qdrant, sqlite │
│ • embeddings/ollama, onnx │
└─────────────────────────────────────────────────────────────────┘
2.1 Capas
Ports (interfaces puras)
- Definen QUÉ hace el dominio, no CÓMO
- Son contratos sin implementación
- Permiten sustituir adapters sin tocar lógica de negocio
Domain (pkg/)
- Contiene toda la lógica reusable
- NO depende de nada externo
- Solo importa interfaces de sus propios ports
Adapters (internals de cada adapter)
- Implementaciones concretas de los ports
- Aquí viven las dependencias externas (HTTP clients, filesystem, DB drivers)
🧠 3. Core Interfaces
3.1 LLMClient (pkg/llm/)
Abstracción multi-provider. Los productos nunca tocan un SDK de provider directamente — siempre pasan por esta interfaz.
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 // sólo en chunk final
}
type TokenUsage struct {
InputTokens int
OutputTokens int
TotalTokens int
}
type ProviderCapabilities struct {
SupportsTools bool
SupportsVision bool
SupportsJSON bool
MaxContextWindow int
}
Ver detalles completos: pkg/llm/README.md
3.2 Tool System (pkg/tools/)
Función invocable por el LLM con JSON Schema, permisos, y sandbox.
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 para el 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 // opcional: 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
}
Ver detalles completos: pkg/tools/README.md
3.3 Agent Loop (pkg/agent/)
Bucle iterativo entre LLM y ejecución de tools. Es el "cerebro" que orquesta todo.
type Loop interface {
Run(ctx context.Context, input string, history ...Message) (Response, error)
RunStream(ctx context.Context, input string, history ...Message) 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
}
Algoritmo:
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")
Parámetros:
| Parámetro | Default | Descripción |
|---|---|---|
MaxIterations |
50 | Máximo de ciclos antes de cortar |
MaxTokensPerSession |
1,000,000 | Hard cap de tokens consumidos |
MaxToolOutputBytes |
50KB | Truncar outputs grandes |
ToolTimeout |
30s | Default, overrideable por tool |
Termination conditions:
- ✅ LLM devuelve respuesta sin
ToolCalls(caso normal) - 🛑
MaxIterationsalcanzado - 💰
MaxTokensPerSessionexcedido - ⏱️ Timeout global
- 🚫 Usuario aborta (
Ctrl+Co/stop)
Ver detalles completos: pkg/agent/README.md
3.4 Persona System (pkg/persona/)
Ensamblador de system prompts combinando base + persona YAML + AGENTS.md.
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)
}
Cómo se construye el system prompt final:
1. Base prompt (hardcoded en la librería)
2. Persona YAML (configurable)
3. AGENTS.md del proyecto (descubierto en árbol de directorios)
4. Working memory context (si hay compaction)
AGENTS.md discovery: Busca ./AGENTS.md, sube al padre, etc., concatenando todos. También incluye ~/.config/rony/AGENTS.md como default global.
Ver detalles completos: pkg/persona/README.md
3.5 Memory (pkg/rag/)
Retrieval-Augmented Generation: memoria persistente y búsqueda semántica.
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
}
Tipos de memoria:
| Tipo | Qué guarda | Persistencia |
|---|---|---|
| Working | Mensajes de la sesión actual | RAM |
| Episodic | Eventos pasados | Vector DB |
| Semantic | Conocimiento consolidado | Vector DB (curado) |
| Procedural | Patrones de uso | Vector DB (auto-aprendido) |
Backends soportados:
- ChromaDB embedded (default)
- Qdrant embedded
- SQLite + sqlite-vec
Ver detalles completos: pkg/rag/README.md
3.6 Sandbox (pkg/tools/sandbox/)
Sandbox de filesystem a nivel kernel usando os.Root (Go 1.24+).
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
Por qué os.Root (Go 1.24+) es security-critical:
| Vector de ataque | strings.HasPrefix (naive) |
os.Root |
|---|---|---|
../../../etc/passwd |
Bloqueado si abs path no tiene prefix | Bloqueado por kernel |
Symlink dentro de workspace → /etc/passwd |
Bloqueado solo si resolvemos manualmente | Bloqueado nativamente |
| Race condition TOCTOU | Posible | Imposible (kernel-checked) |
Path encoding (%2e%2e) |
No detectado | Detectado por stdlib |
| Null bytes en path | Depende del OS | Manejado por stdlib |
🔒 Por eso la librería requiere Go 1.26+ (ver README §16.1.3.1).
3.7 Configuration (pkg/config/)
Carga YAML con precedencia jerárquica.
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)
}
Orden de precedencia (mayor a menor):
flags CLI > env vars > ./rony.yaml > ~/.config/rony/config.yaml > defaults
Ver detalles completos: pkg/config/README.md
🔒 4. Modelo de Seguridad
4.1 Amenazas cubiertas
| Amenaza | Mitigación en la librería |
|---|---|
| Path traversal | os.Root sandbox |
| Command injection | Validación de comandos (delegado a productos que usan bash tool) |
| Resource exhaustion | MaxIterations, MaxTokensPerSession, MaxToolOutputBytes |
| Prompt injection | Wrap de contenido externo en tags <untrusted_content> (Phase 2) |
| Secret leakage | Redact() función para logs (Phase 2) |
| Tool misuse | Approval gates + permission policies |
4.2 Principio de mínimo privilegio
- Tools tienen
Permission: Allow | Ask | Deny - Default es
Allowsolo para tools read-only Askpara tools que mutan estadoDenyes raro, usado para tools deshabilitados explícitamente
4.3 Capas de defensa (Defense in depth)
Capa 1: Permission policy → rechaza tools no autorizados
Capa 2: Approval gate (Ask) → usuario confirma antes de ejecutar
Capa 3: Sandbox validation → paths dentro de allowed roots
Capa 4: Kernel enforcement (os.Root) → garantía a nivel OS
Capa 5: Output truncation → no devuelve outputs enormes
Capa 6: Secret redaction → no expone secrets en logs/outputs
📐 5. Especificaciones Técnicas (SDD)
5.1 Requisitos Funcionales Core
| ID | Requisito | Implementación |
|---|---|---|
| LRF-001 | LLMClient interface | pkg/llm/ |
| LRF-002 | Multi-provider (OpenAI, Anthropic, Ollama, llama.cpp) | pkg/llm/providers/ |
| LRF-003 | Streaming nativo | iter.Seq2 |
| LRF-004 | Tool calling con JSON Schema | pkg/tools/ |
| LRF-005 | Agent loop con guardrails | pkg/agent/ |
| LRF-006 | Persona system + AGENTS.md | pkg/persona/ |
| LRF-007 | RAG memory | pkg/rag/ |
| LRF-008 | Sandbox de filesystem (kernel) | pkg/tools/sandbox/ |
| LRF-009 | Configuration con precedencia | pkg/config/ |
| LRF-010 | Approval gates | pkg/agent/ |
5.2 Requisitos No Funcionales
| ID | Categoría | Target |
|---|---|---|
| LRNF-001 | Go version mínimo | 1.26 (por os.Root) |
| LRNF-002 | Cobertura de tests | ≥80% |
| LRNF-003 | API stability | Semver estricto |
| LRNF-004 | Dependencies mínimas | Solo stdlib + SDKs de provider |
| LRNF-005 | Thread safety | Toda API pública es safe para uso concurrente |
| LRNF-006 | Context propagation | Toda llamada toma context.Context |
🧪 6. Testing
6.1 Mock LLM Server
Para tests deterministas, los productos pueden usar pkg/llm/mock:
import "github.com/VictorVargas/rony-llm-agent/pkg/llm/mock"
mockClient := mock.New(mock.Responses{
{Match: "hola", Response: "¡Hola! ¿Cómo estás?"},
{Match: "*", Response: "default"},
})
6.2 Mock Memory
import "github.com/VictorVargas/rony-llm-agent/pkg/rag/mock"
memory := mock.NewMemory([]rag.Fragment{
{Content: "doc1", ProjectID: "test"},
})
6.3 Tabla de tests críticos (toda la librería)
| Test | Paquete | Prioridad |
|---|---|---|
LLMClient.Generate retorna respuesta válida |
pkg/llm/ |
Alta |
LLMClient.Stream emite chunks en orden |
pkg/llm/ |
Alta |
ToolRegistry.Register/Get/List |
pkg/tools/ |
Alta |
| Agent loop termina sin tool calls | pkg/agent/ |
Alta |
| Agent loop termina en max iterations | pkg/agent/ |
Alta |
Path sandbox rechaza ../../../etc/passwd |
pkg/tools/sandbox/ |
Alta |
| Persona loader desde YAML | pkg/persona/ |
Alta |
| AGENTS.md discovery (sube directorios) | pkg/persona/ |
Media |
| Config precedence (env > file > defaults) | pkg/config/ |
Alta |
| Memory Search retorna top-K por similitud | pkg/rag/ |
Alta |
📚 7. Convenciones de código
7.1 Naming
- Packages: lowercase, singular (
agent,tools,llm) - Interfaces: terminan en sustantivo o capability (
Loop,LLMClient,Embedder) - Errors:
ErrXXXpara sentinels, wrapped con%w - Constructors:
Newpara constructor principal,NewXxxpara variantes
7.2 Errors
// 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
Toda función que pueda bloquear toma ctx context.Context como primer parámetro:
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. Versionado
- Semver estricto (
vMAJOR.MINOR.PATCH) - MAJOR: breaking changes en
pkg/(interfaces, signatures, tipos públicos) - MINOR: nuevas features, nuevos paquetes, nuevos adapters
- PATCH: bugfixes
Los adapters privados (pkg/llm/providers/openai/) pueden cambiar sin bump de MAJOR si la interfaz LLMClient no cambia.
📖 9. Referencias
- Go 1.26 release notes: https://go.dev/doc/go1.26
os.Rootdocumentation: https://pkg.go.dev/os#Rootiter.Seq2documentation: 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
🔗 Documentos relacionados
README.md— Overview de la libreríacomponents.md— Referencia detallada por paquetephase2.md— Features avanzadas (MCP, RAG completo, Skills, etc.)- Productos que usan esta librería