Plan step

This commit is contained in:
2026-09-23 22:42:21 +03:00
parent 1f2dc25e0b
commit 762ac2f14b
15 changed files with 823 additions and 283 deletions
+1
View File
@@ -5,6 +5,7 @@
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/agents/issue_triage/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
<excludeFolder url="file://$MODULE_DIR$/projects/agents" />
</content>
<orderEntry type="jdk" jdkName="Python 3.14 (Agents)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebResourcesPaths">
<contentEntries>
<entry url="file://$PROJECT_DIR$">
<entryData>
<resourceRoots>
<path value="file://$PROJECT_DIR$/projects" />
</resourceRoots>
</entryData>
</entry>
</contentEntries>
</component>
</project>
+5
View File
@@ -1,3 +1,8 @@
### 2026-09-23
1. Add memory for Agent - memory dir. Not count timestamps, need work
2. Add FileSelection - file graph is converting to consumable table, works for small amount of files
3. Refactor planification prompt - it is better now
### 2026-09-22
1. Add verification step - ensure the issue can be solved by coding
2. Add repo download step
+17 -40
View File
@@ -25,11 +25,11 @@ import os
from pathlib import Path
from typing import List, Literal, Optional, Type, TypeVar
from pydantic import BaseModel, Field, ValidationError, TypeAdapter
from pydantic import BaseModel, Field, ValidationError, TypeAdapter, field_validator
from agents.coders.repo import RepoContext
from common.llm_client import LLMClient
from contracts.RepoContext import Language, LanguageLiteral
from common.llm_client import LLMClient, ResponseCache
from contracts.RepoContext import RepoContext
from contracts.CodeLanguages import Language, LanguageLiteral
from contracts.coders.VerifyOutput import sanitize_for_openai
log = logging.getLogger("entrypoint_analyzer")
@@ -86,10 +86,21 @@ class Attempt(BaseModel):
class AnalysisResult(BaseModel):
language: Language
entrypoint: str
entrypoint: str = Field(
...,
description="Relative path from repo_path to the entrypoint file",
)
attempts: List[Attempt]
reasoning: str
@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
# --------------------------------------------------------------------------- #
# LLM plumbing: LLMClient wrapper with strict schema + cache + retries #
@@ -97,41 +108,6 @@ class AnalysisResult(BaseModel):
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:
@@ -404,6 +380,7 @@ class EntrypointAnalyzer:
def gather_repo_entrypoint(llm: LLMClient, repo: RepoContext, cache_path: Optional[str] = None) -> AnalysisResult:
log.debug("Gathering repo entrypoint")
return EntrypointAnalyzer(
StructuredLLM(llm, cache=ResponseCache(Path(cache_path)) if cache_path else None)
).analyze(Path(repo.repo_dir))
+122 -66
View File
@@ -1,10 +1,13 @@
import json
import logging
import os
import pickle
import shutil
import subprocess
from abc import ABC, abstractmethod
from functools import partial
from pathlib import Path
from typing import Optional, Callable
from typing import Optional, Callable, TypedDict, Generic, Hashable, TypeVar
import networkx as nx
import pandas as pd
@@ -12,9 +15,16 @@ from networkx.algorithms.community import louvain_communities
from pydantic import BaseModel, Field, ValidationError
from agents.codebase_analyst.entrypoint import gather_repo_entrypoint
from common.llm_client import LLMClient
from contracts.RepoContext import Language, ToolRequirement, DependencyGraph, DependencyNode, DependencyEdge, \
AnalysisResult, AnalysisInput, AnalysisError
from contracts.GraphAnalyzer import AnalyzeResult, DependencyEdge, DependencyNode
from contracts.RepoContext import Language, ToolRequirement, DependencyGraph, \
AnalysisResult, AnalysisInput, AnalysisError, RepoContext
from contracts.coders.RunContext import RunContext
logger = logging.getLogger("graph_tool")
logger.setLevel(logging.DEBUG)
def find_tool(command: str) -> Optional[str]:
@@ -452,7 +462,8 @@ class JavaAdapter(LanguageAdapter):
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:
@staticmethod
def _parse_tree_text(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] = []
@@ -636,7 +647,30 @@ ADAPTER_REGISTRY: dict[Language, AdapterFactory] = {
}
def analyze_repository(inp: AnalysisInput) -> AnalysisResult:
def analyze_repo_with_cache(run_ctx: RunContext, repo: RepoContext) -> AnalysisResult:
repo_analyze = None
analysis_cache_path = run_ctx.config.project_mem_dir() / "repo_analysis.json"
if os.path.exists(analysis_cache_path):
try:
repo_analyze = pickle.load(open(analysis_cache_path, "rb"))
logger.debug("repo analysis from cache")
except Exception as e:
print(e)
pass
if not repo_analyze:
logger.debug("generating repo analysis")
repo_analyze = analyze_repository(AnalysisInput(
repo=repo
), run_ctx)
if repo_analyze.error:
raise BaseException(repo_analyze.error)
with open(analysis_cache_path, "wb") as f:
pickle.dump(repo_analyze, f)
return repo_analyze
def analyze_repository(inp: AnalysisInput, run_ctx: RunContext) -> AnalysisResult:
"""
Idempotent entry point.
@@ -644,15 +678,19 @@ def analyze_repository(inp: AnalysisInput) -> AnalysisResult:
(either a graph or a structured error). No files are written to the
repository under analysis.
"""
adapter_factory = ADAPTER_REGISTRY.get(inp.language)
entrypoint_analyze = gather_repo_entrypoint(run_ctx.llm, inp.repo, run_ctx.config.project_mem_dir() / "entrypoint.json")
language = entrypoint_analyze.language
entrypoint = entrypoint_analyze.entrypoint
adapter_factory = ADAPTER_REGISTRY.get(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,
message=f"No adapter registered for language '{language}'",
language=language,
entrypoint=entrypoint,
),
)
@@ -670,27 +708,27 @@ def analyze_repository(inp: AnalysisInput) -> AnalysisResult:
error=AnalysisError(
error_code="MISSING_TOOL",
message=(
f"Cannot build dependency graph for '{inp.language.value}': "
f"Cannot build dependency graph for '{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,
language=language,
entrypoint=entrypoint,
),
)
# --- Build graph ---
try:
graph = adapter.build_graph(inp.repo_path, inp.entrypoint)
graph = adapter.build_graph(inp.repo.repo_dir, 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,
message=f"Tool timed out while analyzing {entrypoint}",
language=language,
entrypoint=entrypoint,
),
)
except Exception as exc:
@@ -699,39 +737,45 @@ def analyze_repository(inp: AnalysisInput) -> AnalysisResult:
error=AnalysisError(
error_code="ANALYSIS_FAILED",
message=str(exc),
language=inp.language,
entrypoint=inp.entrypoint,
language=language,
entrypoint=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"])
leaf_clusters = cluster_leaves(G, res_internal.leaf_no_deps)
res_all = analyze(to_networkx(graph, include_external=True), graph, label="_all")
return AnalysisResult(success=True, graph=graph, res_all=res_all, leaf_clusters=leaf_clusters)
def to_networkx(dep_graph: DependencyGraph, include_external=False):
def to_networkx(dep_graph: "DependencyGraph", include_external: bool = False) -> nx.DiGraph[DependencyNode]:
"""
Convert a DependencyGraph into a networkx DiGraph.
Nodes are identified by their string id. Edges point from the source
(the file that declares the dependency) to the target (the file that
is depended upon).
If include_external is False, external nodes (and edges touching them)
are omitted.
"""
G = nx.DiGraph()
# Nodes
id_to_node: dict[str, DependencyNode] = {}
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)
G.add_node(n) # DependencyNode must be hashable
id_to_node[n.id] = n
# 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)
src = id_to_node.get(e.source)
tgt = id_to_node.get(e.target)
if src is not None and tgt is not None:
G.add_edge(src, tgt, kind=e.kind)
return G
@@ -741,54 +785,66 @@ def package_of(path: str) -> str:
COLS = [
"file", "language", "in_degree", "out_degree",
"file", "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())
def analyze(
G: nx.DiGraph[DependencyNode],
graph: DependencyGraph, # NOTE: currently unused in the body
label: str = "",
) -> AnalyzeResult:
in_deg: dict[DependencyNode, int] = dict(G.in_degree())
out_deg: dict[DependencyNode, int] = dict(G.out_degree())
# 1. Truly isolated: no in and no out edges
isolated = [n for n in G if G.degree(n) == 0]
isolated: list[DependencyNode] = [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]
leaf_no_deps: list[DependencyNode] = [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]
entry_no_dependents: list[DependencyNode] = [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)
widely_used: list[tuple[DependencyNode, int]] = 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 = {
pkg: dict[DependencyNode, Hashable] = {n: package_of(n.id) for n in G}
used_by_packages: dict[DependencyNode, int] = {
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)
wccs: list[set[DependencyNode]] = sorted(
nx.weakly_connected_components(G), key=len, reverse=True
)
# 7. Strongly connected components = cycles / tightly coupled groups
sccs = sorted(
sccs: list[set[DependencyNode]] = sorted(
(c for c in nx.strongly_connected_components(G) if len(c) > 1),
key=len, reverse=True,
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}
U: nx.Graph[DependencyNode] = G.to_undirected()
comms: list[set[DependencyNode]] = (
louvain_communities(U, seed=42) if U.number_of_edges() else []
)
node2comm: dict[DependencyNode, int] = {
n: i for i, c in enumerate(comms) for n in c
}
# Per-file table
rows = []
rows: list[dict[str, object]] = []
for n, data in G.nodes(data=True):
rows.append({
"file": n,
"language": data.get("language"),
"file": n.id,
"in_degree": in_deg[n],
"out_degree": out_deg[n],
"isolated": G.degree(n) == 0,
@@ -808,27 +864,27 @@ def analyze(G, graph, label=""):
df.to_csv(f"file_metrics{label}.csv", index=False)
pd.DataFrame([
{"group_id": i, "size": len(c), "members": ";".join(sorted(c))}
{"group_id": i, "size": len(c), "members": ";".join(sorted(n.id for n in 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))}
{"scc_id": i, "size": len(c), "members": ";".join(sorted(n.id for n in 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,
}
return AnalyzeResult(
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):
+262
View File
@@ -0,0 +1,262 @@
# steps/select_files.py
from __future__ import annotations
import json
import re
from dataclasses import dataclass
import pandas as pd
from pydantic import TypeAdapter, ValidationError
from contracts.coders.FileSelection import FileSelectionOutput, FileSelection
from contracts.coders.GatherOutput import GatherOutput
SelectionAdapter = TypeAdapter(FileSelectionOutput)
SYSTEM_PROMPT = """\
You select the files a coding agent should read to plan a change.
You will receive:
1. The issue (title, body, acceptance criteria).
2. A table of repository files with structural metadata.
3. Column definitions.
Your job: pick the files the agent needs, split into three roles:
- **primary**: files that almost certainly need edits for this task. If the \
task says "add X to the user settings page", the settings page component \
is primary. Be conservative — include only files you'd bet will be edited.
- **context**: files needed to understand primary files — imports they rely \
on, type definitions they use, config they read. Include if a planner would \
be confused without them.
- **test**: existing tests that cover the primary files. Include even if the \
task doesn't mention tests; the agent needs to know what already exists.
Rules:
- Choose ONLY from the paths in the table. Do not invent paths.
- For each file, the reason MUST quote or paraphrase a specific identifier, \
module name, or phrase from the issue. If you can't write such a reason, \
do not select the file.
- Prefer fewer, higher-confidence selections over many low-confidence ones. \
A bad selection wastes context; a missing file causes a bad plan.
- High `in` means the file is imported by many others — useful context, but \
rarely the file to edit. Do not select a file just because `in` is high.
- Files in the same `community` as a primary file are often related; use \
this as a signal, not a rule.
- `[test]`-prefixed files are tests. Use them to populate the test role.
- At most 30 selections total.
Output JSON matching the schema. No prose.
"""
@dataclass(frozen=True)
class SelectFilesInput:
gather: GatherOutput
table: str # pre-formatted, pre-filtered table text
filtered_out_count: int # for the notes
def select_files(
inp: SelectFilesInput,
*,
llm,
) -> FileSelectionOutput:
prompt = _build_user_prompt(inp)
schema = SelectionAdapter.json_schema()
raw = llm.chat_with_schema(
prompt,
{
"type": "json_schema",
"json_schema": {
"name": "FileSelectionOutput",
"schema": schema,
"strict": True,
},
},
system=SYSTEM_PROMPT,
)
raw = _ensure_dict(raw)
try:
out = SelectionAdapter.validate_python(raw)
except ValidationError as e:
raise SelectionError(str(e)) from e
return _validate_against_table(out, inp.table)
# ── prompt ─────────────────────────────────────────────────────
def _build_user_prompt(inp: SelectFilesInput) -> str:
g = inp.gather
issue = g.issue_context
parts: list[str] = []
parts.append(f"# Issue {issue.issue_id}: {issue.title}")
parts.append("")
parts.append(issue.body.strip() or "(empty)")
parts.append("")
if issue.acceptance_criteria:
parts.append("## Acceptance criteria")
for i, ac in enumerate(issue.acceptance_criteria, 1):
parts.append(f"{i}. {ac}")
parts.append("")
parts.append("## Column definitions")
parts.append("- `path`: repo-relative path (authoritative)")
parts.append("- `lang`: language")
parts.append("- `in`: number of files that import this file (in-degree)")
parts.append("- `out`: number of files this file imports (out-degree)")
parts.append("- `pkg`: number of packages that depend on this file")
parts.append("- `comm`: cluster id; files with the same id tend to be "
"related by construction")
parts.append("")
parts.append("## Files")
parts.append("```")
parts.append(inp.table)
parts.append("```")
parts.append("")
if inp.filtered_out_count:
parts.append(
f"({inp.filtered_out_count} additional files were pre-filtered "
f"out as clearly unrelated. Do not select them.)"
)
parts.append("Select the files. Return ONLY the JSON object.")
return "\n".join(parts)
# ── validation ─────────────────────────────────────────────────
class SelectionError(ValueError):
"""LLM output violated the selection contract."""
def _validate_against_table(
out: FileSelectionOutput,
table: str,
) -> FileSelectionOutput:
"""
Reject selections that reference paths not in the table.
LLMs hallucinate paths surprisingly often, especially on large tables.
"""
valid_paths = _paths_from_table(table)
bad = [s.path for s in out.selections if s.path not in valid_paths]
if bad:
raise SelectionError(
f"LLM selected paths not in the table: {bad[:5]}"
+ (f" (+{len(bad)-5} more)" if len(bad) > 5 else "")
)
# dedupe in case the model repeated a path with different roles
seen: dict[str, FileSelection] = {}
for s in out.selections:
if s.path not in seen or s.confidence > seen[s.path].confidence:
seen[s.path] = s
deduped = list(seen.values())
return FileSelectionOutput(selections=deduped, notes=out.notes)
def _paths_from_table(table: str) -> set[str]:
paths = set()
for line in table.splitlines()[1:]: # skip header
first = line.split(" | ", 1)[0].strip()
if first.startswith("[test] "):
first = first[len("[test] "):]
paths.add(first)
return paths
def _ensure_dict(raw):
if isinstance(raw, str):
return json.loads(raw)
return raw
RENAMES = {
"file": "path",
"in_degree": "in",
"out_degree": "out",
"used_by_packages": "pkg",
"community": "comm",
}
# Columns actually used by the LLM. Everything else is either redundant
# (isolated, leaf_no_deps, entry_no_dependents) or unused (label,
# used_by_files — a duplicate of in_degree).
KEEP = list(RENAMES.keys())
_TEST_PATH_RE = re.compile(
r"(^|/)(tests?|__tests__|spec)(/|$)" # tests/, test/, __tests__/, spec/
r"|(^|/)test_[^/]+\.py$" # test_foo.py
r"|_test\.(py|go)$" # foo_test.py, foo_test.go
r"|\.(test|spec)\.(ts|tsx|js|jsx)$", # foo.test.ts, foo.spec.tsx
re.IGNORECASE,
)
def is_test_path(path: str) -> bool:
return bool(_TEST_PATH_RE.search(path))
def _force_int(series: pd.Series) -> pd.Series:
"""
Coerce any pandas dtype to a plain int64 numpy array wrapped in a Series.
Handles Categorical (ordered or not), Int64 (nullable), float with NaN,
object with strings, and boolean.
"""
if isinstance(series.dtype, pd.CategoricalDtype):
# cat.codes is always plain int8/int16/... — no ordering needed.
return pd.Series(series.cat.codes, index=series.index, dtype="int64")
# For everything else, let pandas convert, then force int64.
return (
pd.to_numeric(series, errors="coerce")
.fillna(0)
.astype("int64")
.reset_index(drop=True)
.set_axis(series.index)
)
def format_table(df: pd.DataFrame) -> str:
"""
Render the dependency-graph DataFrame as a compact, pipe-separated
table suitable for an LLM prompt.
- Keeps only the columns the model needs.
- Renames them to short forms.
- Sorts by (community, path) so related files cluster visually.
- Prefixes test paths with [test] so the model doesn't have to guess.
- Returns the header line + rows as a single string.
"""
if df.empty:
return "path | in | out | pkg | comm"
cols = [c for c in KEEP if c in df.columns]
view = df[cols].rename(columns=RENAMES).copy()
# ── 1. Force every column to a plain Python type ──────────
# This is the step that guarantees sort_values never sees a
# Categorical, nullable Int64, or object-with-NaN.
view["path"] = view["path"].astype(str)
for col in ("in", "out", "pkg", "comm"):
view[col] = _force_int(view[col])
view = view.sort_values(["comm", "path"], kind="stable")
view["path"] = view["path"].map(
lambda p: f"[test] {p}" if is_test_path(p) else p
)
header = "path | lang | in | out | pkg | comm"
view = view.astype(object).where(pd.notna(view), None)
rows = [
" | ".join("" if v is None else str(v) for v in row)
for row in view.itertuples(index=False, name=None)
]
return "\n".join([header, *rows])
+8 -4
View File
@@ -10,7 +10,7 @@ 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.llm_client import LLMClient, ResponseCache
from common.youtrack_mcp_client import YouTrackMCPClient
from contracts.IssueContext import IssueContext
from contracts.coders.RunContext import RunContext, new_run_id, AgentConfig
@@ -60,10 +60,14 @@ class CoderAgent:
gitea=self.gitea_mcp,
state=gather_result.state,
config=AgentConfig(
youtrack_project=issue_ctx.issue_id.split(":")[0],
gitea_repo=""
agent_dir=Path(__file__).resolve().parents[2] / "projects" / "agents" / self.id,
youtrack_project=issue_ctx.issue_id.split("-")[0],
git_name = "zarch",
git_email = "zarch@zaek.eu"
),
)
run_ctx.llm.cache = ResponseCache(run_ctx.config.project_mem_dir() / "init.json")
step = await handle_verify(
VerifyPossibilityInput(gather_result),
run_ctx
@@ -73,7 +77,7 @@ class CoderAgent:
step = await handle_plan(
PlanInput(gather_result, step.output),
llm=self.llm,
run_ctx=run_ctx
)
print(step)
+1 -18
View File
@@ -1,26 +1,9 @@
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)
from contracts.RepoContext import RepoContext
def _run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
+192 -78
View File
@@ -3,54 +3,101 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
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 agents.codebase_analyst.graph_tool import analyze_repo_with_cache
from agents.codebase_analyst.select_files import format_table, select_files, SelectFilesInput
from agents.coders.repo import ensure_repo_ready
from contracts.coders.GatherOutput import GatherOutput
from contracts.coders.PlanOutput import PlanOutput
from contracts.coders.RunContext import RunContext
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 are a senior engineer writing an implementation plan that a coding agent \
will execute file-by-file.
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.
## Inputs
Produce a plan with these properties:
You receive:
- The issue: title, body, acceptance criteria, comments.
- Repository context: remote URL, default branch.
- A feasibility verdict from a prior triage step, with reasoning and confidence.
- Files linked from the issue, with line numbers and per-file confidence.
1. **One step per file.** Each step describes a single file change. If two \
files must change together, that's two steps.
The feasibility verdict is final. Do not re-derive it, do not argue with it, \
do not mention it in the plan. It is context, not a question.
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.
## Output
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.
Return ONLY a JSON object matching the schema at the end of this message. No \
prose, no markdown fences, no commentary outside the JSON.
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.
## Plan shape
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.
A plan is an ordered list of steps. Each step targets exactly one file. After \
applying a step the repo should still parse and build — if it wouldn't, split \
the step.
Order steps so no step depends on code a later step will write. Independent \
steps go smallest-blast-radius first.
Each step must contain:
- `file`: repo-relative path. For a new file, mark it as such.
- `change`: 1–4 sentences a reviewer would understand. Name the symbols being \
added, modified, or removed. Do NOT paste code — the Act step writes it. You \
may name signatures, types, or schema fields where ambiguity matters.
- `breaks_existing`: see below.
- `risk_notes`: required when `breaks_existing=true`; otherwise optional \
reviewer notes (migrations, security, perf).
- `verifies`: how we'll know this step worked — a test to run, a file to check, \
a command. If nothing automated applies, write `"manual review"`.
## Acceptance-criteria coverage
Every acceptance criterion must be satisfied by at least one step. Before \
finalizing, walk the AC list and confirm coverage. If an AC cannot be satisfied \
by a code change (e.g. "get sign-off"), record it in `uncoverable_criteria` \
rather than inventing a step for it.
## Language and tooling
Infer the primary language from the repo context and the files you plan to \
touch. Set `language` accordingly, and pick the test runner, formatter, and \
linter idiomatic to that language. In a polyglot repo, set `language` to the \
majority language and use a per-step language override where steps differ.
## Breaking changes
Set `breaks_existing=true` only for:
- public API changes (signature, return type, thrown errors)
- schema or data-format changes
- config default changes
- feature removal
Pure additions, bug fixes, and internal refactors are NOT breaking. When in \
doubt, prefer `false` and add a `risk_notes` entry. A false positive here costs \
more than a false negative.
## Gaps and ambiguity
If the issue is ambiguous or the linked files don't cover the surface area, do \
not invent. Add a step whose `change` is "investigate X before proceeding" with \
`file: null`, or populate `open_questions`. A plan that flags uncertainty is \
more useful than one that looks complete.
## Constraints
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.
- Do not reference files you haven't seen, unless the step creates them.
- Prefer the smallest plan that satisfies the acceptance criteria.
- Return ONLY valid JSON matching the schema.
- Do not restate the issue. The reader has it.
"""
@@ -64,7 +111,7 @@ class PlanInput:
feasibility: FeasibleVerdict
async def handle_plan(inp: PlanInput, *, llm: LLMClient) -> PlanOutput:
async def handle_plan(inp: PlanInput, *, run_ctx: RunContext) -> PlanOutput:
if not inp.gather.issue_context.project or len(inp.gather.issue_context.project.repos) == 0:
raise "No project repo"
@@ -74,23 +121,24 @@ async def handle_plan(inp: PlanInput, *, llm: LLMClient) -> PlanOutput:
"zarch_" + inp.gather.issue_context.issue_id,
inp.gather.issue_context.project.repos[0].base_branch,
author_name="zarch",
author_email="zarch@zaek.eu"
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)
repo_analyze = analyze_repo_with_cache(run_ctx, repo)
selected_files = select_files(
SelectFilesInput(
inp.gather,
format_table(repo_analyze.res_all.df.drop(columns=[c for c in {"leaf_no_deps", "isolated", "entry_no_dependents", "language"} if
c in repo_analyze.res_all.df.columns])),
0
),
llm=run_ctx.llm
)
print(selected_files)
prompt = _build_user_prompt(inp, repo)
prompt = _build_user_prompt(inp, repo, selected_files)
schema = PlanAdapter.json_schema()
raw = llm.chat_with_schema(
raw = run_ctx.llm.chat_with_schema(
prompt,
{
"type": "json_schema",
@@ -122,49 +170,115 @@ def _ensure_dict(raw):
return raw
def _build_user_prompt(inp: PlanInput, repo: RepoContext) -> str:
g = inp.gather
issue = g.issue_context
MAX_FILE_LINES = 400
MAX_TOTAL_FILE_CHARS = 60_000
MIN_FILE_CONFIDENCE = 0.7
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")
def _numbered(path: Path) -> str:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
return f"(could not read {path}: {e})"
lines = text.splitlines()
if len(lines) <= MAX_FILE_LINES:
return "\n".join(f"{i:>4} {l}" for i, l in enumerate(lines, 1))
head_n = MAX_FILE_LINES * 2 // 3
tail_n = MAX_FILE_LINES - head_n
head = lines[:head_n]
tail = lines[-tail_n:]
elided = len(lines) - head_n - tail_n
body = [f"{i:>4} {l}" for i, l in enumerate(head, 1)]
body.append(f" ... ({elided} lines elided) ...")
body += [f"{i:>4} {l}" for i, l in enumerate(tail, len(lines) - tail_n + 1)]
return "\n".join(body)
def _issue_block(issue) -> str:
out = [f"# Issue {issue.issue_id}: {issue.title}", "", "## Description",
issue.body.strip() or "(empty)", "", "## Acceptance criteria"]
if issue.acceptance_criteria:
for i, ac in enumerate(issue.acceptance_criteria, 1):
parts.append(f"{i}. {ac}")
out += [f"{i}. {ac}" for i, ac in enumerate(issue.acceptance_criteria, 1)]
else:
parts.append("(none stated)")
parts.append("")
out.append("(none stated)")
return "\n".join(out)
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("")
def _comments_block(comments) -> str:
lines = ["## Prior comments"]
lines += [f"- [{c.created_at}] {c.author}: {c.body.strip()}" for c in comments]
return "\n".join(lines)
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("")
def _feasibility_block(f) -> str:
return "\n".join([
"## Feasibility verdict (already confirmed)",
f"Verdict: feasible",
f"Reasoning: {f.reasoning}",
f"Confidence: {f.confidence}",
])
parts.append("Produce the plan. Return ONLY the JSON object.")
return "\n".join(parts)
def _repo_block(repo) -> str:
return "\n".join([
"## Repository context",
f"- url: {repo.remote_url}",
f"- default branch: {repo.base_branch}",
])
def _files_block(repo, selected_files) -> str:
kept = [f for f in selected_files.selections if f.confidence >= MIN_FILE_CONFIDENCE]
kept.sort(key=lambda f: f.confidence, reverse=True)
lines = ["## Linked files"]
if not kept:
lines.append("(none selected above confidence threshold)")
return "\n".join(lines)
budget = MAX_TOTAL_FILE_CHARS
for f in kept:
if budget <= 0:
lines.append(f"- {f.path} (omitted: file budget exhausted)")
continue
content = _numbered(repo.repo_dir / f.path)
if len(content) > budget:
content = content[:budget] + "\n ... (truncated) ..."
budget -= len(content)
lines += [
"",
f"### `{f.path}` (confidence {f.confidence:.2f})",
"```",
content,
"```",
]
return "\n".join(lines)
def _instructions_block() -> str:
return "\n".join([
"## Task",
"Produce an implementation plan for the issue above.",
"",
"Rules:",
"- Reference concrete file paths and line numbers from the linked files.",
"- Each step must be independently verifiable.",
"- Call out any assumptions you're making.",
"",
"Return ONLY the JSON object matching the plan schema. No prose, no markdown fence.",
])
def _build_user_prompt(inp, repo, selected_files) -> str:
g = inp.gather
blocks = [
_issue_block(g.issue_context),
_comments_block(g.issue_context.comments) if g.issue_context.comments else None,
_feasibility_block(inp.feasibility),
_repo_block(repo),
_files_block(repo, selected_files),
_instructions_block(),
]
return "\n\n".join(b for b in blocks if b)
+59 -1
View File
@@ -1,12 +1,66 @@
import hashlib
import json
import logging
from pathlib import Path
from typing import Optional, Type
from openai import OpenAI
from pydantic import BaseModel
class NoSchema(BaseModel): ...
logger = logging.getLogger("llm client")
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
logger.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)
else:
logger.warning("No path specified for cache!")
class LLMClient:
def __init__(self, base_url, api_key: str | None = None, model: str = "gpt-4o-mini"):
def __init__(self, base_url, api_key: str | None = None, model: str = "gpt-4o-mini", cache: Optional[ResponseCache] = None):
self.model = model
self.client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
self.cache = cache
def chat_with_schema(self, prompt: str, schema: dict, system: str | None = None) -> str | None:
# --- idempotency: serve from cache when prompt+system+schema match ---
cache_key = ResponseCache.key(system or "", prompt, NoSchema) if self.cache else None
if cache_key and self.cache and (hit := self.cache.get(cache_key)) is not None:
return json.loads(hit).get('response', "")
assert_strict_mode_clean(schema)
kwargs = {
"model": self.model,
@@ -19,6 +73,10 @@ class LLMClient:
"response_format": schema,
}
resp = self.client.chat.completions.create(**kwargs)
if cache_key and self.cache:
self.cache.put(cache_key, json.dumps({"response": resp.choices[0].message.content or ""}))
return resp.choices[0].message.content
def chat(self, system: str, user: str, json_mode: bool = False, images: list[str] | None = None) -> str | None:
+24
View File
@@ -0,0 +1,24 @@
from enum import Enum
from typing import Literal
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,
]
+51
View File
@@ -0,0 +1,51 @@
import pandas as pd
from pydantic import BaseModel, Field, ConfigDict
from contracts.RepoContext import Language
class DependencyNode(BaseModel):
"""A single node in the dependency graph."""
model_config = ConfigDict(frozen=True)
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 AnalyzeResult(BaseModel):
"""Return shape of :func:`analyze`."""
model_config = ConfigDict(arbitrary_types_allowed=True)
df: pd.DataFrame
isolated: list[DependencyNode]
leaf_no_deps: list[DependencyNode]
entry_no_dependents: list[DependencyNode]
widely_used: list[tuple[DependencyNode, int]]
used_by_packages: dict[DependencyNode, int]
wccs: list[set[DependencyNode]]
sccs: list[set[DependencyNode]]
node2comm: dict[DependencyNode, int]
comms: list[set[DependencyNode]]
+38 -75
View File
@@ -1,65 +1,40 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional, Literal, Any
from typing import Optional, Literal, Any, Generic
from pydantic import field_validator, BaseModel, Field
import pandas as pd
from pydantic import field_validator, BaseModel, Field, ConfigDict, field_serializer
from contracts.CodeLanguages import Language
from contracts.GraphAnalyzer import AnalyzeResult, DependencyGraph
@dataclass(frozen=True)
@dataclass
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",
)
"""Result object carrying all info a coding agent needs."""
remote_url: str
branch: str
base_branch: str
head_commit: str
is_new_clone: bool
is_new_branch: bool
repo_dir: Path = Field(..., description="Absolute path to the cloned repository")
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)
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.",
)
@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()
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):
@@ -81,33 +56,21 @@ class AnalysisError(BaseModel):
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
repo: RepoContext
class AnalysisResult(BaseModel):
"""Top-level result — either a graph or an error."""
model_config = ConfigDict(arbitrary_types_allowed=True)
success: bool
graph: Optional[DependencyGraph] = None
error: Optional[AnalysisError] = None
leaf_clusters: Any
res_all: Any
leaf_clusters: Any = None
res_all: AnalyzeResult | None = None
@field_serializer("leaf_clusters")
def _ser_leaf_clusters(self, v: Any, _info):
if isinstance(v, pd.DataFrame):
return v.to_dict(orient="records")
return v # already JSON-safe
+16
View File
@@ -0,0 +1,16 @@
from typing import Literal
from pydantic import BaseModel, Field
from contracts.base import _StrictModel
class FileSelection(_StrictModel):
path: str = Field(...)
role: Literal["primary", "context", "test"] = Field(...)
reason: str = Field(...)
confidence: float = Field(ge=0.0, le=1.0)
class FileSelectionOutput(_StrictModel):
selections: list[FileSelection] = Field(...)
notes: list[str] = Field(...)
+13 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from agents.coders.state import AgentState
@@ -15,9 +16,13 @@ from common.youtrack_mcp_client import YouTrackMCPClient
@dataclass(frozen=True)
class AgentConfig:
agent_dir: Path
# ── project-level ──────────────────────────────────────────
youtrack_project: str
gitea_repo: str
git_name: str
git_email: str
gitea_repo: str = ""
gitea_target_branch: str = "main"
# ── limits (snapshotted into state at run start) ───────────
@@ -39,6 +44,13 @@ class AgentConfig:
# ── language hints (for Plan) ──────────────────────────────
language_markers: dict[str, list[str]] = None # filled from project
def project_mem_dir(self):
p = self.agent_dir / "memory" / f"{self.youtrack_project}"
if not p.exists():
p.mkdir(parents=True, exist_ok=True)
return p
@dataclass
class RunContext: