rony-llm-agent/docs/architecture.es.md

20 KiB

🏗️ rony-llm-agent — Architecture

🌐 Idioma: English | Español

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ía
  • components.md — Referencia detallada por paquete
  • phase2.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:

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.Seq2 de 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) (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
}

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:

  1. LLM devuelve respuesta sin ToolCalls (caso normal)
  2. 🛑 MaxIterations alcanzado
  3. 💰 MaxTokensPerSession excedido
  4. ⏱️ Timeout global
  5. 🚫 Usuario aborta (Ctrl+C o /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 Allow solo para tools read-only
  • Ask para tools que mutan estado
  • Deny es 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: ErrXXX para sentinels, wrapped con %w
  • Constructors: New para constructor principal, NewXxx para 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


🔗 Documentos relacionados