From 4fa9163ea95e5862ad71565621080b8425d7e2bf Mon Sep 17 00:00:00 2001 From: krypticmouse Date: Fri, 29 May 2026 00:41:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(agents):=20add=20OpenCodeAgent=20=E2=80=94?= =?UTF-8?q?=20run=20the=20opencode=20coding=20agent=20on=20a=20local=20eng?= =?UTF-8?q?ine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/v1`) and writes an `opencode.json` registering it as an `@ai-sdk/openai-compatible` provider (`openjarvis/`). - 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) --- docs/user-guide/agents.md | 49 ++++ src/openjarvis/agents/__init__.py | 5 + src/openjarvis/agents/opencode.py | 332 ++++++++++++++++++++++++++++ tests/agents/test_opencode_agent.py | 174 +++++++++++++++ 4 files changed, 560 insertions(+) create mode 100644 src/openjarvis/agents/opencode.py create mode 100644 tests/agents/test_opencode_agent.py diff --git a/docs/user-guide/agents.md b/docs/user-guide/agents.md index 2a988f54..8cec9dd6 100644 --- a/docs/user-guide/agents.md +++ b/docs/user-guide/agents.md @@ -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 `/v1`) and writes an `opencode.json` in the workspace registering it as an `@ai-sdk/openai-compatible` provider (`openjarvis/`). +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`. diff --git a/src/openjarvis/agents/__init__.py b/src/openjarvis/agents/__init__.py index 506e2c13..e616628e 100644 --- a/src/openjarvis/agents/__init__.py +++ b/src/openjarvis/agents/__init__.py @@ -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: diff --git a/src/openjarvis/agents/opencode.py b/src/openjarvis/agents/opencode.py new file mode 100644 index 00000000..a53cce11 --- /dev/null +++ b/src/openjarvis/agents/opencode.py @@ -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 ``/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 ``/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 " 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"] diff --git a/tests/agents/test_opencode_agent.py b/tests/agents/test_opencode_agent.py new file mode 100644 index 00000000..27c2b21c --- /dev/null +++ b/tests/agents/test_opencode_agent.py @@ -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"