feat: switch to opencode

This commit is contained in:
2026-07-21 00:00:24 +02:00
parent 60cb143402
commit 6f24df8cd3
45 changed files with 1288 additions and 757 deletions
+1 -2
View File
@@ -1,4 +1,3 @@
"""Gitea-triggered Codex workflow host."""
"""Gitea-triggered OpenCode workflow host."""
__version__ = "0.1.0"
-250
View File
@@ -1,250 +0,0 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import tempfile
from pathlib import Path
from time import monotonic
from typing import TypeVar
from pydantic import BaseModel
from agentci.adapters.codegraph import CodeGraphClient
T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
class CodexError(RuntimeError):
pass
class CodexClient:
def __init__(
self,
*,
codex_home: Path,
schemas_dir: Path,
timeout_seconds: int,
research_model: str,
research_reasoning: str,
context7_api_key: str | None,
tools_bin: Path | None = None,
codegraph: CodeGraphClient | None = None,
) -> None:
self.codex_home = codex_home
self.schemas_dir = schemas_dir
self.timeout_seconds = timeout_seconds
self.context7_api_key = context7_api_key
self.tools_bin = tools_bin
self.codegraph = codegraph or CodeGraphClient()
self._write_research_agent(research_model, research_reasoning)
async def login_ready(self) -> bool:
try:
process = await asyncio.create_subprocess_exec(
"codex",
"login",
"status",
env=self._environment(),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
except OSError:
return False
return await process.wait() == 0
async def start(
self,
*,
workspace: Path,
prompt: str,
model: str,
reasoning: str,
permission: str,
schema_name: str,
result_type: type[T],
) -> tuple[str, T]:
await self.codegraph.prepare(workspace)
args = [
"codex",
"exec",
"--json",
"--strict-config",
"-C",
str(workspace),
*self._turn_args(model, reasoning, permission, schema_name),
"-",
]
session_id, result = await self._invoke(args, prompt, result_type, workspace=workspace)
if not session_id:
raise CodexError("Codex did not emit a thread.started event")
return session_id, result
async def resume(
self,
*,
session_id: str,
prompt: str,
model: str,
reasoning: str,
permission: str,
workspace: Path,
schema_name: str,
result_type: type[T],
) -> T:
await self.codegraph.prepare(workspace)
args = [
"codex",
"exec",
"resume",
"--json",
"--strict-config",
*self._turn_args(model, reasoning, permission, schema_name),
session_id,
"-",
]
_, result = await self._invoke(args, prompt, result_type, workspace=workspace)
return result
def _turn_args(
self,
model: str,
reasoning: str,
permission: str,
schema_name: str,
) -> list[str]:
return [
"--skip-git-repo-check",
"-m",
model,
"-c",
f'model_reasoning_effort="{reasoning}"',
"-c",
f'default_permissions="{permission}"',
"--output-schema",
str(self.schemas_dir / schema_name),
]
async def _invoke(
self, args: list[str], prompt: str, result_type: type[T], *, workspace: Path
) -> tuple[str | None, T]:
started = monotonic()
operation = "codex.resume" if "resume" in args else "codex.start"
log.info("Codex turn started", extra={"operation": operation})
file_descriptor, output_name = tempfile.mkstemp(
prefix="agentci-codex-", suffix=".json"
)
os.close(file_descriptor)
output_path = Path(output_name)
args[-1:-1] = ["--output-last-message", str(output_path)]
try:
process = await asyncio.create_subprocess_exec(
*args,
cwd=workspace,
env=self._environment(),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(prompt.encode()), timeout=self.timeout_seconds
)
except TimeoutError as exc:
process.terminate()
await process.wait()
log.exception(
"Codex turn timed out",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise CodexError(f"Codex turn exceeded {self.timeout_seconds} seconds") from exc
if process.returncode:
detail = stderr.decode(errors="replace").strip()
log.error(
"Codex turn failed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise CodexError(f"Codex exited with {process.returncode}: {detail[-2000:]}")
session_id = _session_id(stdout.decode(errors="replace"))
text = output_path.read_text() # noqa: ASYNC240 - tiny host-owned result file
result = result_type.model_validate_json(text)
log.info(
"Codex turn completed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
return session_id, result
except (OSError, ValueError) as exc:
log.exception(
"Codex result could not be processed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise CodexError(f"Invalid Codex result: {exc}") from exc
finally:
output_path.unlink(missing_ok=True) # noqa: ASYNC240
def _environment(self) -> dict[str, str]:
allowed = {
"PATH",
"LANG",
"LC_ALL",
"SSL_CERT_FILE",
"CODEX_CA_CERTIFICATE",
"XDG_CONFIG_HOME",
}
environment = {key: value for key, value in os.environ.items() if key in allowed}
if self.tools_bin is not None:
environment["PATH"] = f"{self.tools_bin}:{environment.get('PATH', '')}"
environment["CODEX_HOME"] = str(self.codex_home)
if self.context7_api_key:
environment["CONTEXT7_API_KEY"] = self.context7_api_key
return environment
def _write_research_agent(self, model: str, reasoning: str) -> None:
agents_dir = self.codex_home / "agents"
agents_dir.mkdir(parents=True, exist_ok=True)
agent = f'''name = "research"
description = "Research specialist for current docs, web evidence, and public code examples."
model = {json.dumps(model)}
model_reasoning_effort = {json.dumps(reasoning)}
default_permissions = "agentci-research"
web_search = "live"
developer_instructions = """
Research external, current, or unfamiliar technical facts for the parent agent.
Use Context7 for library documentation, gh_grep for real public-code examples, and web search for
primary sources or broader verification. Prefer authoritative sources, report links, distinguish
facts from inference, and return a concise evidence-focused summary. You may inspect the workspace
but must not modify it. Never include secrets or proprietary source in external queries.
Do not use CodeGraph; repository analysis belongs to the parent agent.
"""
[mcp_servers.codegraph]
enabled = false
[mcp_servers.context7]
url = "https://mcp.context7.com/mcp"
[mcp_servers.context7.env_http_headers]
CONTEXT7_API_KEY = "CONTEXT7_API_KEY"
[mcp_servers.gh_grep]
url = "https://mcp.grep.app"
'''
(agents_dir / "research.toml").write_text(agent)
def _session_id(output: str) -> str | None:
for line in output.splitlines():
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") == "thread.started":
return str(event["thread_id"])
return None
def _elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
+7
View File
@@ -34,6 +34,13 @@ class GiteaClient:
async def close(self) -> None:
await self.client.aclose()
async def has_write_permission(self, owner: str, repo: str, username: str) -> bool:
response = await self._request(
"GET", f"/repos/{owner}/{repo}/collaborators/{username}/permission"
)
permission = str(response.json().get("permission", "")).lower()
return permission in {"write", "admin", "owner"}
async def repository(self, owner: str, repo: str) -> RepositoryInfo:
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
return RepositoryInfo(
+8 -3
View File
@@ -39,8 +39,8 @@ class JobStore(Database):
INSERT INTO jobs (
id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, workflow_id,
status, stage, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
status, stage, runtime_session_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job.id,
@@ -56,6 +56,7 @@ class JobStore(Database):
job.workflow_id,
job.status,
job.stage,
job.runtime_session_id,
now(),
),
)
@@ -91,6 +92,7 @@ class JobStore(Database):
stage: str | None = None,
error: str | None = None,
workflow_id: str | None = None,
runtime_session_id: str | None = None,
) -> None:
updates: dict[str, object] = {}
if status is not None:
@@ -103,6 +105,8 @@ class JobStore(Database):
updates["error"] = error
if workflow_id is not None:
updates["workflow_id"] = workflow_id
if runtime_session_id is not None:
updates["runtime_session_id"] = runtime_session_id
await self._update("jobs", job_id, updates)
log.info(
"job state updated",
@@ -161,7 +165,7 @@ class JobStore(Database):
(
JobStatus.FAILED,
"interrupted",
"Service restarted during an active Codex turn",
"Service restarted during an active OpenCode turn",
now(),
JobStatus.RUNNING,
),
@@ -192,4 +196,5 @@ def job_from_row(
status=status or JobStatus(row["status"]),
stage=stage or row["stage"],
accepted_comment_id=row["accepted_comment_id"],
runtime_session_id=row["runtime_session_id"],
)
+249
View File
@@ -0,0 +1,249 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from time import monotonic
from typing import Any, TypeVar
import httpx
from pydantic import BaseModel, ValidationError
from agentci.adapters.codegraph import CodeGraphClient
from agentci.adapters.opencode_support import (
api_contract_ready,
directory_headers,
elapsed_ms,
error_message,
load_schema,
model_parts,
models_ready,
)
T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
class OpenCodeError(RuntimeError):
pass
class OpenCodeClient:
def __init__(
self,
*,
base_url: str,
username: str,
password: str,
schemas_dir: Path,
health_directory: Path,
required_models: tuple[tuple[str, str | None], ...],
timeout_seconds: int,
codegraph: CodeGraphClient | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self.schemas_dir = schemas_dir
self.health_directory = health_directory
self.required_models = {
(*model_parts(model), variant) for model, variant in required_models
}
self._contract_valid: bool | None = None
self.timeout_seconds = timeout_seconds
self.codegraph = codegraph or CodeGraphClient()
self._active_sessions: dict[str, Path] = {}
self.client = httpx.AsyncClient(
base_url=base_url.rstrip("/"),
auth=httpx.BasicAuth(username, password),
timeout=httpx.Timeout(timeout_seconds, connect=10),
transport=transport,
)
async def close(self) -> None:
for session_id, workspace in tuple(self._active_sessions.items()):
await self.abort(session_id, workspace)
await self.client.aclose()
async def ready(self) -> bool:
try:
health = await self.client.get("/global/health", timeout=10)
health.raise_for_status()
if health.json().get("healthy") is not True:
return False
if not str(health.json().get("version", "")).startswith("1."):
return False
if self._contract_valid is None:
document = await self.client.get("/doc", timeout=10)
document.raise_for_status()
self._contract_valid = api_contract_ready(document.json())
if not self._contract_valid:
return False
providers = await self.client.get(
"/provider", headers=directory_headers(self.health_directory), timeout=10
)
providers.raise_for_status()
return models_ready(providers.json(), self.required_models)
except (httpx.HTTPError, TypeError, ValueError):
return False
async def start(
self,
*,
workspace: Path,
prompt: str,
model: str,
variant: str | None,
schema_name: str,
result_type: type[T],
) -> tuple[str, T]:
session_id = await self.create_session(workspace, schema_name)
result = await self.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=model,
variant=variant,
schema_name=schema_name,
result_type=result_type,
)
return session_id, result
async def create_session(self, workspace: Path, title: str) -> str:
response = await self._request(
"POST",
"/session",
workspace=workspace,
json={"title": f"Agent CI: {title.removesuffix('.json')}"},
)
session_id = response.get("id")
if not isinstance(session_id, str) or not session_id:
raise OpenCodeError("OpenCode did not return a session ID")
return session_id
async def resume(
self,
*,
session_id: str,
prompt: str,
model: str,
variant: str | None,
workspace: Path,
schema_name: str,
result_type: type[T],
) -> T:
await self.codegraph.prepare(workspace)
self._active_sessions[session_id] = workspace
try:
return await self._prompt(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=model,
variant=variant,
schema_name=schema_name,
result_type=result_type,
)
except (asyncio.CancelledError, OpenCodeError):
await asyncio.shield(self.abort(session_id, workspace))
raise
finally:
self._active_sessions.pop(session_id, None)
async def _prompt(
self,
*,
session_id: str,
workspace: Path,
prompt: str,
model: str,
variant: str | None,
schema_name: str,
result_type: type[T],
) -> T:
try:
schema = load_schema(self.schemas_dir, schema_name)
except ValueError as exc:
raise OpenCodeError(str(exc)) from exc
provider_id, model_id = model_parts(model)
repair = "The previous response did not produce the required structured result. "
repair += "Return the requested result now without repeating repository work."
for attempt, message in enumerate((prompt, repair), start=1):
payload: dict[str, Any] = {
"model": {"providerID": provider_id, "modelID": model_id},
"agent": "build",
"parts": [{"type": "text", "text": message}],
"format": {"type": "json_schema", "schema": schema, "retryCount": 0},
}
if variant:
payload["variant"] = variant
started = monotonic()
extra = {"operation": "opencode.prompt", "attempt": attempt}
log.info("OpenCode turn started", extra=extra)
try:
response = await self._request(
"POST",
f"/session/{session_id}/message",
workspace=workspace,
json=payload,
)
except httpx.TimeoutException as exc:
await self.abort(session_id, workspace)
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
raise OpenCodeError(message) from exc
info = response.get("info")
if not isinstance(info, dict):
raise OpenCodeError("OpenCode response did not include assistant metadata")
error = error_message(info.get("error"))
structured = info.get("structured")
validation_failed = False
if structured is not None:
try:
result = result_type.model_validate(structured)
except ValidationError as exc:
error = f"structured result failed validation: {exc}"
validation_failed = True
else:
extra["duration_ms"] = elapsed_ms(started)
log.info("OpenCode turn completed", extra=extra)
return result
if attempt == 2 or (
error and "StructuredOutput" not in error and not validation_failed
):
raise OpenCodeError(f"OpenCode did not return a valid result: {error or 'missing'}")
log.warning("OpenCode structured result will be retried", extra=extra)
raise OpenCodeError("OpenCode did not return a valid result")
async def _request(
self,
method: str,
path: str,
*,
workspace: Path,
json: dict[str, Any] | None = None,
) -> dict[str, Any]:
try:
response = await self.client.request(
method, path, headers=directory_headers(workspace), json=json
)
response.raise_for_status()
payload = response.json()
except httpx.TimeoutException:
raise
except (httpx.HTTPError, ValueError) as exc:
detail = ""
if isinstance(exc, httpx.HTTPStatusError):
detail = f": {exc.response.text[-2000:]}"
message = f"OpenCode request failed: {method} {path}{detail}"
raise OpenCodeError(message) from exc
if not isinstance(payload, dict):
raise OpenCodeError(f"OpenCode returned an invalid response for {method} {path}")
return payload
async def abort(self, session_id: str, workspace: Path) -> None:
try:
await self.client.post(
f"/session/{session_id}/abort",
headers=directory_headers(workspace),
timeout=10,
)
except httpx.HTTPError:
log.exception("OpenCode session could not be aborted")
+77
View File
@@ -0,0 +1,77 @@
import json
from pathlib import Path
from time import monotonic
from typing import Any
def model_parts(model: str) -> tuple[str, str]:
provider, separator, model_id = model.partition("/")
if not separator or not provider or not model_id:
raise ValueError(f"OpenCode model must use provider/model format: {model}")
return provider, model_id
def error_message(error: object) -> str | None:
if not error:
return None
if isinstance(error, dict):
return str(error.get("name") or error.get("message") or error)
return str(error)
def elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
def directory_headers(workspace: Path) -> dict[str, str]:
return {"X-Opencode-Directory": str(workspace.resolve())}
def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]:
try:
value = json.loads((schemas_dir / name).read_text())
except (OSError, ValueError) as exc:
raise ValueError(f"Cannot load result schema {name}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError(f"Result schema {name} is not a JSON object")
return value
def api_contract_ready(document: object) -> bool:
if not isinstance(document, dict) or not isinstance(document.get("paths"), dict):
return False
paths = document["paths"]
fixed = {"/global/health": "get", "/provider": "get", "/session": "post"}
if any(method not in paths.get(path, {}) for path, method in fixed.items()):
return False
session_paths = [path for path in paths if path.startswith("/session/{")]
has_message = any(
path.endswith("/message") and "post" in paths[path] for path in session_paths
)
has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths)
return has_message and has_abort
def models_ready(
payload: object, requirements: set[tuple[str, str, str | None]]
) -> bool:
if not isinstance(payload, dict):
return False
connected = set(payload.get("connected", []))
providers = {
item.get("id"): item
for item in payload.get("all", [])
if isinstance(item, dict) and isinstance(item.get("models"), dict)
}
for provider_id, model_id, variant in requirements:
provider = providers.get(provider_id)
if provider_id not in connected or not isinstance(provider, dict):
return False
model: Any = provider["models"].get(model_id)
if not isinstance(model, dict) or model.get("status") == "deprecated":
return False
if model.get("capabilities", {}).get("toolcall") is not True:
return False
if variant and variant not in model.get("variants", {}):
return False
return True
+13 -2
View File
@@ -8,6 +8,15 @@ from agentci.domain.models import Workflow, WorkflowKind, WorkflowStatus
class WorkflowStore(Database):
async def get_workflow(self, workflow_id: str) -> Workflow | None:
return await self._run(
lambda connection: workflow_from_row(
connection.execute(
"SELECT * FROM workflows WHERE id=?", (workflow_id,)
).fetchone()
)
)
async def create_workflow(self, workflow: Workflow) -> None:
timestamp = now()
await self._run(
@@ -16,9 +25,9 @@ class WorkflowStore(Database):
INSERT INTO workflows (
id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id,
reviewer_session_id, artifact, review_json, status,
reviewer_session_id, artifact, review_json, status, runtime,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
workflow.id,
@@ -35,6 +44,7 @@ class WorkflowStore(Database):
workflow.artifact,
workflow.review_json,
workflow.status,
workflow.runtime,
timestamp,
timestamp,
),
@@ -135,6 +145,7 @@ def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
issue_number=row["issue_number"],
pr_number=row["pr_number"],
base_sha=row["base_sha"],
runtime=row["runtime"],
branch=row["branch"],
workspace_path=Path(row["workspace_path"]),
primary_session_id=row["primary_session_id"],
+2 -3
View File
@@ -12,8 +12,7 @@ async def live() -> dict[str, str]:
@router.get("/health/ready")
async def ready(request: Request, response: Response) -> dict[str, str]:
if not await request.app.state.container.codex.login_ready():
if not await request.app.state.container.opencode.ready():
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not-ready", "reason": "codex is not authenticated"}
return {"status": "not-ready", "reason": "opencode provider is not connected"}
return {"status": "ready"}
+13
View File
@@ -70,6 +70,19 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
if not event.body.strip().startswith("/agent"):
return Response(status_code=status.HTTP_204_NO_CONTENT)
log.info("agent command received", extra=extra)
permitted = await container.gitea.has_write_permission(
event.repo_owner, event.repo_name, event.requester
)
if not permitted:
log.warning("agent command rejected: insufficient permission", extra=extra)
if await container.storage.record_delivery(event.delivery_id, event.comment_id):
await container.gitea.create_comment(
event.repo_owner,
event.repo_name,
event.issue_number,
"Agent command rejected: repository write permission is required.",
)
return Response(status_code=status.HTTP_202_ACCEPTED)
try:
command = parse_command(event.body)
except CommandError as exc:
+4 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
@@ -37,8 +37,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
finally:
log.info("service shutdown started", extra={"operation": "service.shutdown"})
stop.set()
worker_task.cancel()
try:
await worker_task
with suppress(asyncio.CancelledError):
await worker_task
finally:
await container.close()
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
+24 -11
View File
@@ -5,7 +5,7 @@ from functools import cached_property
from pathlib import Path
from typing import Annotated
from pydantic import Field, SecretStr, field_validator
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
INSTALL_SCRIPT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
@@ -21,38 +21,45 @@ class Settings(BaseSettings):
host: str = "0.0.0.0"
port: int = 8080
data_dir: Path = Path("/var/lib/agentci")
codex_home: Path = Path("/var/lib/codex")
gitea_url: str = "http://gitea:3000"
gitea_token_file: Path = Path("/run/secrets/gitea_token")
webhook_secret_file: Path = Path("/run/secrets/webhook_secret")
opencode_url: str = "http://opencode:4096"
opencode_server_username: str = "opencode"
opencode_server_password_file: Path = Path("/run/secrets/opencode_server_password")
bot_username: str = "agentci"
bot_name: str = "Agent CI"
bot_email: str = "agentci@localhost"
branch_prefix: str = "agent"
askpass_path: Path = Path("/opt/agentci/scripts/gitea-askpass.sh")
plan_model: str = "gpt-5.6-sol"
plan_reasoning: str = "medium"
implement_model: str = "gpt-5.6-sol"
implement_reasoning: str = "high"
research_model: str = "gpt-5.6-luna"
research_reasoning: str = "high"
context7_api_key: SecretStr | None = None
plan_model: str = "openai/gpt-5.6-sol"
plan_variant: str | None = None
implement_model: str = "openai/gpt-5.6-sol"
implement_variant: str | None = None
research_model: str = "openai/gpt-5.6-luna"
plan_review_rounds: int = Field(default=4, ge=1, le=20)
implement_review_rounds: int = Field(default=3, ge=1, le=20)
turn_timeout_seconds: int = Field(default=3600, ge=60)
install_script_timeout_seconds: int = Field(default=900, ge=1)
worker_poll_seconds: float = Field(default=1.0, ge=0.1)
public_agent_network: bool = True
install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list)
install_scripts_dir: Path = Path("/etc/agentci/install-scripts")
python_version: str = "3.13"
dotnet_channel: str = "10.0"
@field_validator("gitea_url")
@field_validator("gitea_url", "opencode_url")
@classmethod
def strip_url(cls, value: str) -> str:
return value.rstrip("/")
@field_validator("plan_model", "implement_model", "research_model")
@classmethod
def validate_opencode_model(cls, value: str) -> str:
provider, separator, model = value.partition("/")
if not separator or not provider or not model:
raise ValueError("OpenCode models must use provider/model format")
return value
@field_validator("install_scripts", mode="before")
@classmethod
def parse_install_scripts(cls, value: object) -> list[str]:
@@ -77,6 +84,12 @@ class Settings(BaseSettings):
def webhook_secret(self) -> bytes:
return self._read_secret(self.webhook_secret_file, "webhook secret").encode()
@cached_property
def opencode_server_password(self) -> str:
return self._read_secret(
self.opencode_server_password_file, "OpenCode server password"
)
@property
def database_path(self) -> Path:
return self.data_dir / "agentci.sqlite3"
+17 -16
View File
@@ -4,10 +4,10 @@ import logging
from dataclasses import dataclass
from pathlib import Path
from agentci.adapters.codex import CodexClient
from agentci.adapters.development import DevelopmentEnvironment
from agentci.adapters.git import GitClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.config import Settings
from agentci.prompts import PromptLibrary
@@ -24,11 +24,12 @@ class Container:
storage: Storage
gitea: GiteaClient
git: GitClient
codex: CodexClient
opencode: OpenCodeClient
worker: Worker
async def close(self) -> None:
log.info("container shutdown started", extra={"operation": "container.close"})
await self.opencode.close()
await self.gitea.close()
log.info("container shutdown completed", extra={"operation": "container.close"})
@@ -38,7 +39,6 @@ async def build_container(settings: Settings) -> Container:
package_dir = Path(__file__).parent
settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
settings.codex_home.mkdir(parents=True, exist_ok=True)
storage = Storage(settings.database_path, package_dir / "migrations")
await storage.initialize()
gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
@@ -50,18 +50,18 @@ async def build_container(settings: Settings) -> Container:
commit_name=settings.bot_name,
commit_email=settings.bot_email,
)
codex = CodexClient(
codex_home=settings.codex_home,
opencode = OpenCodeClient(
base_url=settings.opencode_url,
username=settings.opencode_server_username,
password=settings.opencode_server_password,
schemas_dir=package_dir / "prompts" / "schemas",
timeout_seconds=settings.turn_timeout_seconds,
research_model=settings.research_model,
research_reasoning=settings.research_reasoning,
context7_api_key=(
settings.context7_api_key.get_secret_value()
if settings.context7_api_key is not None
else None
health_directory=settings.workspaces_dir,
required_models=(
(settings.plan_model, settings.plan_variant),
(settings.implement_model, settings.implement_variant),
(settings.research_model, None),
),
tools_bin=settings.dev_tools_dir / "bin",
timeout_seconds=settings.turn_timeout_seconds,
)
prompts = PromptLibrary()
context = ContextBuilder(gitea, storage)
@@ -78,7 +78,7 @@ async def build_container(settings: Settings) -> Container:
storage=storage,
gitea=gitea,
git=git,
codex=codex,
opencode=opencode,
prompts=prompts,
context=context,
development=development,
@@ -87,10 +87,11 @@ async def build_container(settings: Settings) -> Container:
worker = Worker(
storage=storage,
gitea=gitea,
codex=codex,
opencode=opencode,
dispatcher=dispatcher,
poll_seconds=settings.worker_poll_seconds,
workspaces_dir=settings.workspaces_dir,
)
container = Container(settings, storage, gitea, git, codex, worker)
container = Container(settings, storage, gitea, git, opencode, worker)
log.info("container initialization completed", extra={"operation": "container.build"})
return container
+2
View File
@@ -125,6 +125,7 @@ class Job:
status: JobStatus = JobStatus.QUEUED
stage: str = "queued"
accepted_comment_id: int | None = None
runtime_session_id: str | None = None
@dataclass
@@ -136,6 +137,7 @@ class Workflow:
issue_number: int
workspace_path: Path
base_sha: str
runtime: str = "opencode"
branch: str | None = None
pr_number: int | None = None
primary_session_id: str | None = None
@@ -0,0 +1,2 @@
ALTER TABLE workflows ADD COLUMN runtime TEXT NOT NULL DEFAULT 'codex';
ALTER TABLE jobs ADD COLUMN runtime_session_id TEXT;
+29 -5
View File
@@ -3,9 +3,10 @@ from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from pathlib import Path
from agentci.adapters.codex import CodexClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.domain.models import Job, JobStatus
from agentci.workflows.common import JobRejected
@@ -20,24 +21,26 @@ class Worker:
*,
storage: Storage,
gitea: GiteaClient,
codex: CodexClient,
opencode: OpenCodeClient,
dispatcher: Dispatcher,
poll_seconds: float,
workspaces_dir: Path,
) -> None:
self.storage = storage
self.gitea = gitea
self.codex = codex
self.opencode = opencode
self.dispatcher = dispatcher
self.poll_seconds = poll_seconds
self.workspaces_dir = workspaces_dir
async def run(self, stop: asyncio.Event) -> None:
log.info("worker started", extra={"operation": "worker.run"})
await self._report_interrupted()
try:
while not stop.is_set():
if not await self.codex.login_ready():
if not await self.opencode.ready():
log.warning(
"worker waiting for Codex authentication",
"worker waiting for OpenCode provider authentication",
extra={"operation": "worker.poll"},
)
await self._wait(stop)
@@ -106,12 +109,33 @@ class Worker:
extra={"operation": "worker.recover", "item_count": len(jobs)},
)
for job in jobs:
await self._abort_job_sessions(job)
await self._safe_fail_workflow(job)
await self._safe_comment(
job,
f"Agent job `{job.id}` failed because the service restarted during execution.",
)
async def _abort_job_sessions(self, job: Job) -> None:
sessions: set[tuple[str, Path]] = set()
workflow = None
if job.workflow_id:
workflow = await self.storage.get_workflow(job.workflow_id)
if workflow is not None:
sessions.update(
(session_id, workflow.workspace_path)
for session_id in (
workflow.primary_session_id,
workflow.reviewer_session_id,
)
if session_id
)
elif job.runtime_session_id:
workspace = self.workspaces_dir / f"fix-{job.id}" / "repo"
sessions.add((job.runtime_session_id, workspace))
for session_id, workspace in sessions:
await self.opencode.abort(session_id, workspace)
async def _safe_comment(self, job: Job, body: str) -> None:
try:
await self.gitea.create_comment(
+1 -2
View File
@@ -1,2 +1 @@
"""Codex workflow orchestration."""
"""OpenCode workflow orchestration."""
+1 -2
View File
@@ -22,7 +22,7 @@ class ChangeSet:
) -> str:
await self.deps.storage.update_job(job.id, stage="validating changes")
if not await self.deps.git.has_changes(workspace):
raise JobRejected("Codex completed without producing any file changes.")
raise JobRejected("OpenCode completed without producing any file changes.")
await self.deps.git.diff_check(workspace)
title = _commit_title(result.summary_markdown)
await self.deps.storage.update_job(job.id, stage="committing changes")
@@ -54,4 +54,3 @@ def _commit_title(markdown: str) -> str:
if value:
return value[:72]
return "apply requested changes"
+19 -18
View File
@@ -1,7 +1,12 @@
from __future__ import annotations
from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow
from agentci.workflows.common import Dependencies, report_for_prompt, report_json
from agentci.workflows.common import (
Dependencies,
report_for_prompt,
report_json,
required_session,
)
class CodeReviewLoop:
@@ -44,12 +49,11 @@ class CodeReviewLoop:
review=report_for_prompt(workflow.review_json),
development_environment=self.deps.development.description,
)
result = await self.deps.codex.resume(
session_id=_required(workflow.primary_session_id),
result = await self.deps.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
@@ -71,30 +75,27 @@ class CodeReviewLoop:
pull_context=pull_context,
)
if workflow.reviewer_session_id:
return await self.deps.codex.resume(
return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-review",
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id, report = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(
workflow.workspace_path, "implementation-review"
)
workflow.reviewer_session_id = session_id
await self.deps.storage.update_workflow(workflow)
report = await self.deps.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-review",
variant=self.deps.settings.implement_variant,
schema_name="review.json",
result_type=ReviewReport,
)
workflow.reviewer_session_id = session_id
return report
def _required(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted Codex session ID")
return value
+8 -2
View File
@@ -3,10 +3,10 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from agentci.adapters.codex import CodexClient
from agentci.adapters.development import DevelopmentEnvironment
from agentci.adapters.git import GitClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.config import Settings
from agentci.domain.models import ReviewReport
@@ -18,13 +18,19 @@ class JobRejected(RuntimeError):
"""A safe, expected workflow rejection to publish to the requester."""
def required_session(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted OpenCode session ID")
return value
@dataclass(frozen=True)
class Dependencies:
settings: Settings
storage: Storage
gitea: GiteaClient
git: GitClient
codex: CodexClient
opencode: OpenCodeClient
prompts: PromptLibrary
context: ContextBuilder
development: DevelopmentEnvironment
+7 -4
View File
@@ -76,16 +76,19 @@ class ImplementWorkflow:
request=job.message or "(no additional request)",
development_environment=self.deps.development.description,
)
session_id, result = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(workspace, "implementation")
workflow.primary_session_id = session_id
await self.deps.storage.update_workflow(workflow)
await self.deps.storage.update_job(job.id, runtime_session_id=session_id)
result = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
variant=self.deps.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow.primary_session_id = session_id
workflow.artifact = result.model_dump_json()
await self.deps.storage.update_workflow(workflow)
result, report = await self.review.run(
+30 -27
View File
@@ -17,6 +17,7 @@ from agentci.workflows.common import (
agent_comment,
report_for_prompt,
report_json,
required_session,
review_markdown,
)
@@ -56,16 +57,19 @@ class PlanWorkflow:
context=context,
request=job.message or "(no additional request)",
)
session_id, artifact = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(workspace, "plan")
workflow.primary_session_id = session_id
await self.deps.storage.update_workflow(workflow)
await self.deps.storage.update_job(job.id, runtime_session_id=session_id)
artifact = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
variant=self.deps.settings.plan_variant,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow.primary_session_id = session_id
workflow.artifact = artifact.plan_markdown
await self.deps.storage.update_workflow(workflow)
report = await self._review_loop(job, workflow, context, artifact)
@@ -73,18 +77,22 @@ class PlanWorkflow:
async def discuss(self, job: Job) -> None:
workflow = await self._latest_plan(job)
if workflow.runtime != "opencode":
raise JobRejected(
"The latest plan predates OpenCode and cannot be resumed; "
"start a new `/agent plan`."
)
if not workflow.primary_session_id or not workflow.artifact:
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="discussing")
prompt = self.deps.prompts.render(
"discuss", artifact=workflow.artifact, message=job.message
)
reply = await self.deps.codex.resume(
reply = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="discussion.json",
result_type=DiscussionReply,
@@ -99,6 +107,8 @@ class PlanWorkflow:
async def iterate(self, job: Job) -> None:
await self._reject_if_active_or_merged_pr(job)
workflow = await self._latest_plan(job)
if workflow.runtime != "opencode":
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
if not workflow.artifact:
@@ -116,12 +126,11 @@ class PlanWorkflow:
review=report_for_prompt(workflow.review_json),
message=job.message or "(refine using the latest discussion and prior review)",
)
artifact = await self.deps.codex.resume(
artifact = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
@@ -152,12 +161,11 @@ class PlanWorkflow:
artifact=artifact.plan_markdown,
review=report_for_prompt(workflow.review_json),
)
artifact = await self.deps.codex.resume(
session_id=_required(workflow.primary_session_id),
artifact = await self.deps.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
@@ -171,26 +179,27 @@ class PlanWorkflow:
"plan_review", context=context, artifact=artifact.plan_markdown
)
if workflow.reviewer_session_id:
return await self.deps.codex.resume(
return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-review",
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id, report = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(workflow.workspace_path, "plan-review")
workflow.reviewer_session_id = session_id
await self.deps.storage.update_workflow(workflow)
report = await self.deps.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-review",
variant=self.deps.settings.plan_variant,
schema_name="review.json",
result_type=ReviewReport,
)
workflow.reviewer_session_id = session_id
return report
async def _finish(
@@ -239,9 +248,3 @@ class PlanWorkflow:
f"Issue plan iteration is disabled because agent PR #{pull.number} "
"is open or merged. Iterate an open implementation on its PR."
)
def _required(value: str | None) -> str:
if value is None: # Defensive: workflows with missing sessions are rejected earlier.
raise RuntimeError("Expected a persisted Codex session ID")
return value
+9 -6
View File
@@ -28,6 +28,8 @@ class PullRequestWorkflow:
raise JobRejected(
"This is not an open agent-created implementation PR. Use `/agent fix`."
)
if workflow.runtime != "opencode":
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The implementation sessions cannot be resumed.")
pull, context = await self.deps.context.pull_request_context(
@@ -54,12 +56,11 @@ class PullRequestWorkflow:
message=job.message or "(perform one additional reviewed refinement)",
development_environment=self.deps.development.description,
)
result = await self.deps.codex.resume(
result = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
@@ -125,12 +126,14 @@ class PullRequestWorkflow:
development_environment=self.deps.development.description,
)
await self.deps.storage.update_job(job.id, stage="fixing")
_, result = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(workspace, "fix")
await self.deps.storage.update_job(job.id, runtime_session_id=session_id)
result = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
variant=self.deps.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)