mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 00:47:52 +00:00
feat(agents): add OpenCodeAgent — run the opencode coding agent on a local engine
Adds an `OpenCodeAgent` (registry key `opencode`) that delegates coding tasks to opencode (https://opencode.ai, MIT) while keeping inference local-first: OpenJarvis's engine backs opencode via an OpenAI-compatible provider. How it works: - Derives an OpenAI-compatible base URL from the engine (e.g. Ollama/vLLM at `<host>/v1`) and writes an `opencode.json` registering it as an `@ai-sdk/openai-compatible` provider (`openjarvis/<model>`). - Spawns a headless `opencode serve` (loopback, random port), waits for `/global/health`, then drives a session: `POST /session` → `POST /session/{id}/message` with `model={providerID,modelID}` + agent (`build`/`plan`) → parses message `parts` (text → content, tool → tool_results) into an `AgentResult`. `close()` disposes the server. - opencode is an external binary (not bundled); `run()` returns a clear, actionable error when it's missing, mirroring ClaudeCodeAgent's degradation. Verified end-to-end against the real opencode binary wired to a stub OpenAI-compatible engine: opencode called the local endpoint and the agent parsed the response (content/finish/model) correctly. Unit tests cover part parsing, base-URL derivation, provider-config writing (incl. merge), binary detection, graceful degradation, and run() parsing with a mocked client — 15 passed, ruff clean. Registered via the standard try/except import in agents/__init__.py; documented in docs/user-guide/agents.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
75d26d23ea
commit
4fa9163ea9
@@ -13,6 +13,7 @@ Agents are the agentic logic layer of OpenJarvis. They determine how a query is
|
||||
| `RLMAgent` | `rlm` | Yes | Yes | Recursive LM with persistent REPL |
|
||||
| `OpenHandsAgent` | `openhands` | No | Yes | Wraps real openhands-sdk |
|
||||
| `ClaudeCodeAgent` | `claude_code` | No | Yes | Claude Agent SDK via Node.js subprocess |
|
||||
| `OpenCodeAgent` | `opencode` | No | Yes | [opencode](https://opencode.ai) coding agent on your local engine |
|
||||
| `OperativeAgent` | `operative` | Yes | Yes | Persistent scheduled agent with state management |
|
||||
| `MonitorOperativeAgent` | `monitor_operative` | Yes | Yes | Long-horizon agent with 4 configurable strategy axes |
|
||||
|
||||
@@ -383,6 +384,54 @@ jarvis ask --agent claude_code "Refactor the tests to use pytest fixtures"
|
||||
|
||||
---
|
||||
|
||||
## OpenCodeAgent
|
||||
|
||||
The `OpenCodeAgent` delegates coding tasks to [opencode](https://opencode.ai), the open-source coding agent, running it **on your local engine**. opencode handles the agentic loop, file edits, and tool use; OpenJarvis supplies the model — keeping coding-agent work local-first.
|
||||
|
||||
!!! warning "Requirements"
|
||||
Requires the `opencode` binary on `PATH` (`npm i -g opencode-ai` or `brew install anomalyco/tap/opencode`). It is **not** bundled; `run()` returns a clear error if it is missing. No `ANTHROPIC_API_KEY` needed — inference goes through your OpenJarvis engine.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Derives an OpenAI-compatible base URL from the `engine` (e.g. Ollama/vLLM/llama.cpp at `<host>/v1`) and writes an `opencode.json` in the workspace registering it as an `@ai-sdk/openai-compatible` provider (`openjarvis/<model>`).
|
||||
2. Spawns a headless `opencode serve` (loopback, random port) and waits for `/global/health`.
|
||||
3. Creates a session (`POST /session`) and sends the task (`POST /session/{id}/message`) with `model={providerID, modelID}` and the selected `agent` (`build` or `plan`).
|
||||
4. Parses the returned message `parts` — text parts → `content`, tool parts → `tool_results` — into an `AgentResult`.
|
||||
5. `close()` disposes the session/server.
|
||||
|
||||
**Constructor parameters (selected):**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------------|-------------------|------------------|----------------------------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | Used to derive the local OpenAI-compatible provider URL |
|
||||
| `model` | `str` | -- | Model id served at the provider (e.g. `qwen3:8b`) |
|
||||
| `workspace` | `str` | `os.getcwd()` | Directory opencode operates in |
|
||||
| `agent` | `str` | `"build"` | opencode agent: `build` (full access) or `plan` (read-only) |
|
||||
| `provider_base_url` | `str` | derived | Override the engine-derived OpenAI base URL |
|
||||
| `provider_id` | `str` | `"openjarvis"` | opencode provider id to register/use |
|
||||
| `model_id` | `str` | `model` | Model id within the provider |
|
||||
| `server_password` | `str` | `$OPENCODE_SERVER_PASSWORD` | Optional basic-auth for the opencode server |
|
||||
| `timeout` | `int` | `600` | HTTP timeout in seconds |
|
||||
|
||||
```python
|
||||
from openjarvis.agents.opencode import OpenCodeAgent
|
||||
|
||||
agent = OpenCodeAgent(engine, "qwen3:8b", workspace="/path/to/project", agent="build")
|
||||
result = agent.run("Add type hints to utils.py and run the tests")
|
||||
print(result.content)
|
||||
agent.close()
|
||||
```
|
||||
|
||||
```bash
|
||||
# Via CLI (opencode must be installed)
|
||||
jarvis ask --agent opencode "Refactor the parser to use a state machine"
|
||||
```
|
||||
|
||||
!!! tip "Pass-through providers"
|
||||
If the `engine` has no derivable base URL, pass `model` as `provider/model` (e.g. `ollama/llama3`) and opencode resolves it from its own configuration — no `opencode.json` is written.
|
||||
|
||||
---
|
||||
|
||||
## OperativeAgent
|
||||
|
||||
The `OperativeAgent` is a persistent, scheduled autonomous agent with built-in session persistence and state recall. Designed for "Operators" -- autonomous agents that run on a schedule with automatic state management between ticks. Extends `ToolUsingAgent`.
|
||||
|
||||
@@ -54,6 +54,11 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.agents.opencode # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.agents.operative # noqa: F401
|
||||
except ImportError:
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
"""OpenCodeAgent -- wraps the `opencode` coding agent via its headless HTTP server.
|
||||
|
||||
Spawns ``opencode serve`` (https://opencode.ai) and drives a session over its
|
||||
HTTP API, configured to use OpenJarvis's local engine through an
|
||||
OpenAI-compatible provider. This keeps coding-agent work local-first: opencode
|
||||
handles the agentic loop / tools, OpenJarvis supplies the model.
|
||||
|
||||
opencode is an external binary (install: ``npm i -g opencode-ai`` or
|
||||
``brew install anomalyco/tap/opencode``). It is not bundled; :meth:`run`
|
||||
raises a clear error if it is not on ``PATH``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LISTENING_RE = re.compile(r"listening on\s+(https?://\S+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def is_opencode_available() -> bool:
|
||||
"""Return True if the ``opencode`` binary is on PATH."""
|
||||
return shutil.which("opencode") is not None
|
||||
|
||||
|
||||
def _derive_openai_base_url(engine: Any) -> str:
|
||||
"""Best-effort OpenAI-compatible base URL for an OpenJarvis engine.
|
||||
|
||||
HTTP engines (Ollama, vLLM, llama.cpp, SGLang, LM Studio, …) expose a
|
||||
``_host`` and serve an OpenAI-compatible API at ``<host>/v1``. Returns ""
|
||||
when it cannot be derived (caller then requires an explicit base URL or a
|
||||
pre-configured opencode provider).
|
||||
"""
|
||||
for attr in ("openai_base_url", "base_url"):
|
||||
val = getattr(engine, attr, "")
|
||||
if val:
|
||||
return str(val).rstrip("/")
|
||||
host = getattr(engine, "_host", "") or getattr(engine, "host", "")
|
||||
if host:
|
||||
host = str(host).rstrip("/")
|
||||
return host if host.endswith("/v1") else f"{host}/v1"
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_text(parts: List[dict]) -> str:
|
||||
"""Join the assistant's text parts from an opencode message response."""
|
||||
return "".join(
|
||||
p.get("text", "")
|
||||
for p in parts
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
).strip()
|
||||
|
||||
|
||||
def _extract_tool_results(parts: List[dict]) -> List[ToolResult]:
|
||||
"""Map opencode ``tool`` parts to OpenJarvis ToolResults (best-effort)."""
|
||||
results: List[ToolResult] = []
|
||||
for p in parts:
|
||||
if not isinstance(p, dict) or p.get("type") != "tool":
|
||||
continue
|
||||
state = p.get("state", {}) if isinstance(p.get("state"), dict) else {}
|
||||
status = state.get("status", "")
|
||||
output = state.get("output")
|
||||
if output is None:
|
||||
output = state.get("title", "") or json.dumps(state) if state else ""
|
||||
results.append(
|
||||
ToolResult(
|
||||
tool_name=p.get("tool", p.get("name", "unknown")),
|
||||
content=str(output),
|
||||
success=status not in ("error", "failed"),
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@AgentRegistry.register("opencode")
|
||||
class OpenCodeAgent(BaseAgent):
|
||||
"""Agent that delegates coding tasks to a local ``opencode`` server.
|
||||
|
||||
The ``engine`` is used to wire opencode at an OpenAI-compatible provider so
|
||||
inference runs on OpenJarvis's selected local model. ``agent`` selects
|
||||
opencode's built-in agent: ``build`` (full access) or ``plan`` (read-only).
|
||||
"""
|
||||
|
||||
agent_id = "opencode"
|
||||
accepts_tools = False
|
||||
_default_temperature = 0.7
|
||||
_default_max_tokens = 1024
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
model: str,
|
||||
*,
|
||||
bus: Optional[EventBus] = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
workspace: str = "",
|
||||
agent: str = "build",
|
||||
provider_id: str = "openjarvis",
|
||||
provider_base_url: str = "",
|
||||
model_id: str = "",
|
||||
api_key: str = "",
|
||||
hostname: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
server_password: str = "",
|
||||
timeout: int = 600,
|
||||
opencode_bin: str = "",
|
||||
) -> None:
|
||||
super().__init__(
|
||||
engine,
|
||||
model,
|
||||
bus=bus,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
self._workspace = workspace or os.getcwd()
|
||||
self._agent = agent
|
||||
self._provider_id = provider_id
|
||||
self._provider_base_url = provider_base_url or _derive_openai_base_url(engine)
|
||||
self._model_id = model_id or model
|
||||
self._api_key = api_key
|
||||
self._hostname = hostname
|
||||
self._port = port
|
||||
self._server_password = server_password or os.environ.get(
|
||||
"OPENCODE_SERVER_PASSWORD", ""
|
||||
)
|
||||
self._timeout = timeout
|
||||
self._opencode_bin = opencode_bin or shutil.which("opencode") or "opencode"
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._base: str = ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Server lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _write_provider_config(self) -> None:
|
||||
"""Register OpenJarvis's engine as an OpenAI-compatible opencode provider.
|
||||
|
||||
Written to ``<workspace>/opencode.json`` (opencode reads project config
|
||||
from its working directory). Skipped when no base URL is available — in
|
||||
that case ``model`` is assumed to name a provider opencode already
|
||||
knows (e.g. ``ollama/llama3``).
|
||||
"""
|
||||
if not self._provider_base_url:
|
||||
return
|
||||
cfg_path = Path(self._workspace) / "opencode.json"
|
||||
existing: dict = {}
|
||||
if cfg_path.exists():
|
||||
try:
|
||||
existing = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
existing = {}
|
||||
options: dict = {"baseURL": self._provider_base_url}
|
||||
if self._api_key:
|
||||
options["apiKey"] = self._api_key
|
||||
providers = existing.setdefault("provider", {})
|
||||
providers[self._provider_id] = {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "OpenJarvis Local",
|
||||
"options": options,
|
||||
"models": {self._model_id: {"name": self._model_id}},
|
||||
}
|
||||
existing.setdefault("$schema", "https://opencode.ai/config.json")
|
||||
cfg_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
||||
|
||||
def _ensure_server(self) -> str:
|
||||
"""Spawn ``opencode serve`` (once) and return its base URL."""
|
||||
if self._base and self._proc and self._proc.poll() is None:
|
||||
return self._base
|
||||
if not is_opencode_available() and not Path(self._opencode_bin).exists():
|
||||
raise RuntimeError(
|
||||
"OpenCodeAgent requires the 'opencode' binary. Install it with "
|
||||
"`npm i -g opencode-ai` or `brew install anomalyco/tap/opencode` "
|
||||
"(see https://opencode.ai)."
|
||||
)
|
||||
self._write_provider_config()
|
||||
env = dict(os.environ)
|
||||
if self._server_password:
|
||||
env["OPENCODE_SERVER_PASSWORD"] = self._server_password
|
||||
self._proc = subprocess.Popen(
|
||||
[
|
||||
self._opencode_bin,
|
||||
"serve",
|
||||
"--port",
|
||||
str(self._port),
|
||||
"--hostname",
|
||||
self._hostname,
|
||||
],
|
||||
cwd=self._workspace,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
# Parse the "listening on <url>" line from startup output.
|
||||
deadline = time.monotonic() + 60
|
||||
base = ""
|
||||
assert self._proc.stdout is not None
|
||||
while time.monotonic() < deadline:
|
||||
line = self._proc.stdout.readline()
|
||||
if not line:
|
||||
if self._proc.poll() is not None:
|
||||
raise RuntimeError("opencode server exited during startup")
|
||||
continue
|
||||
m = _LISTENING_RE.search(line)
|
||||
if m:
|
||||
base = m.group(1).rstrip("/")
|
||||
break
|
||||
if not base:
|
||||
self.close()
|
||||
raise RuntimeError("opencode server did not report a listening URL")
|
||||
self._base = base
|
||||
return base
|
||||
|
||||
def _client(self):
|
||||
import httpx
|
||||
|
||||
headers = {}
|
||||
if self._server_password:
|
||||
import base64
|
||||
|
||||
token = base64.b64encode(
|
||||
f"opencode:{self._server_password}".encode()
|
||||
).decode()
|
||||
headers["Authorization"] = f"Basic {token}"
|
||||
return httpx.Client(base_url=self._base, headers=headers, timeout=self._timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Dispose the opencode session/server and terminate the process."""
|
||||
if self._base:
|
||||
try:
|
||||
with self._client() as c:
|
||||
c.post("/global/dispose")
|
||||
except Exception:
|
||||
pass
|
||||
if self._proc and self._proc.poll() is None:
|
||||
self._proc.terminate()
|
||||
try:
|
||||
self._proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
self._proc = None
|
||||
self._base = ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Run
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str,
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
"""Run a coding task through opencode and return the assistant result."""
|
||||
self._emit_turn_start(input)
|
||||
try:
|
||||
self._ensure_server()
|
||||
except RuntimeError as exc:
|
||||
self._emit_turn_end(turns=1, error=True)
|
||||
return AgentResult(
|
||||
content=str(exc), turns=1, metadata={"error": True}
|
||||
)
|
||||
|
||||
try:
|
||||
with self._client() as c:
|
||||
ses = c.post("/session", json={"title": input[:80]})
|
||||
ses.raise_for_status()
|
||||
session_id = ses.json()["id"]
|
||||
|
||||
body: dict = {
|
||||
"agent": self._agent,
|
||||
"parts": [{"type": "text", "text": input}],
|
||||
}
|
||||
if self._provider_base_url or "/" not in self._model_id:
|
||||
body["model"] = {
|
||||
"providerID": self._provider_id,
|
||||
"modelID": self._model_id,
|
||||
}
|
||||
else:
|
||||
prov, _, mid = self._model_id.partition("/")
|
||||
body["model"] = {"providerID": prov, "modelID": mid}
|
||||
|
||||
resp = c.post(f"/session/{session_id}/message", json=body)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("opencode run failed: %s", exc, exc_info=True)
|
||||
self._emit_turn_end(turns=1, error=True)
|
||||
return AgentResult(
|
||||
content=f"opencode agent failed: {exc}",
|
||||
turns=1,
|
||||
metadata={"error": True},
|
||||
)
|
||||
|
||||
parts = data.get("parts", []) if isinstance(data, dict) else []
|
||||
info = data.get("info", {}) if isinstance(data, dict) else {}
|
||||
content = _extract_text(parts)
|
||||
tool_results = _extract_tool_results(parts)
|
||||
|
||||
self._emit_turn_end(turns=1)
|
||||
return AgentResult(
|
||||
content=content,
|
||||
tool_results=tool_results,
|
||||
turns=1,
|
||||
metadata={
|
||||
"finish": info.get("finish"),
|
||||
"tokens": info.get("tokens"),
|
||||
"provider_id": info.get("providerID", self._provider_id),
|
||||
"model_id": info.get("modelID", self._model_id),
|
||||
"session_id": info.get("sessionID", ""),
|
||||
"agent": self._agent,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OpenCodeAgent", "is_opencode_available"]
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Tests for OpenCodeAgent (wraps the `opencode` coding agent).
|
||||
|
||||
The pure helpers, provider-config wiring, graceful degradation, and response
|
||||
parsing are tested without the `opencode` binary. SPIKE_RESPONSE is the actual
|
||||
message shape captured from a live `opencode serve` session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openjarvis.agents.opencode import (
|
||||
OpenCodeAgent,
|
||||
_derive_openai_base_url,
|
||||
_extract_text,
|
||||
_extract_tool_results,
|
||||
is_opencode_available,
|
||||
)
|
||||
|
||||
SPIKE_RESPONSE = {
|
||||
"info": {
|
||||
"role": "assistant",
|
||||
"agent": "build",
|
||||
"modelID": "local-model",
|
||||
"providerID": "openjarvis",
|
||||
"finish": "stop",
|
||||
"tokens": {"input": 0, "output": 0},
|
||||
"sessionID": "ses_x",
|
||||
"id": "msg_x",
|
||||
},
|
||||
"parts": [
|
||||
{"type": "step-start"},
|
||||
{"type": "text", "text": "Hello from the local model. "},
|
||||
{"type": "step-finish", "reason": "stop"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestPartParsing:
|
||||
def test_extract_text_joins_text_parts(self):
|
||||
assert _extract_text(SPIKE_RESPONSE["parts"]) == "Hello from the local model."
|
||||
|
||||
def test_extract_text_ignores_non_text(self):
|
||||
assert _extract_text([{"type": "step-start"}, {"type": "tool"}]) == ""
|
||||
|
||||
def test_extract_tool_results_success(self):
|
||||
parts = [{"type": "tool", "tool": "bash",
|
||||
"state": {"status": "completed", "output": "ok"}}]
|
||||
tr = _extract_tool_results(parts)
|
||||
assert len(tr) == 1
|
||||
assert tr[0].tool_name == "bash"
|
||||
assert tr[0].content == "ok"
|
||||
assert tr[0].success is True
|
||||
|
||||
def test_extract_tool_results_error(self):
|
||||
parts = [{"type": "tool", "tool": "edit",
|
||||
"state": {"status": "error", "output": "boom"}}]
|
||||
assert _extract_tool_results(parts)[0].success is False
|
||||
|
||||
|
||||
class TestDeriveBaseUrl:
|
||||
def test_from_host_appends_v1(self):
|
||||
eng = SimpleNamespace(_host="http://localhost:11434")
|
||||
assert _derive_openai_base_url(eng) == "http://localhost:11434/v1"
|
||||
|
||||
def test_host_already_v1(self):
|
||||
eng = SimpleNamespace(_host="http://x:8000/v1")
|
||||
assert _derive_openai_base_url(eng) == "http://x:8000/v1"
|
||||
|
||||
def test_explicit_base_url_attr(self):
|
||||
eng = SimpleNamespace(base_url="http://y/v1")
|
||||
assert _derive_openai_base_url(eng) == "http://y/v1"
|
||||
|
||||
def test_none_when_unknown(self):
|
||||
assert _derive_openai_base_url(SimpleNamespace()) == ""
|
||||
|
||||
|
||||
class TestAvailability:
|
||||
def test_true(self, monkeypatch):
|
||||
monkeypatch.setattr("openjarvis.agents.opencode.shutil.which",
|
||||
lambda n: "/usr/bin/opencode")
|
||||
assert is_opencode_available() is True
|
||||
|
||||
def test_false(self, monkeypatch):
|
||||
monkeypatch.setattr("openjarvis.agents.opencode.shutil.which", lambda n: None)
|
||||
assert is_opencode_available() is False
|
||||
|
||||
|
||||
class TestProviderConfig:
|
||||
def test_writes_provider(self, tmp_path):
|
||||
agent = OpenCodeAgent(SimpleNamespace(_host="http://localhost:11434"),
|
||||
"qwen3:8b", workspace=str(tmp_path))
|
||||
agent._write_provider_config()
|
||||
cfg = json.loads((tmp_path / "opencode.json").read_text())
|
||||
prov = cfg["provider"]["openjarvis"]
|
||||
assert prov["npm"] == "@ai-sdk/openai-compatible"
|
||||
assert prov["options"]["baseURL"] == "http://localhost:11434/v1"
|
||||
assert "qwen3:8b" in prov["models"]
|
||||
|
||||
def test_merges_existing(self, tmp_path):
|
||||
(tmp_path / "opencode.json").write_text(
|
||||
json.dumps({"theme": "dark", "provider": {"other": {}}})
|
||||
)
|
||||
OpenCodeAgent(SimpleNamespace(_host="http://h:1"), "m",
|
||||
workspace=str(tmp_path))._write_provider_config()
|
||||
cfg = json.loads((tmp_path / "opencode.json").read_text())
|
||||
assert cfg["theme"] == "dark" # preserved
|
||||
assert "other" in cfg["provider"] and "openjarvis" in cfg["provider"]
|
||||
|
||||
def test_skips_when_no_base_url(self, tmp_path):
|
||||
# No derivable base URL + pre-namespaced model -> rely on opencode's
|
||||
# own provider; don't write a config.
|
||||
OpenCodeAgent(SimpleNamespace(), "ollama/llama3",
|
||||
workspace=str(tmp_path))._write_provider_config()
|
||||
assert not (tmp_path / "opencode.json").exists()
|
||||
|
||||
|
||||
class TestRunGracefulDegradation:
|
||||
def test_missing_binary_returns_error_result(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr("openjarvis.agents.opencode.shutil.which", lambda n: None)
|
||||
agent = OpenCodeAgent(SimpleNamespace(_host="http://h:1"), "m",
|
||||
workspace=str(tmp_path),
|
||||
opencode_bin="/nonexistent/opencode")
|
||||
res = agent.run("do something")
|
||||
assert res.metadata.get("error") is True
|
||||
assert "opencode" in res.content.lower()
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, data):
|
||||
self._d = data
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return self._d
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def post(self, path, json=None):
|
||||
if path == "/session":
|
||||
return _FakeResp({"id": "ses_x"})
|
||||
if path.endswith("/message"):
|
||||
_FakeClient.last_body = json
|
||||
return _FakeResp(SPIKE_RESPONSE)
|
||||
return _FakeResp({})
|
||||
|
||||
|
||||
class TestRunParsing:
|
||||
def test_run_parses_message(self, monkeypatch, tmp_path):
|
||||
agent = OpenCodeAgent(SimpleNamespace(_host="http://h:1"), "local-model",
|
||||
workspace=str(tmp_path), agent="build")
|
||||
monkeypatch.setattr(agent, "_ensure_server", lambda: "http://127.0.0.1:7654")
|
||||
agent._base = "http://127.0.0.1:7654"
|
||||
monkeypatch.setattr(agent, "_client", lambda: _FakeClient())
|
||||
|
||||
res = agent.run("Write a hello world")
|
||||
assert res.content == "Hello from the local model."
|
||||
assert res.metadata["finish"] == "stop"
|
||||
assert res.metadata["model_id"] == "local-model"
|
||||
assert res.metadata["agent"] == "build"
|
||||
# the model was addressed as openjarvis/local-model
|
||||
assert _FakeClient.last_body["model"] == {
|
||||
"providerID": "openjarvis", "modelID": "local-model"
|
||||
}
|
||||
assert _FakeClient.last_body["agent"] == "build"
|
||||
Reference in New Issue
Block a user