feat: codegraph
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user