feat: switch to opencode
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
"""Gitea-triggered Codex workflow host."""
|
||||
"""Gitea-triggered OpenCode workflow host."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 +1 @@
|
||||
"""Codex workflow orchestration."""
|
||||
|
||||
"""OpenCode workflow orchestration."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user