feat: switch to opencode
This commit is contained in:
+6
-7
@@ -3,12 +3,12 @@ AGENTCI_GITEA_URL=http://gitea:3000
|
||||
AGENTCI_BOT_USERNAME=agentci
|
||||
AGENTCI_BOT_NAME=Agent CI
|
||||
AGENTCI_BOT_EMAIL=agentci@localhost
|
||||
AGENTCI_PLAN_MODEL=gpt-5.6-sol
|
||||
AGENTCI_PLAN_REASONING=medium
|
||||
AGENTCI_IMPLEMENT_MODEL=gpt-5.6-sol
|
||||
AGENTCI_IMPLEMENT_REASONING=high
|
||||
AGENTCI_RESEARCH_MODEL=gpt-5.6-luna
|
||||
AGENTCI_RESEARCH_REASONING=high
|
||||
OPENCODE_SERVER_USERNAME=opencode
|
||||
AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol
|
||||
AGENTCI_PLAN_VARIANT=
|
||||
AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol
|
||||
AGENTCI_IMPLEMENT_VARIANT=
|
||||
AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna
|
||||
# Optional; Context7 works without a key at lower rate limits.
|
||||
AGENTCI_CONTEXT7_API_KEY=
|
||||
AGENTCI_PLAN_REVIEW_ROUNDS=4
|
||||
@@ -19,5 +19,4 @@ AGENTCI_INSTALL_SCRIPTS=
|
||||
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
|
||||
AGENTCI_PYTHON_VERSION=3.13
|
||||
AGENTCI_DOTNET_CHANNEL=10.0
|
||||
CODEX_VERSION=0.144.6
|
||||
CODEGRAPH_VERSION=1.3.1
|
||||
|
||||
+33
-23
@@ -1,12 +1,16 @@
|
||||
ARG TEA_VERSION=0.14.2
|
||||
|
||||
FROM node:24-bookworm-slim AS codex
|
||||
FROM node:24-bookworm-slim AS agent-tools
|
||||
|
||||
ARG CODEX_VERSION=0.144.6
|
||||
ARG CODEGRAPH_VERSION=1.3.1
|
||||
RUN npm install --global \
|
||||
"@openai/codex@${CODEX_VERSION}" \
|
||||
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}"
|
||||
ARG OPENCODE_REFRESH
|
||||
RUN test -n "$OPENCODE_REFRESH" \
|
||||
|| { echo "OPENCODE_REFRESH is required; run scripts/build.sh" >&2; exit 1; } \
|
||||
&& npm install --global \
|
||||
'opencode-ai@^1' \
|
||||
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}" \
|
||||
&& version="$(opencode --version)" \
|
||||
&& case "$version" in 1.*) ;; *) echo "Expected OpenCode 1.x, got $version" >&2; exit 1;; esac
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:0.8.14 AS uv
|
||||
|
||||
@@ -19,30 +23,27 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_NO_DEV=1 \
|
||||
CODEGRAPH_TELEMETRY=0 \
|
||||
CODEX_HOME=/var/lib/codex \
|
||||
XDG_CONFIG_HOME=/run/agentci \
|
||||
PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends \
|
||||
adduser bubblewrap ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \
|
||||
adduser ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \
|
||||
libicu72 libssl3 libstdc++6 zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \
|
||||
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/codex /etc/codex /run/agentci \
|
||||
&& chown agentci:agentci /run/agentci \
|
||||
&& chmod u+s /usr/bin/bwrap
|
||||
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/opencode \
|
||||
/etc/opencode/home/.opencode /etc/opencode/xdg/opencode /run/agentci \
|
||||
&& chmod 0555 /etc/opencode/home /etc/opencode/home/.opencode \
|
||||
/etc/opencode/xdg /etc/opencode/xdg/opencode \
|
||||
&& chown agentci:agentci /run/agentci
|
||||
|
||||
COPY --from=uv /uv /uvx /usr/local/bin/
|
||||
COPY --from=tea /bin/tea /usr/local/bin/tea
|
||||
COPY --from=codex /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=codex /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex
|
||||
COPY --from=codex /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry
|
||||
RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex \
|
||||
&& ln -s \
|
||||
/usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex \
|
||||
/usr/local/bin/codex-linux-sandbox \
|
||||
&& ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
|
||||
COPY --from=tea /bin/tea /usr/local/libexec/tea
|
||||
COPY --from=agent-tools /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=agent-tools /usr/local/lib/node_modules/opencode-ai/bin/opencode.exe /usr/local/bin/opencode
|
||||
COPY --from=agent-tools /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry
|
||||
RUN ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
|
||||
/usr/local/bin/codegraph
|
||||
|
||||
WORKDIR /opt/agentci
|
||||
@@ -50,16 +51,25 @@ COPY pyproject.toml uv.lock README.md ./
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
COPY install-scripts /etc/agentci/install-scripts
|
||||
COPY codex/config.toml /etc/codex/config.toml
|
||||
COPY opencode /etc/opencode
|
||||
|
||||
RUN chmod 0755 \
|
||||
/opt/agentci/scripts/entrypoint.sh \
|
||||
/opt/agentci/scripts/gitea-askpass.sh \
|
||||
/usr/local/bin/tea \
|
||||
/usr/local/bin/codex \
|
||||
/opt/agentci/scripts/tea.sh \
|
||||
/usr/local/libexec/tea \
|
||||
/usr/local/bin/opencode \
|
||||
/usr/local/bin/codegraph \
|
||||
&& ln -s /opt/agentci/scripts/tea.sh /usr/local/bin/tea \
|
||||
&& uv sync --frozen --no-dev \
|
||||
&& chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/codex
|
||||
&& chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/opencode \
|
||||
&& opencode --version \
|
||||
&& OPENCODE_CONFIG=/etc/opencode/opencode.json \
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG=1 \
|
||||
OPENCODE_PURE=1 \
|
||||
AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna \
|
||||
CONTEXT7_API_KEY= \
|
||||
opencode debug config >/dev/null
|
||||
|
||||
USER agentci
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Agent CI
|
||||
|
||||
Agent CI is a private Gitea webhook host that turns issue and pull-request
|
||||
comments into resumable Codex planning and implementation workflows. It runs as
|
||||
one persistent Docker Compose service on the same Docker network as Gitea.
|
||||
Agent CI is a private Gitea webhook host that turns issue and pull-request comments into resumable
|
||||
OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a
|
||||
private OpenCode server on the same Docker network as Gitea.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -15,68 +15,89 @@ one persistent Docker Compose service on the same Docker network as Gitea.
|
||||
| PR | `/agent iterate [message]` | Resume an agent implementation and its reviewer once. |
|
||||
| PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. |
|
||||
|
||||
Anyone who can comment on an issue or pull request can enqueue commands. Each
|
||||
command gets separate queued and started comments. Final plans, PR results,
|
||||
failures, and remaining review findings are posted separately.
|
||||
The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Each
|
||||
accepted command gets separate queued and started comments. Final plans, PR results, failures, and
|
||||
remaining review findings are posted separately.
|
||||
|
||||
## Deploy
|
||||
|
||||
1. Create a Gitea bot user with repository read/write access and an API token.
|
||||
2. Copy `.env.example` to `.env` and set the external network and internal
|
||||
Gitea URL.
|
||||
3. Create `secrets/gitea_token` containing the bot token and
|
||||
`secrets/webhook_secret` containing a high-entropy webhook secret.
|
||||
4. Build and start the service:
|
||||
2. Copy `.env.example` to `.env` and set the external network, Gitea URL, and provider-qualified
|
||||
OpenCode models.
|
||||
3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
|
||||
`secrets/opencode_server_password`. Use high-entropy values for both secret/password files.
|
||||
4. Build the image:
|
||||
|
||||
```sh
|
||||
docker compose up --build -d
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
5. Authenticate Codex interactively in the persistent container:
|
||||
5. Authenticate the configured OpenCode providers before starting the persistent server:
|
||||
|
||||
```sh
|
||||
docker compose exec agentci codex login --device-auth
|
||||
docker compose exec agentci codex login status
|
||||
docker compose run --rm opencode opencode auth login
|
||||
docker compose run --rm opencode opencode auth list
|
||||
```
|
||||
|
||||
6. In Gitea, create a JSON webhook targeting
|
||||
`http://agentci:8080/webhooks/gitea`. Set the same secret and subscribe to
|
||||
issue comments, PR timeline comments, and PR review comments.
|
||||
6. Start the services:
|
||||
|
||||
`/health/live` reports process health. `/health/ready` returns 503 until Codex
|
||||
authentication is usable. The worker leaves jobs queued while authentication is
|
||||
missing.
|
||||
```sh
|
||||
docker compose up --no-build -d
|
||||
```
|
||||
|
||||
## Configuration
|
||||
7. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same
|
||||
webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments.
|
||||
|
||||
Model, reasoning effort, review-pass counts, bot identity, branch prefix, and
|
||||
turn timeout use `AGENTCI_` environment variables. Defaults are shown in
|
||||
`.env.example`. Gitea credentials and webhook secrets are intentionally
|
||||
file-based Compose secrets.
|
||||
OpenCode caches provider state. After adding or changing authentication on an already running
|
||||
deployment, run the one-off `auth login` command above and then `docker compose restart opencode`.
|
||||
|
||||
Every planning and implementation session can delegate external research to a
|
||||
read-only `research` subagent. It defaults to `gpt-5.6-luna` with high reasoning
|
||||
and has public network access, live web search, Context7 documentation lookup,
|
||||
and `gh_grep` public GitHub code search. Configure its model and effort with
|
||||
`AGENTCI_RESEARCH_MODEL` and `AGENTCI_RESEARCH_REASONING`. Context7 works
|
||||
without authentication at lower rate limits; set the optional
|
||||
`AGENTCI_CONTEXT7_API_KEY` for authenticated usage. The key is passed only to
|
||||
Codex's Context7 MCP transport and is excluded from agent shell environments.
|
||||
`/health/live` reports process health. `/health/ready` returns 503 until the OpenCode server is
|
||||
healthy and every configured model exists, supports tool calls, accepts its configured variant, and
|
||||
has a connected provider. The worker leaves jobs queued while the runtime is unavailable.
|
||||
|
||||
Planning, implementation, and all review sessions also receive the local
|
||||
CodeGraph MCP server for repository structure, symbol relationships, and change
|
||||
impact. Agent CI initializes or refreshes the index before every Codex turn and
|
||||
locally excludes `.codegraph/` from Git. The research subagent intentionally
|
||||
does not receive CodeGraph.
|
||||
## OpenCode
|
||||
|
||||
### Development environments
|
||||
Models use OpenCode's `provider/model` format. Planning, implementation, and research can use
|
||||
different providers. Optional `AGENTCI_PLAN_VARIANT` and `AGENTCI_IMPLEMENT_VARIANT` values are
|
||||
passed directly to OpenCode for providers that support variants.
|
||||
|
||||
`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development
|
||||
environment installers. The supplied `python` and `dotnet` scripts install only
|
||||
their runtimes; they are ordinary scripts that can be replaced or removed.
|
||||
Implementation agents remain responsible for restoring project dependencies
|
||||
and selecting build/test commands. Configure the supplied scripts with
|
||||
`AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`:
|
||||
`scripts/build.sh` supplies a unique required cache key on every invocation. The image then runs
|
||||
`npm install -g 'opencode-ai@^1'`, verifies the installed major version, and prints it. Compose also
|
||||
requests a no-cache build. A direct build without `OPENCODE_REFRESH` fails rather than silently
|
||||
reusing an old OpenCode installation layer. This is intentionally fresh rather than reproducible.
|
||||
Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it is built.
|
||||
|
||||
The trusted configuration is `opencode/opencode.json`. It grants `permission: "allow"` globally
|
||||
and to every built-in or custom agent that Agent CI can invoke. Repository-local OpenCode config
|
||||
and plugins are disabled so a clone cannot replace the service policy. OpenCode's scanned home and
|
||||
global configuration paths are root-owned and read-only, and external skill discovery is disabled,
|
||||
so an agent cannot persist instructions for later repositories. CodeGraph, Context7, and `gh_grep`
|
||||
are configured as MCP servers. Set `AGENTCI_CONTEXT7_API_KEY` to raise Context7 rate limits.
|
||||
|
||||
The `research` subagent has the same unrestricted permissions as every other agent. Its prompt
|
||||
asks it to focus on external evidence, but this is guidance rather than an isolation boundary.
|
||||
Agent CI initializes or refreshes CodeGraph before every turn and locally excludes `.codegraph/`
|
||||
from Git.
|
||||
|
||||
### Security boundary
|
||||
|
||||
OpenCode does not provide an OS sandbox. It can execute shell commands, edit Git metadata and
|
||||
environment files, access external directories, use loopback and network services, bind ports, and
|
||||
invoke subagents without approval. It can access every shared workspace, development tool, runtime
|
||||
credential, and mounted file readable by the non-root container user. Environment filtering is
|
||||
only accidental-exposure hygiene and cannot protect readable files from shell commands.
|
||||
|
||||
Docker remains the OS boundary. The services run as non-root without added capabilities,
|
||||
privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. The only
|
||||
host bind mount is the read-only installer directory; there are no
|
||||
writable host filesystem mounts. The OpenCode HTTP server is not published to the host, is password
|
||||
protected, and is reachable by Agent CI over an internal Compose network.
|
||||
|
||||
## Development environments
|
||||
|
||||
`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development environment installers.
|
||||
The supplied `python` and `dotnet` scripts install only their runtimes; they can be replaced or
|
||||
removed. Configure them with `AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`:
|
||||
|
||||
```env
|
||||
AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools
|
||||
@@ -84,44 +105,29 @@ AGENTCI_PYTHON_VERSION=3.13
|
||||
AGENTCI_DOTNET_CHANNEL=10.0
|
||||
```
|
||||
|
||||
Every name resolves to an executable file in `install-scripts/`, mounted
|
||||
read-only at `/etc/agentci/install-scripts`. Names cannot contain paths and
|
||||
duplicates are rejected. See `install-scripts/README.md` for the script contract.
|
||||
Every name resolves to a file in `install-scripts/`, mounted read-only at
|
||||
`/etc/agentci/install-scripts`. Names cannot contain paths and duplicates are rejected. Installers
|
||||
run after each implementation clone or branch sync and fail the job on an unknown script, timeout,
|
||||
or non-zero exit. They receive no Agent CI or Gitea secret values in their environment, but remain
|
||||
trusted operator code. Tools persist under `/var/lib/agentci/dev-tools`, and OpenCode can read or
|
||||
modify them through its unrestricted shell. See `install-scripts/README.md` for the script contract.
|
||||
|
||||
Installers run in order after each implementation clone or branch sync and fail
|
||||
the job on an unknown script, timeout, or non-zero exit. They receive no AgentCI
|
||||
or Gitea secret values in their environment, but remain trusted operator code
|
||||
running as the service user. Tools persist under `/var/lib/agentci/dev-tools`;
|
||||
its `bin` directory is added to implementation agents' `PATH` with read-only
|
||||
sandbox access. The agents can use those tools for builds and validation, but
|
||||
Agent CI does not impose host-side build commands.
|
||||
|
||||
Planning/review commands can only read their workflow clone and have no shell
|
||||
network access. Implementation/fix commands can edit the clone but cannot
|
||||
modify `.git`; they can reach public internet destinations while private and
|
||||
loopback destinations remain blocked. Codex's interactive Git trust check is
|
||||
skipped because every turn runs non-interactively against a service-owned clone;
|
||||
the configured filesystem and network permissions still apply. Git credentials
|
||||
exist only in the service-owned clone/push subprocess and are not inherited by
|
||||
Codex turns. The image and Compose capability/security settings let the non-root
|
||||
service create Codex's nested `bwrap` sandbox. They follow Codex's secure
|
||||
devcontainer pattern instead of making the service container privileged. Do not
|
||||
remove Codex's configured filesystem and network restrictions. In particular,
|
||||
`systempaths=unconfined` removes Docker's outer masked `/proc` subpaths so the
|
||||
nested user/PID namespace can mount its own procfs. Runtimes such as CoreCLR
|
||||
require `/proc/self/maps`; Codex still controls visibility through the fresh
|
||||
procfs and its filesystem policy.
|
||||
Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This
|
||||
keeps workflow behavior deterministic, but unrestricted OpenCode is not prevented from running Git
|
||||
commands itself.
|
||||
|
||||
## State and recovery
|
||||
|
||||
The `agentci_data` volume contains SQLite, persistent workflow clones, and
|
||||
installed development runtimes.
|
||||
`codex_home` contains login state and resumable Codex sessions. Both are kept
|
||||
indefinitely and should be backed up together.
|
||||
The `agentci_data` volume contains SQLite, workflow clones, and installed development runtimes.
|
||||
The `opencode_home` volume contains provider authentication, OpenCode's database, and resumable
|
||||
sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to
|
||||
`opencode_home`. Back up both persistent volumes together.
|
||||
|
||||
Queued jobs survive restart. An in-progress job is marked failed after restart
|
||||
instead of being replayed, because replaying a partially completed model turn
|
||||
could duplicate changes. Git pushes are never forced.
|
||||
The OpenCode migration tags existing workflows as Codex-owned and preserves their session IDs for
|
||||
rollback, but OpenCode refuses to resume them. Follow-up commands against those workflows ask for a
|
||||
new plan or implementation. Queued jobs survive restart. An in-progress job is aborted in OpenCode
|
||||
and marked failed instead of being replayed because a partial model turn may already have changed
|
||||
files. Git pushes are never forced.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -132,7 +138,9 @@ uv sync
|
||||
uv run ruff check .
|
||||
uv run pyright
|
||||
uv run pytest
|
||||
docker compose config
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON
|
||||
schemas live outside Python so orchestration modules remain small and readable.
|
||||
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside
|
||||
Python so orchestration modules remain small and readable.
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
cli_auth_credentials_store = "file"
|
||||
approval_policy = "never"
|
||||
check_for_update_on_startup = false
|
||||
web_search = "disabled"
|
||||
default_permissions = "agentci-read"
|
||||
developer_instructions = """
|
||||
A custom research subagent named `research` is available in every workflow. Delegate focused
|
||||
external research to it when current documentation, web evidence, or public code examples would
|
||||
materially improve the plan or implementation. Keep repository analysis and edits in the parent.
|
||||
Use CodeGraph for repository architecture, symbol relationships, and change-impact analysis when
|
||||
it is useful. It is available to planning, implementation, and review agents, but not research.
|
||||
"""
|
||||
|
||||
[mcp_servers.codegraph]
|
||||
command = "codegraph"
|
||||
args = ["serve", "--mcp"]
|
||||
startup_timeout_sec = 60
|
||||
tool_timeout_sec = 60
|
||||
|
||||
[mcp_servers.codegraph.env]
|
||||
CODEGRAPH_TELEMETRY = "0"
|
||||
|
||||
[agents]
|
||||
max_threads = 4
|
||||
max_depth = 1
|
||||
|
||||
[shell_environment_policy]
|
||||
inherit = "core"
|
||||
exclude = ["*TOKEN*", "*SECRET*", "*KEY*", "AGENTCI_*", "GITEA_*"]
|
||||
|
||||
[permissions.agentci-read]
|
||||
description = "Read a workflow repository without modifying it or using the network."
|
||||
|
||||
[permissions.agentci-read.filesystem]
|
||||
":minimal" = "read"
|
||||
glob_scan_max_depth = 5
|
||||
|
||||
[permissions.agentci-read.filesystem.":workspace_roots"]
|
||||
"." = "read"
|
||||
".git" = "read"
|
||||
"**/*.env" = "deny"
|
||||
|
||||
[permissions.agentci-review]
|
||||
description = "Review scoped workflow material and query Gitea with tea without editing files."
|
||||
|
||||
[permissions.agentci-review.filesystem]
|
||||
":minimal" = "read"
|
||||
glob_scan_max_depth = 5
|
||||
|
||||
[permissions.agentci-review.filesystem.":workspace_roots"]
|
||||
"." = "read"
|
||||
".git" = "read"
|
||||
"**/*.env" = "deny"
|
||||
|
||||
[permissions.agentci-review.network]
|
||||
enabled = true
|
||||
allow_local_binding = false
|
||||
|
||||
[permissions.agentci-review.network.domains]
|
||||
"*" = "allow"
|
||||
|
||||
[permissions.agentci-write]
|
||||
description = "Edit a workflow repository without changing Git metadata."
|
||||
|
||||
[permissions.agentci-write.filesystem]
|
||||
":minimal" = "read"
|
||||
"/var/lib/agentci/dev-tools" = "read"
|
||||
glob_scan_max_depth = 5
|
||||
|
||||
[permissions.agentci-write.filesystem.":workspace_roots"]
|
||||
"." = "write"
|
||||
".git" = "read"
|
||||
"**/*.env" = "deny"
|
||||
|
||||
[permissions.agentci-write.network]
|
||||
enabled = true
|
||||
allow_local_binding = false
|
||||
|
||||
[permissions.agentci-write.network.domains]
|
||||
"*" = "allow"
|
||||
|
||||
[permissions.agentci-research]
|
||||
description = "Read a workflow repository and research public internet sources without editing."
|
||||
|
||||
[permissions.agentci-research.filesystem]
|
||||
":minimal" = "read"
|
||||
glob_scan_max_depth = 5
|
||||
|
||||
[permissions.agentci-research.filesystem.":workspace_roots"]
|
||||
"." = "read"
|
||||
".git" = "read"
|
||||
"**/*.env" = "deny"
|
||||
|
||||
[permissions.agentci-research.network]
|
||||
enabled = true
|
||||
allow_local_binding = false
|
||||
|
||||
[permissions.agentci-research.network.domains]
|
||||
"*" = "allow"
|
||||
+67
-24
@@ -1,38 +1,29 @@
|
||||
services:
|
||||
agentci:
|
||||
image: agentci:local
|
||||
build:
|
||||
context: .
|
||||
no_cache: true
|
||||
args:
|
||||
CODEX_VERSION: ${CODEX_VERSION:-0.144.6}
|
||||
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
|
||||
OPENCODE_REFRESH: ${OPENCODE_REFRESH:-}
|
||||
TEA_VERSION: ${TEA_VERSION:-0.14.2}
|
||||
restart: unless-stopped
|
||||
# Codex applies its own bwrap sandbox inside this otherwise unprivileged container.
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
- SYS_CHROOT
|
||||
- SETUID
|
||||
- SETGID
|
||||
- SYS_PTRACE
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
security_opt:
|
||||
- seccomp=unconfined
|
||||
- apparmor=unconfined
|
||||
# Docker's masked /proc paths prevent Codex's user namespace from mounting fresh procfs.
|
||||
- systempaths=unconfined
|
||||
depends_on:
|
||||
opencode:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
|
||||
AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci}
|
||||
AGENTCI_BOT_NAME: ${AGENTCI_BOT_NAME:-Agent CI}
|
||||
AGENTCI_BOT_EMAIL: ${AGENTCI_BOT_EMAIL:-agentci@localhost}
|
||||
AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-gpt-5.6-sol}
|
||||
AGENTCI_PLAN_REASONING: ${AGENTCI_PLAN_REASONING:-medium}
|
||||
AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-gpt-5.6-sol}
|
||||
AGENTCI_IMPLEMENT_REASONING: ${AGENTCI_IMPLEMENT_REASONING:-high}
|
||||
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-gpt-5.6-luna}
|
||||
AGENTCI_RESEARCH_REASONING: ${AGENTCI_RESEARCH_REASONING:-high}
|
||||
AGENTCI_CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-}
|
||||
AGENTCI_OPENCODE_URL: http://opencode:4096
|
||||
AGENTCI_OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode}
|
||||
AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-openai/gpt-5.6-sol}
|
||||
AGENTCI_PLAN_VARIANT: ${AGENTCI_PLAN_VARIANT:-}
|
||||
AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-openai/gpt-5.6-sol}
|
||||
AGENTCI_IMPLEMENT_VARIANT: ${AGENTCI_IMPLEMENT_VARIANT:-}
|
||||
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna}
|
||||
AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4}
|
||||
AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3}
|
||||
AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600}
|
||||
@@ -43,26 +34,78 @@ services:
|
||||
secrets:
|
||||
- gitea_token
|
||||
- webhook_secret
|
||||
- opencode_server_password
|
||||
volumes:
|
||||
- agentci_data:/var/lib/agentci
|
||||
- codex_home:/var/lib/codex
|
||||
- ./install-scripts:/etc/agentci/install-scripts:ro
|
||||
tmpfs:
|
||||
- /run/agentci
|
||||
expose:
|
||||
- "8080"
|
||||
networks:
|
||||
- gitea
|
||||
- agentci_control
|
||||
|
||||
opencode:
|
||||
image: agentci:local
|
||||
command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HOME: /etc/opencode/home
|
||||
XDG_CONFIG_HOME: /etc/opencode/xdg
|
||||
XDG_DATA_HOME: /var/lib/opencode/data
|
||||
XDG_CACHE_HOME: /var/lib/opencode/cache
|
||||
XDG_STATE_HOME: /var/lib/opencode/state
|
||||
OPENCODE_CONFIG: /etc/opencode/opencode.json
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG: "1"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "1"
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1"
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1"
|
||||
OPENCODE_DISABLE_CLAUDE_CODE: "1"
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1"
|
||||
OPENCODE_PURE: "1"
|
||||
OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode}
|
||||
OPENCODE_SERVER_PASSWORD_FILE: /run/secrets/opencode_server_password
|
||||
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
|
||||
AGENTCI_TEA_CONFIG_HOME: /run/agentci
|
||||
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna}
|
||||
CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-}
|
||||
PATH: /var/lib/agentci/dev-tools/bin:/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
secrets:
|
||||
- gitea_token
|
||||
- opencode_server_password
|
||||
volumes:
|
||||
- agentci_data:/var/lib/agentci
|
||||
- opencode_home:/var/lib/opencode
|
||||
tmpfs:
|
||||
- /run/agentci
|
||||
expose:
|
||||
- "4096"
|
||||
networks:
|
||||
- gitea
|
||||
- agentci_control
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl --fail --silent --user \"$${OPENCODE_SERVER_USERNAME}:$$(cat $${OPENCODE_SERVER_PASSWORD_FILE})\" http://127.0.0.1:4096/global/health >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 6
|
||||
|
||||
secrets:
|
||||
gitea_token:
|
||||
file: ./secrets/gitea_token
|
||||
webhook_secret:
|
||||
file: ./secrets/webhook_secret
|
||||
opencode_server_password:
|
||||
file: ./secrets/opencode_server_password
|
||||
|
||||
volumes:
|
||||
agentci_data:
|
||||
codex_home:
|
||||
opencode_home:
|
||||
|
||||
networks:
|
||||
gitea:
|
||||
external: true
|
||||
name: ${GITEA_NETWORK:-gitea}
|
||||
agentci_control:
|
||||
internal: true
|
||||
|
||||
@@ -18,10 +18,9 @@ files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/b
|
||||
directory is prepended to implementation agents' `PATH`. Environment changes made by a script do
|
||||
not persist into implementation turns.
|
||||
|
||||
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR`, but places the writable
|
||||
.NET CLI home and NuGet package cache under `/tmp`. Codex implementation sandboxes do not expose a
|
||||
normal user home and receive read-only access to installed tools, so runtime caches cannot live
|
||||
beside the SDK.
|
||||
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR` and places writable CLI state
|
||||
and NuGet caches under `DEV_TOOLS_DIR/runtime/dotnet`. OpenCode's own home is deliberately read-only
|
||||
so it cannot be used to persist global agent configuration.
|
||||
|
||||
The configured scripts run in list order after the repository is cloned (or an existing agent PR
|
||||
branch is synchronized) and before the first implementation turn for `implement`, `iterate`, and
|
||||
|
||||
@@ -6,6 +6,7 @@ set -eu
|
||||
|
||||
install_dir="$DEV_TOOLS_DIR/dotnet"
|
||||
bin_dir="$DEV_TOOLS_DIR/bin"
|
||||
runtime_dir="$DEV_TOOLS_DIR/runtime/dotnet"
|
||||
installer=$(mktemp)
|
||||
trap 'rm -f "$installer"' EXIT
|
||||
|
||||
@@ -25,9 +26,9 @@ PYTHON
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
"export DOTNET_ROOT='$install_dir'" \
|
||||
'export DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' \
|
||||
'export NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' \
|
||||
'export NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"' \
|
||||
"export DOTNET_CLI_HOME='$runtime_dir/home'" \
|
||||
"export NUGET_PACKAGES='$runtime_dir/nuget/packages'" \
|
||||
"export NUGET_HTTP_CACHE_PATH='$runtime_dir/nuget/http-cache'" \
|
||||
'export HOME="$DOTNET_CLI_HOME"' \
|
||||
'export DOTNET_CLI_TELEMETRY_OPTOUT=1' \
|
||||
'export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1' \
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Agent CI runtime
|
||||
|
||||
A `research` subagent is available in every workflow. Delegate focused external research when
|
||||
current documentation, web evidence, or public code examples would materially improve the result.
|
||||
Keep repository analysis and edits in the parent agent. Use CodeGraph for repository architecture,
|
||||
symbol relationships, and change-impact analysis when useful.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"autoupdate": false,
|
||||
"share": "disabled",
|
||||
"subagent_depth": 1,
|
||||
"instructions": ["/etc/opencode/AGENTS.md"],
|
||||
"permission": "allow",
|
||||
"agent": {
|
||||
"build": {
|
||||
"permission": "allow"
|
||||
},
|
||||
"plan": {
|
||||
"permission": "allow"
|
||||
},
|
||||
"general": {
|
||||
"permission": "allow"
|
||||
},
|
||||
"explore": {
|
||||
"permission": "allow"
|
||||
},
|
||||
"research": {
|
||||
"description": "Research current documentation, web evidence, and public code examples.",
|
||||
"mode": "subagent",
|
||||
"model": "{env:AGENTCI_RESEARCH_MODEL}",
|
||||
"permission": "allow",
|
||||
"prompt": "Research external, current, or unfamiliar technical facts for the parent agent. Use Context7 for library documentation, gh_grep for 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. Do not include secrets or proprietary source in external queries."
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"codegraph": {
|
||||
"type": "local",
|
||||
"command": ["codegraph", "serve", "--mcp"],
|
||||
"environment": {
|
||||
"CODEGRAPH_TELEMETRY": "0"
|
||||
},
|
||||
"enabled": true,
|
||||
"timeout": 60000
|
||||
},
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": {
|
||||
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"gh_grep": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.grep.app",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "agentci"
|
||||
version = "0.1.0"
|
||||
description = "A Gitea webhook host that orchestrates resumable Codex workflows"
|
||||
description = "A Gitea webhook host that orchestrates resumable OpenCode workflows"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
cd "$script_dir/.."
|
||||
|
||||
export OPENCODE_REFRESH="$(date +%s)-$$"
|
||||
exec docker compose build "$@"
|
||||
@@ -1,12 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
python -c '
|
||||
if [ -f "${OPENCODE_SERVER_PASSWORD_FILE:-}" ]; then
|
||||
export OPENCODE_SERVER_PASSWORD
|
||||
OPENCODE_SERVER_PASSWORD=$(cat "$OPENCODE_SERVER_PASSWORD_FILE")
|
||||
fi
|
||||
|
||||
if [ -f "${AGENTCI_GITEA_TOKEN_FILE:-/run/secrets/gitea_token}" ]; then
|
||||
python -c '
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
config_dir = Path(os.environ["XDG_CONFIG_HOME"]) / "tea"
|
||||
config_dir = Path(os.environ.get("AGENTCI_TEA_CONFIG_HOME", "/run/agentci")) / "tea"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_path = config_dir / "config.yml"
|
||||
token_path = Path(
|
||||
@@ -22,5 +28,6 @@ login = {
|
||||
config_path.write_text(json.dumps({"logins": [login], "preferences": {}}))
|
||||
config_path.chmod(0o600)
|
||||
'
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
export XDG_CONFIG_HOME="${AGENTCI_TEA_CONFIG_HOME:-/run/agentci}"
|
||||
exec /usr/local/libexec/tea "$@"
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+13
-14
@@ -28,15 +28,14 @@ def serious_report() -> ReviewReport:
|
||||
)
|
||||
|
||||
|
||||
class FakeCodex:
|
||||
class FakeOpenCode:
|
||||
def __init__(self, reports: list[ReviewReport]) -> None:
|
||||
self.reports = iter(reports)
|
||||
self.reviews = 0
|
||||
self.revisions = 0
|
||||
|
||||
async def start(self, **_kwargs):
|
||||
self.reviews += 1
|
||||
return "reviewer", next(self.reports)
|
||||
async def create_session(self, *_args):
|
||||
return "reviewer"
|
||||
|
||||
async def resume(self, **kwargs):
|
||||
if kwargs["result_type"] is ReviewReport:
|
||||
@@ -60,15 +59,15 @@ class FakePrompts:
|
||||
|
||||
|
||||
def objects(rounds: int, reports: list[ReviewReport]):
|
||||
codex = FakeCodex(reports)
|
||||
opencode = FakeOpenCode(reports)
|
||||
settings = SimpleNamespace(
|
||||
implement_review_rounds=rounds,
|
||||
implement_model="model",
|
||||
implement_reasoning="high",
|
||||
implement_variant="high",
|
||||
)
|
||||
deps = SimpleNamespace(
|
||||
settings=settings,
|
||||
codex=codex,
|
||||
opencode=opencode,
|
||||
storage=FakeStorage(),
|
||||
prompts=FakePrompts(),
|
||||
development=SimpleNamespace(description="python"),
|
||||
@@ -95,12 +94,12 @@ def objects(rounds: int, reports: list[ReviewReport]):
|
||||
message="",
|
||||
comment_id=1,
|
||||
)
|
||||
return CodeReviewLoop(deps), codex, workflow, job # type: ignore[arg-type]
|
||||
return CodeReviewLoop(deps), opencode, workflow, job # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_stops_after_clean_second_review() -> None:
|
||||
clean = ReviewReport(summary="Ready", findings=[])
|
||||
loop, codex, workflow, job = objects(4, [serious_report(), clean])
|
||||
loop, opencode, workflow, job = objects(4, [serious_report(), clean])
|
||||
_, report = await loop.run(
|
||||
job,
|
||||
workflow,
|
||||
@@ -109,12 +108,12 @@ async def test_stops_after_clean_second_review() -> None:
|
||||
AgentResult(summary_markdown="initial", tests=[]),
|
||||
)
|
||||
assert not report.has_serious_findings
|
||||
assert codex.reviews == 2
|
||||
assert codex.revisions == 1
|
||||
assert opencode.reviews == 2
|
||||
assert opencode.revisions == 1
|
||||
|
||||
|
||||
async def test_does_not_make_unreviewed_final_revision() -> None:
|
||||
loop, codex, workflow, job = objects(
|
||||
loop, opencode, workflow, job = objects(
|
||||
3, [serious_report(), serious_report(), serious_report()]
|
||||
)
|
||||
_, report = await loop.run(
|
||||
@@ -125,5 +124,5 @@ async def test_does_not_make_unreviewed_final_revision() -> None:
|
||||
AgentResult(summary_markdown="initial", tests=[]),
|
||||
)
|
||||
assert report.has_serious_findings
|
||||
assert codex.reviews == 3
|
||||
assert codex.revisions == 2
|
||||
assert opencode.reviews == 3
|
||||
assert opencode.revisions == 2
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import asyncio
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.adapters.codex import CodexClient, _session_id
|
||||
from agentci.domain.models import AgentResult
|
||||
|
||||
|
||||
def test_enables_codegraph_in_shared_codex_config() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
config = tomllib.loads((root / "codex" / "config.toml").read_text())
|
||||
|
||||
codegraph = config["mcp_servers"]["codegraph"]
|
||||
assert codegraph["command"] == "codegraph"
|
||||
assert codegraph["args"] == ["serve", "--mcp"]
|
||||
assert codegraph["env"]["CODEGRAPH_TELEMETRY"] == "0"
|
||||
|
||||
|
||||
def test_compose_allows_nested_codex_procfs() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
|
||||
compose = (root / "compose.yaml").read_text()
|
||||
|
||||
assert "systempaths=unconfined" in compose
|
||||
|
||||
|
||||
def test_extracts_thread_id_from_jsonl() -> None:
|
||||
output = '\n'.join(
|
||||
[
|
||||
'{"type":"turn.started"}',
|
||||
'{"type":"thread.started","thread_id":"abc-123"}',
|
||||
"not json",
|
||||
]
|
||||
)
|
||||
assert _session_id(output) == "abc-123"
|
||||
|
||||
|
||||
def test_missing_thread_id_returns_none() -> None:
|
||||
assert _session_id('{"type":"turn.completed"}') is None
|
||||
|
||||
|
||||
def test_turns_skip_interactive_git_trust_check(tmp_path) -> None:
|
||||
client = CodexClient(
|
||||
codex_home=tmp_path / "codex",
|
||||
schemas_dir=tmp_path / "schemas",
|
||||
timeout_seconds=60,
|
||||
research_model="gpt-5.6-luna",
|
||||
research_reasoning="high",
|
||||
context7_api_key=None,
|
||||
)
|
||||
|
||||
args = client._turn_args("model", "medium", "agentci-read", "plan.json")
|
||||
|
||||
assert "--skip-git-repo-check" in args
|
||||
|
||||
|
||||
def test_configures_research_agent_and_optional_context7_key(tmp_path) -> None:
|
||||
client = CodexClient(
|
||||
codex_home=tmp_path / "codex",
|
||||
schemas_dir=tmp_path / "schemas",
|
||||
timeout_seconds=60,
|
||||
research_model="research-model",
|
||||
research_reasoning="high",
|
||||
context7_api_key="ctx7-secret",
|
||||
)
|
||||
|
||||
agent = (tmp_path / "codex" / "agents" / "research.toml").read_text()
|
||||
parsed = tomllib.loads(agent)
|
||||
assert 'name = "research"' in agent
|
||||
assert parsed["model"] == "research-model"
|
||||
assert parsed["model_reasoning_effort"] == "high"
|
||||
assert parsed["web_search"] == "live"
|
||||
assert parsed["mcp_servers"]["codegraph"]["enabled"] is False
|
||||
assert 'url = "https://mcp.context7.com/mcp"' in agent
|
||||
assert 'url = "https://mcp.grep.app"' in agent
|
||||
assert "ctx7-secret" not in agent
|
||||
assert client._environment()["CONTEXT7_API_KEY"] == "ctx7-secret"
|
||||
|
||||
|
||||
def test_exposes_development_tools_without_service_secrets(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
monkeypatch.setenv("AGENTCI_PRIVATE_VALUE", "secret")
|
||||
tools_bin = tmp_path / "tools" / "bin"
|
||||
client = CodexClient(
|
||||
codex_home=tmp_path / "codex",
|
||||
schemas_dir=tmp_path / "schemas",
|
||||
timeout_seconds=60,
|
||||
research_model="gpt-5.6-luna",
|
||||
research_reasoning="high",
|
||||
context7_api_key=None,
|
||||
tools_bin=tools_bin,
|
||||
)
|
||||
|
||||
environment = client._environment()
|
||||
|
||||
assert environment["PATH"] == f"{tools_bin}:/usr/bin"
|
||||
assert "AGENTCI_PRIVATE_VALUE" not in environment
|
||||
|
||||
|
||||
async def test_invokes_codex_from_workflow_workspace(tmp_path, monkeypatch) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class Process:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self, prompt: bytes) -> tuple[bytes, bytes]:
|
||||
assert prompt == b"prompt"
|
||||
return b'{"type":"thread.started","thread_id":"thread"}', b""
|
||||
|
||||
async def create_subprocess_exec(*args, **kwargs):
|
||||
captured["cwd"] = kwargs["cwd"]
|
||||
output = Path(args[args.index("--output-last-message") + 1])
|
||||
output.write_text( # noqa: ASYNC240 - tiny test-owned result file
|
||||
'{"summary_markdown":"summary","tests":[]}'
|
||||
)
|
||||
return Process()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_subprocess_exec)
|
||||
client = CodexClient(
|
||||
codex_home=tmp_path / "codex",
|
||||
schemas_dir=tmp_path / "schemas",
|
||||
timeout_seconds=60,
|
||||
research_model="gpt-5.6-luna",
|
||||
research_reasoning="high",
|
||||
context7_api_key=None,
|
||||
)
|
||||
|
||||
_, result = await client._invoke(
|
||||
["codex", "exec", "-"],
|
||||
"prompt",
|
||||
AgentResult,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
assert captured["cwd"] == workspace
|
||||
assert result.summary_markdown == "summary"
|
||||
@@ -33,3 +33,9 @@ def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) ->
|
||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
|
||||
assert settings.install_scripts == ["python", "dotnet"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["plan_model", "implement_model", "research_model"])
|
||||
def test_requires_provider_qualified_opencode_models(field: str) -> None:
|
||||
with pytest.raises(ValidationError, match="provider/model"):
|
||||
Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_dotnet_wrapper_uses_sandbox_writable_runtime_directories() -> None:
|
||||
def test_dotnet_wrapper_uses_persistent_runtime_directories() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
script = (root / "install-scripts" / "dotnet").read_text()
|
||||
|
||||
assert 'DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' in script
|
||||
assert 'NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' in script
|
||||
http_cache = 'NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"'
|
||||
assert http_cache in script
|
||||
assert "export DOTNET_ROOT=" in script
|
||||
assert "/tmp/agentci-dotnet" not in script
|
||||
assert "DEV_TOOLS_DIR/runtime/dotnet" in script
|
||||
assert "NUGET_PACKAGES" in script
|
||||
assert 'export HOME="$DOTNET_CLI_HOME"' in script
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
|
||||
from agentci.domain.models import AgentResult
|
||||
|
||||
API_DOCUMENT = {
|
||||
"paths": {
|
||||
"/global/health": {"get": {}},
|
||||
"/provider": {"get": {}},
|
||||
"/session": {"post": {}},
|
||||
"/session/{sessionID}/message": {"post": {}},
|
||||
"/session/{sessionID}/abort": {"post": {}},
|
||||
}
|
||||
}
|
||||
PROVIDERS = {
|
||||
"connected": ["openai"],
|
||||
"all": [
|
||||
{
|
||||
"id": "openai",
|
||||
"models": {
|
||||
"model": {
|
||||
"status": "active",
|
||||
"capabilities": {"toolcall": True},
|
||||
"variants": {"high": {}},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class FakeCodeGraph:
|
||||
def __init__(self) -> None:
|
||||
self.prepared: list[Path] = []
|
||||
|
||||
async def prepare(self, workspace: Path) -> None:
|
||||
self.prepared.append(workspace)
|
||||
|
||||
|
||||
def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCodeClient:
|
||||
selected_codegraph = codegraph or FakeCodeGraph()
|
||||
return OpenCodeClient(
|
||||
base_url="http://opencode:4096",
|
||||
username="opencode",
|
||||
password="server-secret",
|
||||
schemas_dir=Path(__file__).parents[1] / "src" / "agentci" / "prompts" / "schemas",
|
||||
health_directory=tmp_path,
|
||||
required_models=(("openai/model", None),),
|
||||
timeout_seconds=60,
|
||||
codegraph=selected_codegraph, # type: ignore[arg-type]
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
async def test_ready_requires_healthy_server_and_connected_provider(tmp_path: Path) -> None:
|
||||
expected_directory = str(tmp_path)
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["authorization"].startswith("Basic ")
|
||||
if request.url.path == "/global/health":
|
||||
return httpx.Response(200, json={"healthy": True, "version": "1.18.4"})
|
||||
if request.url.path == "/doc":
|
||||
return httpx.Response(200, json=API_DOCUMENT)
|
||||
assert request.headers["x-opencode-directory"] == expected_directory
|
||||
return httpx.Response(200, json=PROVIDERS)
|
||||
|
||||
value = client(tmp_path, handler)
|
||||
assert await value.ready()
|
||||
await value.close()
|
||||
|
||||
|
||||
async def test_ready_rejects_missing_provider(tmp_path: Path) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/global/health":
|
||||
return httpx.Response(200, json={"healthy": True, "version": "1.18.4"})
|
||||
if request.url.path == "/doc":
|
||||
return httpx.Response(200, json=API_DOCUMENT)
|
||||
return httpx.Response(200, json={**PROVIDERS, "connected": ["anthropic"]})
|
||||
|
||||
value = client(tmp_path, handler)
|
||||
assert not await value.ready()
|
||||
await value.close()
|
||||
|
||||
|
||||
async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
codegraph = FakeCodeGraph()
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
assert request.headers["x-opencode-directory"] == str(workspace.resolve())
|
||||
if request.url.path == "/session":
|
||||
return httpx.Response(200, json={"id": "ses_new"})
|
||||
body = json.loads(request.content)
|
||||
assert body["model"] == {"providerID": "openai", "modelID": "model"}
|
||||
assert body["agent"] == "build"
|
||||
assert body["variant"] == "high"
|
||||
assert body["format"]["type"] == "json_schema"
|
||||
assert body["format"]["schema"]["required"] == ["summary_markdown", "tests"]
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"info": {
|
||||
"role": "assistant",
|
||||
"sessionID": "ses_new",
|
||||
"structured": {"summary_markdown": "summary", "tests": []},
|
||||
},
|
||||
"parts": [],
|
||||
},
|
||||
)
|
||||
|
||||
value = client(tmp_path, handler, codegraph)
|
||||
session_id, result = await value.start(
|
||||
workspace=workspace,
|
||||
prompt="implement",
|
||||
model="openai/model",
|
||||
variant="high",
|
||||
schema_name="agent_result.json",
|
||||
result_type=AgentResult,
|
||||
)
|
||||
|
||||
assert session_id == "ses_new"
|
||||
assert result.summary_markdown == "summary"
|
||||
assert codegraph.prepared == [workspace]
|
||||
assert [request.url.path for request in requests] == [
|
||||
"/session",
|
||||
"/session/ses_new/message",
|
||||
]
|
||||
await value.close()
|
||||
|
||||
|
||||
async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
prompts: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
prompts.append(body["parts"][0]["text"])
|
||||
if len(prompts) == 1:
|
||||
return httpx.Response(
|
||||
200, json={"info": {"error": {"name": "StructuredOutputError"}}}
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"info": {"structured": {"summary_markdown": "fixed", "tests": []}},
|
||||
"parts": [],
|
||||
},
|
||||
)
|
||||
|
||||
value = client(tmp_path, handler)
|
||||
result = await value.resume(
|
||||
session_id="ses_existing",
|
||||
workspace=workspace,
|
||||
prompt="continue",
|
||||
model="openai/model",
|
||||
variant=None,
|
||||
schema_name="agent_result.json",
|
||||
result_type=AgentResult,
|
||||
)
|
||||
|
||||
assert result.summary_markdown == "fixed"
|
||||
assert prompts[0] == "continue"
|
||||
assert "without repeating repository work" in prompts[1]
|
||||
await value.close()
|
||||
|
||||
|
||||
async def test_aborts_timed_out_session(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
aborted = False
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal aborted
|
||||
if request.url.path.endswith("/abort"):
|
||||
aborted = True
|
||||
return httpx.Response(200, json=True)
|
||||
raise httpx.ReadTimeout("slow", request=request)
|
||||
|
||||
value = client(tmp_path, handler)
|
||||
with pytest.raises(OpenCodeError, match="exceeded 60 seconds"):
|
||||
await value.resume(
|
||||
session_id="ses_existing",
|
||||
workspace=workspace,
|
||||
prompt="continue",
|
||||
model="openai/model",
|
||||
variant=None,
|
||||
schema_name="agent_result.json",
|
||||
result_type=AgentResult,
|
||||
)
|
||||
|
||||
assert aborted
|
||||
await value.close()
|
||||
|
||||
|
||||
async def test_aborts_cancelled_session(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
started = asyncio.Event()
|
||||
aborted = False
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal aborted
|
||||
if request.url.path.endswith("/abort"):
|
||||
aborted = True
|
||||
return httpx.Response(200, json=True)
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
value = client(tmp_path, handler)
|
||||
turn = asyncio.create_task(
|
||||
value.resume(
|
||||
session_id="ses_existing",
|
||||
workspace=workspace,
|
||||
prompt="continue",
|
||||
model="openai/model",
|
||||
variant=None,
|
||||
schema_name="agent_result.json",
|
||||
result_type=AgentResult,
|
||||
)
|
||||
)
|
||||
await started.wait()
|
||||
turn.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await turn
|
||||
|
||||
assert aborted
|
||||
await value.close()
|
||||
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_config_grants_all_agents_unrestricted_permissions() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
config = json.loads((root / "opencode" / "opencode.json").read_text())
|
||||
|
||||
assert config["permission"] == "allow"
|
||||
assert all(
|
||||
config["agent"][name]["permission"] == "allow"
|
||||
for name in ("build", "plan", "general", "explore", "research")
|
||||
)
|
||||
assert config["mcp"]["codegraph"]["command"] == ["codegraph", "serve", "--mcp"]
|
||||
assert config["mcp"]["context7"]["url"] == "https://mcp.context7.com/mcp"
|
||||
|
||||
|
||||
def test_compose_removes_codex_sandbox_exceptions() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
compose = (root / "compose.yaml").read_text()
|
||||
dockerfile = (root / "Dockerfile").read_text()
|
||||
|
||||
for forbidden in ("cap_add", "seccomp=unconfined", "apparmor=unconfined", "bubblewrap"):
|
||||
assert forbidden not in compose
|
||||
assert "no_cache: true" in compose
|
||||
assert "opencode_home:/var/lib/opencode" in compose
|
||||
assert "HOME: /etc/opencode/home" in compose
|
||||
assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose
|
||||
assert "ARG OPENCODE_REFRESH" in dockerfile
|
||||
assert "OPENCODE_REFRESH is required" in dockerfile
|
||||
assert "'opencode-ai@^1'" in dockerfile
|
||||
@@ -0,0 +1,40 @@
|
||||
from agentci.adapters.opencode_support import api_contract_ready, models_ready
|
||||
|
||||
|
||||
def test_api_contract_requires_session_message_and_abort_routes() -> None:
|
||||
valid = {
|
||||
"paths": {
|
||||
"/global/health": {"get": {}},
|
||||
"/provider": {"get": {}},
|
||||
"/session": {"post": {}},
|
||||
"/session/{sessionID}/message": {"post": {}},
|
||||
"/session/{sessionID}/abort": {"post": {}},
|
||||
}
|
||||
}
|
||||
|
||||
assert api_contract_ready(valid)
|
||||
del valid["paths"]["/session/{sessionID}/abort"]
|
||||
assert not api_contract_ready(valid)
|
||||
|
||||
|
||||
def test_model_readiness_requires_tools_and_configured_variant() -> None:
|
||||
payload = {
|
||||
"connected": ["openai"],
|
||||
"all": [
|
||||
{
|
||||
"id": "openai",
|
||||
"models": {
|
||||
"model": {
|
||||
"status": "active",
|
||||
"capabilities": {"toolcall": True},
|
||||
"variants": {"high": {}},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert models_ready(payload, {("openai", "model", "high")})
|
||||
assert not models_ready(payload, {("openai", "model", "missing")})
|
||||
payload["all"][0]["models"]["model"]["capabilities"]["toolcall"] = False
|
||||
assert not models_ready(payload, {("openai", "model", "high")})
|
||||
@@ -1,3 +1,4 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -101,3 +102,50 @@ async def test_failed_followup_does_not_invalidate_completed_workflow(
|
||||
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
||||
assert loaded is not None
|
||||
assert loaded.status is WorkflowStatus.COMPLETED
|
||||
|
||||
|
||||
async def test_opencode_migration_preserves_and_tags_legacy_session_ids(tmp_path: Path) -> None:
|
||||
legacy_migrations = tmp_path / "legacy-migrations"
|
||||
legacy_migrations.mkdir()
|
||||
migrations = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
(legacy_migrations / "001_initial.sql").write_text(
|
||||
(migrations / "001_initial.sql").read_text()
|
||||
)
|
||||
database = tmp_path / "legacy.sqlite3"
|
||||
legacy = Storage(database, legacy_migrations)
|
||||
await legacy.initialize()
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO workflows (
|
||||
id, kind, repo_owner, repo_name, issue_number, base_sha,
|
||||
workspace_path, primary_session_id, reviewer_session_id,
|
||||
artifact, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"legacy-workflow",
|
||||
"plan",
|
||||
"alice",
|
||||
"repo",
|
||||
3,
|
||||
"abc",
|
||||
str(tmp_path / "repo"),
|
||||
"legacy-primary",
|
||||
"legacy-reviewer",
|
||||
"# Preserved plan",
|
||||
"completed",
|
||||
"2026-07-20T00:00:00+00:00",
|
||||
"2026-07-20T00:00:00+00:00",
|
||||
),
|
||||
)
|
||||
|
||||
migrated = Storage(database, migrations)
|
||||
await migrated.initialize()
|
||||
loaded = await migrated.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.artifact == "# Preserved plan"
|
||||
assert loaded.primary_session_id == "legacy-primary"
|
||||
assert loaded.reviewer_session_id == "legacy-reviewer"
|
||||
assert loaded.runtime == "codex"
|
||||
|
||||
+20
-1
@@ -29,9 +29,13 @@ class FakeStorage:
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, permitted: bool = True) -> None:
|
||||
self.permitted = permitted
|
||||
self.comments: list[str] = []
|
||||
|
||||
async def has_write_permission(self, *_args):
|
||||
return self.permitted
|
||||
|
||||
async def create_comment(self, _owner, _repo, _number, body):
|
||||
self.comments.append(body)
|
||||
return len(self.comments)
|
||||
@@ -72,6 +76,21 @@ async def test_authorized_command_is_queued() -> None:
|
||||
assert "queued" in gitea.comments[0]
|
||||
|
||||
|
||||
async def test_unauthorized_command_is_rejected_and_deduplicated() -> None:
|
||||
storage = FakeStorage()
|
||||
gitea = FakeGitea(permitted=False)
|
||||
container = SimpleNamespace(storage=storage, gitea=gitea)
|
||||
event = _event_from_payload("delivery", payload("/agent implement"))
|
||||
assert event is not None
|
||||
|
||||
await _handle_command(container, event)
|
||||
await _handle_command(container, event)
|
||||
|
||||
assert storage.jobs == []
|
||||
assert len(gitea.comments) == 1
|
||||
assert "write permission" in gitea.comments[0]
|
||||
|
||||
|
||||
async def test_iterate_message_is_preserved_on_queued_job() -> None:
|
||||
storage = FakeStorage()
|
||||
container = SimpleNamespace(storage=storage, gitea=FakeGitea())
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.domain.models import Job, JobKind, Workflow, WorkflowKind
|
||||
from agentci.worker import Worker
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self, workflow: Workflow | None) -> None:
|
||||
self.workflow = workflow
|
||||
|
||||
async def get_workflow(self, _workflow_id: str) -> Workflow | None:
|
||||
return self.workflow
|
||||
|
||||
|
||||
class FakeOpenCode:
|
||||
def __init__(self) -> None:
|
||||
self.aborted: set[tuple[str, Path]] = set()
|
||||
|
||||
async def abort(self, session_id: str, workspace: Path) -> None:
|
||||
self.aborted.add((session_id, workspace))
|
||||
|
||||
|
||||
def job(*, workflow_id: str | None, runtime_session_id: str | None = None) -> Job:
|
||||
return Job(
|
||||
id="job",
|
||||
kind=JobKind.FIX,
|
||||
target_key="org/repo:pr:1",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=1,
|
||||
requester="alice",
|
||||
message="",
|
||||
comment_id=1,
|
||||
workflow_id=workflow_id,
|
||||
runtime_session_id=runtime_session_id,
|
||||
)
|
||||
|
||||
|
||||
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker:
|
||||
return Worker(
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
gitea=None, # type: ignore[arg-type]
|
||||
opencode=opencode, # type: ignore[arg-type]
|
||||
dispatcher=None, # type: ignore[arg-type]
|
||||
poll_seconds=1,
|
||||
workspaces_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
async def test_recovery_aborts_all_workflow_sessions(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workflow" / "repo"
|
||||
workflow = Workflow(
|
||||
id="flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
workspace_path=workspace,
|
||||
base_sha="base",
|
||||
primary_session_id="primary",
|
||||
reviewer_session_id="reviewer",
|
||||
)
|
||||
opencode = FakeOpenCode()
|
||||
|
||||
await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions(
|
||||
job(workflow_id=workflow.id)
|
||||
)
|
||||
|
||||
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
|
||||
|
||||
|
||||
async def test_recovery_aborts_one_shot_fix_session(tmp_path: Path) -> None:
|
||||
opencode = FakeOpenCode()
|
||||
|
||||
await worker(tmp_path, FakeStorage(None), opencode)._abort_job_sessions(
|
||||
job(workflow_id=None, runtime_session_id="fix-session")
|
||||
)
|
||||
|
||||
assert opencode.aborted == {("fix-session", tmp_path / "fix-job" / "repo")}
|
||||
Reference in New Issue
Block a user