diff --git a/CHANGELOG.md b/CHANGELOG.md index a74eeea..11e2ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +### 2026-09-22 +1. Add verification step - ensure the issue can be solved by coding +2. Add repo download step +3. Add repo analyzer: find entrypoint, build dependency graph +4. Add IssueComment class instead of dic + ### 2026-09-21 1. Now comments are directed to team members with care of the language they can read 2. Comments for context are filtered from TriageAgent to avoid hallucination diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/base_agent.py b/agents/base_agent.py index 20a4073..ab9e89e 100644 --- a/agents/base_agent.py +++ b/agents/base_agent.py @@ -1,6 +1,45 @@ from abc import ABC, abstractmethod +from datetime import timezone, datetime +import json, os, tempfile + class BaseAgent(ABC): @abstractmethod def run(self, issue_id: str): - pass # To be implemented by subclasses \ No newline at end of file + pass # To be implemented by subclasses + +REQUIRED_KEYS = { + "schema_version", "agent_id", "issue_id", "state", + "state_since", "run_id", "attempt", "counters", "max", + "branch_name", "events", +} + +def write_state(agent_dir: str, issue_id: str, state: dict) -> None: + path = os.path.join(agent_dir, "state", f"{issue_id}.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + state["updated_at"] = datetime.now(timezone.utc).isoformat() + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + json.dump(state, f, indent=2, sort_keys=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) # atomic on POSIX and Windows + except Exception: + os.unlink(tmp) + raise + +def read_state(agent_dir: str, issue_id: str) -> dict | None: + path = os.path.join(agent_dir, "state", f"{issue_id}.json") + if not os.path.exists(path): + return None + try: + with open(path) as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return None # caller treats as corrupt → re-run + if data.get("schema_version") != 1: + return None # schema mismatch → re-run + if not REQUIRED_KEYS.issubset(data): + return None + return data \ No newline at end of file diff --git a/agents/codebase_analyst/entrypoint.py b/agents/codebase_analyst/entrypoint.py new file mode 100644 index 0000000..dddbcb5 --- /dev/null +++ b/agents/codebase_analyst/entrypoint.py @@ -0,0 +1,409 @@ +""" +Idempotent repository entrypoint analyzer (LLMClient edition). + +Flow: + 1. List repo files (deterministic walk). + 2. Ask LLM for primary language. + 3. Loop: + a. Ask LLM to pick ONE candidate file. + b. Read an excerpt. + c. Ask LLM: is it the entrypoint / does it forward to another file / wrong? + d. On "entrypoint" -> stop. On "forward" -> probe next_file. On "wrong" -> pick again. + +All LLM calls go through the project's `LLMClient.chat_with_schema` with a +strict JSON schema, validated against a Pydantic model. Validation failures are +retried at most 2 times (per project convention). Successful responses are +cached keyed on (schema_name, system, user) so re-running against the same +repository is idempotent and makes zero additional LLM calls. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import os +from pathlib import Path +from typing import List, Literal, Optional, Type, TypeVar + +from pydantic import BaseModel, Field, ValidationError, TypeAdapter + +from agents.coders.repo import RepoContext +from common.llm_client import LLMClient +from contracts.RepoContext import Language, LanguageLiteral +from contracts.coders.VerifyOutput import sanitize_for_openai + +log = logging.getLogger("entrypoint_analyzer") + +MAX_LLM_RETRIES = 2 # retries AFTER the initial attempt (so <= 3 total) +EXCERPT_CHARS = 4_000 +MAX_ITERATIONS = 40 +MAX_LISTING_ENTRIES = 2_000 + +IGNORED_DIRS = { + ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", + "env", "dist", "build", ".idea", ".vscode", ".mypy_cache", ".pytest_cache", + ".tox", "target", "out", "obj", "bin", "coverage", +} +IGNORED_EXTS = { + ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip", ".tar", ".gz", ".lock", + ".pyc", ".so", ".dll", ".dylib", ".exe", ".woff", ".woff2", ".ico", + ".svg", ".mp4", ".mp3", ".class", ".jar", +} + + +# --------------------------------------------------------------------------- # +# Pydantic schemas # +# --------------------------------------------------------------------------- # + +class LanguageGuess(BaseModel): + """Primary language of the repository.""" + language: LanguageLiteral = Field(description="Primary programming language, lowercase (e.g. 'python').") + confidence: float = Field(ge=0.0, le=1.0) + reasoning: str + + +class FilePick(BaseModel): + """A single candidate file to inspect, chosen from the listing.""" + file_path: str = Field(description="Relative path exactly as it appears in the listing.") + reason: str + + +class ProbeVerdict(BaseModel): + """Verdict for the currently inspected file.""" + verdict: Literal["entrypoint", "forward", "wrong"] + next_file: Optional[str] = Field( + default=None, + description="If verdict='forward': the next file to inspect (must be in listing).", + ) + reason: str + + +class Attempt(BaseModel): + file_path: str + excerpt: str + verdict: Optional[ProbeVerdict] = None + + +class AnalysisResult(BaseModel): + language: Language + entrypoint: str + attempts: List[Attempt] + reasoning: str + + +# --------------------------------------------------------------------------- # +# LLM plumbing: LLMClient wrapper with strict schema + cache + retries # +# --------------------------------------------------------------------------- # + +T = TypeVar("T", bound=BaseModel) + + +class ResponseCache: + """File-backed cache: prompt-hash -> validated-model JSON. Makes runs idempotent.""" + + def __init__(self, path: Optional[Path] = None) -> None: + self.path = path + self._mem: dict[str, str] = {} + if path and path.exists(): + try: + self._mem = json.loads(path.read_text("utf-8")) + except Exception: # noqa: BLE001 + log.warning("Corrupt cache at %s; starting fresh", path) + + @staticmethod + def key(system: str, user: str, schema: Type[BaseModel]) -> str: + h = hashlib.sha256() + h.update(schema.__name__.encode()) + h.update(b"\x00") + h.update(system.encode()) + h.update(b"\x00") + h.update(user.encode()) + return h.hexdigest() + + def get(self, k: str) -> Optional[str]: + return self._mem.get(k) + + def put(self, k: str, v: str) -> None: + self._mem[k] = v + if self.path: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(self.path.suffix + ".tmp") + tmp.write_text(json.dumps(self._mem, indent=2, sort_keys=True), "utf-8") + tmp.replace(self.path) + + +class StructuredLLM: + """ + Wrap the project's `LLMClient` with: + + * strict JSON-schema enforcement (same shape as the reference usage), + * Pydantic validation of the response, + * at most MAX_LLM_RETRIES retries on validation failure, + * a deterministic prompt-keyed cache so reruns are idempotent. + + Usage mirrors the reference snippet but is generic over the output model. + """ + + def __init__( + self, + client: LLMClient, + cache: Optional[ResponseCache] = None, + max_retries: int = MAX_LLM_RETRIES, + ) -> None: + self.client = client + self.cache = cache + self.max_retries = max_retries + + def call(self, system: str, user: str, schema: Type[T]) -> T: + schema_adapter = TypeAdapter(schema) + # --- idempotency: serve from cache when prompt+system+schema match --- + cache_key = ResponseCache.key(system, user, schema) if self.cache else None + if cache_key and (hit := self.cache.get(cache_key)) is not None: + try: + return schema.model_validate_json(hit) + except ValidationError: + log.warning("Stale cache entry for %s; ignoring", schema.__name__) + + # --- build the strict response_format exactly as in the reference --- + response_format = { + "type": "json_schema", + "json_schema": { + "name": schema.__name__, + "schema": sanitize_for_openai(schema_adapter.json_schema()), + "strict": True, + }, + } + + last_err: Optional[BaseException] = None + + for attempt in range(self.max_retries + 1): + augmented_system = system + if attempt > 0 and last_err is not None: + augmented_system = ( + f"{system}\n\n" + f"Your previous response failed schema validation with: {last_err}. " + f"Return ONLY a single JSON object conforming exactly to the " + f"provided schema — no prose, no code fences." + ) + try: + raw = self.client.chat_with_schema( + user, + response_format, + system=augmented_system, + ) + if isinstance(raw, str): + raw = json.loads(raw) + parsed = schema_adapter.validate_python(raw) # pydantic v2 + except (ValidationError, json.JSONDecodeError, ValueError) as exc: + last_err = exc + log.warning( + "Schema %s failed validation (attempt %d/%d): %s", + schema.__name__, attempt + 1, self.max_retries + 1, exc, + ) + continue + except Exception as exc: # noqa: BLE001 (transport / API errors) + last_err = exc + log.warning( + "LLM transport error for %s (attempt %d/%d): %s", + schema.__name__, attempt + 1, self.max_retries + 1, exc, + ) + continue + + # --- memoize under the *original* prompt key --------------------- + if cache_key and self.cache: + self.cache.put(cache_key, parsed.model_dump_json()) + return parsed + + # Exhausted retries — hard failure, per project convention. + raise RuntimeError( + f"LLM failed to produce a valid {schema.__name__} " + f"after {self.max_retries + 1} attempt(s)" + ) from last_err + + +# --------------------------------------------------------------------------- # +# Repository listing # +# --------------------------------------------------------------------------- # + +def list_repo(root: Path) -> List[str]: + """Deterministic, sorted listing of analyzable files relative to `root`.""" + root = root.resolve() + out: List[str] = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = sorted(d for d in dirnames if d not in IGNORED_DIRS) + for fname in sorted(filenames): + if Path(fname).suffix.lower() in IGNORED_EXTS: + continue + rel = (Path(dirpath) / fname).relative_to(root) + out.append(rel.as_posix()) + return out + + +def _listing_repr(listing: List[str], cap: int = MAX_LISTING_ENTRIES) -> str: + if len(listing) <= cap: + return "\n".join(listing) + return "\n".join(listing[:cap]) + f"\n...<{len(listing) - cap} more entries truncated>..." + + +# --------------------------------------------------------------------------- # +# Analyzer # +# --------------------------------------------------------------------------- # + +class EntrypointAnalyzer: + def __init__( + self, + llm: StructuredLLM, + max_iterations: int = MAX_ITERATIONS, + excerpt_chars: int = EXCERPT_CHARS, + ) -> None: + self.llm = llm + self.max_iterations = max_iterations + self.excerpt_chars = excerpt_chars + + # -- public ------------------------------------------------------------ # + + def analyze(self, repo_root: Path) -> AnalysisResult: + repo_root = Path(repo_root).resolve() + if not repo_root.is_dir(): + raise ValueError(f"Not a directory: {repo_root}") + + listing = list_repo(repo_root) + if not listing: + raise ValueError(f"No analyzable files under {repo_root}") + + guess = self._guess_language(listing) + language: Language = guess.language + log.info("Detected language=%s (confidence=%.2f)", language.value, guess.confidence) + + attempts: List[Attempt] = [] + carried: Optional[str] = None # "forward" hint from a previous verdict + + for iteration in range(1, self.max_iterations + 1): + # Resolve to a definitely-non-None local for this iteration. + current_file: str = ( + carried if carried is not None + else self._pick_file(listing, language, attempts) + ) + + if current_file not in listing: + log.warning( + "LLM suggested %r which is not in the listing; discarding.", + current_file, + ) + carried = None + continue + + excerpt = self._read_excerpt(repo_root / current_file) + verdict = self._probe(language, current_file, excerpt, attempts) + attempts.append( + Attempt(file_path=current_file, excerpt=excerpt, verdict=verdict) + ) + + log.info( + "iter=%d file=%s verdict=%s reason=%s", + iteration, current_file, verdict.verdict, verdict.reason, + ) + + if verdict.verdict == "entrypoint": + return AnalysisResult( + language=language, + entrypoint=current_file, + attempts=attempts, + reasoning=verdict.reason, + ) + + if verdict.verdict == "forward" and verdict.next_file: + carried = verdict.next_file + else: + if verdict.verdict == "forward": + log.warning("Verdict 'forward' without next_file; falling back to fresh pick.") + carried = None + + raise RuntimeError( + f"Entrypoint not found after {self.max_iterations} iterations. " + f"Tried: {[a.file_path for a in attempts]}" + ) + + # -- LLM steps --------------------------------------------------------- # + + def _guess_language(self, listing: List[str]) -> LanguageGuess: + system = ( + "You are a senior code analyst. Given a repository's file listing " + "(relative paths), determine the primary programming language of " + "the project. Weigh file extensions, conventional filenames " + "(setup.py, package.json, Cargo.toml, go.mod, pom.xml, ...), and " + "directory structure." + ) + user = "Repository file listing:\n" + _listing_repr(listing) + return self.llm.call(system, user, LanguageGuess) + + def _pick_file( + self, listing: List[str], language: Language, attempts: List[Attempt] + ) -> str: + system = ( + "You are a senior code analyst. Pick exactly ONE file from the " + "provided listing that is the most promising candidate to be, or " + "to lead toward, the program's entrypoint. Prefer conventional " + "entrypoint names for the detected language " + "(main.py / __main__.py / app.py / cli.py / manage.py for Python; " + "index.js / server.js / main.ts for JS/TS; cmd/main.go for Go; " + "Program.cs / Startup.cs for C#; main.rs / lib.rs for Rust). " + "You MUST NOT pick a file that has already been tried. Return the " + "path exactly as it appears in the listing." + ) + tried = [a.file_path for a in attempts] + user = ( + f"Language: {language.value}\n\n" + f"Listing:\n{_listing_repr(listing)}\n\n" + f"Already tried (DO NOT pick these): {tried or '[]'}" + ) + return self.llm.call(system, user, FilePick).file_path + + def _probe( + self, + language: Language, + path: str, + excerpt: str, + attempts: List[Attempt], + ) -> ProbeVerdict: + system = ( + f"You are a senior code analyst locating the entrypoint of a " + f"{language.value} project. You are shown one file at a time and must " + f"decide exactly one of:\n" + f" - 'entrypoint': this file IS the program's entrypoint — e.g. it " + f"contains 'if __name__ == \"__main__\"', calls main() at module " + f"scope, does top-level CLI dispatch, is a declared binary target, " + f"or is the bootstrapping module itself.\n" + f" - 'forward': this file is not the entrypoint itself but clearly " + f"imports/dispatches into another file that is closer — set " + f"'next_file' to that file's path (it MUST appear in the listing).\n" + f" - 'wrong': this file is unrelated or a dead end — a different " + f"file must be picked.\n" + f"Be strict: only return 'entrypoint' when the file itself " + f"bootstraps execution." + ) + tried = [a.file_path for a in attempts] + user = ( + f"File under inspection: {path}\n\n" + f"--- content excerpt ---\n{excerpt}\n--- end excerpt ---\n\n" + f"Previously tried files: {tried or '[]'}" + ) + return self.llm.call(system, user, ProbeVerdict) + + # -- helpers ----------------------------------------------------------- # + + def _read_excerpt(self, path: Path) -> str: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except Exception as exc: # noqa: BLE001 + return f"" + if len(text) > self.excerpt_chars: + return text[: self.excerpt_chars] + "\n......" + return text + + + +def gather_repo_entrypoint(llm: LLMClient, repo: RepoContext, cache_path: Optional[str] = None) -> AnalysisResult: + return EntrypointAnalyzer( + StructuredLLM(llm, cache=ResponseCache(Path(cache_path)) if cache_path else None) + ).analyze(Path(repo.repo_dir)) diff --git a/agents/codebase_analyst/graph_tool.py b/agents/codebase_analyst/graph_tool.py new file mode 100644 index 0000000..f6a84a1 --- /dev/null +++ b/agents/codebase_analyst/graph_tool.py @@ -0,0 +1,850 @@ +import json +import shutil +import subprocess +from abc import ABC, abstractmethod +from functools import partial +from pathlib import Path +from typing import Optional, Callable + +import networkx as nx +import pandas as pd +from networkx.algorithms.community import louvain_communities + +from pydantic import BaseModel, Field, ValidationError + +from common.llm_client import LLMClient +from contracts.RepoContext import Language, ToolRequirement, DependencyGraph, DependencyNode, DependencyEdge, \ + AnalysisResult, AnalysisInput, AnalysisError + + +def find_tool(command: str) -> Optional[str]: + """Return the absolute path to `command` if it is on PATH, else None.""" + return shutil.which(command) + +TOOL_REGISTRY: dict[Language, ToolRequirement] = { + Language.PYTHON: ToolRequirement( + tool_name="pydeps", + command="pydeps", + install_guide="""\ +## Install pydeps + +```bash +pip install pydeps +You also need Graphviz for rendering: +Ubuntu/Debian: sudo apt install graphviz +macOS: brew install graphviz +Windows: Download from https://graphviz.org/download/ +Ensure the dot command is on your PATH.""", + docs_url="https://github.com/thebjorn/pydeps", + ), + Language.JAVASCRIPT: ToolRequirement( + tool_name="madge", + command="madge", + install_guide="""\ +Install madge +bash +npm -g install madge +Graphviz (optional, for image output): +Ubuntu/Debian: sudo apt install graphviz +macOS: brew install graphviz""", + docs_url="https://github.com/pahen/madge", + ), + Language.TYPESCRIPT: ToolRequirement( + tool_name="madge", + command="madge", + install_guide="""\ + +Install madge (TypeScript support) +bash +npm -g install madge +For TypeScript projects, ensure typescript is also installed: + +bash +npm install -g typescript +```""", + docs_url="https://github.com/pahen/madge", + ), + Language.JAVA: ToolRequirement( + tool_name="mvn", + command="mvn", + install_guide="""\ +## Java dependency analysis + +**Maven:** +```bash +mvn dependency:tree -DoutputFile=deps.txt +Gradle: + +bash +gradle dependencies > deps.txt +Install Maven: https://maven.apache.org/install.html +Install Gradle: https://gradle.org/install/[reference:9]""", + docs_url="https://maven.apache.org/plugins/maven-dependency-plugin/", + ), + Language.GO: ToolRequirement( + tool_name="go", + command="go", + install_guide="""\ + +Install Go +The Go toolchain provides built-in dependency listing: + +bash +# Full module graph +go mod graph + +# All modules (direct + indirect) +go list -m all + +# Package import graph +go list -f '{{.ImportPath}} {{.Imports}}' ./... +Install Go: https://go.dev/doc/install[reference:10]""", + docs_url="https://go.dev/ref/mod#go-list-m", + ), + Language.RUST: ToolRequirement( + tool_name="cargo", + command="cargo", + install_guide="""\ +Install Rust / Cargo +cargo tree is built-in since Rust 1.44: + +bash +cargo tree +cargo tree --depth 1 # direct deps only +cargo tree -i # reverse deps +Install Rust: https://www.rust-lang.org/tools/install[reference:11]""", + docs_url="https://doc.rust-lang.org/cargo/commands/cargo-tree.html", + ), + Language.CPP: ToolRequirement( + tool_name="include-what-you-use", + command="include-what-you-use", + install_guide="""\ + +Install include-what-you-use (IWYU) +Ubuntu/Debian: sudo apt-get install iwyu +macOS: brew install include-what-you-use +Arch: sudo pacman -S include-what-you-use +Ensure include-what-you-use is on your PATH.""", + docs_url="https://include-what-you-use.org/", + ), + Language.C: ToolRequirement( + tool_name="include-what-you-use", + command="include-what-you-use", + install_guide="""\ +Install include-what-you-use (IWYU) +Ubuntu/Debian: sudo apt-get install iwyu +macOS: brew install include-what-you-use +Arch: sudo pacman -S include-what-you-use""", + docs_url="https://include-what-you-use.org/", + ), +} + +class LanguageAdapter(ABC): + """Contract for every language-specific analyzer.""" + + @property + @abstractmethod + def language(self) -> Language: ... + + @property + @abstractmethod + def required_tools(self) -> list[ToolRequirement]: ... + + @abstractmethod + def build_graph( + self, repo_path: Path, entrypoint: str + ) -> DependencyGraph: ... + +class PythonAdapter(LanguageAdapter): + @property + def language(self) -> Language: + return Language.PYTHON + + @property + def required_tools(self) -> list[ToolRequirement]: + return [TOOL_REGISTRY[Language.PYTHON]] + + def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph: + entry_abs = repo_path / entrypoint + + # pydeps emits JSON when --show-deps is used + result = subprocess.run( + [ + "pydeps", + str(entry_abs), + "--show-deps", + "--no-output", + "--no-config", + "-T", "json", + ], + capture_output=True, + text=True, + cwd=str(repo_path), + timeout=120, + ) + if result.returncode != 0: + raise RuntimeError( + f"pydeps failed (rc={result.returncode}): {result.stderr.strip()}" + ) + + raw = json.loads(result.stdout) + nodes: list[DependencyNode] = [] + edges: list[DependencyEdge] = [] + seen: set[str] = set() + + def _add_node(name: str, external: bool = False) -> None: + if name not in seen: + seen.add(name) + nodes.append( + DependencyNode( + id=name, + label=name.split(".")[-1], + language=Language.PYTHON, + is_external=external, + ) + ) + + _add_node(entrypoint) + for mod_name, info in raw.items(): + _add_node(mod_name, external=info.get("external", False)) + for imported in info.get("imports", []): + _add_node(imported) + edges.append( + DependencyEdge(source=mod_name, target=imported, kind="import") + ) + + return DependencyGraph( + root=entrypoint, + nodes=nodes, + edges=edges, + metadata={"tool": "pydeps", "entrypoint": entrypoint}, + ) + +class JsTsAdapter(LanguageAdapter): + def __init__(self, language: Language) -> None: + self._language = language + + @property + def language(self) -> Language: + return self._language + + @property + def required_tools(self) -> list[ToolRequirement]: + return [TOOL_REGISTRY[self._language]] + + def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph: + entry_abs = repo_path / entrypoint + + cmd = ["madge", "--json", str(entry_abs), "--basedir", str(repo_path)] + if self._language == Language.TYPESCRIPT: + cmd.append("--ts-config") + cmd.append(str(repo_path / "tsconfig.json")) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=str(repo_path), + timeout=180, + ) + if result.returncode != 0: + raise RuntimeError( + f"madge failed (rc={result.returncode}): {result.stderr.strip()}" + ) + + raw: dict[str, list[str]] = json.loads(result.stdout) + nodes: list[DependencyNode] = [] + edges: list[DependencyEdge] = [] + seen: set[str] = set() + + def _add_node(name: str) -> None: + if name not in seen: + seen.add(name) + nodes.append( + DependencyNode( + id=name, + label=Path(name).name, + language=self._language, + is_external=not name.startswith("."), + ) + ) + + _add_node(entrypoint) + for src, deps in raw.items(): + _add_node(src) + for dep in deps: + _add_node(dep) + edges.append( + DependencyEdge(source=src, target=dep, kind="import") + ) + + return DependencyGraph( + root=entrypoint, + nodes=nodes, + edges=edges, + metadata={"tool": "madge", "entrypoint": entrypoint}, + ) + +class GoAdapter(LanguageAdapter): + @property + def language(self) -> Language: + return Language.GO + + @property + def required_tools(self) -> list[ToolRequirement]: + return [TOOL_REGISTRY[Language.GO]] + + def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph: + result = subprocess.run( + [ + "go", "list", + "-f", "{{.ImportPath}} {{join .Imports \" \"}}", + "./...", + ], + capture_output=True, + text=True, + cwd=str(repo_path), + timeout=180, + ) + if result.returncode != 0: + raise RuntimeError( + f"go list failed (rc={result.returncode}): {result.stderr.strip()}" + ) + + nodes: list[DependencyNode] = [] + edges: list[DependencyEdge] = [] + seen: set[str] = set() + + def _add_node(name: str) -> None: + if name not in seen: + seen.add(name) + nodes.append( + DependencyNode( + id=name, + label=name.split("/")[-1], + language=Language.GO, + is_external="/" in name and not name.startswith("."), + ) + ) + + for line in result.stdout.strip().splitlines(): + parts = line.split() + if not parts: + continue + pkg = parts[0] + _add_node(pkg) + for imp in parts[1:]: + _add_node(imp) + edges.append( + DependencyEdge(source=pkg, target=imp, kind="import") + ) + + return DependencyGraph( + root=entrypoint, + nodes=nodes, + edges=edges, + metadata={"tool": "go list", "entrypoint": entrypoint}, + ) + + +class RustAdapter(LanguageAdapter): + @property + def language(self) -> Language: + return Language.RUST + + @property + def required_tools(self) -> list[ToolRequirement]: + return [TOOL_REGISTRY[Language.RUST]] + + def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph: + result = subprocess.run( + ["cargo", "tree", "--prefix", "none", "--format", "{p} {f}"], + capture_output=True, + text=True, + cwd=str(repo_path), + timeout=180, + ) + if result.returncode != 0: + raise RuntimeError( + f"cargo tree failed (rc={result.returncode}): {result.stderr.strip()}" + ) + + nodes: list[DependencyNode] = [] + edges: list[DependencyEdge] = [] + seen: set[str] = set() + + def _add_node(name: str) -> None: + if name not in seen: + seen.add(name) + nodes.append( + DependencyNode( + id=name, + label=name, + language=Language.RUST, + is_external=False, # refined below + ) + ) + + # cargo tree --prefix none outputs one package per line, + # with indentation indicating depth. We parse the flat form + # and reconstruct edges from indentation. + lines = result.stdout.strip().splitlines() + stack: list[str] = [] + for line in lines: + if not line.strip(): + continue + indent = len(line) - len(line.lstrip("│ ├─└ ")) + name = line.strip().split(" ")[0] + _add_node(name) + while len(stack) > indent: + stack.pop() + if stack: + edges.append( + DependencyEdge(source=stack[-1], target=name, kind="depends_on") + ) + stack.append(name) + + return DependencyGraph( + root=entrypoint, + nodes=nodes, + edges=edges, + metadata={"tool": "cargo tree", "entrypoint": entrypoint}, + ) + + +class JavaAdapter(LanguageAdapter): + @property + def language(self) -> Language: + return Language.JAVA + + @property + def required_tools(self) -> list[ToolRequirement]: + return [TOOL_REGISTRY[Language.JAVA]] + + def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph: + if (repo_path / "pom.xml").exists(): + return self._maven_graph(repo_path, entrypoint) + if (repo_path / "build.gradle").exists() or (repo_path / "build.gradle.kts").exists(): + return self._gradle_graph(repo_path, entrypoint) + raise RuntimeError("No pom.xml or build.gradle found in repository root") + + def _maven_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph: + result = subprocess.run( + ["mvn", "dependency:tree", "-DoutputType=text"], + capture_output=True, + text=True, + cwd=str(repo_path), + timeout=300, + ) + if result.returncode != 0: + raise RuntimeError(f"mvn dependency:tree failed: {result.stderr.strip()}") + return self._parse_tree_text(result.stdout, entrypoint) + + def _gradle_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph: + result = subprocess.run( + ["gradle", "dependencies", "--configuration", "runtimeClasspath"], + capture_output=True, + text=True, + cwd=str(repo_path), + timeout=300, + ) + if result.returncode != 0: + raise RuntimeError(f"gradle dependencies failed: {result.stderr.strip()}") + return self._parse_tree_text(result.stdout, entrypoint) + + def _parse_tree_text(self, text: str, entrypoint: str) -> DependencyGraph: + # Simplified parser for the textual tree format. + # In production, use the JSON output of the Maven/Gradle plugin. + nodes: list[DependencyNode] = [] + edges: list[DependencyEdge] = [] + seen: set[str] = set() + + def _add_node(name: str) -> None: + if name not in seen: + seen.add(name) + nodes.append( + DependencyNode( + id=name, + label=name.split(":")[-1] if ":" in name else name, + language=Language.JAVA, + is_external=True, + ) + ) + + _add_node(entrypoint) + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("+-") or stripped.startswith("\\-"): + continue + # crude: treat every artifact line as a dependency of the root + name = stripped.split(":")[0] + if name and name != entrypoint: + _add_node(name) + edges.append( + DependencyEdge(source=entrypoint, target=name, kind="depends_on") + ) + + return DependencyGraph( + root=entrypoint, + nodes=nodes, + edges=edges, + metadata={"tool": "maven/gradle", "entrypoint": entrypoint}, + ) + + +class CppAdapter(LanguageAdapter): + @property + def language(self) -> Language: + return Language.CPP + + @property + def required_tools(self) -> list[ToolRequirement]: + return [TOOL_REGISTRY[Language.CPP]] + + def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph: + entry_abs = repo_path / entrypoint + + result = subprocess.run( + [ + "include-what-you-use", + "-Xiwyu", "--no_fwd_decls", + str(entry_abs), + ], + capture_output=True, + text=True, + cwd=str(repo_path), + timeout=180, + ) + # IWYU returns non-zero when it recommends changes; parse stdout anyway + output = result.stdout + result.stderr + + nodes: list[DependencyNode] = [] + edges: list[DependencyEdge] = [] + seen: set[str] = set() + + def _add_node(name: str) -> None: + if name not in seen: + seen.add(name) + nodes.append( + DependencyNode( + id=name, + label=Path(name).name, + language=Language.CPP, + is_external=not name.startswith("."), + ) + ) + + _add_node(entrypoint) + # IWYU output contains lines like: + # #include // for ... + # #include "foo.h" // for ... + import re + pattern = re.compile(r'#include\s+[<"]([^>"]+)[>"]') + for match in pattern.finditer(output): + header = match.group(1) + _add_node(header) + edges.append( + DependencyEdge(source=entrypoint, target=header, kind="include") + ) + + return DependencyGraph( + root=entrypoint, + nodes=nodes, + edges=edges, + metadata={"tool": "include-what-you-use", "entrypoint": entrypoint}, + ) + +class LLMDependencyInference(BaseModel): + """Schema the LLM must fill to infer dependencies.""" + nodes: list[dict[str, str]] + edges: list[dict[str, str]] + confidence: float = Field(ge=0.0, le=1.0) + + +SYSTEM_PROMPT = ( + "You are a static-analysis assistant. Given the source code of a file, " + "extract all direct dependencies (imports, includes, requires). " + "Return ONLY valid JSON matching the provided schema." +) + + +def infer_dependencies_with_llm( + llm: LLMClient, + file_content: str, + language: str, +) -> LLMDependencyInference: + prompt = ( + f"Language: {language}\n" + f"Source code:\n```\n{file_content}\n```\n" + "Extract all dependencies as a list of nodes and edges." + ) + + schema = LLMDependencyInference.model_json_schema() + raw = llm.chat_with_schema( + prompt, + { + "type": "json_schema", + "json_schema": { + "name": "LLMDependencyInference", + "schema": schema, + "strict": True, + }, + }, + system=SYSTEM_PROMPT, + ) + + try: + if isinstance(raw, str): + raw = json.loads(raw) + return LLMDependencyInference.model_validate(raw) + except ValidationError as exc: + # Hard failure — do not build downstream steps on malformed data. + raise RuntimeError(f"LLM returned invalid dependency schema: {exc}") from exc + + +def emit_youtrack_comment(result: AnalysisResult, issue_id: str) -> None: + """Post a single comment to Youtrack describing the analysis outcome.""" + if result.success: + # Optionally post a summary; by convention, avoid duplicating comments. + return + + err = result.error + body = f""" + **Dependency analysis failed** (`{err.error_code}`) + + {err.message} + + """ + if err.missing_tools: + for tool in err.missing_tools: + body += f"\n\n### {tool.tool_name}\n\n{tool.install_guide}" + + print(body) + + +AdapterFactory = Callable[[], LanguageAdapter] + +ADAPTER_REGISTRY: dict[Language, AdapterFactory] = { + Language.PYTHON: PythonAdapter, # type[LanguageAdapter] is Callable[[], LanguageAdapter] + Language.JAVASCRIPT: partial(JsTsAdapter, Language.JAVASCRIPT), # -> JsTsAdapter + Language.TYPESCRIPT: partial(JsTsAdapter, Language.TYPESCRIPT), # -> JsTsAdapter + Language.JAVA: JavaAdapter, + Language.GO: GoAdapter, + Language.RUST: RustAdapter, + Language.CPP: partial(CppAdapter, Language.CPP), # if CppAdapter takes a language + Language.C: partial(CppAdapter, Language.C), +} + + +def analyze_repository(inp: AnalysisInput) -> AnalysisResult: + """ + Idempotent entry point. + + Given the same AnalysisInput, always returns the same AnalysisResult + (either a graph or a structured error). No files are written to the + repository under analysis. + """ + adapter_factory = ADAPTER_REGISTRY.get(inp.language) + if adapter_factory is None: + return AnalysisResult( + success=False, + error=AnalysisError( + error_code="UNSUPPORTED_LANGUAGE", + message=f"No adapter registered for language '{inp.language}'", + language=inp.language, + entrypoint=inp.entrypoint, + ), + ) + + adapter: LanguageAdapter = adapter_factory() + + # --- Tool detection --- + missing: list[ToolRequirement] = [] + for req in adapter.required_tools: + if find_tool(req.command) is None: + missing.append(req) + + if missing: + return AnalysisResult( + success=False, + error=AnalysisError( + error_code="MISSING_TOOL", + message=( + f"Cannot build dependency graph for '{inp.language.value}': " + f"required tool(s) not found on PATH: " + f"{', '.join(r.tool_name for r in missing)}" + ), + missing_tools=missing, + language=inp.language, + entrypoint=inp.entrypoint, + ), + ) + + # --- Build graph --- + try: + graph = adapter.build_graph(inp.repo_path, inp.entrypoint) + except subprocess.TimeoutExpired: + return AnalysisResult( + success=False, + error=AnalysisError( + error_code="TOOL_TIMEOUT", + message=f"Tool timed out while analyzing {inp.entrypoint}", + language=inp.language, + entrypoint=inp.entrypoint, + ), + ) + except Exception as exc: + return AnalysisResult( + success=False, + error=AnalysisError( + error_code="ANALYSIS_FAILED", + message=str(exc), + language=inp.language, + entrypoint=inp.entrypoint, + ), + ) + + G_all = to_networkx(graph, include_external=True) + G = to_networkx(graph, include_external=False) # internal-only analysis + res_internal = analyze(G, graph, label="_internal") + res_all = analyze(G_all, graph, label="_all") + leaf_clusters = cluster_leaves(G, res_internal["leaf_no_deps"]) + + return AnalysisResult(success=True, graph=graph, res_all=res_all, leaf_clusters=leaf_clusters) + + +def to_networkx(dep_graph: DependencyGraph, include_external=False): + G = nx.DiGraph() + + # Nodes + for n in dep_graph.nodes: + if not include_external and n.is_external: + continue + G.add_node(n.id, label=n.label, language=n.language.value, is_external=n.is_external) + + # Edges (source depends on target) + for e in dep_graph.edges: + if e.source in G and e.target in G: + G.add_edge(e.source, e.target, kind=e.kind) + + # Add isolated internal files that never appear in edges + if not include_external: + for n in dep_graph.nodes: + if not n.is_external and n.id not in G: + G.add_node(n.id, label=n.label, language=n.language.value, is_external=False) + + return G + +def package_of(path: str) -> str: + parts = path.split("/") + return "/".join(parts[:-1]) or "." + + +COLS = [ + "file", "language", "in_degree", "out_degree", + "isolated", "leaf_no_deps", "entry_no_dependents", + "used_by_files", "used_by_packages", "community", +] + + +def analyze(G, graph, label=""): + in_deg = dict(G.in_degree()) + out_deg = dict(G.out_degree()) + + # 1. Truly isolated: no in and no out edges + isolated = [n for n in G if G.degree(n) == 0] + + # 2. Leaf: depends on nothing + leaf_no_deps = [n for n in G if G.out_degree(n) == 0] + + # 3. Entrypoint: nothing depends on it + entry_no_dependents = [n for n in G if G.in_degree(n) == 0] + + # 4. Widely used: how many distinct files depend on it + widely_used = sorted(in_deg.items(), key=lambda kv: kv[1], reverse=True) + + # 5. How many distinct packages depend on it + pkg = {n: package_of(n) for n in G} + used_by_packages = { + n: len({pkg[p] for p in G.predecessors(n)}) for n in G + } + + # 6. Weakly connected components = isolated groups + wccs = sorted(nx.weakly_connected_components(G), key=len, reverse=True) + + # 7. Strongly connected components = cycles / tightly coupled groups + sccs = sorted( + (c for c in nx.strongly_connected_components(G) if len(c) > 1), + key=len, reverse=True, + ) + + # 8. Communities via Louvain + U = G.to_undirected() + comms = louvain_communities(U, seed=42) if U.number_of_edges() else [] + node2comm = {n: i for i, c in enumerate(comms) for n in c} + + # Per-file table + rows = [] + for n, data in G.nodes(data=True): + rows.append({ + "file": n, + "language": data.get("language"), + "in_degree": in_deg[n], + "out_degree": out_deg[n], + "isolated": G.degree(n) == 0, + "leaf_no_deps": out_deg[n] == 0, + "entry_no_dependents": in_deg[n] == 0, + "used_by_files": in_deg[n], + "used_by_packages": used_by_packages[n], + "community": node2comm.get(n, -1), + }) + + df = pd.DataFrame(rows, columns=COLS) + if not df.empty: + df = df.sort_values( + ["used_by_packages", "used_by_files"], + ascending=False, + ) + df.to_csv(f"file_metrics{label}.csv", index=False) + + pd.DataFrame([ + {"group_id": i, "size": len(c), "members": ";".join(sorted(c))} + for i, c in enumerate(wccs) + ]).to_csv(f"isolated_groups{label}.csv", index=False) + + pd.DataFrame([ + {"scc_id": i, "size": len(c), "members": ";".join(sorted(c))} + for i, c in enumerate(sccs) + ]).to_csv(f"cycles{label}.csv", index=False) + + return { + "df": df, + "isolated": isolated, + "leaf_no_deps": leaf_no_deps, + "entry_no_dependents": entry_no_dependents, + "widely_used": widely_used, + "used_by_packages": used_by_packages, + "wccs": wccs, + "sccs": sccs, + "node2comm": node2comm, + "comms": comms, + } + + +def cluster_leaves(G, leaves): + L = nx.Graph() + L.add_nodes_from(leaves) + + parent_to_leaves = {} + for leaf in leaves: + for parent in G.predecessors(leaf): + parent_to_leaves.setdefault(parent, []).append(leaf) + + # Leaves used by the same parent get connected + for parent, ls in parent_to_leaves.items(): + for i in range(len(ls)): + for j in range(i + 1, len(ls)): + L.add_edge(ls[i], ls[j]) + + clusters = sorted(nx.connected_components(L), key=len, reverse=True) + return clusters diff --git a/agents/coders/BaseCoder.py b/agents/coders/BaseCoder.py new file mode 100644 index 0000000..aa4c57b --- /dev/null +++ b/agents/coders/BaseCoder.py @@ -0,0 +1,84 @@ +""" +Can read repo, branching, commit, push, create MR, review changes +""" +import logging +from pathlib import Path + +from agents.coders.steps.gather import GatherStep +from agents.coders.steps.plan import handle_plan, PlanInput +from agents.coders.steps.verify import handle_verify +from agents.issue_triage.context_builder import YouTrackContextBuilder +from agents.registry import agent, AgentRegistry +from common.gitea_mcp_client import GiteaMCPClient +from common.llm_client import LLMClient +from common.youtrack_mcp_client import YouTrackMCPClient +from contracts.IssueContext import IssueContext +from contracts.coders.RunContext import RunContext, new_run_id, AgentConfig +from contracts.coders.VerifyOutput import VerifyPossibilityInput + +logger = logging.getLogger("base_coder") +logger.setLevel(logging.DEBUG) + + +@agent( + name="BaseCoderAgent", + description="Base Coder Agent", + version="1.0.0", + capabilities={"read_issue", "comment_issue", "marker_issue", "comment_classify"}, + tags={"development"}, + input_schema={"type": "object", "properties": {"text": {"type": "string"}}}, + priority=10, +) +class CoderAgent: + def __init__(self, id: str, context_builder: YouTrackContextBuilder, llm: LLMClient, agent_registry: AgentRegistry, + youtrack_mcp: YouTrackMCPClient, gitea_mcp: GiteaMCPClient): + self.name = "BaseCoderAgent" + self.id = id + self.ctx_builder = context_builder + self.llm = llm + self.agent_registry = agent_registry + self.youtrack_mcp = youtrack_mcp + self.gitea_mcp = gitea_mcp + + async def fix_an_issue(self, issue_ctx: IssueContext): + gather_result = await GatherStep( + Path(__file__).resolve().parents[2] / "projects" / "agents", + self.llm, + self.agent_registry, + self.youtrack_mcp, + self.id + ).run(issue_ctx) + + if gather_result.state.state == "INIT": + run_ctx = RunContext( + agent_id=self.id, + issue_id=issue_ctx.issue_id, + run_id=new_run_id(), + attempt=gather_result.attempt, + llm=self.llm, + youtrack=self.youtrack_mcp, + gitea=self.gitea_mcp, + state=gather_result.state, + config=AgentConfig( + youtrack_project=issue_ctx.issue_id.split(":")[0], + gitea_repo="" + ), + ) + step = await handle_verify( + VerifyPossibilityInput(gather_result), + run_ctx + ) + if step.kind != "continue": + return "Verified step await" + + step = await handle_plan( + PlanInput(gather_result, step.output), + llm=self.llm, + ) + print(step) + + # Init + return "Other loop" + + + diff --git a/agents/coders/__init__.py b/agents/coders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/coders/prompts/follow_up.py b/agents/coders/prompts/follow_up.py new file mode 100644 index 0000000..2cc0047 --- /dev/null +++ b/agents/coders/prompts/follow_up.py @@ -0,0 +1,62 @@ +FOLLOW_UP_SYSTEM_PROMPT = """ +You are an autonomous coding agent that communicates with humans through +issue-tracker comments. + +CONTEXT +You previously posted a QUESTION on an issue. A human replied with RESPONSE, +but the reply is INCOMPLETE: it partially addresses the question or leaves +out information you need before you can continue. You must now post a short, +polite follow-up that asks ONLY for the missing information. + +INPUTS + - QUESTION: the exact text you posted earlier (may contain an HTML + marker comment `` and boilerplate). + - RESPONSE: the human's reply. + +GOAL +Produce a single comment that: + 1. Acknowledges what the human already answered (briefly, one clause). + 2. States precisely what is still missing or ambiguous. + 3. Asks a focused question (or a short numbered list of questions) + that a human can answer without re-reading the whole thread. + 4. Stays under 120 words. + +HARD RULES + - Do NOT repeat the full original question; reference it in a clause. + - Do NOT invent requirements, constraints, or options the original + QUESTION did not contain. + - Do NOT include the HTML marker ``; the + caller appends it. + - Do NOT wrap the output in code fences, quotes, or a preamble like + "Here is the follow-up:". + - Do NOT emit a JSON object. Output plain comment text only. + - Match the language of the QUESTION. If the QUESTION was in English, + answer in English; if the RESPONSE was in a different language, + still answer in the QUESTION's language. + - Preserve any usernames, file paths, identifiers, or code snippets + the human used, without paraphrasing them. + - Use Markdown formatting only if the original QUESTION used it. + - Do NOT mention that you are an AI, an LLM, or that a classifier + deemed the reply incomplete. + +TONE + - Concise, courteous, direct. + - No filler ("Thanks for getting back to me!" is acceptable once, + but keep it to a single short clause). + - No apologies for asking a follow-up. + - No emojis unless the human used them first. +""" + +FOLLOW_UP_USER_PROMPT = """ +QUESTION: +\"\"\" +{question_text} +\"\"\" + +RESPONSE (incomplete): +\"\"\" +{response_text} +\"\"\" + +Write the follow-up comment. Plain text only, no marker, no fences. +""" \ No newline at end of file diff --git a/agents/coders/prompts/relevance.py b/agents/coders/prompts/relevance.py new file mode 100644 index 0000000..4e4403c --- /dev/null +++ b/agents/coders/prompts/relevance.py @@ -0,0 +1,81 @@ +from pydantic import BaseModel + +CHECK_RELEVANCE_SYSTEM_PROMPT = """ +You are a relevance classifier for an autonomous coding agent that +communicates with humans through issue-tracker comments. + +CONTEXT +The agent previously posted a QUESTION as an issue comment and is now +waiting for a HUMAN to answer it. A new human comment has appeared. Your +job is to decide whether that comment is a valid answer to the QUESTION, +and if so, whether the answer is sufficient for the agent to proceed. + +You will receive: + - QUESTION: the exact text the agent posted (may contain an + HTML marker comment `` and + possibly other boilerplate). + - RESPONSE: the latest human comment on the issue. + +You do NOT know the agent's internal state or the wider issue history. +Judge ONLY the semantic relationship between QUESTION and RESPONSE. + +CLASSIFICATION RULES +Classify the RESPONSE into exactly one of three labels: + +1. "unrelated" + The response does not address the question at all. + Examples: + - Off-topic chatter, greetings, emojis, "any update?". + - A response to a DIFFERENT agent comment, not to the QUESTION. + - A comment that merely quotes the question back without answering. + - A comment that says "I'll look into it" or "ping me later" — this + is an acknowledgement, not an answer. + When in doubt between "unrelated" and "incomplete", prefer "unrelated" + only if the response clearly does not attempt to answer. + +2. "incomplete" + The response attempts to answer but is missing required information, + is ambiguous, contradicts itself, or only partially covers the + question's asks. The agent cannot proceed without more detail. + Examples: + - The question asks for two things and only one is provided. + - The answer is vague ("just do the obvious thing", "make it work"). + - The answer references information the agent cannot see ("see the + doc I mentioned earlier", "same as before"). + - The answer contains a question back to the agent that must be + answered before the original question can be resolved. + +3. "relevant" + The response fully and unambiguously answers the QUESTION with enough + information for the agent to continue the pipeline. + Examples: + - A yes/no decision where either is acceptable. + - A concrete value, list, path, name, or configuration the agent + requested. + - A clear choice among the options the agent presented. + +IGNORE THESE WHEN CLASSIFYING + - HTML marker comments (``). + - Quoted text: if the human replies quoting the QUESTION, strip the + quoted lines and judge only the NEW prose the human wrote. + - Signature blocks, email footers, and issue-tracker metadata. + - Language: the response may be in a different language than the + question. Judge semantics, not language. + +OUTPUT +Return ONLY a single JSON object, no prose, no code fences: + +{ + "label": "unrelated" | "incomplete" | "relevant", + "confidence": , + "reason": "" +} + +Do not add any other keys. +""" + +class RelevanceResponse(BaseModel): + label: str + confidence: float + reason: str + diff --git a/agents/coders/repo.py b/agents/coders/repo.py new file mode 100644 index 0000000..b9171c4 --- /dev/null +++ b/agents/coders/repo.py @@ -0,0 +1,205 @@ +import os +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class RepoContext: + """Result object carrying all info a coding agent needs.""" + repo_dir: Path + remote_url: str + branch: str + base_branch: str + head_commit: str + is_new_clone: bool + is_new_branch: bool + original_branch: Optional[str] = None + uncommitted_changes: Optional[str] = None # git diff (tracked) + untracked_files: list[str] = field(default_factory=list) + stash_ref: Optional[str] = None + env: dict = field(default_factory=dict) + + +def _run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess: + """Small helper around subprocess.run that captures output cleanly.""" + result = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + check=False, + ) + if check and result.returncode != 0: + raise RuntimeError( + f"Command failed ({result.returncode}): {' '.join(cmd)}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + return result + + +def _branch_exists(repo_dir: Path, branch: str) -> bool: + """Check local OR remote-tracking branch existence.""" + local = _run(["git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=repo_dir, check=False) + if local.returncode == 0: + return True + remote = _run(["git", "ls-remote", "--heads", "origin", branch], cwd=repo_dir, check=False) + return bool(remote.stdout.strip()) + + +def _repo_name_from_url(remote_url: str) -> str: + """ + Извлекает имя репозитория из URL. + Поддерживает: + - https://github.com/org/repo.git + - git@github.com:org/repo.git + - ssh://git@host/org/repo.git + - /local/path/to/repo.git + - /local/path/to/repo + """ + # Нормализуем scp-подобный синтаксис: git@host:org/repo.git -> git@host/org/repo.git + url = remote_url.rstrip("/") + if "://" not in url and ":" in url: + # scp-like + url = url.replace(":", "/", 1) + + # Берём последний сегмент пути + name = url.rsplit("/", 1)[-1] + if name.endswith(".git"): + name = name[:-4] + if not name: + raise ValueError(f"Cannot derive repo name from URL: {remote_url!r}") + + # Санитайзим (на случай странных имён) + name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-") + if not name: + raise ValueError(f"Cannot derive repo name from URL: {remote_url!r}") + return name + + +def ensure_repo_ready( + remote_url: str, + repo_dir: Path, + issue_branch: str, + base_branch: str = "main", + repo_name: Optional[str] = None, + username: Optional[str] = None, + token: Optional[str] = None, + author_name: Optional[str] = None, + author_email: Optional[str] = None, +) -> RepoContext: + """ + Idempotently prepare a working copy of a repo for a coding agent. + + Steps (each is idempotent): + 1. Ensure repo_dir exists. + 2. Clone if empty; otherwise fetch and reuse. + 3. Snapshot current changes (diff + untracked + stash) into context. + 4. Checkout/create issue_branch (based on base_branch when new). + + Safe to call repeatedly: existing clone is fetched (not re-cloned), + existing branch is reused, and any pending changes are captured + before switching branches so nothing is lost. + """ + parent_dir = Path(repo_dir).expanduser().resolve() + + if repo_name is None: + repo_name: str = _repo_name_from_url(remote_url) + + repo_dir = (repo_dir / repo_name).resolve() + repo_dir.mkdir(parents=True, exist_ok=True) + + # Защита от path traversal (repo_name не должен вылезти из parent_dir) + if parent_dir != repo_dir and parent_dir not in repo_dir.parents: + raise ValueError(f"Resolved repo_dir {repo_dir} escapes parent_dir {parent_dir}") + + is_new_clone = not (repo_dir / ".git").exists() + + # --- 1. Clone or fetch --- + if is_new_clone: + _run(["git", "clone", remote_url, str(repo_dir)]) + else: + # Make sure origin points where we expect. + current_remote = _run( + ["git", "config", "--get", "remote.origin.url"], + cwd=repo_dir, check=False, + ).stdout.strip() + if current_remote != remote_url: + _run(["git", "remote", "set-url", "origin", remote_url], cwd=repo_dir) + _run(["git", "fetch", "origin", "--prune"], cwd=repo_dir) + + # --- 2. Optional committer identity for the agent --- + if author_name: + _run(["git", "config", "user.name", author_name], cwd=repo_dir) + if author_email: + _run(["git", "config", "user.email", author_email], cwd=repo_dir) + + # --- 3. Snapshot current state BEFORE switching --- + original_branch = _run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=repo_dir, check=False, + ).stdout.strip() or None + + uncommitted_changes = _run( + ["git", "diff", "HEAD"], cwd=repo_dir, check=False, + ).stdout or None + + untracked = _run( + ["git", "ls-files", "--others", "--exclude-standard"], + cwd=repo_dir, check=False, + ).stdout.strip() + untracked_files = untracked.splitlines() if untracked else [] + + stash_ref: Optional[str] = None + if uncommitted_changes or untracked_files: + # Safe stash: keep index, include untracked; verify a stash was made. + before = _run(["git", "rev-parse", "--verify", "refs/stash"], cwd=repo_dir, check=False).stdout.strip() + _run(["git", "stash", "push", "-u", "-m", f"agent-snapshot-{original_branch or 'detached'}"], cwd=repo_dir) + after = _run(["git", "rev-parse", "--verify", "refs/stash"], cwd=repo_dir, check=False).stdout.strip() + if after and after != before: + stash_ref = "stash@{0}" + + # --- 4. Ensure issue branch --- + existing_branch = _branch_exists(repo_dir, issue_branch) + is_new_branch = not existing_branch + + if existing_branch: + _run(["git", "checkout", issue_branch], cwd=repo_dir) + _run(["git", "pull", "--ff-only", "origin", issue_branch], cwd=repo_dir, check=False) + else: + # Prefer starting from the freshest origin/. + base_ref = f"origin/{base_branch}" + have_base = _run( + ["git", "rev-parse", "--verify", "--quiet", base_ref], + cwd=repo_dir, check=False, + ).returncode == 0 + if have_base: + _run(["git", "checkout", "-B", issue_branch, base_ref], cwd=repo_dir) + else: + _run(["git", "checkout", "-b", issue_branch], cwd=repo_dir) + + head_commit = _run(["git", "rev-parse", "HEAD"], cwd=repo_dir).stdout.strip() + + # --- Auth env (for later push operations) --- + env: dict = {} + if token and username: + env["GIT_ASKPASS"] = "echo" + env["GIT_USERNAME"] = username + env["GIT_PASSWORD"] = token + + return RepoContext( + repo_dir=repo_dir, + remote_url=remote_url, + branch=issue_branch, + base_branch=base_branch, + head_commit=head_commit, + is_new_clone=is_new_clone, + is_new_branch=is_new_branch, + original_branch=original_branch, + uncommitted_changes=uncommitted_changes, + untracked_files=untracked_files, + stash_ref=stash_ref, + env=env, + ) \ No newline at end of file diff --git a/agents/coders/state.py b/agents/coders/state.py new file mode 100644 index 0000000..98a711a --- /dev/null +++ b/agents/coders/state.py @@ -0,0 +1,174 @@ +import json +import os +import re +import tempfile +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional, Dict, Any + +from pydantic import BaseModel, Field + + +def atomic_write_json(path, data): + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w") as f: + json.dump(data, f) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + +STATE_PATH_MASK = "/state//.json" +STATE_SCHEMA_CURRENT_VERSION = 1 +STATE_SCHEMA_EXAMPLE = { + "schema_version": 1, + "agent_id": "agent-123", + "issue_id": "YT-456", + "state": "AWAITING_CLARIFICATION", + "run_id": "run-...", + "attempt": 1, + "counters": { + "attempt_act": 0, + "attempt_plan": 0, + "ci_self_resolve_attempts": 0, + "consecutive_questions": 1 + }, + "shas": { + "last_pushed_sha": None, + "remote_sha": None, + "base_sha": None + }, + "mr_id": None, + "signatures": [], + "pending_question": { + "comment_id": "c-1", + "kind": "clarification", + "asked_at": "2026-01-01T00:00:00Z", + "last_ping_at": None + }, + "event_log": [], + "updated_at": "2026-01-01T00:00:00Z" +} + +class CoarseState(str, Enum): + AWAITING_CLARIFICATION = "AWAITING_CLARIFICATION" + AWAITING_FEASIBILITY = "AWAITING_FEASIBILITY" + AWAITING_CONFIRMATION = "AWAITING_CONFIRMATION" + AWAITING_CI = "AWAITING_CI" + AWAITING_CI_CODE_DECISION = "AWAITING_CI_CODE_DECISION" + AWAITING_REVIEW = "AWAITING_REVIEW" + AWAITING_MERGE = "AWAITING_MERGE" + AWAITING_REWORK_DECISION = "AWAITING_REWORK_DECISION" + ABANDONED = "ABANDONED" + COMPLETED = "COMPLETED" + +class Routing(str, Enum): + CONTINUE = "CONTINUE" + RETRY_ACT = "RETRY_ACT" + RETRY_PLAN = "RETRY_PLAN" + ESCALATE = "ESCALATE" + +class CommentKind(str, Enum): + CLARIFICATION = "clarification" + FEASIBILITY = "feasibility" + CONFIRMATION = "confirmation" + CI_CODE_FAIL = "ci_code_fail" + REVIEW_BRIEF = "review_brief" + REWORK = "rework" + +MARKER_FORMAT = "" +MARKER_RE = re.compile(r"", re.S) +FIELD_RE = re.compile(r"(\w+)=([^\s]+)") + +class AgentState(BaseModel): + state_file: Path + schema_version: int = STATE_SCHEMA_CURRENT_VERSION + issue_id: str = "" + agent_id: str = "" + state: str = "INIT" + updated_at: str = "" + last_processed_comment_id: Optional[str] = None + pending_question_comment_id: Optional[str] = None + run_id: str = "" + attempt: int = 0 + extra: Dict[str, Any] = Field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "schema_version": self.schema_version, + "issue_id": self.issue_id, + "agent_id": self.agent_id, + "state": self.state, + "updated_at": self.updated_at, + "last_processed_comment_id": self.last_processed_comment_id, + "pending_question_comment_id": self.pending_question_comment_id, + "run_id": self.run_id, + "attempt": self.attempt, + **self.extra, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any], state_file: Path) -> AgentState: + known = { + "schema_version", "issue_id", "agent_id", "state", "updated_at", + "last_processed_comment_id", "pending_question_comment_id", + "run_id", "attempt", + } + extra = {k: v for k, v in data.items() if k not in known} + return cls( + schema_version=data.get("schema_version", STATE_SCHEMA_CURRENT_VERSION), + issue_id=data.get("issue_id", ""), + agent_id=data.get("agent_id", ""), + state=data.get("state", "INIT"), + updated_at=data.get("updated_at", ""), + last_processed_comment_id=data.get("last_processed_comment_id"), + pending_question_comment_id=data.get("pending_question_comment_id"), + run_id=data.get("run_id", ""), + attempt=data.get("attempt", 0), + extra=extra, + state_file=state_file, + ) + + def with_values(self, data: Dict[str, Any]): + as_dict = self.to_dict() + for key, value in data.items(): + as_dict.__setattr__(key, value) + return self.from_dict(as_dict, self.state_file) + + def store(self): + save_state(self.state_file, self) + +def load_state(state_dir: Path, agent_id: str, issue_id: str) -> AgentState: + path = state_dir / agent_id / f"{issue_id}.json" + if not path.exists(): + return AgentState(issue_id=issue_id, agent_id=agent_id, state="INIT", state_file=path) + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if data.get("schema_version") != STATE_SCHEMA_CURRENT_VERSION: + # Schema mismatch -> re-run + return AgentState(issue_id=issue_id, agent_id=agent_id, state="INIT", state_file=path) + return AgentState.from_dict(data, path) + except (json.JSONDecodeError, KeyError, OSError): + # Corrupt or unreadable -> re-run + return AgentState(issue_id=issue_id, agent_id=agent_id, state="INIT", state_file=path) + + +def save_state(state_dir: Path, state: AgentState) -> None: + path = state_dir / state.agent_id / f"{state.issue_id}.json" + path.parent.mkdir(parents=True, exist_ok=True) + state.updated_at = datetime.utcnow().isoformat() + "Z" + data = state.to_dict() + + fd, tmp_path = tempfile.mkstemp(dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + except Exception: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + raise + diff --git a/agents/coders/steps/__init__.py b/agents/coders/steps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/coders/steps/gather.py b/agents/coders/steps/gather.py new file mode 100644 index 0000000..2c136fd --- /dev/null +++ b/agents/coders/steps/gather.py @@ -0,0 +1,281 @@ +# gather.py + +import re +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Dict + +from agents.coders.prompts.follow_up import FOLLOW_UP_USER_PROMPT, FOLLOW_UP_SYSTEM_PROMPT +from agents.coders.prompts.relevance import RelevanceResponse, CHECK_RELEVANCE_SYSTEM_PROMPT +from agents.coders.state import MARKER_FORMAT, AgentState, load_state, save_state +from agents.registry import AgentRegistry +from common.llm_client import LLMClient +from common.youtrack_mcp_client import YouTrackMCPClient +from contracts.IssueContext import IssueContext, IssueComment +from contracts.coders.GatherOutput import GatherOutput + +# --------------------------------------------------------------------------- +# Marker parsing +# --------------------------------------------------------------------------- + +MARKER_REGEX = re.compile( + r'' +) + + +def parse_marker(text: str) -> Optional[Dict[str, str]]: + """Scan text for a coding-agent marker. Tolerates quoted markers.""" + match = MARKER_REGEX.search(text) + if not match: + return None + return { + "agent": str(match.group(1)), + "kind": str(match.group(2)), + "run": str(match.group(3)), + "attempt": str(int(match.group(4))), + "ts": str(match.group(5)), + } + + +def format_marker(agent_id: str, kind: str, run_id: str, attempt: int) -> str: + ts = datetime.utcnow().isoformat() + "Z" + return ( + MARKER_FORMAT + .replace('', agent_id) + .replace('', kind) + .replace('', run_id) + .replace('', str(attempt)) + .replace('', ts) + ) + + +# --------------------------------------------------------------------------- +# Gather step +# --------------------------------------------------------------------------- + +class GatherStep: + """ + Reads current state, decides whether the agent should run, and gathers + context from a YouTrack issue. + + If the state is awaiting a human response, it checks for new human comments + and uses the LLM to verify relevance. If no relevant response is found it + returns AWAITING. + """ + + AWAITING_HUMAN_STATES = { + "AWAITING_CLARIFICATION", + "AWAITING_FEASIBILITY", + "AWAITING_CONFIRMATION", + "AWAITING_REWORK_DECISION", + } + + def __init__( + self, + state_dir: Path, + llm: LLMClient, + agent_registry: AgentRegistry, + youtrack_mcp: YouTrackMCPClient, + agent_id: str, + ): + self.state_dir = state_dir + self.llm = llm + self.agent_registry = agent_registry + self.youtrack_mcp = youtrack_mcp + self.agent_id = agent_id + + async def run(self, issue_context: IssueContext) -> GatherOutput: + # 1. Load state + state = load_state(self.state_dir, self.agent_id, issue_context.issue_id) + + # 2. Terminal states -> return immediately with empty context + if state.state in ("COMPLETED", "ABANDONED"): + return GatherOutput( + state=state, + issue_context=issue_context, + pending_questions=[], + run_id=state.run_id, + attempt=state.attempt, + ) + + # 6. Handle awaiting-human states + if state.state in self.AWAITING_HUMAN_STATES: + return await self._handle_awaiting_human(state, issue_context) + + # 7. Otherwise just return the gathered context for the orchestrator. + return GatherOutput( + state=state, + issue_context=issue_context, + pending_questions=[], + run_id=state.run_id, + attempt=state.attempt, + ) + + # ----------------------------------------------------------------------- + # Awaiting human response handling + # ----------------------------------------------------------------------- + + async def _handle_awaiting_human( + self, + state: AgentState, + issue_context: IssueContext, + ) -> GatherOutput: + pending_ids = list(state.pending_questions) + if not pending_ids: + # No pending question recorded – reset to INIT + state.state = "INIT" + save_state(self.state_dir, state) + return GatherOutput( + state=state, + issue_context=issue_context, + pending_questions=[], + run_id=state.run_id, + attempt=state.attempt, + ) + + # Resolve pending question comments + comments_by_id = {c.id: c for c in issue_context.comments} + pending_comments: List[IssueComment] = [ + comments_by_id[pid] for pid in pending_ids if pid in comments_by_id + ] + if not pending_comments: + # All pending questions disappeared – reset + state.state = "INIT" + state.pending_questions = [] + save_state(self.state_dir, state) + return GatherOutput( + state=state, + issue_context=issue_context, + pending_questions=[], + run_id=state.run_id, + attempt=state.attempt, + ) + + # Anchor on the earliest pending question (chronological ordering) + pending_comments.sort(key=lambda c: c.created_at) + anchor_question = pending_comments[0] + + # Find new human comments that appear after the anchor question + found_anchor = False + new_human_comments: List[IssueComment] = [] + for c in issue_context.comments: + if c.id == anchor_question.id: + found_anchor = True + continue + if found_anchor and not c.is_agent: + new_human_comments.append(c) + + if not new_human_comments: + # No new human comment -> stay in AWAITING + return GatherOutput( + state=state, + issue_context=issue_context, + pending_questions=list(state.pending_questions), + run_id=state.run_id, + attempt=state.attempt, + ) + + # Use the latest human comment as the response + latest_human = new_human_comments[-1] + + # LLM-based relevance check anchored on the pending question's comment + system = CHECK_RELEVANCE_SYSTEM_PROMPT + user = (""" + QUESTION: + \"\"\" + {question_text} + \"\"\" + + RESPONSE: + \"\"\" + {response_text} + \"\"\" + + Classify the RESPONSE according to the rules. + """ + .replace("{question_text}", anchor_question.body) + .replace("{response_text}", latest_human.body) + ) + relevance = self.llm.chat_with_schema( + user, { + "type": "json_schema", + "json_schema": { + "name": "RelevanceResponse", + "schema": RelevanceResponse.model_json_schema(), + "strict": True, + }, + }, system + ) + + if relevance == "unrelated": + # Stay in AWAITING, do not consume the response + return GatherOutput( + state=state, + issue_context=issue_context, + pending_questions=list(state.pending_questions), + run_id=state.run_id, + attempt=state.attempt, + ) + + if relevance == "incomplete": + # Post a follow-up question, keep state AWAITING + follow_up_text = self.llm.chat( + FOLLOW_UP_SYSTEM_PROMPT, + (FOLLOW_UP_USER_PROMPT + .replace("{question_text}", anchor_question.body) + .replace("{response_text}", latest_human.body) + ), + ) or "" + + marker = format_marker( + agent_id=self.agent_id, + kind=self._kind_for_state(state.state), + run_id=state.run_id, + attempt=state.attempt, + ) + + new_comment_id = await self.youtrack_mcp.call_tool( + "add_issue_comment", + { + "issueId": issue_context.issue_id, + "text": marker + follow_up_text, + }, + ) + + state.pending_questions = list(state.pending_questions) + [new_comment_id] + state.last_processed_comment_id = latest_human.id + save_state(self.state_dir, state) + return GatherOutput( + state=state, + issue_context=issue_context, + pending_questions=list(state.pending_questions), + run_id=state.run_id, + attempt=state.attempt, + ) + + # Relevant response -> clear awaiting state and proceed + state.last_processed_comment_id = latest_human.id + state.pending_questions = [] + state.state = "INIT" # ready for next pipeline step + save_state(self.state_dir, state) + return GatherOutput( + state=state, + issue_context=issue_context, + pending_questions=[], + run_id=state.run_id, + attempt=state.attempt, + ) + + @staticmethod + def _kind_for_state(state: str) -> str: + return { + "AWAITING_CLARIFICATION": "clarification", + "AWAITING_FEASIBILITY": "feasibility", + "AWAITING_CONFIRMATION": "confirmation", + "AWAITING_REWORK_DECISION": "rework", + }.get(state, "clarification") \ No newline at end of file diff --git a/agents/coders/steps/plan.py b/agents/coders/steps/plan.py new file mode 100644 index 0000000..54b4b03 --- /dev/null +++ b/agents/coders/steps/plan.py @@ -0,0 +1,170 @@ +# steps/plan.py +from __future__ import annotations + +import json +from dataclasses import dataclass + +from pydantic import TypeAdapter, ValidationError + +from agents.codebase_analyst.entrypoint import gather_repo_entrypoint +from agents.codebase_analyst.graph_tool import analyze_repository +from agents.coders.repo import ensure_repo_ready, RepoContext +from common.llm_client import LLMClient +from contracts.RepoContext import AnalysisInput +from contracts.coders.GatherOutput import GatherOutput +from contracts.coders.PlanOutput import PlanOutput +from contracts.coders.VerifyOutput import FeasibleVerdict + +PlanAdapter = TypeAdapter(PlanOutput) + + +SYSTEM_PROMPT = """\ +You are a senior engineer writing an implementation plan for a coding agent. + +You will receive an issue (title, body, acceptance criteria, comments), a \ +repository context, the feasibility verdict from a prior triage, and a list \ +of files linked from the issue. + +Produce a plan with these properties: + +1. **One step per file.** Each step describes a single file change. If two \ + files must change together, that's two steps. + +2. **Commit to a language.** Infer the primary language from the repository \ + context and the files you plan to touch. Choose the test runner, formatter, \ + and linter idiomatic to that language. Populate `language` accordingly. + +3. **Target files, not file contents.** Describe *what* changes in each file, \ + not the code itself. The Act step writes the code; you decide the shape. + +4. **Be honest about breaking changes.** Set `breaks_existing=true` only if \ + the change modifies behavior that callers or users rely on: public API \ + changes, schema changes, config default changes, removal of features. \ + Pure additions, bug fixes, and internal refactors are NOT breaking. + +5. **Risk notes.** If `breaks_existing=true`, list the specific risks. If not, \ + you may still add notes for reviewer attention (migrations, security, \ + performance), but this is optional. + +Rules: +- Do not plan to modify files outside the repository. +- Do not invent files that clearly don't exist unless the plan is to create them. +- Prefer the smallest plan that satisfies the acceptance criteria. +- Return ONLY valid JSON matching the schema. +""" + + +class PlanError(ValueError): + """LLM output violated the PlanOutput contract.""" + + +@dataclass(frozen=True) +class PlanInput: + gather: GatherOutput + feasibility: FeasibleVerdict + + +async def handle_plan(inp: PlanInput, *, llm: LLMClient) -> PlanOutput: + if not inp.gather.issue_context.project or len(inp.gather.issue_context.project.repos) == 0: + raise "No project repo" + + repo = ensure_repo_ready( + inp.gather.issue_context.project.repos[0].remote_url, + inp.gather.state.state_file.parent / "repos", + "zarch_" + inp.gather.issue_context.issue_id, + inp.gather.issue_context.project.repos[0].base_branch, + author_name="zarch", + author_email="zarch@zaek.eu" + ) + entrypoint_analyze = gather_repo_entrypoint(llm, repo) + repo_analyze = analyze_repository(AnalysisInput( + repo_path=repo.repo_dir, + language=entrypoint_analyze.language, + entrypoint=entrypoint_analyze.entrypoint, + )) + + if repo_analyze.error: + raise repo_analyze.error + print(repo_analyze) + + prompt = _build_user_prompt(inp, repo) + schema = PlanAdapter.json_schema() + + raw = llm.chat_with_schema( + prompt, + { + "type": "json_schema", + "json_schema": { + "name": "PlanOutput", + "schema": schema, + "strict": True, + }, + }, + system=SYSTEM_PROMPT, + ) + + raw = _ensure_dict(raw) + try: + return PlanAdapter.validate_python(raw) + except ValidationError as e: + raise PlanError(str(e)) from e + + +# ── helpers ──────────────────────────────────────────────────── + +def _ensure_dict(raw): + """Guard against LLM clients that return raw JSON strings.""" + if isinstance(raw, str): + try: + return json.loads(raw) + except json.JSONDecodeError as e: + raise PlanError(f"LLM returned non-JSON string: {e}") from e + return raw + + +def _build_user_prompt(inp: PlanInput, repo: RepoContext) -> str: + g = inp.gather + issue = g.issue_context + + parts: list[str] = [] + parts.append(f"# Issue {issue.issue_id}: {issue.title}") + parts.append("") + parts.append("## Description") + parts.append(issue.body.strip() or "(empty)") + parts.append("") + + parts.append("## Acceptance criteria") + if issue.acceptance_criteria: + for i, ac in enumerate(issue.acceptance_criteria, 1): + parts.append(f"{i}. {ac}") + else: + parts.append("(none stated)") + parts.append("") + + if issue.comments: + parts.append("## Prior comments") + for c in issue.comments: + parts.append(f"- [{c.created_at}] {c.author}: {c.body.strip()}") + parts.append("") + + parts.append("## Feasibility verdict (already confirmed)") + parts.append(f"Verdict: feasible") + parts.append(f"Reasoning: {inp.feasibility.reasoning}") + parts.append(f"Confidence: {inp.feasibility.confidence}") + parts.append("") + + parts.append("## Repository context") + parts.append(f"- url: {repo.remote_url}") + parts.append(f"- default branch: {repo.base_branch}") + parts.append("") + + parts.append("## Linked files") + if g.linked_files: + for f in g.linked_files: + parts.append(f"- {f}") + else: + parts.append("(none)") + parts.append("") + + parts.append("Produce the plan. Return ONLY the JSON object.") + return "\n".join(parts) \ No newline at end of file diff --git a/agents/coders/steps/verify.py b/agents/coders/steps/verify.py new file mode 100644 index 0000000..961a7e0 --- /dev/null +++ b/agents/coders/steps/verify.py @@ -0,0 +1,195 @@ +# steps/verify_possibility.py +from __future__ import annotations + +import json +from datetime import datetime + +from pydantic import TypeAdapter, ValidationError + +from agents.coders.steps.gather import format_marker +from common.llm_client import LLMClient +from common.time import now_iso_plus, now_iso +from contracts.coders.GatherOutput import GatherOutput +from contracts.coders.RunContext import RunContext +from contracts.coders.StepResult import StepResult +from contracts.coders.VerifyOutput import VerifyPossibilityInput, VerifyPossibilityOutput, Option, FeasibleVerdict, \ + AmbiguousVerdict, InfeasibleVerdict, sanitize_for_openai + +VerifyPossibilityAdapter = TypeAdapter(VerifyPossibilityOutput) + + +SYSTEM_PROMPT = """\ +You are a senior engineer performing a feasibility triage on a software task \ +before any code is written. + +You will receive an issue (title, body, acceptance criteria, comments), the \ +repository context, and a list of files linked from the issue. If there is \ +a comment about missing capabilities - ignore it. + +Your job is to classify the task into exactly one of three verdicts: + +- "feasible": The task is clear enough to plan, and it can be done within the \ + repository as it exists. You do NOT need to know *how* — only that it is \ + possible and well-specified. + +- "ambiguous": The task cannot be planned because a required decision, scope \ + boundary, or piece of information is missing. You must ask exactly one \ + clarifying question. Do not ask multiple questions. Do not ask about things \ + you could reasonably infer from the issue or the codebase. + +- "infeasible": The task is clear but cannot be accomplished as stated. \ + Examples: it contradicts an architectural invariant, requires permissions \ + the agent does not have, depends on an external system that is unavailable, \ + or requires a decision that belongs to a human. Provide 1-3 concrete \ + alternative options. + +Rules: +- Prefer "feasible" if the task is clear and the only unknowns are HOW to do \ + it. HOW is Plan's job, not yours. +- Prefer "ambiguous" only when a missing decision blocks planning entirely. +- Prefer "infeasible" only when no reasonable plan could exist as stated. +- Confidence is your honest probability that the verdict is correct. + +Return ONLY valid JSON matching the schema. No prose, no markdown fences. +""" + + +class InvalidVerdictError(ValueError): + """The provider returned output that violates the contract.""" + + +def verify_possibility( + inp: VerifyPossibilityInput, + *, + llm: LLMClient, +) -> VerifyPossibilityOutput: + """ + Classify a task as feasible / ambiguous / infeasible. + + Pure with respect to the world: no comments posted, no state written. + The orchestrator consumes the output and performs side effects. + """ + prompt = _build_user_prompt(inp.gather) + schema = sanitize_for_openai(VerifyPossibilityAdapter.json_schema()) + raw = llm.chat_with_schema( + prompt, + { + "type": "json_schema", + "json_schema": { + "name": "VerifyPossibilityOutput", + "schema": schema, + "strict": True, + }, + }, + system=SYSTEM_PROMPT, + ) + + try: + if isinstance(raw, str): + raw = json.loads(raw) + return VerifyPossibilityAdapter.validate_python(raw) + except ValidationError as e: + # Should be rare under strict mode; treat as a hard failure so the + # caller doesn't build downstream steps on a malformed verdict. + raise InvalidVerdictError(str(e)) from e + + +# ── Prompt construction ──────────────────────────────────────── + +def _build_user_prompt(g: GatherOutput) -> str: + issue = g.issue_context + + parts: list[str] = [f"# Issue {issue.issue_id}: {issue.title}", "", "## Description", + issue.body.strip() or "(empty)", ""] + + if issue.comments: + parts.append("## Prior comments") + for c in issue.comments: + parts.append(f"- [{datetime.fromtimestamp(c.created_at / 1000).isoformat()}] {c.author}: {c.body.strip()}") + parts.append("") + + parts.append( + "Classify this task. Return ONLY the JSON object described in the " + "schema hint." + ) + return "\n".join(parts) + + +async def handle_verify(inp: VerifyPossibilityInput, ctx: RunContext) -> StepResult: + out = verify_possibility(inp, llm=ctx.llm).result + + if isinstance(out, FeasibleVerdict): + return StepResult.continue_to("PLANNING", output=out) + + if isinstance(out, AmbiguousVerdict): + # out.question is str, not str | None — no check needed + comment_id = ctx.youtrack.extract_comment_id(await ctx.youtrack.call_tool( + "add_issue_comment", + { + "issueId": ctx.issue_id, + "text": format_marker( + agent_id=ctx.agent_id, kind="clarification", + run_id=ctx.run_id, attempt=ctx.attempt, + ) + _render_ambiguous(out), + }, + )) + + ctx.state.with_values({ + "comment_id": comment_id, + "state": "AWAITING_CLARIFICATION", + "updated_at": now_iso(), + "abandon_at": now_iso_plus(days=5) + }).store() + + return StepResult.awaiting("AWAITING_CLARIFICATION", + output=out.model_dump()) + + if isinstance(out, InfeasibleVerdict): + comment_id = ctx.youtrack.extract_comment_id(await ctx.youtrack.call_tool( + "add_issue_comment", + { + "issueId": ctx.issue_id, + "text": format_marker( + agent_id=ctx.agent_id, kind="feasibility", + run_id=ctx.run_id, attempt=ctx.attempt, + ) + _render_infeasible(out), + }, + )) + + ctx.state.with_values({ + "pending_question_comment_id": comment_id, + "state": "AWAITING_FEASIBILITY", + "updated_at": now_iso(), + "abandon_at": now_iso_plus(days=5) + }).store() + + return StepResult.awaiting("AWAITING_FEASIBILITY", out) + + print(json.dumps(out, indent=2)) + + raise AssertionError("unreachable") + + +def _render_ambiguous(out: VerifyPossibilityOutput) -> str: + return ( + f"I can't plan this yet — one thing is unclear.\n\n" + f"**Question:** {out.question}\n\n" if isinstance(out, AmbiguousVerdict) else "" + f"**Why this blocks planning:** {out.reasoning}\n" + ) + + +def _render_infeasible(out: VerifyPossibilityOutput) -> str: + lines = [ + "I can't do this as stated. Here are some ways forward:", + "", + f"**Why:** {out.reasoning}", + "", + ] + for i, o in enumerate(out.options if isinstance(out, InfeasibleVerdict) else [], 1): + lines.append(f"**{i}. {o.label}**") + lines.append(o.description) + if o.tradeoff: + lines.append(f"_Trade-off:_ {o.tradeoff}") + lines.append("") + lines.append("Let me know which direction you'd like, or adjust the issue.") + return "\n".join(lines) \ No newline at end of file diff --git a/agents/issue_triage/IssueTriageAgent.py b/agents/issue_triage/IssueTriageAgent.py index bf4bdee..8dd37bd 100644 --- a/agents/issue_triage/IssueTriageAgent.py +++ b/agents/issue_triage/IssueTriageAgent.py @@ -13,9 +13,8 @@ from agents.issue_triage.triage import render_validation_failure, render_plan_co render_questions_comment, render_blocker_comment from agents.registry import agent, AgentRegistry, Capability from common.llm_client import LLMClient -from common.youtrack_mcp_client import IssueNotFound -from contracts.IssueContext import IssueContext -from contracts.TaskPlan import TaskPlan +from common.youtrack_mcp_client import IssueNotFound, YouTrackMCPClient +from contracts.IssueContext import IssueContext, IssueComment logger = logging.getLogger("triage_agent") logger.setLevel(logging.DEBUG) @@ -551,7 +550,7 @@ def precheck(issue: IssueContext, registry: AgentRegistry) -> tuple[Action, Tria priority=10, ) class IssueTriageAgent: - def __init__(self, context_builder: YouTrackContextBuilder, llm: LLMClient, agent_registry: AgentRegistry, youtrack_mcp): + def __init__(self, context_builder: YouTrackContextBuilder, llm: LLMClient, agent_registry: AgentRegistry, youtrack_mcp: YouTrackMCPClient): self.name = "IssueTriageAgent" self.ctx_builder = context_builder self.decomposer = IssueDecomposer(llm) @@ -576,7 +575,7 @@ class IssueTriageAgent: self, issue: IssueContext ) -> CapabilityResult: cloned_issue = issue - cloned_issue.comments = [c for c in cloned_issue.comments if c.get("author") != os.getenv("YOUTRACK_TRIAGE_AUTHOR")] + cloned_issue.comments = [c for c in cloned_issue.comments if c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")] prompt = (CAPABILITY_PROMPT .replace("{{AGENTS}}", self.agent_registry.to_prompt_text()) .replace("{{COMMENT_LANGUAGE}}", issue.project.project_language if issue.project else "english") @@ -602,7 +601,7 @@ class IssueTriageAgent: ) -> AmbiguityResult: cloned_issue = issue cloned_issue.comments = [c for c in cloned_issue.comments if - c.get("author") != os.getenv("YOUTRACK_TRIAGE_AUTHOR")] + c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")] prompt = ( AMBIGUITY_PROMPT .replace("{{ISSUE}}", cloned_issue.to_prompt_text()) diff --git a/agents/issue_triage/context_builder.py b/agents/issue_triage/context_builder.py index 63deb60..a4c4154 100644 --- a/agents/issue_triage/context_builder.py +++ b/agents/issue_triage/context_builder.py @@ -9,7 +9,7 @@ import httpx from agents.issue_triage.project_loader import load_project from common.as_type import _as_dict, _as_list, _first, _extract_base64 from common.youtrack_mcp_client import IssueNotFound -from contracts.IssueContext import IssueContext +from contracts.IssueContext import IssueContext, IssueComment logger = logging.getLogger("context_builder") logger.setLevel(logging.DEBUG) @@ -64,12 +64,12 @@ class YouTrackContextBuilder: title=_first(issue, "summary", "title", "name", default=""), body=_first(issue, "description", "body", default=""), comments=[ - { - "id": c.get("url"), - "author": c.get("author") or "unknown", - "body": c.get("text") or c.get("body") or "", - "created_at": str(c.get("created") or c.get("createdAt") or ""), - } + IssueComment( + id=c.get("url") or "undefined", + author=c.get("author") or "unknown", + body=c.get("text") or c.get("body") or "", + created_at=c.get("created") or c.get("createdAt") or 0, + ) for c in comments ], labels=[ diff --git a/agents/issue_triage/marker.py b/agents/issue_triage/marker.py index eadaf22..e1ba8dd 100644 --- a/agents/issue_triage/marker.py +++ b/agents/issue_triage/marker.py @@ -61,9 +61,9 @@ def find_prior_marker(issue: IssueContext) -> TriageMarker | None: def has_reporter_reply_since(issue: IssueContext, marker_comment_id: str) -> bool: found = False for c in issue.comments: - if c['id'] == marker_comment_id: + if c.id == marker_comment_id: found = True continue - if found and c['author'] != os.getenv('YOUTRACK_TRIAGE_AUTHOR'): + if found and c.author != os.getenv('YOUTRACK_TRIAGE_AUTHOR'): return True return False \ No newline at end of file diff --git a/common/gitea_mcp_client.py b/common/gitea_mcp_client.py new file mode 100644 index 0000000..87e3bd7 --- /dev/null +++ b/common/gitea_mcp_client.py @@ -0,0 +1 @@ +class GiteaMCPClient: ... \ No newline at end of file diff --git a/common/llm_client.py b/common/llm_client.py index 5d8050c..85c771e 100644 --- a/common/llm_client.py +++ b/common/llm_client.py @@ -6,11 +6,14 @@ class LLMClient: self.model = model self.client = OpenAI(base_url=base_url, api_key=api_key or "ollama") - def chat_with_schema(self, prompt: str, schema: dict): + def chat_with_schema(self, prompt: str, schema: dict, system: str | None = None) -> str | None: assert_strict_mode_clean(schema) kwargs = { "model": self.model, "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ] if system else [ {"role": "user", "content": prompt}, ], "response_format": schema, diff --git a/common/time.py b/common/time.py new file mode 100644 index 0000000..ce99cc4 --- /dev/null +++ b/common/time.py @@ -0,0 +1,31 @@ +# orchestrator/time.py +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +# Single canonical format for all timestamps the agent writes. +# ISO 8601, UTC, second precision, trailing "Z". +# Examples: "2026-09-21T11:45:00Z" +ISO_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def now_iso() -> str: + """Current UTC time as an ISO 8601 string.""" + return datetime.now(timezone.utc).strftime(ISO_FORMAT) + + +def now_iso_plus( + *, + days: int = 0, + hours: int = 0, + minutes: int = 0, + seconds: int = 0, +) -> str: + """Current UTC time plus a delta, as an ISO 8601 string.""" + delta = timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) + return (datetime.now(timezone.utc) + delta).strftime(ISO_FORMAT) + + +def parse_iso(s: str) -> datetime: + """Parse an ISO 8601 string produced by now_iso / now_iso_plus.""" + return datetime.strptime(s, ISO_FORMAT).replace(tzinfo=timezone.utc) \ No newline at end of file diff --git a/common/youtrack_mcp_client.py b/common/youtrack_mcp_client.py index ac53a6f..d1a6760 100644 --- a/common/youtrack_mcp_client.py +++ b/common/youtrack_mcp_client.py @@ -1,5 +1,7 @@ import asyncio +import json import logging +from typing import Optional, Any import httpx from contextlib import AsyncExitStack @@ -109,4 +111,50 @@ class YouTrackMCPClient: async def list_tools(self): """List all available tools from the MCP server.""" - return await self._client.list_tools() \ No newline at end of file + return await self._client.list_tools() + + @classmethod + def extract_comment_id(cls, result: Any) -> Optional[str]: + """ + Best-effort extraction of the created comment's ID from a + CallToolResult (or any MCP-ish response). + + Returns None if the ID can't be determined. Callers MUST tolerate + None by falling back to marker-based lookup on the next Gather. + """ + # 1. Unwrap the MCP envelope. + content = getattr(result, "content", None) + if content is None and isinstance(result, dict): + content = result.get("content") + + if not content: + return None + + # 2. Take the first text block. + first = content[0] + text = getattr(first, "text", None) + if text is None and isinstance(first, dict): + text = first.get("text") + + if not text: + return None + + # 3. Try JSON first. + try: + data = json.loads(text or "{}") + except json.JSONDecodeError: + # 4. Not JSON — assume the server returned a bare ID. + return text.strip() or None + + # 5. Common shapes: {"id": ...} or {"comment": {"id": ...}}. + if isinstance(data, dict): + if "id" in data: + return str(data["id"]) + if "comment" in data and isinstance(data["comment"], dict): + return str(data["comment"].get("id")) or None + # YouTrack often uses "idReadable" for the human-facing ID. + for key in ("idReadable", "commentId", "comment_id"): + if key in data: + return str(data[key]) + + return None \ No newline at end of file diff --git a/contracts/IssueContext.py b/contracts/IssueContext.py index 90e43af..ed5e290 100644 --- a/contracts/IssueContext.py +++ b/contracts/IssueContext.py @@ -2,6 +2,8 @@ import hashlib from dataclasses import dataclass, field from typing import Optional +from pydantic import BaseModel + from contracts.ProjectContext import ProjectContext @@ -10,8 +12,8 @@ class IssueContext: issue_id: str title: str body: str - acceptance_criteria: str | None - comments: list[dict] + acceptance_criteria: str | None = None + comments: list[IssueComment] = field(default_factory=list) labels: list[str] = field(default_factory=list) repo: Optional[str] = None metadata: dict = field(default_factory=dict) @@ -44,6 +46,14 @@ class IssueContext: if self.comments: parts.append("\n## Discussion / Comments") for c in self.comments: - parts.append(f"- @{c['author']} ({c['created_at']}): {c['body']}") + parts.append(f"- @{c.author} ({c.created_at}): {c.body}") return "\n".join(parts) + +class IssueComment(BaseModel): + id: str + author: str + body: str + created_at: int + is_agent: bool = False + marker: dict[str, str] = field(default_factory=dict) diff --git a/contracts/ProjectContext.py b/contracts/ProjectContext.py index 44d30d0..62dd6b8 100644 --- a/contracts/ProjectContext.py +++ b/contracts/ProjectContext.py @@ -7,6 +7,11 @@ class TeamMemberModel(BaseModel): role: str nick: str +class Repo(BaseModel): + id: str + remote_url: str + base_branch: str = "main" + class ProjectContext(BaseModel): """Static, per-project knowledge injected into every decomposition.""" project_key: str # e.g. "ARCH" @@ -22,6 +27,7 @@ class ProjectContext(BaseModel): deployment: str = "" # "Docker Compose on Hetzner" project_language: str = "english" default_responder: str | None = None + repos: list[Repo] = field(default_factory=list) team: list[TeamMemberModel] = field(default_factory=list) conventions: list[str] = field(default_factory=list) # free-form notes extra: dict[str, str] = field(default_factory=dict) # anything else diff --git a/contracts/RepoContext.py b/contracts/RepoContext.py new file mode 100644 index 0000000..38ad48e --- /dev/null +++ b/contracts/RepoContext.py @@ -0,0 +1,113 @@ +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Optional, Literal, Any + +from pydantic import field_validator, BaseModel, Field + + +@dataclass(frozen=True) +class RepoContext: + url: str + default_branch: str + language_hint: str | None # "python" | "typescript" | "go" | None + +class Language(str, Enum): + PYTHON = "python" + JAVASCRIPT = "javascript" + TYPESCRIPT = "typescript" + JAVA = "java" + GO = "go" + RUST = "rust" + CPP = "cpp" + C = "c" + +LanguageLiteral = Literal[ + Language.PYTHON, + Language.JAVASCRIPT, + Language.TYPESCRIPT, + Language.JAVA, + Language.GO, + Language.RUST, + Language.CPP, + Language.C, +] + +class DependencyNode(BaseModel): + """A single node in the dependency graph.""" + id: str = Field(..., description="Unique identifier (file path or module name)") + label: str = Field(..., description="Human-readable label") + language: Language + is_external: bool = Field( + default=False, + description="True if the dependency is outside the repository", + ) + + +class DependencyEdge(BaseModel): + """A directed edge from source → target (source depends on target).""" + source: str = Field(..., description="Node id of the dependant") + target: str = Field(..., description="Node id of the dependency") + kind: str = Field( + default="import", + description="Edge kind: import, include, inherit, call, etc.", + ) + + +class DependencyGraph(BaseModel): + """Complete dependency graph result.""" + root: str = Field(..., description="Entrypoint node id") + nodes: list[DependencyNode] + edges: list[DependencyEdge] + metadata: dict[str, str] = Field(default_factory=dict) + + +class ToolRequirement(BaseModel): + """Describes a required external tool and how to install it.""" + tool_name: str + command: str = Field(..., description="CLI command that must be on PATH") + install_guide: str = Field(..., description="Markdown installation instructions") + docs_url: Optional[str] = None + + +class AnalysisError(BaseModel): + """Structured error returned when analysis cannot proceed.""" + error_code: str + message: str + missing_tools: list[ToolRequirement] = Field(default_factory=list) + language: Optional[Language] = None + entrypoint: Optional[str] = None + + +class AnalysisInput(BaseModel): + """Input schema for the analyzer.""" + repo_path: Path = Field(..., description="Absolute path to the cloned repository") + language: Language + entrypoint: str = Field( + ..., + description="Relative path from repo_path to the entrypoint file", + ) + + @field_validator("repo_path") + @classmethod + def repo_must_exist(cls, v: Path) -> Path: + if not v.is_dir(): + raise ValueError(f"Repository path does not exist or is not a directory: {v}") + return v.resolve() + + @field_validator("entrypoint") + @classmethod + def entrypoint_must_exist(cls, v: str, info) -> str: + repo = info.data.get("repo_path") + if repo and not (repo / v).is_file(): + raise ValueError(f"Entrypoint not found: {repo / v}") + return v + + +class AnalysisResult(BaseModel): + """Top-level result — either a graph or an error.""" + success: bool + graph: Optional[DependencyGraph] = None + error: Optional[AnalysisError] = None + leaf_clusters: Any + res_all: Any \ No newline at end of file diff --git a/contracts/base.py b/contracts/base.py index fb5c12a..e47c39d 100644 --- a/contracts/base.py +++ b/contracts/base.py @@ -1,6 +1,13 @@ from abc import abstractmethod, ABC -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict + + +class _StrictModel(BaseModel): + # Required for OpenAI strict JSON-schema mode: forbids extra keys, + # no defaults, every field required. + model_config = ConfigDict(extra="forbid") + ''' A contract between agents diff --git a/contracts/coders/GatherOutput.py b/contracts/coders/GatherOutput.py new file mode 100644 index 0000000..8baa8a6 --- /dev/null +++ b/contracts/coders/GatherOutput.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass, field +from typing import Optional, List + +from pydantic import BaseModel, Field + +from agents.coders.state import AgentState +from contracts.IssueContext import IssueContext + + +class GatherOutput(BaseModel): + state: AgentState + issue_context: IssueContext + pending_questions: List[str] = Field(default_factory=list) + run_id: str = "" + attempt: int = 0 + diff --git a/contracts/coders/PlanOutput.py b/contracts/coders/PlanOutput.py new file mode 100644 index 0000000..f4bd83b --- /dev/null +++ b/contracts/coders/PlanOutput.py @@ -0,0 +1,89 @@ +from typing import Literal + +from pydantic import Field, model_validator + +from contracts.base import _StrictModel + + +class LanguageSnapshot(_StrictModel): + """ + The language the plan commits to, plus the toolchain it will use. + Chosen by Plan from the target files; consumed by Act and Verify. + """ + name: str = Field( + min_length=1, + description="Language name, e.g. 'python', 'typescript', 'go'.", + ) + version: str | None = Field( + description="Language version if known, else null.", + ) + test_runner: str = Field( + min_length=1, + description="How tests are run, e.g. 'pytest', 'vitest', 'go test'.", + ) + formatter: str | None = Field( + description="Formatter command, or null if none.", + ) + linter: str | None = Field( + description="Linter command, or null if none.", + ) + detected_from: list[str] = Field( + description="Marker files used to detect, e.g. ['pyproject.toml'].", + ) + + +class PlanStep(_StrictModel): + order: int = Field(ge=1, description="1-based ordering of this step.") + file: str = Field( + min_length=1, + description="Repo-relative path (no leading '/', no '..').", + ) + change_kind: Literal["create", "modify", "delete"] + description: str = Field( + min_length=1, + description="What changes in this file and why, 1–3 sentences.", + ) + + +class PlanOutput(_StrictModel): + summary: str = Field( + min_length=1, description="1–3 sentence summary of the whole change." + ) + steps: list[PlanStep] = Field( + min_length=1, description="Ordered steps. Each step is one file." + ) + target_files: list[str] = Field( + min_length=1, description="Unique repo-relative files this plan touches." + ) + target_tests: list[str] = Field( + description="Tests that must pass. May be empty for non-code changes." + ) + language: LanguageSnapshot + breaks_existing: bool = Field( + description="True if this changes existing behavior a user or caller " + "could rely on.", + ) + risk_notes: list[str] = Field( + description="Reasons this might be risky. Non-empty if breaks_existing.", + ) + + @model_validator(mode="after") + def _consistency(self) -> "PlanOutput": + # 1. target_files must equal the set of files in steps. + step_files = {s.file for s in self.steps} + if set(self.target_files) != step_files: + raise ValueError( + f"target_files {sorted(self.target_files)} must equal " + f"the set of step files {sorted(step_files)}" + ) + + # 2. breaks_existing implies risk_notes non-empty. + if self.breaks_existing and not self.risk_notes: + raise ValueError("breaks_existing=True requires at least one risk note") + + # 3. No absolute paths, no traversal. + for f in self.target_files: + if f.startswith("/") or ".." in f.split("/"): + raise ValueError(f"unsafe path: {f!r}") + + return self \ No newline at end of file diff --git a/contracts/coders/RunContext.py b/contracts/coders/RunContext.py new file mode 100644 index 0000000..452d61a --- /dev/null +++ b/contracts/coders/RunContext.py @@ -0,0 +1,91 @@ +# orchestrator/run_context.py +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from agents.coders.state import AgentState +from common.gitea_mcp_client import GiteaMCPClient +from common.llm_client import LLMClient +from common.time import now_iso, now_iso_plus +from common.youtrack_mcp_client import YouTrackMCPClient + + +@dataclass(frozen=True) +class AgentConfig: + # ── project-level ────────────────────────────────────────── + youtrack_project: str + gitea_repo: str + gitea_target_branch: str = "main" + + # ── limits (snapshotted into state at run start) ─────────── + max_plan_attempts: int = 3 + max_act_attempts: int = 5 + max_ci_self_resolve_attempts: int = 2 + max_clarification_rounds: int = 3 + max_rework_rounds: int = 2 + wall_clock_minutes: int = 60 + token_budget: int = 500_000 + + # ── human-await timers ───────────────────────────────────── + re_ping_after_days: int = 3 + abandon_after_days: int = 5 + + # ── reviewer policy ──────────────────────────────────────── + reviewer_fallback: str = "codeowners" # codeowners | blame | on_call + + # ── language hints (for Plan) ────────────────────────────── + language_markers: dict[str, list[str]] = None # filled from project + + +@dataclass +class RunContext: + """ + Everything a handler needs to do its job: the ports it talks to, the + persisted state for this issue, and the identifiers it must stamp into + comments and state. + """ + # ── identifiers ──────────────────────────────────────────── + agent_id: str + issue_id: str + run_id: str # one logical attempt; stable across calls + attempt: int # retry counter within the run + + # ── ports (outbound systems) ─────────────────────────────── + llm: LLMClient + youtrack: YouTrackMCPClient + gitea: GiteaMCPClient + + # ── persisted pipeline state for this issue ──────────────── + state: AgentState + + # ── static config for this run ───────────────────────────── + config: AgentConfig + + # ── convenience (thin, no hidden behavior) ───────────────── + + def now(self) -> str: + return now_iso() + + def now_plus(self, **delta) -> str: + return now_iso_plus(**delta) + + +def new_run_id() -> str: + """ + Generate a run id: one logical attempt at a task. + + Format: run-YYYYMMDD-HHMMSS-<6 hex chars> + Example: run-20260921-114500-a3f1c2 + + - Timestamp prefix makes ids sortable and human-readable in logs. + - Random suffix prevents collisions when two runs start in the same + second (e.g., parallel issues, fast retries, tests). + - The "run-" prefix makes it obvious what kind of id this is when it + appears next to a comment marker or in a state file. + """ + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + suffix = secrets.token_hex(3) # 3 bytes -> 6 hex chars + return f"run-{ts}-{suffix}" \ No newline at end of file diff --git a/contracts/coders/StepResult.py b/contracts/coders/StepResult.py new file mode 100644 index 0000000..7b76fc9 --- /dev/null +++ b/contracts/coders/StepResult.py @@ -0,0 +1,56 @@ +# orchestrator/step_result.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +# Terminal reasons are strings, not an enum, so you can add domain-specific +# reasons (e.g. "escalated:ci_infra") without touching this module. +TerminalReason = str +AwaitState = str # must be one of the AWAITING_* names, or "ABANDONED" + + +@dataclass(frozen=True) +class StepResult: + """ + The outcome of a step handler. + + This is control flow, not data. The step's contract *output* lives in + `output`; the fields here tell the runner what to do next. The runner + is the only thing that reads these fields; handlers only construct them. + """ + kind: Literal["continue", "await", "terminal"] + next_step: str | None = None # set iff kind == "continue" + await_state: AwaitState | None = None # set iff kind == "await" + terminal_reason: TerminalReason | None = None # set iff kind == "terminal" + output: Any = None # the step's contract output + + # ── constructors ───────────────────────────────────────────── + + @classmethod + def continue_to(cls, step: str, output: Any = None) -> "StepResult": + """Advance to another step in the pipeline.""" + return cls(kind="continue", next_step=step, output=output) + + @classmethod + def awaiting(cls, state: AwaitState, output: Any = None) -> "StepResult": + """Stop this call; resume when the await trigger fires.""" + if not (state.startswith("AWAITING_") or state == "ABANDONED"): + raise ValueError(f"not an await state: {state!r}") + return cls(kind="await", await_state=state, output=output) + + @classmethod + def complete(cls, output: Any = None) -> "StepResult": + """Terminal: MR merged and task closed.""" + return cls(kind="terminal", terminal_reason="completed", output=output) + + @classmethod + def abandoned(cls, output: Any = None) -> "StepResult": + """Terminal: no response within the timeout window.""" + return cls(kind="terminal", terminal_reason="abandoned", output=output) + + @classmethod + def escalated(cls, reason: str, output: Any = None) -> "StepResult": + """Terminal: stopping because a human must intervene.""" + return cls(kind="terminal", terminal_reason=f"escalated:{reason}", + output=output) \ No newline at end of file diff --git a/contracts/coders/VerifyOutput.py b/contracts/coders/VerifyOutput.py new file mode 100644 index 0000000..4ac74e3 --- /dev/null +++ b/contracts/coders/VerifyOutput.py @@ -0,0 +1,121 @@ +# contracts/verify_possibility.py +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal, Protocol, Annotated, Union, Any + +from pydantic import BaseModel, ConfigDict, Field + +from contracts.base import _StrictModel +from contracts.coders.GatherOutput import GatherOutput + +@dataclass(frozen=True) +class VerifyPossibilityInput: + gather: GatherOutput + + +def sanitize_for_openai(schema: dict[str, Any]) -> dict[str, Any]: + """ + Recursively rewrite a Pydantic-generated JSON schema into the subset + OpenAI strict mode accepts. + + - oneOf -> anyOf (OpenAI rejects oneOf) + - discriminator -> removed (OpenAI rejects it; the const fields + already make branches disjoint) + - adds additionalProperties: false to every object missing it + - strips None defaults + """ + return _walk(schema) + + +def _walk(node: Any) -> Any: + if isinstance(node, list): + return [_walk(x) for x in node] + if not isinstance(node, dict): + return node + + # Recurse into every nested schema container first. + for key in ("properties", "$defs", "definitions"): + if key in node and isinstance(node[key], dict): + node[key] = {k: _walk(v) for k, v in node[key].items()} + + for key in ("items", "additionalProperties"): + if key in node and isinstance(node[key], (dict, list)): + node[key] = _walk(node[key]) + + for key in ("anyOf", "allOf"): + if key in node and isinstance(node[key], list): + node[key] = [_walk(v) for v in node[key]] + + # ── the two rewrites that matter ────────────────────────── + if "oneOf" in node and isinstance(node["oneOf"], list): + existing = node.get("anyOf", []) + if not isinstance(existing, list): + existing = [] + node["anyOf"] = existing + [_walk(v) for v in node["oneOf"]] + node.pop("oneOf") + + node.pop("discriminator", None) # OpenAI doesn't accept it + + # ── strict-mode hygiene ─────────────────────────────────── + if node.get("type") == "object": + node.setdefault("additionalProperties", False) + props = node.get("properties") + if isinstance(props, dict): + # Strict mode: every property must be in required. + node["required"] = list(props.keys()) + + if node.get("default", object()) is None: + node.pop("default", None) + + return node + +class Option(_StrictModel): + label: str = Field(min_length=1, max_length=80) + description: str = Field(min_length=1) + tradeoff: str | None = Field( + description="Optional trade-off; null if none.", + ) + +class _BaseVerdict(_StrictModel): + reasoning: str = Field( + min_length=1, + description="2–5 sentences explaining the verdict.", + ) + confidence: float = Field( + ge=0.0, le=1.0, + description="Your honest probability that the verdict is correct.", + ) + + +class FeasibleVerdict(_BaseVerdict): + verdict: Literal["feasible"] + + +class AmbiguousVerdict(_BaseVerdict): + verdict: Literal["ambiguous"] + question: str = Field( + min_length=1, + description="Exactly one clarifying question that unblocks planning.", + ) + + +class InfeasibleVerdict(_BaseVerdict): + verdict: Literal["infeasible"] + options: list[Option] = Field( + min_length=1, max_length=3, + description="1–3 concrete alternatives.", + ) + + +Verdict = Annotated[ + Union[FeasibleVerdict, AmbiguousVerdict, InfeasibleVerdict], + Field(discriminator="verdict"), +] + + +class VerifyPossibilityOutput(_StrictModel): + """Top-level object required by OpenAI strict mode.""" + result: Verdict = Field( + description="The feasibility verdict.", + ) \ No newline at end of file diff --git a/main.py b/main.py index f52b4b8..09fb744 100644 --- a/main.py +++ b/main.py @@ -5,9 +5,12 @@ from contextlib import asynccontextmanager import httpx from dotenv import load_dotenv from fastapi import FastAPI, HTTPException + +from agents.coders.BaseCoder import CoderAgent from agents.issue_triage.IssueTriageAgent import IssueTriageAgent from agents.issue_triage.context_builder import YouTrackContextBuilder from agents.registry import AgentRegistry +from common.gitea_mcp_client import GiteaMCPClient from common.llm_client import LLMClient from common.youtrack_mcp_client import YouTrackMCPClient, IssueNotFound @@ -23,7 +26,7 @@ def env(key: str) -> str: @asynccontextmanager async def lifespan(app: FastAPI): - mcp = await YouTrackMCPClient( + app.state.youtrack_mcp = await YouTrackMCPClient( str(os.getenv('YOUTRACK_MCP_SERVER')), str(os.getenv('YOUTRACK_MCP_TOKEN')), ).connect() @@ -43,20 +46,20 @@ async def lifespan(app: FastAPI): ) app.state.issue_reader_agent = IssueTriageAgent( - YouTrackContextBuilder(mcp, http_for_attachments), + YouTrackContextBuilder(app.state.youtrack_mcp, http_for_attachments), LLMClient( base_url=env("LLM_ADDRESS"), api_key=env("LLM_API_KEY"), model=env("LLM_MODEL"), ), AgentRegistry(), - mcp + app.state.youtrack_mcp ) yield # ---- shutdown ---- - await mcp.close() + await app.state.youtrack_mcp.close() app = FastAPI(lifespan=lifespan) @app.get("/") @@ -84,3 +87,32 @@ async def decomposing_issue(issue: str): except Exception as e: logger.exception("An unexpected error occurred") return {"error": str(e)} + +@app.get("/code_issue") +async def code_issue(issue: str): + issue_agent: IssueTriageAgent = app.state.issue_reader_agent + gitea_mcp_client = GiteaMCPClient() + coder_agent: CoderAgent = CoderAgent( + str(os.getenv("CODER_ID")), + issue_agent.ctx_builder, + issue_agent.llm, + issue_agent.agent_registry, + issue_agent.youtrack_mcp, + gitea_mcp_client + ) + + try: + logger.info(f"Resolve an issue: {issue}") + issue_ctx = await issue_agent.ctx_builder.build(issue) + if issue_ctx: + response = await coder_agent.fix_an_issue(issue_ctx) + logger.info("Task planning successful.") + return {"status": "ok", "response": response} + except IssueNotFound: + logger.error(f"Issue {issue} not found.") + raise HTTPException(status_code=404, detail=f"Issue {issue} not found") + except Exception as e: + logger.exception("An unexpected error occurred") + return {"error": str(e)} + + return {"error": "No context was created"} \ No newline at end of file