Coder preparations
This commit is contained in:
@@ -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
|
||||
|
||||
+40
-1
@@ -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
|
||||
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
|
||||
@@ -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"<unreadable: {exc}>"
|
||||
if len(text) > self.excerpt_chars:
|
||||
return text[: self.excerpt_chars] + "\n...<truncated>..."
|
||||
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))
|
||||
@@ -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 <crate> # 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 <vector> // 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
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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 `<!-- coding-agent:... -->` 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 `<!-- coding-agent:... -->`; 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.
|
||||
"""
|
||||
@@ -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 `<!-- coding-agent:... -->` 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 (`<!-- coding-agent:... -->`).
|
||||
- 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": <float between 0.0 and 1.0>,
|
||||
"reason": "<one short sentence, <= 200 chars>"
|
||||
}
|
||||
|
||||
Do not add any other keys.
|
||||
"""
|
||||
|
||||
class RelevanceResponse(BaseModel):
|
||||
label: str
|
||||
confidence: float
|
||||
reason: str
|
||||
|
||||
@@ -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_branch>.
|
||||
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,
|
||||
)
|
||||
@@ -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 = "<agent_dir>/state/<agent_id>/<issue_id>.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 = "<!-- coding-agent:v1 agent=<agent_id> kind=<kind> run=<run_id> attempt=<attempt> ts=<ts_iso8601> -->"
|
||||
MARKER_RE = re.compile(r"<!--\s*coding-agent:v1\s+(?P<body>.*?)-->", 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
|
||||
|
||||
@@ -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'<!--\s*coding-agent:v1\s+'
|
||||
r'agent=(\S+)\s+'
|
||||
r'kind=(\S+)\s+'
|
||||
r'run=(\S+)\s+'
|
||||
r'attempt=(\d+)\s+'
|
||||
r'ts=(\S+)\s*-->'
|
||||
)
|
||||
|
||||
|
||||
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>', agent_id)
|
||||
.replace('<kind>', kind)
|
||||
.replace('<run_id>', run_id)
|
||||
.replace('<attempt>', str(attempt))
|
||||
.replace('<ts_iso8601>', 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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
class GiteaMCPClient: ...
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+8
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user