feat: codegraph

This commit is contained in:
2026-07-19 19:55:34 +02:00
parent a52ebabf7f
commit 29d12974fd
12 changed files with 203 additions and 13 deletions
+1
View File
@@ -15,3 +15,4 @@ AGENTCI_PLAN_REVIEW_ROUNDS=4
AGENTCI_IMPLEMENT_REVIEW_ROUNDS=3
AGENTCI_TURN_TIMEOUT_SECONDS=3600
CODEX_VERSION=0.144.6
CODEGRAPH_VERSION=1.3.1
+13 -3
View File
@@ -1,7 +1,10 @@
FROM node:24-bookworm-slim AS codex
ARG CODEX_VERSION=0.144.6
RUN npm install --global "@openai/codex@${CODEX_VERSION}"
ARG CODEGRAPH_VERSION=1.3.1
RUN npm install --global \
"@openai/codex@${CODEX_VERSION}" \
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}"
FROM ghcr.io/astral-sh/uv:0.8.14 AS uv
@@ -11,6 +14,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
UV_NO_DEV=1 \
CODEGRAPH_TELEMETRY=0 \
CODEX_HOME=/var/lib/codex \
PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
@@ -23,7 +27,10 @@ RUN apt-get update \
COPY --from=uv /uv /uvx /usr/local/bin/
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
RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/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/@colbymchenry/codegraph/npm-shim.js \
/usr/local/bin/codegraph
WORKDIR /opt/agentci
COPY pyproject.toml uv.lock README.md ./
@@ -31,7 +38,10 @@ COPY src ./src
COPY scripts ./scripts
COPY codex/config.toml /etc/codex/config.toml
RUN chmod 0755 /opt/agentci/scripts/gitea-askpass.sh /usr/local/bin/codex \
RUN chmod 0755 \
/opt/agentci/scripts/gitea-askpass.sh \
/usr/local/bin/codex \
/usr/local/bin/codegraph \
&& uv sync --frozen --no-dev \
&& chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/codex
+6
View File
@@ -63,6 +63,12 @@ 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.
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.
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
+11
View File
@@ -7,8 +7,19 @@ 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
+1
View File
@@ -4,6 +4,7 @@ services:
context: .
args:
CODEX_VERSION: ${CODEX_VERSION:-0.144.6}
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
restart: unless-stopped
environment:
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import asyncio
import logging
import os
from pathlib import Path
from time import monotonic
log = logging.getLogger(__name__)
class CodeGraphError(RuntimeError):
pass
class CodeGraphClient:
async def prepare(self, workspace: Path) -> None:
self._exclude_index(workspace)
command = "sync" if (workspace / ".codegraph").is_dir() else "init"
started = monotonic()
log.info(
"CodeGraph index preparation started",
extra={"operation": f"codegraph.{command}"},
)
try:
process = await asyncio.create_subprocess_exec(
"codegraph",
command,
str(workspace),
cwd=workspace,
env={**os.environ, "CODEGRAPH_TELEMETRY": "0"},
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await process.communicate()
except OSError as exc:
log.exception(
"CodeGraph could not start",
extra={
"operation": f"codegraph.{command}",
"duration_ms": _elapsed_ms(started),
},
)
raise CodeGraphError(f"Could not run CodeGraph: {exc}") from exc
if process.returncode:
detail = stderr.decode(errors="replace").strip()
log.error(
"CodeGraph index preparation failed",
extra={
"operation": f"codegraph.{command}",
"duration_ms": _elapsed_ms(started),
},
)
raise CodeGraphError(
f"codegraph {command} failed: {detail[-1000:]}"
)
log.info(
"CodeGraph index preparation completed",
extra={
"operation": f"codegraph.{command}",
"duration_ms": _elapsed_ms(started),
},
)
@staticmethod
def _exclude_index(workspace: Path) -> None:
exclude = workspace / ".git" / "info" / "exclude"
exclude.parent.mkdir(parents=True, exist_ok=True)
content = exclude.read_text() if exclude.exists() else ""
patterns = {
line.strip()
for line in content.splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
if ".codegraph/" in patterns:
return
separator = "" if not content or content.endswith("\n") else "\n"
exclude.write_text(f"{content}{separator}.codegraph/\n")
def _elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
+11
View File
@@ -11,6 +11,8 @@ from typing import TypeVar
from pydantic import BaseModel
from agentci.adapters.codegraph import CodeGraphClient
T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
@@ -29,11 +31,13 @@ class CodexClient:
research_model: str,
research_reasoning: str,
context7_api_key: str | 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.codegraph = codegraph or CodeGraphClient()
self._write_research_agent(research_model, research_reasoning)
async def login_ready(self) -> bool:
@@ -61,6 +65,7 @@ class CodexClient:
schema_name: str,
result_type: type[T],
) -> tuple[str, T]:
await self.codegraph.prepare(workspace)
args = [
"codex",
"exec",
@@ -84,9 +89,11 @@ class CodexClient:
model: str,
reasoning: str,
permission: str,
workspace: Path,
schema_name: str,
result_type: type[T],
) -> T:
await self.codegraph.prepare(workspace)
args = [
"codex",
"exec",
@@ -198,8 +205,12 @@ Use Context7 for library documentation, gh_grep for real public-code examples, a
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"
+2 -1
View File
@@ -40,6 +40,7 @@ class CodeReviewLoop:
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
@@ -54,6 +55,7 @@ class CodeReviewLoop:
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-read",
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
@@ -74,4 +76,3 @@ def _required(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted Codex session ID")
return value
+6 -9
View File
@@ -85,6 +85,7 @@ class PlanWorkflow:
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
workspace=workflow.workspace_path,
schema_name="discussion.json",
result_type=DiscussionReply,
)
@@ -121,6 +122,7 @@ class PlanWorkflow:
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
@@ -128,11 +130,7 @@ class PlanWorkflow:
await self._finish(job, workflow, artifact, report)
async def _review_loop(
self,
job: Job,
workflow: Workflow,
context: str,
artifact: PlanArtifact,
self, job: Job, workflow: Workflow, context: str, artifact: PlanArtifact
) -> ReviewReport:
report = ReviewReport(summary="", findings=[])
for round_index in range(self.deps.settings.plan_review_rounds):
@@ -160,16 +158,14 @@ class PlanWorkflow:
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
return report
async def _review(
self,
workflow: Workflow,
context: str,
artifact: PlanArtifact,
self, workflow: Workflow, context: str, artifact: PlanArtifact
) -> ReviewReport:
prompt = self.deps.prompts.render(
"plan_review", context=context, artifact=artifact.plan_markdown
@@ -181,6 +177,7 @@ class PlanWorkflow:
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
+1
View File
@@ -54,6 +54,7 @@ class PullRequestWorkflow:
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
+57
View File
@@ -0,0 +1,57 @@
from pathlib import Path
from agentci.adapters.codegraph import CodeGraphClient
class FakeProcess:
returncode = 0
async def communicate(self) -> tuple[bytes, bytes]:
return b"", b""
async def test_initializes_index_and_excludes_it_from_git(
tmp_path: Path, monkeypatch
) -> None:
workspace = tmp_path / "repo"
(workspace / ".git" / "info").mkdir(parents=True)
calls: list[tuple[object, ...]] = []
async def create_subprocess_exec(*args, **_kwargs):
calls.append(args)
return FakeProcess()
monkeypatch.setattr(
"agentci.adapters.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
await CodeGraphClient().prepare(workspace)
assert calls == [("codegraph", "init", str(workspace))]
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
async def test_syncs_an_existing_index_without_duplicating_exclude(
tmp_path: Path, monkeypatch
) -> None:
workspace = tmp_path / "repo"
(workspace / ".git" / "info").mkdir(parents=True)
(workspace / ".codegraph").mkdir()
exclude = workspace / ".git" / "info" / "exclude"
exclude.write_text("# local excludes\n.codegraph/\n")
calls: list[tuple[object, ...]] = []
async def create_subprocess_exec(*args, **_kwargs):
calls.append(args)
return FakeProcess()
monkeypatch.setattr(
"agentci.adapters.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
await CodeGraphClient().prepare(workspace)
assert calls == [("codegraph", "sync", str(workspace))]
assert exclude.read_text() == "# local excludes\n.codegraph/\n"
+12
View File
@@ -1,8 +1,19 @@
import tomllib
from pathlib import Path
from agentci.adapters.codex import CodexClient, _session_id
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_extracts_thread_id_from_jsonl() -> None:
output = '\n'.join(
[
@@ -49,6 +60,7 @@ def test_configures_research_agent_and_optional_context7_key(tmp_path) -> None:
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