Files
OpenJarvis/docs/development/extending.md
T
Jon Saad-FalconandClaude Opus 4.6 d7002e22d8 Update MkDocs documentation for Phase 10 agent restructuring
Reflects the new agent hierarchy (BaseAgent helpers, ToolUsingAgent
intermediate base, NativeReActAgent, NativeOpenHandsAgent, RLMAgent,
OpenHandsAgent SDK) across all architecture, user-guide, extending,
contributing, roadmap, and API reference pages. Removes CustomAgent
references and adds Mermaid diagrams for new agent types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:29:04 +00:00

916 lines
27 KiB
Markdown

# Extending OpenJarvis
OpenJarvis is designed to be extended through its registry pattern. Every
major subsystem defines an abstract base class (ABC) and uses a typed registry
for runtime discovery. To add a new component, implement the ABC, decorate
it with the registry, and import it in the module's `__init__.py`.
This guide provides complete, working code examples for each extension point.
---
## Adding a New Inference Engine
Inference engines connect OpenJarvis to an LLM runtime. All engines implement
the `InferenceEngine` ABC defined in `engine/_stubs.py`.
### Step 1: Create the Engine Module
Create `src/openjarvis/engine/my_engine.py`:
```python
"""My custom inference engine backend."""
from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
from typing import Any, Dict, List
import httpx
from openjarvis.core.registry import EngineRegistry
from openjarvis.core.types import Message
from openjarvis.engine._base import (
EngineConnectionError,
InferenceEngine,
messages_to_dicts,
)
@EngineRegistry.register("my_engine") # (1)!
class MyEngine(InferenceEngine):
"""Custom inference engine backend."""
engine_id = "my_engine" # (2)!
def __init__(
self,
host: str = "http://localhost:9000",
*,
timeout: float = 120.0,
) -> None:
self._host = host.rstrip("/")
self._client = httpx.Client(base_url=self._host, timeout=timeout)
def generate(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> Dict[str, Any]:
"""Synchronous completion."""
payload = {
"model": model,
"messages": messages_to_dicts(messages), # (3)!
"temperature": temperature,
"max_tokens": max_tokens,
}
# Pass tools if provided
tools = kwargs.get("tools")
if tools:
payload["tools"] = tools
try:
resp = self._client.post("/v1/chat/completions", json=payload)
resp.raise_for_status()
except (httpx.ConnectError, httpx.TimeoutException) as exc:
raise EngineConnectionError(
f"Engine not reachable at {self._host}"
) from exc
data = resp.json()
choice = data.get("choices", [{}])[0]
message = choice.get("message", {})
usage = data.get("usage", {})
result: Dict[str, Any] = {
"content": message.get("content", ""),
"usage": {
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
},
"model": data.get("model", model),
"finish_reason": choice.get("finish_reason", "stop"),
}
# Extract tool calls if present
raw_tool_calls = message.get("tool_calls", [])
if raw_tool_calls:
result["tool_calls"] = [
{
"id": tc.get("id", f"call_{i}"),
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
}
for i, tc in enumerate(raw_tool_calls)
]
return result
async def stream(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> AsyncIterator[str]:
"""Yield token strings as they are generated."""
# Implement SSE or WebSocket streaming for your engine
result = self.generate(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
)
yield result.get("content", "")
def list_models(self) -> List[str]:
"""Return identifiers of models available on this engine."""
try:
resp = self._client.get("/v1/models")
resp.raise_for_status()
data = resp.json()
return [m["id"] for m in data.get("data", [])]
except Exception:
return []
def health(self) -> bool:
"""Return True when the engine is reachable and healthy."""
try:
resp = self._client.get("/health", timeout=2.0)
return resp.status_code == 200
except Exception:
return False
```
1. The `@EngineRegistry.register("my_engine")` decorator makes this engine
discoverable by key at runtime.
2. The `engine_id` class attribute is used in telemetry and benchmark results.
3. `messages_to_dicts()` converts `Message` objects to OpenAI-format dicts.
### Step 2: Register in `__init__.py`
Add your engine import to `src/openjarvis/engine/__init__.py`:
```python
import openjarvis.engine.my_engine # noqa: F401
```
If your engine requires optional dependencies, wrap the import:
```python
try:
import openjarvis.engine.my_engine # noqa: F401
except ImportError:
pass
```
### Step 3: Add Optional Dependencies
If your engine needs extra packages, add them to `pyproject.toml`:
```toml
[project.optional-dependencies]
inference-myengine = [
"my-engine-sdk>=1.0",
]
```
### Required ABC Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
| `generate` | `(messages, *, model, temperature, max_tokens, **kwargs)` | `Dict[str, Any]` | Synchronous completion with `content` and `usage` keys |
| `stream` | `(messages, *, model, temperature, max_tokens, **kwargs)` | `AsyncIterator[str]` | Yields token strings as they are generated |
| `list_models` | `()` | `List[str]` | Model identifiers available on this engine |
| `health` | `()` | `bool` | `True` when the engine is reachable |
The `generate` return dict must include at minimum:
```python
{
"content": "The response text",
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
},
"model": "model-name",
"finish_reason": "stop", # or "tool_calls"
}
```
!!! tip "Tool call support"
If your engine supports tool/function calling, include a `"tool_calls"`
key in the return dict. Each tool call should have `id`, `name`, and
`arguments` (JSON string) keys.
---
## Adding a New Memory Backend
Memory backends provide persistent, searchable storage. All backends implement
the `MemoryBackend` ABC defined in `tools/storage/_stubs.py` (previously `memory/_stubs.py`).
### Complete Example
Create `src/openjarvis/tools/storage/my_backend.py`:
```python
"""Custom memory backend example."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from openjarvis.core.registry import MemoryRegistry
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
@MemoryRegistry.register("my_backend")
class MyMemoryBackend(MemoryBackend):
"""Custom memory backend implementation."""
backend_id = "my_backend"
def __init__(self, **kwargs: Any) -> None:
# Initialize your storage (database, index, etc.)
self._store: Dict[str, Dict[str, Any]] = {}
def store(
self,
content: str,
*,
source: str = "",
metadata: Optional[Dict[str, Any]] = None,
) -> str:
"""Persist content and return a unique document id."""
import uuid
doc_id = uuid.uuid4().hex
self._store[doc_id] = {
"content": content,
"source": source,
"metadata": metadata or {},
}
return doc_id
def retrieve(
self,
query: str,
*,
top_k: int = 5,
**kwargs: Any,
) -> List[RetrievalResult]:
"""Search for query and return the top-k results."""
results: List[RetrievalResult] = []
for doc_id, doc in self._store.items():
# Implement your search/ranking logic here
if query.lower() in doc["content"].lower():
results.append(RetrievalResult(
content=doc["content"],
score=1.0,
source=doc["source"],
metadata=doc["metadata"],
))
return results[:top_k]
def delete(self, doc_id: str) -> bool:
"""Delete a document by id. Return True if it existed."""
return self._store.pop(doc_id, None) is not None
def clear(self) -> None:
"""Remove all stored documents."""
self._store.clear()
```
### Register in `__init__.py`
Add to `src/openjarvis/tools/storage/__init__.py`:
```python
try:
import openjarvis.tools.storage.my_backend # noqa: F401
except ImportError:
pass
```
!!! note "Backward compatibility"
The old `from openjarvis.memory._stubs import MemoryBackend` import path still works via backward-compatibility shims, but new code should use `openjarvis.tools.storage._stubs`.
### Required ABC Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
| `store` | `(content, *, source, metadata)` | `str` | Persist content, return document ID |
| `retrieve` | `(query, *, top_k, **kwargs)` | `List[RetrievalResult]` | Search and return ranked results |
| `delete` | `(doc_id)` | `bool` | Delete by ID, return whether it existed |
| `clear` | `()` | `None` | Remove all stored documents |
The `RetrievalResult` dataclass has these fields:
```python
@dataclass(slots=True)
class RetrievalResult:
content: str # The retrieved text
score: float = 0.0 # Relevance score
source: str = "" # Source identifier
metadata: Dict[str, Any] = field(default_factory=dict)
```
---
## Adding a New Agent
Agents implement the logic for handling queries, calling tools, and managing
multi-turn interactions. There are two paths depending on whether your agent
uses tools:
- **Path A: Non-tool agent** -- Extend `BaseAgent` directly
- **Path B: Tool-using agent** -- Extend `ToolUsingAgent` (which sets `accepts_tools = True` and provides a `ToolExecutor`)
### Path A: Non-tool Agent (extending BaseAgent)
Create `src/openjarvis/agents/my_agent.py`:
```python
"""Custom agent implementation — single-turn, no tools."""
from __future__ import annotations
from typing import Any, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
from openjarvis.core.registry import AgentRegistry
from openjarvis.engine._stubs import InferenceEngine
@AgentRegistry.register("my_agent")
class MyAgent(BaseAgent):
"""Custom agent with specialized behavior."""
agent_id = "my_agent"
def run(
self,
input: str,
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
"""Execute the agent on input and return an AgentResult."""
# Use BaseAgent helpers instead of manual event bus code
self._emit_turn_start(input)
# Build messages from context + user input (with optional system prompt)
messages = self._build_messages(
input, context,
system_prompt="You are a helpful assistant with specialized knowledge.",
)
# Call engine.generate() with stored defaults (model, temperature, max_tokens)
result = self._generate(messages)
content = self._strip_think_tags(result.get("content", ""))
self._emit_turn_end(turns=1)
return AgentResult(content=content, turns=1)
```
!!! tip "BaseAgent helpers"
`BaseAgent` provides these concrete helpers so you don't need to manually
manage the event bus or engine calls:
| Helper | Purpose |
|--------|---------|
| `_emit_turn_start(input)` | Publish `AGENT_TURN_START` |
| `_emit_turn_end(**data)` | Publish `AGENT_TURN_END` |
| `_build_messages(input, context, *, system_prompt)` | Assemble message list |
| `_generate(messages, **kwargs)` | Call engine with stored defaults |
| `_strip_think_tags(text)` | Remove `<think>` blocks |
| `_max_turns_result(tool_results, turns, content)` | Standard max-turns result |
### Path B: Tool-using Agent (extending ToolUsingAgent)
Create `src/openjarvis/agents/my_tool_agent.py`:
```python
"""Custom tool-using agent with a multi-turn loop."""
from __future__ import annotations
from typing import Any, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import ToolCall, ToolResult
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.tools._stubs import BaseTool
@AgentRegistry.register("my_tool_agent")
class MyToolAgent(ToolUsingAgent):
"""Custom agent with tool-calling loop."""
agent_id = "my_tool_agent"
def __init__(
self,
engine: InferenceEngine,
model: str,
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 10,
temperature: float = 0.7,
max_tokens: int = 1024,
) -> None:
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
max_tokens=max_tokens,
)
def run(
self,
input: str,
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
self._emit_turn_start(input)
messages = self._build_messages(input, context)
tools_spec = self._executor.get_openai_tools()
all_tool_results: list[ToolResult] = []
turns = 0
for _ in range(self._max_turns):
turns += 1
result = self._generate(messages, tools=tools_spec)
content = result.get("content", "")
tool_calls = result.get("tool_calls", [])
if not tool_calls:
self._emit_turn_end(turns=turns)
return AgentResult(
content=content,
tool_results=all_tool_results,
turns=turns,
)
# Execute each tool call
for tc in tool_calls:
call = ToolCall(
id=tc.get("id", f"call_{turns}"),
name=tc["name"],
arguments=tc["arguments"],
)
tr = self._executor.execute(call)
all_tool_results.append(tr)
# Max turns exceeded — use the standard helper
return self._max_turns_result(all_tool_results, turns)
```
!!! info "What ToolUsingAgent adds"
`ToolUsingAgent` extends `BaseAgent` with:
- **`accepts_tools = True`** — enables `--tools` in CLI and `tools=` in SDK
- **`self._executor`** — a `ToolExecutor` initialized from the provided tools
- **`self._tools`** — the raw list of `BaseTool` instances
- **`self._max_turns`** — configurable loop iteration limit (default: 10)
### Register in `__init__.py`
Add to `src/openjarvis/agents/__init__.py`:
```python
try:
import openjarvis.agents.my_agent # noqa: F401
except ImportError:
pass
```
### Key Types
=== "AgentContext"
```python
@dataclass(slots=True)
class AgentContext:
conversation: Conversation = field(default_factory=Conversation)
tools: List[str] = field(default_factory=list)
memory_results: List[Any] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
```
=== "AgentResult"
```python
@dataclass(slots=True)
class AgentResult:
content: str
tool_results: List[ToolResult] = field(default_factory=list)
turns: int = 0
metadata: Dict[str, Any] = field(default_factory=dict)
```
---
## Adding a New Tool
Tools are callable capabilities that agents can invoke during multi-turn
reasoning. All tools implement the `BaseTool` ABC from `tools/_stubs.py`.
### Complete Example
Create `src/openjarvis/tools/my_tool.py`:
```python
"""Custom tool implementation."""
from __future__ import annotations
from typing import Any
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
@ToolRegistry.register("my_tool")
class MyTool(BaseTool):
"""A custom tool that does something useful."""
tool_id = "my_tool"
@property
def spec(self) -> ToolSpec:
"""Return the tool specification."""
return ToolSpec(
name="my_tool",
description="Does something useful with the provided input.",
parameters={ # (1)!
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The input to process",
},
"max_results": {
"type": "integer",
"description": "Maximum number of results to return",
"default": 5,
},
},
"required": ["query"],
},
category="utility",
cost_estimate=0.001, # Estimated cost in USD per call
latency_estimate=0.5, # Estimated latency in seconds
requires_confirmation=False,
)
def execute(self, **params: Any) -> ToolResult:
"""Execute the tool with the given parameters."""
query = params.get("query", "")
max_results = params.get("max_results", 5)
if not query:
return ToolResult(
tool_name="my_tool",
content="No query provided.",
success=False,
)
try:
# Your tool logic here
result_text = f"Processed '{query}' (max_results={max_results})"
return ToolResult(
tool_name="my_tool",
content=result_text,
success=True,
)
except Exception as exc:
return ToolResult(
tool_name="my_tool",
content=f"Error: {exc}",
success=False,
)
```
1. The `parameters` dict follows the [JSON Schema](https://json-schema.org/)
format used by OpenAI function calling. The `ToolExecutor` will parse
incoming JSON arguments and pass them as keyword arguments to `execute()`.
### Register in `__init__.py`
Add to `src/openjarvis/tools/__init__.py`:
```python
try:
import openjarvis.tools.my_tool # noqa: F401
except ImportError:
pass
```
### How Tools Are Invoked
The `ToolExecutor` handles the dispatch loop:
1. The agent's LLM generates a `tool_calls` response with tool name and
JSON arguments
2. `ToolExecutor.execute()` parses the JSON arguments
3. The matching tool's `execute(**params)` is called
4. The `ToolResult` is returned to the agent for the next turn
```python
from openjarvis.tools._stubs import ToolExecutor
executor = ToolExecutor(
tools=[MyTool()],
bus=event_bus, # Optional — enables TOOL_CALL_START/END events
)
# Dispatch a tool call
from openjarvis.core.types import ToolCall
call = ToolCall(id="call_1", name="my_tool", arguments='{"query": "test"}')
result = executor.execute(call)
```
The `to_openai_function()` method converts a tool's spec to OpenAI function
calling format, which is sent to the LLM alongside the conversation:
```python
tool = MyTool()
openai_format = tool.to_openai_function()
# {
# "type": "function",
# "function": {
# "name": "my_tool",
# "description": "Does something useful...",
# "parameters": { ... }
# }
# }
```
---
## Adding a New Benchmark
Benchmarks measure engine performance. All benchmarks implement the
`BaseBenchmark` ABC from `bench/_stubs.py` and use the `ensure_registered()`
pattern for lazy registration.
### Complete Example
Create `src/openjarvis/bench/my_benchmark.py`:
```python
"""Custom benchmark — measures time to first token."""
from __future__ import annotations
import time
from openjarvis.bench._stubs import BaseBenchmark, BenchmarkResult
from openjarvis.core.registry import BenchmarkRegistry
from openjarvis.core.types import Message, Role
from openjarvis.engine._stubs import InferenceEngine
class TTFTBenchmark(BaseBenchmark):
"""Measures time-to-first-token across multiple samples."""
@property
def name(self) -> str:
return "ttft"
@property
def description(self) -> str:
return "Measures time-to-first-token latency"
def run(
self,
engine: InferenceEngine,
model: str,
*,
num_samples: int = 10,
) -> BenchmarkResult:
ttft_values: list[float] = []
errors = 0
for _ in range(num_samples):
messages = [Message(role=Role.USER, content="Hello")]
t0 = time.time()
try:
engine.generate(messages, model=model)
ttft_values.append(time.time() - t0)
except Exception:
errors += 1
if not ttft_values:
return BenchmarkResult(
benchmark_name=self.name,
model=model,
engine=engine.engine_id,
metrics={},
samples=num_samples,
errors=errors,
)
return BenchmarkResult(
benchmark_name=self.name,
model=model,
engine=engine.engine_id,
metrics={
"mean_ttft": sum(ttft_values) / len(ttft_values),
"min_ttft": min(ttft_values),
"max_ttft": max(ttft_values),
},
samples=num_samples,
errors=errors,
)
def ensure_registered() -> None: # (1)!
"""Register the TTFT benchmark if not already present."""
if not BenchmarkRegistry.contains("ttft"):
BenchmarkRegistry.register_value("ttft", TTFTBenchmark)
```
1. The `ensure_registered()` function uses `contains()` before
`register_value()` so it can be called multiple times safely. This is
required because tests clear all registries between runs.
### Register in `__init__.py`
Update `src/openjarvis/bench/__init__.py` to call `ensure_registered()`:
```python
from openjarvis.bench.my_benchmark import ensure_registered as _reg_ttft
_reg_ttft()
```
### BenchmarkResult Fields
```python
@dataclass(slots=True)
class BenchmarkResult:
benchmark_name: str # e.g. "latency", "throughput"
model: str # Model identifier
engine: str # Engine identifier
metrics: Dict[str, float] = ... # Measured values
metadata: Dict[str, Any] = ... # Extra info
samples: int = 0 # Number of samples run
errors: int = 0 # Number of failed samples
```
---
## Adding a New Router Policy
Router policies determine which model handles a given query. All policies
implement the `RouterPolicy` ABC from `learning/_stubs.py`. The
`RoutingContext` dataclass is defined in `core/types.py`.
### Complete Example
Create `src/openjarvis/learning/my_policy.py`:
```python
"""Custom router policy — selects model based on query length."""
from __future__ import annotations
from typing import List, Optional
from openjarvis.core.registry import RouterPolicyRegistry
from openjarvis.core.types import RoutingContext
from openjarvis.learning._stubs import RouterPolicy
class QueryLengthPolicy(RouterPolicy):
"""Routes queries to models based on query length.
Short queries go to a fast, small model. Long or complex queries
go to a larger, more capable model.
"""
def __init__(
self,
available_models: Optional[List[str]] = None,
*,
default_model: str = "",
fallback_model: str = "",
short_threshold: int = 100,
long_threshold: int = 500,
) -> None:
self._available = available_models or []
self._default = default_model
self._fallback = fallback_model
self._short_threshold = short_threshold
self._long_threshold = long_threshold
def select_model(self, context: RoutingContext) -> str:
"""Return the model registry key best suited for this context."""
available = self._available
if not available:
return self._default or self._fallback or ""
if context.query_length < self._short_threshold:
# Prefer the first (presumably smallest) available model
return available[0]
elif context.query_length > self._long_threshold:
# Prefer the last (presumably largest) available model
return available[-1]
# Default to configured model
if self._default and self._default in available:
return self._default
return available[0]
def ensure_registered() -> None:
"""Register QueryLengthPolicy if not already present."""
if not RouterPolicyRegistry.contains("query_length"):
RouterPolicyRegistry.register_value("query_length", QueryLengthPolicy)
ensure_registered()
```
### Register in `__init__.py`
Update `src/openjarvis/learning/__init__.py`:
```python
from openjarvis.learning.my_policy import ensure_registered as _reg_ql
_reg_ql()
```
### Using Your Policy
Once registered, your policy can be selected via the config file or CLI:
=== "Config (TOML)"
```toml
[learning.routing]
policy = "query_length"
```
=== "CLI"
```bash
uv run jarvis ask --router query_length "Hello"
```
### The RoutingContext
The `RoutingContext` dataclass provides all the information a router needs:
```python
@dataclass(slots=True)
class RoutingContext:
query: str = ""
query_length: int = 0
has_code: bool = False
has_math: bool = False
language: str = "en"
urgency: float = 0.5 # 0 = low priority, 1 = real-time
metadata: Dict[str, Any] = field(default_factory=dict)
```
The `build_routing_context()` helper in `learning/router.py` populates
this from a raw query string, detecting code and math patterns automatically.
---
## Summary
| Component | ABC | Registry | Key location |
|---|---|---|---|
| Inference Engine | `InferenceEngine` | `EngineRegistry` | `engine/_stubs.py` |
| Memory Backend | `MemoryBackend` | `MemoryRegistry` | `tools/storage/_stubs.py` |
| Agent | `BaseAgent` | `AgentRegistry` | `agents/_stubs.py` |
| Tool | `BaseTool` | `ToolRegistry` | `tools/_stubs.py` |
| Benchmark | `BaseBenchmark` | `BenchmarkRegistry` | `bench/_stubs.py` |
| Router Policy | `RouterPolicy` | `RouterPolicyRegistry` | `learning/_stubs.py` |
| Learning Policy | `LearningPolicy` | `LearningRegistry` | `learning/_stubs.py` |
The general pattern for all extension points:
1. Implement the ABC in a new module
2. Decorate the class with `@XRegistry.register("key")` or use
`ensure_registered()` for lazy registration
3. Import the module in the package's `__init__.py` (with `try/except
ImportError` if optional deps are involved)
4. Add tests in `tests/<module>/`
5. Add optional dependencies to `pyproject.toml` if needed