API Reference: Agents

API Reference: Agents

agents/loop.py - AgentLoop

Purpose: Orchestrates the ReAct agent loop

AgentLoop(..., memory_retriever: MemoryRetrieverProtocol, skills_retriever: SkillsRetrieverProtocol | None = None, ...)

class AgentLoop:
    def __init__(
        self,
        llm_client: LocalLLMClient,
        context_builder: ContextBuilder,
        session_store: SessionStore,
        memory_retriever: MemoryRetrieverProtocol,
        tool_executor: ToolExecutor,
        app_config: AppConfig,
        working_memory_compressor: WorkingMemoryCompressor | None = None,
        skills_retriever: SkillsRetrieverProtocol | None = None,
        skills_on_event: AgentEventCallback | None = None,
        ...
    )
    
    async def run(
        self,
        user_id: str,
        session_id: str,
        user_input: str,
        agent_config: AgentConfig,
        *,
        resume: bool = False,
        memory_scope: str | None = None,
        trace_id: str | None = None,
        cancel_event: asyncio.Event | None = None,
        on_event: Callable[..., Awaitable[None]] | None = None,
    ) -> AgentRunResult:
        """Run the agent ReAct loop.

        Returns:
            AgentRunResult with `response`, `trace_id`, and `warnings`
        """

Agent Loop Flow

1. SessionStore.append(user message)
2. memory_retriever.retrieve(query, user_id, categories) — no-op when `NullMemoryRetriever`
3. skills_retriever.retrieve(query, user_id, max_tokens) — when `[skills]` enabled
4. Load L1 working memory (when working_memory_enabled and compressor wired)
5. history = SessionStore.load(session_id, user_id, limit=history_limit)
6. For iteration in range(max_steps):
   a. context = context_builder.build(
        system_prompt, tools, memory=RAG, skills=skills_context, history, user_input,
        working_memory=L1,
    )
   b. response = llm_client.chat(context)
   c. If tool call: ToolExecutor.execute → append observation to history
   d. Else: finalize
7. SessionStore.append(assistant response)
8. WorkingMemoryCompressor.update_summary(run_messages)  # when enabled
9. SessionStore.compact(keep_last) if message count > history_limit

agents/tool_parser.py - ToolParser

Purpose: Parses tool calls from LLM responses

ToolParser()

class ToolParser:
    def __init__(self)
    
    @staticmethod
    def parse_tool_call(text: str) -> tuple[str, dict] | None
        """Parse tool call from text.
        
        Accepts:
        - Raw JSON: {"tool": "name", "args": {...}}
        - Fenced code: ```json\n{"tool": "name", "args": {...}}\n```
        
        Returns:
            (tool_name, args_dict) or None if not a tool call
        """

Parsing Rules

Valid formats:

{"tool": "mcp_filesystem_read_file", "args": {"path": "test.txt"}}
{
  "tool": "mcp_filesystem_read_file",
  "args": {
    "path": "test.txt"
  }
}

Invalid formats:

mcp_filesystem_read_file with path="test.txt"
{"tool": "mcp_filesystem_read_file" path: "test.txt"}  # Invalid JSON

Fallback:
If parsing fails with non-JSON text, treat as final answer (no tool call). Salvageable JSON failures re-prompt the model.


agents/registry.py - Tool Executor

Purpose: Manages tool registration and execution

ToolExecutor(tools: list[Tool], timeout: float = 30.0, ...)

class ToolExecutor:
    def __init__(
        self,
        tools: list[Tool],
        timeout: float = 30.0,
        max_concurrent: int = 5,
        *,
        memory_manager: MemoryManager | None = None,
        tool_result_inline_limit: int = 6144,
    )
    
    async def execute(
        self,
        name: str,
        args: dict,
        *,
        allowed: frozenset[str] | None = None,
    ) -> ToolResult
        """Execute a tool. When `allowed` is set, rejects tools not in the set."""
    
    def list_tools(self, allowed: frozenset[str] | None = None) -> str
        """List available tools; filtered by `allowed` when provided."""

Tool Dataclass

@dataclass
class Tool:
    name: str                    # Unique tool name
    description: str             # Human-readable description
    input_schema: dict           # JSON Schema for validation
    func: Callable              # Async function to execute

Native and MCP tools

Capability tools come from host mcp.json as mcp_{server}_{tool} (auto-granted). Core registers only meta/recovery tools:

Tool Condition
report_inability Always
load_message app.message_archive_enabled
load_tool_result [memory] + memory.enabled
search_tool_results memory + memory.tool_result_search_enabled

See docs/tools.md and docs/mcp.md. Built-in filesystem/shell/git/HTTP packages were removed — do not reintroduce them.


core/protocols.py — Memory and skills retrievers

Default wiring uses null implementations; optional packages replace them when installed and enabled.

class MemoryRetrieverProtocol(Protocol):
    async def retrieve(
        self, query: str, user_id: str, categories: list[str], max_tokens: int
    ) -> str: ...

class NullMemoryRetriever:
    async def retrieve(...) -> str:
        return ""

class SkillsRetrieverProtocol(Protocol):
    async def retrieve(self, query: str, user_id: str, max_tokens: int) -> str: ...

class NullSkillsRetriever:
    async def retrieve(...) -> str:
        return ""

Real MemoryRetriever lives in packages/local-ai-memory/memory/retriever.py when [memory] is enabled. Real SkillsRetriever lives in packages/local-ai-skills/local_ai_skills/store.py when [skills] is enabled.


memory/retriever.py - MemoryRetriever (optional [memory])

Purpose: 3-stage memory retrieval (BM25/hybrid → LLM select → load)

MemoryRetriever(memory_store: MemoryStore, llm_client: LocalLLMClient, token_counter: TokenCounter)

class MemoryRetriever:
    def __init__(
        self,
        memory_store: MemoryStore,
        llm_client: LocalLLMClient,
        token_counter: TokenCounter,
    )
    
    async def retrieve(
        self,
        query: str,
        user_id: str,
        categories: list[str],
        max_tokens: int = 2000,
    ) -> str
        """Retrieve relevant memory content.
        
        Uses 3-stage retrieval:
        1. BM25 or hybrid index search (no LLM)
        2. LLM file selection
        3. Content loading within token budget
        
        Args:
            query: User query
            user_id: User identifier
            categories: Memory categories to search
            max_tokens: Maximum tokens to load
            
        Returns:
            Concatenated memory content
        """

3-Stage Retrieval

Stage 1: BM25 / Hybrid

async def bm25_search(query: str, top_n: int = 5) -> list[str]:
    """Rank index entries via BM25Ranker. fast_filter() is an alias."""

async def hybrid_search(query: str, top_n: int = 5, bm25_weight: float = 0.5) -> list[str]:
    """BM25 + embedding vectors fused via RRF when memory.search_mode is hybrid."""

Stage 2: LLM File Selection

async def select_files(query: str, candidates: list[str]) -> list[str]:
    """LLM selects most relevant files from candidates.
    
    Sends prompt with candidate metadata to LLM.
    LLM returns JSON array of filenames to load.
    
    Args:
        query: User query
        candidates: List of candidate filenames
        
    Returns:
        List of selected filenames (max 5)
    """

MemoryStore Interface

class MemoryStore:
    def __init__(self, guard: DirectoryGuard)
    
    def list_user_files(self, user_id: str) -> list[str]
        """List all user files in category."""
    
    def read_file(self, path: str) -> str
        """Read file content."""
    
    def write_file(self, path: str, content: str) -> str
        """Write file content."""
    
    def fast_filter(self, query: str, top_n: int = 5) -> list[str]
        """Fast keyword-based filtering."""
    
    def populate_index_from_files(
        self,
        user_id: str,
        categories: list[str],
        llm_client: LocalLLMClient,
    )
        """Build index from files."""

Index Structure

{
  "index_version": 3,
  "last_updated": 1686234567.123,
  "files": [
    {
      "filename": "user1_notes.md",
      "title": "Personal Notes",
      "description": "Collection of personal notes...",
      "keywords": ["notes", "personal", "reminders"]
    }
  ]
}

tools/executor.py - Tool Execution

ToolExecutor.validate()

def validate(
    tool: Tool,
    args: dict,
) -> tuple[bool, str | None]
    """Validate tool arguments against schema.
    
    Args:
        tool: Tool definition
        args: Arguments to validate
        
    Returns:
        (is_valid, error_message)
    """

ToolExecutor.execute()

async def execute(
    self,
    name: str,
    args: dict,
) -> str
    """Execute tool with validation and timeout.
    
    1. Lookup tool by name
    2. Validate args against schema
    3. Execute tool.func(**args) with 30s timeout
    4. Wrap exceptions in safe messages
    5. Return result string
    """

Error Wrapping

try:
    result = await asyncio.wait_for(tool.func(**args), timeout=30.0)
except asyncio.TimeoutError:
    result = f"Error: tool '{name}' timed out after 30s"
except Exception as exc:
    log_event("ERROR_STACKTRACE", f"tool={name} error={exc}")
    result = f"Error: tool '{name}' failed during execution"

config/loader.py - Configuration

Config

@dataclass
class Config:
    llama: LlamaConfig
    workspace: str | None  # optional; skills storage only
    agents: dict[str, AgentConfig]
    app: AppConfig
    memory: MemoryConfig
    ensemble: EnsembleConfig
    skills: SkillsConfig
    mcp: McpConfig  # servers loaded from mcp.json only

Capability tools come from mcp.json — see docs/mcp.md. Native tools:
report_inability, load_message, load_tool_result, search_tool_results.

McpConfig

@dataclass
class McpConfig:
    enabled: bool = False
    servers: dict[str, McpServerConfig] = field(default_factory=dict)  # from mcp.json
    external_path: str | None = None

LlamaConfig

@dataclass
class LlamaConfig:
    binary_path: str
    models: dict[str, LlamaModelConfig]
    os_reserve_mb: int = 4096
    disk_reserve_mb: int = 4096

LlamaModelConfig

@dataclass
class LlamaModelConfig:
    path: str
    port: int = 8080
    ctx_size: int = 4096
    gpu_layers: int = 32
    n_batch: int = 1024
    n_ubatch: int = 256
    threads: int = 4
    threads_batch: int = 4
    flash_attn: str = "auto"
    cache_type_k: str = "f16"
    cache_type_v: str = "f16"
    optimization_mode: str = "balanced"

AgentConfig

@dataclass
class AgentConfig:
    tools: list[str]  # explicit native names; usually []; mcp_* auto-granted
    prompt_files: list[str] = field(default_factory=list)
    model: str | None = None  # llama.models key when ensemble.enabled

prompt_files: host-owned Jinja markdown stack. Empty ⇒ mode-aware bundled
system.md / system_native.md. Listed paths must exist at create_app().

AppConfig

@dataclass
class AppConfig:
    history_limit: int = 12
    max_steps: int = 20
    context_token_budget: int = 6000
    max_parse_errors: int = 5
    tool_result_inline_limit: int = 6144
    tool_result_summary_max_chars: int = 1000

See AGENTS.md and config.example.yaml for the full field set.

load_config()

def load_config(config_path: str | None = None) -> Config:
    """Load configuration from YAML file.

    Args:
        config_path: Path to config.yaml (default: "config.yaml")

    Returns:
        Config dataclass
    """

EnsembleConfig / SkillsConfig / MemoryConfig

See AGENTS.md and config.example.yaml for current fields.