Coder preparations

This commit is contained in:
2026-09-22 23:21:59 +03:00
parent 3d1df7fee5
commit 1f2dc25e0b
32 changed files with 3205 additions and 26 deletions
+6
View File
@@ -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
View File
+40 -1
View File
@@ -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
+409
View File
@@ -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))
+850
View File
@@ -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
+84
View File
@@ -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"
View File
+62
View File
@@ -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.
"""
+81
View File
@@ -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
+205
View File
@@ -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,
)
+174
View File
@@ -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
View File
+281
View File
@@ -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")
+170
View File
@@ -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)
+195
View File
@@ -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)
+5 -6
View File
@@ -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())
+7 -7
View File
@@ -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=[
+2 -2
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
class GiteaMCPClient: ...
+4 -1
View File
@@ -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,
+31
View File
@@ -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)
+49 -1
View File
@@ -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
@@ -109,4 +111,50 @@ 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
+13 -3
View File
@@ -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)
+6
View File
@@ -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
+113
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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
+89
View File
@@ -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
+91
View File
@@ -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}"
+56
View File
@@ -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)
+121
View File
@@ -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.",
)
+36 -4
View File
@@ -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"}