diff --git a/.idea/Agents.iml b/.idea/Agents.iml
index 64c179c..9d65d31 100644
--- a/.idea/Agents.iml
+++ b/.idea/Agents.iml
@@ -5,6 +5,7 @@
+
diff --git a/.idea/webResources.xml b/.idea/webResources.xml
new file mode 100644
index 0000000..2cc5e01
--- /dev/null
+++ b/.idea/webResources.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11e2ed8..2b3f692 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/agents/codebase_analyst/entrypoint.py b/agents/codebase_analyst/entrypoint.py
index dddbcb5..7058dff 100644
--- a/agents/codebase_analyst/entrypoint.py
+++ b/agents/codebase_analyst/entrypoint.py
@@ -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))
diff --git a/agents/codebase_analyst/graph_tool.py b/agents/codebase_analyst/graph_tool.py
index f6a84a1..eda997c 100644
--- a/agents/codebase_analyst/graph_tool.py
+++ b/agents/codebase_analyst/graph_tool.py
@@ -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):
diff --git a/agents/codebase_analyst/select_files.py b/agents/codebase_analyst/select_files.py
new file mode 100644
index 0000000..ead4d4a
--- /dev/null
+++ b/agents/codebase_analyst/select_files.py
@@ -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])
\ No newline at end of file
diff --git a/agents/coders/BaseCoder.py b/agents/coders/BaseCoder.py
index aa4c57b..98f8761 100644
--- a/agents/coders/BaseCoder.py
+++ b/agents/coders/BaseCoder.py
@@ -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)
diff --git a/agents/coders/repo.py b/agents/coders/repo.py
index b9171c4..48ad804 100644
--- a/agents/coders/repo.py
+++ b/agents/coders/repo.py
@@ -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:
diff --git a/agents/coders/steps/plan.py b/agents/coders/steps/plan.py
index 54b4b03..e8098e7 100644
--- a/agents/coders/steps/plan.py
+++ b/agents/coders/steps/plan.py
@@ -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)
\ No newline at end of file
+
+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)
\ No newline at end of file
diff --git a/common/llm_client.py b/common/llm_client.py
index 85c771e..01049a9 100644
--- a/common/llm_client.py
+++ b/common/llm_client.py
@@ -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:
diff --git a/contracts/CodeLanguages.py b/contracts/CodeLanguages.py
new file mode 100644
index 0000000..9b33b16
--- /dev/null
+++ b/contracts/CodeLanguages.py
@@ -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,
+]
diff --git a/contracts/GraphAnalyzer.py b/contracts/GraphAnalyzer.py
new file mode 100644
index 0000000..66b3b10
--- /dev/null
+++ b/contracts/GraphAnalyzer.py
@@ -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]]
\ No newline at end of file
diff --git a/contracts/RepoContext.py b/contracts/RepoContext.py
index 38ad48e..eacca18 100644
--- a/contracts/RepoContext.py
+++ b/contracts/RepoContext.py
@@ -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
\ No newline at end of file
+ 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
\ No newline at end of file
diff --git a/contracts/coders/FileSelection.py b/contracts/coders/FileSelection.py
new file mode 100644
index 0000000..6f22c22
--- /dev/null
+++ b/contracts/coders/FileSelection.py
@@ -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(...)
\ No newline at end of file
diff --git a/contracts/coders/RunContext.py b/contracts/coders/RunContext.py
index 452d61a..e2d1e6d 100644
--- a/contracts/coders/RunContext.py
+++ b/contracts/coders/RunContext.py
@@ -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: