From 29d12974fd80364d78cdf4bb912645ad1d3b26b0 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Sun, 19 Jul 2026 19:55:34 +0200 Subject: [PATCH] feat: codegraph --- .env.example | 1 + Dockerfile | 16 +++++- README.md | 6 ++ codex/config.toml | 11 ++++ compose.yaml | 1 + src/agentci/adapters/codegraph.py | 82 +++++++++++++++++++++++++++ src/agentci/adapters/codex.py | 11 ++++ src/agentci/workflows/code_review.py | 3 +- src/agentci/workflows/plan.py | 15 ++--- src/agentci/workflows/pull_request.py | 1 + tests/test_codegraph.py | 57 +++++++++++++++++++ tests/test_codex.py | 12 ++++ 12 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 src/agentci/adapters/codegraph.py create mode 100644 tests/test_codegraph.py diff --git a/.env.example b/.env.example index f771f17..6e81e99 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 5638f91..6de18da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index b03ce70..ad59fb8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/codex/config.toml b/codex/config.toml index 0641375..3e06bf3 100644 --- a/codex/config.toml +++ b/codex/config.toml @@ -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 diff --git a/compose.yaml b/compose.yaml index 9c47f3f..862908d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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} diff --git a/src/agentci/adapters/codegraph.py b/src/agentci/adapters/codegraph.py new file mode 100644 index 0000000..86c88f7 --- /dev/null +++ b/src/agentci/adapters/codegraph.py @@ -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) diff --git a/src/agentci/adapters/codex.py b/src/agentci/adapters/codex.py index aa36fe0..52578a5 100644 --- a/src/agentci/adapters/codex.py +++ b/src/agentci/adapters/codex.py @@ -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" diff --git a/src/agentci/workflows/code_review.py b/src/agentci/workflows/code_review.py index 5b98d4c..d286b05 100644 --- a/src/agentci/workflows/code_review.py +++ b/src/agentci/workflows/code_review.py @@ -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 - diff --git a/src/agentci/workflows/plan.py b/src/agentci/workflows/plan.py index adcba29..3a0ec44 100644 --- a/src/agentci/workflows/plan.py +++ b/src/agentci/workflows/plan.py @@ -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, ) diff --git a/src/agentci/workflows/pull_request.py b/src/agentci/workflows/pull_request.py index 192eef3..7df1596 100644 --- a/src/agentci/workflows/pull_request.py +++ b/src/agentci/workflows/pull_request.py @@ -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, ) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py new file mode 100644 index 0000000..0876496 --- /dev/null +++ b/tests/test_codegraph.py @@ -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" diff --git a/tests/test_codex.py b/tests/test_codex.py index c6bd640..df2dffa 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -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