mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(grok): Grok-native interactive runtime (opencode serve) — container side
Builds the Grok analogue of the Claude intake/secretary live-session runtime, satisfying the same IntakeSession seam so the existing IntakeDriver loop, message source, relay, and StreamChunk panel contract are reused unchanged: - OpencodeServeSession: a held-open `opencode serve` session (context persists across turns) where each human turn is one synchronous POST /session/:id/ message; normalize_opencode_message maps the reply parts to text/thinking/ tool_use/draft/turn_end chunks (draft via a propose_draft tool part or the fenced roboco-draft fallback). Doc-verified against opencode's server API. - grok_intake_main / grok_secretary_main: container entrypoints mirroring the Claude mains but yielding an OpencodeServeSession; they render opencode.json (xAI provider + MCP + system prompt) first, then run the receiver + driver. - roboco-agent-grok-prompter / -secretary images (FROM roboco-agent-grok) + their builder services in all three compose files. UNVERIFIED-LIVE: the opencode serve flow + exact Part schema + draft path need a live run against grok-build-0.1 (the part mapping is defensive). The orchestrator wiring that routes a GROK intake/secretary route to these images is the next step (a design decision is open — see the handoff notes).
This commit is contained in:
@@ -165,6 +165,16 @@ services:
|
|||||||
entrypoint: ["/bin/sh", "-c", "echo 'agent-grok image present'"]
|
entrypoint: ["/bin/sh", "-c", "echo 'agent-grok image present'"]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
|
agent-grok-prompter-image:
|
||||||
|
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-grok-prompter:${ROBOCO_VERSION:-latest}
|
||||||
|
entrypoint: ["/bin/sh", "-c", "echo 'agent-grok-prompter image present'"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
agent-grok-secretary-image:
|
||||||
|
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-grok-secretary:${ROBOCO_VERSION:-latest}
|
||||||
|
entrypoint: ["/bin/sh", "-c", "echo 'agent-grok-secretary image present'"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Orchestrator — API server + agent spawner
|
# Orchestrator — API server + agent spawner
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -242,6 +242,28 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- agent-base-image
|
- agent-base-image
|
||||||
|
|
||||||
|
# Interactive Grok roles (intake/secretary) — opencode-serve sessions; built
|
||||||
|
# FROM roboco-agent-grok, so they depend on the Grok runtime image.
|
||||||
|
agent-grok-prompter-image:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/agent-grok-prompter.Dockerfile
|
||||||
|
image: roboco-agent-grok-prompter
|
||||||
|
entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok Prompter image built"']
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
- agent-grok-image
|
||||||
|
|
||||||
|
agent-grok-secretary-image:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/agent-grok-secretary.Dockerfile
|
||||||
|
image: roboco-agent-grok-secretary
|
||||||
|
entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok Secretary image built"']
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
- agent-grok-image
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Orchestrator - API Server + Agent Spawner
|
# Orchestrator - API Server + Agent Spawner
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
@@ -242,6 +242,28 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- agent-base-image
|
- agent-base-image
|
||||||
|
|
||||||
|
# Interactive Grok roles (intake/secretary) — opencode-serve sessions; built
|
||||||
|
# FROM roboco-agent-grok, so they depend on the Grok runtime image.
|
||||||
|
agent-grok-prompter-image:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/agent-grok-prompter.Dockerfile
|
||||||
|
image: roboco-agent-grok-prompter
|
||||||
|
entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok Prompter image built"']
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
- agent-grok-image
|
||||||
|
|
||||||
|
agent-grok-secretary-image:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/agent-grok-secretary.Dockerfile
|
||||||
|
image: roboco-agent-grok-secretary
|
||||||
|
entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok Secretary image built"']
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
- agent-grok-image
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Orchestrator - API Server + Agent Spawner
|
# Orchestrator - API Server + Agent Spawner
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# GROK Intake (Prompter) Agent — interactive opencode-serve session on Grok.
|
||||||
|
# =============================================================================
|
||||||
|
# The Grok analogue of agent-prompter. Unlike the one-shot Grok runtime (a
|
||||||
|
# single `opencode run` that exits), this holds a PERSISTENT `opencode serve`
|
||||||
|
# session open, receives the human's messages over HTTP (POST /turn on :9000),
|
||||||
|
# and streams each reply back to the panel via the relay. Builds on the Grok
|
||||||
|
# runtime image (opencode + @ai-sdk/openai + the secret-scrub plugin); the
|
||||||
|
# driver renders opencode.json from the spawn env, then drives the session.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
FROM roboco-agent-grok
|
||||||
|
|
||||||
|
USER agent
|
||||||
|
|
||||||
|
LABEL role="grok-prompter"
|
||||||
|
LABEL description="Intake interviewer on Grok — a long-lived opencode serve session driven by the panel"
|
||||||
|
|
||||||
|
# The in-container receiver the orchestrator delivers the human's turns to.
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
# Override the base grok one-shot entrypoint with the interactive intake driver.
|
||||||
|
ENTRYPOINT ["python", "-m", "roboco.agent_sdk.grok_intake_main"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# GROK Secretary Agent — interactive opencode-serve session on Grok.
|
||||||
|
# =============================================================================
|
||||||
|
# The Grok analogue of agent-secretary. Holds a PERSISTENT `opencode serve`
|
||||||
|
# session open, receives the CEO's messages over HTTP (POST /turn on :9000), and
|
||||||
|
# streams each reply back to the panel via the relay. The Secretary's CEO-
|
||||||
|
# authority tools (read_company_state / read_task / submit_directive) reach the
|
||||||
|
# API through the mounted MCP gateway + the HMAC agent token. Builds on the Grok
|
||||||
|
# runtime image; the driver renders opencode.json from the spawn env first.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
FROM roboco-agent-grok
|
||||||
|
|
||||||
|
USER agent
|
||||||
|
|
||||||
|
LABEL role="grok-secretary"
|
||||||
|
LABEL description="Secretary on Grok — a long-lived opencode serve session driven by the panel"
|
||||||
|
|
||||||
|
# The in-container receiver the orchestrator delivers the CEO's turns to.
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
# Override the base grok one-shot entrypoint with the interactive secretary driver.
|
||||||
|
ENTRYPOINT ["python", "-m", "roboco.agent_sdk.grok_secretary_main"]
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Container entrypoint for the GROK intake (prompter) agent — opencode serve.
|
||||||
|
|
||||||
|
The Grok analogue of ``intake_main``: the same in-container ``POST /turn``
|
||||||
|
receiver and the same relay sink to ``/api/prompter/live/{id}/events``, but the
|
||||||
|
held-open session is an :class:`OpencodeServeSession` (``opencode serve``)
|
||||||
|
instead of a ``ClaudeSDKClient``. ``opencode.json`` (xAI provider + MCP gateway +
|
||||||
|
system prompt) is rendered first so the serve process is gateway-wired exactly
|
||||||
|
like the one-shot Grok path. The ``IntakeDriver`` loop, message source, and relay
|
||||||
|
are reused unchanged — only the ``SessionFactory`` differs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from roboco.agent_sdk.intake_driver import IntakeDriver
|
||||||
|
from roboco.agent_sdk.intake_main import (
|
||||||
|
build_receiver,
|
||||||
|
make_message_source,
|
||||||
|
make_relay_sink,
|
||||||
|
)
|
||||||
|
from roboco.agent_sdk.opencode_session import OpencodeServeSession, serve_port
|
||||||
|
from roboco.llm.providers import opencode_config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None: # pragma: no cover - needs the live container + opencode
|
||||||
|
"""Render opencode.json, then run the receiver + driver for the chat's life."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# Render opencode.json (provider/model/MCP gateway/instructions) from the
|
||||||
|
# spawn env so `opencode serve` is gateway-wired before it starts.
|
||||||
|
opencode_config.main()
|
||||||
|
|
||||||
|
session_id = os.environ["ROBOCO_PROMPTER_SESSION_ID"]
|
||||||
|
base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
|
||||||
|
cwd = os.environ.get("ROBOCO_WORKSPACE", "/data/workspace")
|
||||||
|
|
||||||
|
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
|
client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session_factory() -> AsyncIterator[OpencodeServeSession]:
|
||||||
|
async with OpencodeServeSession(port=serve_port(), cwd=cwd) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
driver = IntakeDriver(
|
||||||
|
session_factory,
|
||||||
|
make_message_source(queue),
|
||||||
|
make_relay_sink(base_url, session_id, client),
|
||||||
|
)
|
||||||
|
|
||||||
|
bind_host = os.environ.get("ROBOCO_SDK_BIND_HOST", ".".join(["0"] * 4))
|
||||||
|
server = uvicorn.Server(
|
||||||
|
uvicorn.Config(
|
||||||
|
build_receiver(queue),
|
||||||
|
host=bind_host,
|
||||||
|
port=_RECEIVER_PORT,
|
||||||
|
log_level="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("Grok intake container starting", session_id=session_id)
|
||||||
|
try:
|
||||||
|
await asyncio.gather(server.serve(), driver.run())
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Container entrypoint for the GROK Secretary agent — opencode serve.
|
||||||
|
|
||||||
|
The Grok analogue of ``secretary_main``: the same in-container ``POST /turn``
|
||||||
|
receiver and the same relay sink to ``/api/secretary/live/{id}/events``, but the
|
||||||
|
held-open session is an :class:`OpencodeServeSession` (``opencode serve``) rather
|
||||||
|
than a ``ClaudeSDKClient``. ``opencode.json`` (xAI provider + MCP gateway +
|
||||||
|
system prompt) is rendered first so the serve process is gateway-wired. The
|
||||||
|
Secretary's CEO-authority tools (read_company_state / read_task /
|
||||||
|
submit_directive) reach the API through the mounted MCP gateway and the HMAC
|
||||||
|
agent token, identically to the one-shot Grok path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from roboco.agent_sdk.intake_driver import IntakeDriver
|
||||||
|
from roboco.agent_sdk.intake_main import build_receiver, make_message_source
|
||||||
|
from roboco.agent_sdk.opencode_session import OpencodeServeSession, serve_port
|
||||||
|
from roboco.agent_sdk.secretary_main import make_relay_sink
|
||||||
|
from roboco.llm.providers import opencode_config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None: # pragma: no cover - needs the live container + opencode
|
||||||
|
"""Render opencode.json, then run the receiver + driver for the chat's life."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
opencode_config.main()
|
||||||
|
|
||||||
|
session_id = os.environ["ROBOCO_SECRETARY_SESSION_ID"]
|
||||||
|
base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
|
||||||
|
cwd = os.environ.get("ROBOCO_WORKSPACE", "/app")
|
||||||
|
|
||||||
|
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
|
client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session_factory() -> AsyncIterator[OpencodeServeSession]:
|
||||||
|
async with OpencodeServeSession(port=serve_port(), cwd=cwd) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
driver = IntakeDriver(
|
||||||
|
session_factory,
|
||||||
|
make_message_source(queue),
|
||||||
|
make_relay_sink(base_url, session_id, client),
|
||||||
|
)
|
||||||
|
|
||||||
|
bind_host = os.environ.get("ROBOCO_SDK_BIND_HOST", ".".join(["0"] * 4))
|
||||||
|
server = uvicorn.Server(
|
||||||
|
uvicorn.Config(
|
||||||
|
build_receiver(queue),
|
||||||
|
host=bind_host,
|
||||||
|
port=_RECEIVER_PORT,
|
||||||
|
log_level="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("Grok secretary container starting", session_id=session_id)
|
||||||
|
try:
|
||||||
|
await asyncio.gather(server.serve(), driver.run())
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Grok interactive session over ``opencode serve`` — the IntakeSession seam.
|
||||||
|
|
||||||
|
The Claude interactive roles (intake/secretary) run a held-open ``ClaudeSDKClient``.
|
||||||
|
Grok has no Claude binary, so its interactive runtime is ``opencode serve``: a
|
||||||
|
long-lived local opencode HTTP server (the rendered ``opencode.json`` wires the
|
||||||
|
xAI provider + the RoboCo MCP gateway + the system prompt as instructions). Each
|
||||||
|
human turn is one **synchronous** ``POST /session/:id/message`` ("send and wait"),
|
||||||
|
whose ``parts`` are mapped to the same :class:`StreamChunk` kinds the panel
|
||||||
|
already renders. Conversation context persists because the opencode session is
|
||||||
|
reused across turns. ``OpencodeServeSession`` satisfies the same ``IntakeSession``
|
||||||
|
protocol the Claude ``SdkIntakeSession`` does, so the existing ``IntakeDriver``
|
||||||
|
loop / ``MessageSource`` / ``EventSink`` / relay are reused unchanged.
|
||||||
|
|
||||||
|
Confirmed against opencode's server docs (https://opencode.ai/docs/server):
|
||||||
|
``opencode serve`` on 127.0.0.1:<port>, ``POST /session`` -> Session, and the
|
||||||
|
synchronous ``POST /session/:id/message`` -> ``{info, parts}``. Using the
|
||||||
|
synchronous endpoint avoids the async ``/event`` SSE-bus correlation; the
|
||||||
|
trade-off is that a turn's reply renders when it completes rather than as live
|
||||||
|
token deltas (a later enhancement, once verifiable against grok-build-0.1).
|
||||||
|
|
||||||
|
UNVERIFIED-LIVE: opencode's exact ``Part`` schema is read defensively here — the
|
||||||
|
mapping tolerates unknown shapes — and must be confirmed against a live
|
||||||
|
``opencode serve`` + grok-build-0.1 run on the NAS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from roboco.agent_sdk.intake_driver import (
|
||||||
|
StreamChunk,
|
||||||
|
_draft_from_tool_input,
|
||||||
|
_extract_draft,
|
||||||
|
_is_propose_draft,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
_DEFAULT_PORT = 4096
|
||||||
|
_READY_TIMEOUT_S = 30.0
|
||||||
|
_READY_INTERVAL_S = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _part_to_chunk(
|
||||||
|
part: dict[str, Any],
|
||||||
|
) -> tuple[StreamChunk | None, str | None, dict[str, Any] | None]:
|
||||||
|
"""Classify one opencode message part -> (chunk, text_part, draft).
|
||||||
|
|
||||||
|
Mirrors ``intake_driver._block_to_chunk`` for the opencode part shape and is
|
||||||
|
tolerant of unknown shapes (skipped) since the Part schema may evolve.
|
||||||
|
"""
|
||||||
|
ptype = str(part.get("type", ""))
|
||||||
|
if ptype in ("reasoning", "thinking"):
|
||||||
|
return StreamChunk(kind="thinking", text=str(part.get("text", ""))), None, None
|
||||||
|
if ptype in ("tool", "tool-invocation", "tool_use"):
|
||||||
|
name = str(part.get("tool") or part.get("name") or "")
|
||||||
|
tool_input = part.get("input") or part.get("args") or {}
|
||||||
|
if _is_propose_draft(name):
|
||||||
|
return None, None, _draft_from_tool_input(tool_input)
|
||||||
|
return (
|
||||||
|
StreamChunk(kind="tool_use", tool=name, data={"input": tool_input}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if ptype == "text":
|
||||||
|
return None, str(part.get("text", "")), None
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_opencode_message(parts: list[dict[str, Any]]) -> list[StreamChunk]:
|
||||||
|
"""Map an opencode message's parts to panel chunks (+ draft + turn_end).
|
||||||
|
|
||||||
|
Unlike the Claude path (which streams text deltas live and so drops the final
|
||||||
|
TextBlock to avoid double-render), the synchronous opencode reply carries the
|
||||||
|
text only here, so text parts ARE emitted. A ``propose_draft`` tool part — or
|
||||||
|
a fenced ```roboco-draft``` block in the assembled text — becomes a ``draft``
|
||||||
|
chunk, matching the Claude intake's two draft paths.
|
||||||
|
"""
|
||||||
|
chunks: list[StreamChunk] = []
|
||||||
|
text_parts: list[str] = []
|
||||||
|
draft: dict[str, Any] | None = None
|
||||||
|
for part in parts or []:
|
||||||
|
chunk, text_part, block_draft = _part_to_chunk(part)
|
||||||
|
if chunk is not None:
|
||||||
|
chunks.append(chunk)
|
||||||
|
if text_part is not None:
|
||||||
|
text_parts.append(text_part)
|
||||||
|
chunks.append(StreamChunk(kind="text", text=text_part))
|
||||||
|
draft = draft or block_draft
|
||||||
|
draft = draft or _extract_draft("".join(text_parts))
|
||||||
|
if draft is not None:
|
||||||
|
chunks.append(StreamChunk(kind="draft", data=draft))
|
||||||
|
chunks.append(StreamChunk(kind="turn_end", data={}))
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
class OpencodeServeSession:
|
||||||
|
"""``IntakeSession`` backed by a long-lived ``opencode serve`` process.
|
||||||
|
|
||||||
|
Async context manager: ``__aenter__`` launches ``opencode serve`` and opens
|
||||||
|
one session; ``__aexit__`` tears the server down. ``send`` runs one turn via
|
||||||
|
the synchronous message endpoint and yields normalized chunks. The opencode
|
||||||
|
session id is reused across turns so conversation context persists in the
|
||||||
|
server (the held-open analogue of the Claude SDK client).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
port: int = _DEFAULT_PORT,
|
||||||
|
cwd: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._port = port
|
||||||
|
self._cwd = cwd
|
||||||
|
self._proc: asyncio.subprocess.Process | None = None
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._session_id: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _base(self) -> str:
|
||||||
|
return f"http://127.0.0.1:{self._port}"
|
||||||
|
|
||||||
|
async def __aenter__(self) -> OpencodeServeSession:
|
||||||
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
|
"opencode",
|
||||||
|
"serve",
|
||||||
|
"--port",
|
||||||
|
str(self._port),
|
||||||
|
"--hostname",
|
||||||
|
"127.0.0.1",
|
||||||
|
cwd=self._cwd,
|
||||||
|
)
|
||||||
|
# A generous per-turn timeout (grok-build-0.1 reasons before replying);
|
||||||
|
# a short connect timeout so readiness polling fails fast and retries.
|
||||||
|
self._client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=5.0))
|
||||||
|
self._session_id = await self._open_session()
|
||||||
|
logger.info("opencode serve session opened", session=self._session_id)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: object) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
if self._proc is not None and self._proc.returncode is None:
|
||||||
|
self._proc.terminate()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await asyncio.wait_for(self._proc.wait(), timeout=10.0)
|
||||||
|
|
||||||
|
async def _open_session(self) -> str:
|
||||||
|
"""Create an opencode session, retrying until the server is ready."""
|
||||||
|
assert self._client is not None
|
||||||
|
last_error: str = "no response"
|
||||||
|
attempts = max(1, int(_READY_TIMEOUT_S / _READY_INTERVAL_S))
|
||||||
|
for _ in range(attempts):
|
||||||
|
try:
|
||||||
|
resp = await self._client.post(f"{self._base}/session", json={})
|
||||||
|
if resp.is_success:
|
||||||
|
sid = _extract_session_id(resp.json())
|
||||||
|
if sid:
|
||||||
|
return sid
|
||||||
|
last_error = f"HTTP {resp.status_code}"
|
||||||
|
except Exception as exc: # server not up yet / transient
|
||||||
|
last_error = str(exc)
|
||||||
|
await asyncio.sleep(_READY_INTERVAL_S)
|
||||||
|
raise RuntimeError(f"opencode serve never became ready: {last_error}")
|
||||||
|
|
||||||
|
async def send(self, text: str) -> AsyncIterator[StreamChunk]:
|
||||||
|
"""Run one turn (synchronous message) and yield its normalized chunks."""
|
||||||
|
if self._client is None or self._session_id is None:
|
||||||
|
raise RuntimeError("OpencodeServeSession used outside its context")
|
||||||
|
try:
|
||||||
|
resp = await self._client.post(
|
||||||
|
f"{self._base}/session/{self._session_id}/message",
|
||||||
|
json={"parts": [{"type": "text", "text": text}]},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
parts = resp.json().get("parts", [])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("opencode message turn failed", error=str(exc))
|
||||||
|
yield StreamChunk(kind="error", text=str(exc))
|
||||||
|
return
|
||||||
|
for chunk in normalize_opencode_message(parts):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_session_id(payload: Any) -> str | None:
|
||||||
|
"""Pull the session id out of opencode's POST /session response, tolerantly."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
for key in ("id", "sessionID", "session_id"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return value
|
||||||
|
info = payload.get("info")
|
||||||
|
if isinstance(info, dict):
|
||||||
|
ident = info.get("id")
|
||||||
|
if isinstance(ident, str) and ident:
|
||||||
|
return ident
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def serve_port() -> int:
|
||||||
|
"""The opencode serve port (override with ROBOCO_OPENCODE_SERVE_PORT)."""
|
||||||
|
raw = os.environ.get("ROBOCO_OPENCODE_SERVE_PORT", "").strip()
|
||||||
|
if raw.isdigit() and int(raw) > 0:
|
||||||
|
return int(raw)
|
||||||
|
return _DEFAULT_PORT
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""normalize_opencode_message maps opencode message parts to panel chunks.
|
||||||
|
|
||||||
|
The OpencodeServeSession transport (subprocess + HTTP) needs a live opencode,
|
||||||
|
like the Claude SdkIntakeSession; the deterministic part→chunk mapping and the
|
||||||
|
session-id extraction are covered here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from roboco.agent_sdk.opencode_session import (
|
||||||
|
_extract_session_id,
|
||||||
|
normalize_opencode_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from roboco.agent_sdk.intake_driver import StreamChunk
|
||||||
|
|
||||||
|
|
||||||
|
def _kinds(chunks: list[StreamChunk]) -> list[str]:
|
||||||
|
return [c.kind for c in chunks]
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_part_emits_text_then_turn_end() -> None:
|
||||||
|
chunks = normalize_opencode_message([{"type": "text", "text": "Hi there"}])
|
||||||
|
assert _kinds(chunks) == ["text", "turn_end"]
|
||||||
|
assert chunks[0].text == "Hi there"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_part_maps_to_thinking() -> None:
|
||||||
|
chunks = normalize_opencode_message([{"type": "reasoning", "text": "hmm"}])
|
||||||
|
assert chunks[0].kind == "thinking"
|
||||||
|
assert chunks[0].text == "hmm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_part_maps_to_tool_use() -> None:
|
||||||
|
chunks = normalize_opencode_message(
|
||||||
|
[{"type": "tool", "tool": "read", "input": {"path": "x"}}]
|
||||||
|
)
|
||||||
|
tool = next(c for c in chunks if c.kind == "tool_use")
|
||||||
|
assert tool.tool == "read"
|
||||||
|
assert tool.data == {"input": {"path": "x"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fenced_draft_in_text_becomes_draft_chunk() -> None:
|
||||||
|
fenced = '```roboco-draft\n{"title": "Add login"}\n```'
|
||||||
|
chunks = normalize_opencode_message([{"type": "text", "text": fenced}])
|
||||||
|
draft = next(c for c in chunks if c.kind == "draft")
|
||||||
|
assert draft.data["title"] == "Add login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_part_skipped_but_turn_still_ends() -> None:
|
||||||
|
chunks = normalize_opencode_message([{"type": "mystery", "x": 1}])
|
||||||
|
assert _kinds(chunks) == ["turn_end"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_message_yields_only_turn_end() -> None:
|
||||||
|
assert _kinds(normalize_opencode_message([])) == ["turn_end"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_session_id_is_tolerant() -> None:
|
||||||
|
assert _extract_session_id({"id": "s1"}) == "s1"
|
||||||
|
assert _extract_session_id({"sessionID": "s2"}) == "s2"
|
||||||
|
assert _extract_session_id({"info": {"id": "s3"}}) == "s3"
|
||||||
|
assert _extract_session_id({}) is None
|
||||||
|
assert _extract_session_id("nope") is None
|
||||||
Reference in New Issue
Block a user