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
|
### 2026-09-21
|
||||||
1. Now comments are directed to team members with care of the language they can read
|
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
|
2. Comments for context are filtered from TriageAgent to avoid hallucination
|
||||||
|
|||||||
@@ -1,6 +1,45 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import timezone, datetime
|
||||||
|
import json, os, tempfile
|
||||||
|
|
||||||
|
|
||||||
class BaseAgent(ABC):
|
class BaseAgent(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def run(self, issue_id: str):
|
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
|
render_questions_comment, render_blocker_comment
|
||||||
from agents.registry import agent, AgentRegistry, Capability
|
from agents.registry import agent, AgentRegistry, Capability
|
||||||
from common.llm_client import LLMClient
|
from common.llm_client import LLMClient
|
||||||
from common.youtrack_mcp_client import IssueNotFound
|
from common.youtrack_mcp_client import IssueNotFound, YouTrackMCPClient
|
||||||
from contracts.IssueContext import IssueContext
|
from contracts.IssueContext import IssueContext, IssueComment
|
||||||
from contracts.TaskPlan import TaskPlan
|
|
||||||
|
|
||||||
logger = logging.getLogger("triage_agent")
|
logger = logging.getLogger("triage_agent")
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
@@ -551,7 +550,7 @@ def precheck(issue: IssueContext, registry: AgentRegistry) -> tuple[Action, Tria
|
|||||||
priority=10,
|
priority=10,
|
||||||
)
|
)
|
||||||
class IssueTriageAgent:
|
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.name = "IssueTriageAgent"
|
||||||
self.ctx_builder = context_builder
|
self.ctx_builder = context_builder
|
||||||
self.decomposer = IssueDecomposer(llm)
|
self.decomposer = IssueDecomposer(llm)
|
||||||
@@ -576,7 +575,7 @@ class IssueTriageAgent:
|
|||||||
self, issue: IssueContext
|
self, issue: IssueContext
|
||||||
) -> CapabilityResult:
|
) -> CapabilityResult:
|
||||||
cloned_issue = issue
|
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
|
prompt = (CAPABILITY_PROMPT
|
||||||
.replace("{{AGENTS}}", self.agent_registry.to_prompt_text())
|
.replace("{{AGENTS}}", self.agent_registry.to_prompt_text())
|
||||||
.replace("{{COMMENT_LANGUAGE}}", issue.project.project_language if issue.project else "english")
|
.replace("{{COMMENT_LANGUAGE}}", issue.project.project_language if issue.project else "english")
|
||||||
@@ -602,7 +601,7 @@ class IssueTriageAgent:
|
|||||||
) -> AmbiguityResult:
|
) -> AmbiguityResult:
|
||||||
cloned_issue = issue
|
cloned_issue = issue
|
||||||
cloned_issue.comments = [c for c in cloned_issue.comments if
|
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 = (
|
prompt = (
|
||||||
AMBIGUITY_PROMPT
|
AMBIGUITY_PROMPT
|
||||||
.replace("{{ISSUE}}", cloned_issue.to_prompt_text())
|
.replace("{{ISSUE}}", cloned_issue.to_prompt_text())
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import httpx
|
|||||||
from agents.issue_triage.project_loader import load_project
|
from agents.issue_triage.project_loader import load_project
|
||||||
from common.as_type import _as_dict, _as_list, _first, _extract_base64
|
from common.as_type import _as_dict, _as_list, _first, _extract_base64
|
||||||
from common.youtrack_mcp_client import IssueNotFound
|
from common.youtrack_mcp_client import IssueNotFound
|
||||||
from contracts.IssueContext import IssueContext
|
from contracts.IssueContext import IssueContext, IssueComment
|
||||||
|
|
||||||
logger = logging.getLogger("context_builder")
|
logger = logging.getLogger("context_builder")
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
@@ -64,12 +64,12 @@ class YouTrackContextBuilder:
|
|||||||
title=_first(issue, "summary", "title", "name", default=""),
|
title=_first(issue, "summary", "title", "name", default=""),
|
||||||
body=_first(issue, "description", "body", default=""),
|
body=_first(issue, "description", "body", default=""),
|
||||||
comments=[
|
comments=[
|
||||||
{
|
IssueComment(
|
||||||
"id": c.get("url"),
|
id=c.get("url") or "undefined",
|
||||||
"author": c.get("author") or "unknown",
|
author=c.get("author") or "unknown",
|
||||||
"body": c.get("text") or c.get("body") or "",
|
body=c.get("text") or c.get("body") or "",
|
||||||
"created_at": str(c.get("created") or c.get("createdAt") or ""),
|
created_at=c.get("created") or c.get("createdAt") or 0,
|
||||||
}
|
)
|
||||||
for c in comments
|
for c in comments
|
||||||
],
|
],
|
||||||
labels=[
|
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:
|
def has_reporter_reply_since(issue: IssueContext, marker_comment_id: str) -> bool:
|
||||||
found = False
|
found = False
|
||||||
for c in issue.comments:
|
for c in issue.comments:
|
||||||
if c['id'] == marker_comment_id:
|
if c.id == marker_comment_id:
|
||||||
found = True
|
found = True
|
||||||
continue
|
continue
|
||||||
if found and c['author'] != os.getenv('YOUTRACK_TRIAGE_AUTHOR'):
|
if found and c.author != os.getenv('YOUTRACK_TRIAGE_AUTHOR'):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
class GiteaMCPClient: ...
|
||||||
@@ -6,11 +6,14 @@ class LLMClient:
|
|||||||
self.model = model
|
self.model = model
|
||||||
self.client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
|
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)
|
assert_strict_mode_clean(schema)
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": [
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
] if system else [
|
||||||
{"role": "user", "content": prompt},
|
{"role": "user", "content": prompt},
|
||||||
],
|
],
|
||||||
"response_format": schema,
|
"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 asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
@@ -110,3 +112,49 @@ class YouTrackMCPClient:
|
|||||||
async def list_tools(self):
|
async def list_tools(self):
|
||||||
"""List all available tools from the MCP server."""
|
"""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 dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from contracts.ProjectContext import ProjectContext
|
from contracts.ProjectContext import ProjectContext
|
||||||
|
|
||||||
|
|
||||||
@@ -10,8 +12,8 @@ class IssueContext:
|
|||||||
issue_id: str
|
issue_id: str
|
||||||
title: str
|
title: str
|
||||||
body: str
|
body: str
|
||||||
acceptance_criteria: str | None
|
acceptance_criteria: str | None = None
|
||||||
comments: list[dict]
|
comments: list[IssueComment] = field(default_factory=list)
|
||||||
labels: list[str] = field(default_factory=list)
|
labels: list[str] = field(default_factory=list)
|
||||||
repo: Optional[str] = None
|
repo: Optional[str] = None
|
||||||
metadata: dict = field(default_factory=dict)
|
metadata: dict = field(default_factory=dict)
|
||||||
@@ -44,6 +46,14 @@ class IssueContext:
|
|||||||
if self.comments:
|
if self.comments:
|
||||||
parts.append("\n## Discussion / Comments")
|
parts.append("\n## Discussion / Comments")
|
||||||
for c in self.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)
|
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
|
role: str
|
||||||
nick: str
|
nick: str
|
||||||
|
|
||||||
|
class Repo(BaseModel):
|
||||||
|
id: str
|
||||||
|
remote_url: str
|
||||||
|
base_branch: str = "main"
|
||||||
|
|
||||||
class ProjectContext(BaseModel):
|
class ProjectContext(BaseModel):
|
||||||
"""Static, per-project knowledge injected into every decomposition."""
|
"""Static, per-project knowledge injected into every decomposition."""
|
||||||
project_key: str # e.g. "ARCH"
|
project_key: str # e.g. "ARCH"
|
||||||
@@ -22,6 +27,7 @@ class ProjectContext(BaseModel):
|
|||||||
deployment: str = "" # "Docker Compose on Hetzner"
|
deployment: str = "" # "Docker Compose on Hetzner"
|
||||||
project_language: str = "english"
|
project_language: str = "english"
|
||||||
default_responder: str | None = None
|
default_responder: str | None = None
|
||||||
|
repos: list[Repo] = field(default_factory=list)
|
||||||
team: list[TeamMemberModel] = field(default_factory=list)
|
team: list[TeamMemberModel] = field(default_factory=list)
|
||||||
conventions: list[str] = field(default_factory=list) # free-form notes
|
conventions: list[str] = field(default_factory=list) # free-form notes
|
||||||
extra: dict[str, str] = field(default_factory=dict) # anything else
|
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 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
|
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
|
import httpx
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
|
from agents.coders.BaseCoder import CoderAgent
|
||||||
from agents.issue_triage.IssueTriageAgent import IssueTriageAgent
|
from agents.issue_triage.IssueTriageAgent import IssueTriageAgent
|
||||||
from agents.issue_triage.context_builder import YouTrackContextBuilder
|
from agents.issue_triage.context_builder import YouTrackContextBuilder
|
||||||
from agents.registry import AgentRegistry
|
from agents.registry import AgentRegistry
|
||||||
|
from common.gitea_mcp_client import GiteaMCPClient
|
||||||
from common.llm_client import LLMClient
|
from common.llm_client import LLMClient
|
||||||
from common.youtrack_mcp_client import YouTrackMCPClient, IssueNotFound
|
from common.youtrack_mcp_client import YouTrackMCPClient, IssueNotFound
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ def env(key: str) -> str:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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_SERVER')),
|
||||||
str(os.getenv('YOUTRACK_MCP_TOKEN')),
|
str(os.getenv('YOUTRACK_MCP_TOKEN')),
|
||||||
).connect()
|
).connect()
|
||||||
@@ -43,20 +46,20 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.state.issue_reader_agent = IssueTriageAgent(
|
app.state.issue_reader_agent = IssueTriageAgent(
|
||||||
YouTrackContextBuilder(mcp, http_for_attachments),
|
YouTrackContextBuilder(app.state.youtrack_mcp, http_for_attachments),
|
||||||
LLMClient(
|
LLMClient(
|
||||||
base_url=env("LLM_ADDRESS"),
|
base_url=env("LLM_ADDRESS"),
|
||||||
api_key=env("LLM_API_KEY"),
|
api_key=env("LLM_API_KEY"),
|
||||||
model=env("LLM_MODEL"),
|
model=env("LLM_MODEL"),
|
||||||
),
|
),
|
||||||
AgentRegistry(),
|
AgentRegistry(),
|
||||||
mcp
|
app.state.youtrack_mcp
|
||||||
)
|
)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# ---- shutdown ----
|
# ---- shutdown ----
|
||||||
await mcp.close()
|
await app.state.youtrack_mcp.close()
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@@ -84,3 +87,32 @@ async def decomposing_issue(issue: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("An unexpected error occurred")
|
logger.exception("An unexpected error occurred")
|
||||||
return {"error": str(e)}
|
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