Plan step
This commit is contained in:
Generated
+1
@@ -5,6 +5,7 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/agents/issue_triage/tests" isTestSource="true" />
|
<sourceFolder url="file://$MODULE_DIR$/agents/issue_triage/tests" isTestSource="true" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/projects/agents" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.14 (Agents)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.14 (Agents)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
Generated
+14
@@ -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>
|
||||||
@@ -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
|
### 2026-09-22
|
||||||
1. Add verification step - ensure the issue can be solved by coding
|
1. Add verification step - ensure the issue can be solved by coding
|
||||||
2. Add repo download step
|
2. Add repo download step
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Literal, Optional, Type, TypeVar
|
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, ResponseCache
|
||||||
from common.llm_client import LLMClient
|
from contracts.RepoContext import RepoContext
|
||||||
from contracts.RepoContext import Language, LanguageLiteral
|
from contracts.CodeLanguages import Language, LanguageLiteral
|
||||||
from contracts.coders.VerifyOutput import sanitize_for_openai
|
from contracts.coders.VerifyOutput import sanitize_for_openai
|
||||||
|
|
||||||
log = logging.getLogger("entrypoint_analyzer")
|
log = logging.getLogger("entrypoint_analyzer")
|
||||||
@@ -86,10 +86,21 @@ class Attempt(BaseModel):
|
|||||||
|
|
||||||
class AnalysisResult(BaseModel):
|
class AnalysisResult(BaseModel):
|
||||||
language: Language
|
language: Language
|
||||||
entrypoint: str
|
entrypoint: str = Field(
|
||||||
|
...,
|
||||||
|
description="Relative path from repo_path to the entrypoint file",
|
||||||
|
)
|
||||||
attempts: List[Attempt]
|
attempts: List[Attempt]
|
||||||
reasoning: str
|
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 #
|
# LLM plumbing: LLMClient wrapper with strict schema + cache + retries #
|
||||||
@@ -97,41 +108,6 @@ class AnalysisResult(BaseModel):
|
|||||||
|
|
||||||
T = TypeVar("T", bound=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:
|
class StructuredLLM:
|
||||||
"""
|
"""
|
||||||
Wrap the project's `LLMClient` with:
|
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:
|
def gather_repo_entrypoint(llm: LLMClient, repo: RepoContext, cache_path: Optional[str] = None) -> AnalysisResult:
|
||||||
|
log.debug("Gathering repo entrypoint")
|
||||||
return EntrypointAnalyzer(
|
return EntrypointAnalyzer(
|
||||||
StructuredLLM(llm, cache=ResponseCache(Path(cache_path)) if cache_path else None)
|
StructuredLLM(llm, cache=ResponseCache(Path(cache_path)) if cache_path else None)
|
||||||
).analyze(Path(repo.repo_dir))
|
).analyze(Path(repo.repo_dir))
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable, TypedDict, Generic, Hashable, TypeVar
|
||||||
|
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -12,9 +15,16 @@ from networkx.algorithms.community import louvain_communities
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
from agents.codebase_analyst.entrypoint import gather_repo_entrypoint
|
||||||
from common.llm_client import LLMClient
|
from common.llm_client import LLMClient
|
||||||
from contracts.RepoContext import Language, ToolRequirement, DependencyGraph, DependencyNode, DependencyEdge, \
|
from contracts.GraphAnalyzer import AnalyzeResult, DependencyEdge, DependencyNode
|
||||||
AnalysisResult, AnalysisInput, AnalysisError
|
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]:
|
def find_tool(command: str) -> Optional[str]:
|
||||||
@@ -452,7 +462,8 @@ class JavaAdapter(LanguageAdapter):
|
|||||||
raise RuntimeError(f"gradle dependencies failed: {result.stderr.strip()}")
|
raise RuntimeError(f"gradle dependencies failed: {result.stderr.strip()}")
|
||||||
return self._parse_tree_text(result.stdout, entrypoint)
|
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.
|
# Simplified parser for the textual tree format.
|
||||||
# In production, use the JSON output of the Maven/Gradle plugin.
|
# In production, use the JSON output of the Maven/Gradle plugin.
|
||||||
nodes: list[DependencyNode] = []
|
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.
|
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
|
(either a graph or a structured error). No files are written to the
|
||||||
repository under analysis.
|
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:
|
if adapter_factory is None:
|
||||||
return AnalysisResult(
|
return AnalysisResult(
|
||||||
success=False,
|
success=False,
|
||||||
error=AnalysisError(
|
error=AnalysisError(
|
||||||
error_code="UNSUPPORTED_LANGUAGE",
|
error_code="UNSUPPORTED_LANGUAGE",
|
||||||
message=f"No adapter registered for language '{inp.language}'",
|
message=f"No adapter registered for language '{language}'",
|
||||||
language=inp.language,
|
language=language,
|
||||||
entrypoint=inp.entrypoint,
|
entrypoint=entrypoint,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -670,27 +708,27 @@ def analyze_repository(inp: AnalysisInput) -> AnalysisResult:
|
|||||||
error=AnalysisError(
|
error=AnalysisError(
|
||||||
error_code="MISSING_TOOL",
|
error_code="MISSING_TOOL",
|
||||||
message=(
|
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"required tool(s) not found on PATH: "
|
||||||
f"{', '.join(r.tool_name for r in missing)}"
|
f"{', '.join(r.tool_name for r in missing)}"
|
||||||
),
|
),
|
||||||
missing_tools=missing,
|
missing_tools=missing,
|
||||||
language=inp.language,
|
language=language,
|
||||||
entrypoint=inp.entrypoint,
|
entrypoint=entrypoint,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Build graph ---
|
# --- Build graph ---
|
||||||
try:
|
try:
|
||||||
graph = adapter.build_graph(inp.repo_path, inp.entrypoint)
|
graph = adapter.build_graph(inp.repo.repo_dir, entrypoint)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return AnalysisResult(
|
return AnalysisResult(
|
||||||
success=False,
|
success=False,
|
||||||
error=AnalysisError(
|
error=AnalysisError(
|
||||||
error_code="TOOL_TIMEOUT",
|
error_code="TOOL_TIMEOUT",
|
||||||
message=f"Tool timed out while analyzing {inp.entrypoint}",
|
message=f"Tool timed out while analyzing {entrypoint}",
|
||||||
language=inp.language,
|
language=language,
|
||||||
entrypoint=inp.entrypoint,
|
entrypoint=entrypoint,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -699,39 +737,45 @@ def analyze_repository(inp: AnalysisInput) -> AnalysisResult:
|
|||||||
error=AnalysisError(
|
error=AnalysisError(
|
||||||
error_code="ANALYSIS_FAILED",
|
error_code="ANALYSIS_FAILED",
|
||||||
message=str(exc),
|
message=str(exc),
|
||||||
language=inp.language,
|
language=language,
|
||||||
entrypoint=inp.entrypoint,
|
entrypoint=entrypoint,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
G_all = to_networkx(graph, include_external=True)
|
|
||||||
G = to_networkx(graph, include_external=False) # internal-only analysis
|
G = to_networkx(graph, include_external=False) # internal-only analysis
|
||||||
res_internal = analyze(G, graph, label="_internal")
|
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)
|
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()
|
G = nx.DiGraph()
|
||||||
|
|
||||||
# Nodes
|
id_to_node: dict[str, DependencyNode] = {}
|
||||||
for n in dep_graph.nodes:
|
for n in dep_graph.nodes:
|
||||||
if not include_external and n.is_external:
|
if not include_external and n.is_external:
|
||||||
continue
|
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:
|
for e in dep_graph.edges:
|
||||||
if e.source in G and e.target in G:
|
src = id_to_node.get(e.source)
|
||||||
G.add_edge(e.source, e.target, kind=e.kind)
|
tgt = id_to_node.get(e.target)
|
||||||
|
if src is not None and tgt is not None:
|
||||||
# Add isolated internal files that never appear in edges
|
G.add_edge(src, tgt, kind=e.kind)
|
||||||
if not include_external:
|
|
||||||
for n in dep_graph.nodes:
|
|
||||||
if not n.is_external and n.id not in G:
|
|
||||||
G.add_node(n.id, label=n.label, language=n.language.value, is_external=False)
|
|
||||||
|
|
||||||
return G
|
return G
|
||||||
|
|
||||||
@@ -741,54 +785,66 @@ def package_of(path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
COLS = [
|
COLS = [
|
||||||
"file", "language", "in_degree", "out_degree",
|
"file", "in_degree", "out_degree",
|
||||||
"isolated", "leaf_no_deps", "entry_no_dependents",
|
"isolated", "leaf_no_deps", "entry_no_dependents",
|
||||||
"used_by_files", "used_by_packages", "community",
|
"used_by_files", "used_by_packages", "community",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def analyze(G, graph, label=""):
|
def analyze(
|
||||||
in_deg = dict(G.in_degree())
|
G: nx.DiGraph[DependencyNode],
|
||||||
out_deg = dict(G.out_degree())
|
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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 5. How many distinct packages depend on it
|
||||||
pkg = {n: package_of(n) for n in G}
|
pkg: dict[DependencyNode, Hashable] = {n: package_of(n.id) for n in G}
|
||||||
used_by_packages = {
|
used_by_packages: dict[DependencyNode, int] = {
|
||||||
n: len({pkg[p] for p in G.predecessors(n)}) for n in G
|
n: len({pkg[p] for p in G.predecessors(n)}) for n in G
|
||||||
}
|
}
|
||||||
|
|
||||||
# 6. Weakly connected components = isolated groups
|
# 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
|
# 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),
|
(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
|
# 8. Communities via Louvain
|
||||||
U = G.to_undirected()
|
U: nx.Graph[DependencyNode] = G.to_undirected()
|
||||||
comms = louvain_communities(U, seed=42) if U.number_of_edges() else []
|
comms: list[set[DependencyNode]] = (
|
||||||
node2comm = {n: i for i, c in enumerate(comms) for n in c}
|
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
|
# Per-file table
|
||||||
rows = []
|
rows: list[dict[str, object]] = []
|
||||||
for n, data in G.nodes(data=True):
|
for n, data in G.nodes(data=True):
|
||||||
rows.append({
|
rows.append({
|
||||||
"file": n,
|
"file": n.id,
|
||||||
"language": data.get("language"),
|
|
||||||
"in_degree": in_deg[n],
|
"in_degree": in_deg[n],
|
||||||
"out_degree": out_deg[n],
|
"out_degree": out_deg[n],
|
||||||
"isolated": G.degree(n) == 0,
|
"isolated": G.degree(n) == 0,
|
||||||
@@ -808,27 +864,27 @@ def analyze(G, graph, label=""):
|
|||||||
df.to_csv(f"file_metrics{label}.csv", index=False)
|
df.to_csv(f"file_metrics{label}.csv", index=False)
|
||||||
|
|
||||||
pd.DataFrame([
|
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)
|
for i, c in enumerate(wccs)
|
||||||
]).to_csv(f"isolated_groups{label}.csv", index=False)
|
]).to_csv(f"isolated_groups{label}.csv", index=False)
|
||||||
|
|
||||||
pd.DataFrame([
|
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)
|
for i, c in enumerate(sccs)
|
||||||
]).to_csv(f"cycles{label}.csv", index=False)
|
]).to_csv(f"cycles{label}.csv", index=False)
|
||||||
|
|
||||||
return {
|
return AnalyzeResult(
|
||||||
"df": df,
|
df=df,
|
||||||
"isolated": isolated,
|
isolated=isolated,
|
||||||
"leaf_no_deps": leaf_no_deps,
|
leaf_no_deps=leaf_no_deps,
|
||||||
"entry_no_dependents": entry_no_dependents,
|
entry_no_dependents=entry_no_dependents,
|
||||||
"widely_used": widely_used,
|
widely_used=widely_used,
|
||||||
"used_by_packages": used_by_packages,
|
used_by_packages=used_by_packages,
|
||||||
"wccs": wccs,
|
wccs=wccs,
|
||||||
"sccs": sccs,
|
sccs=sccs,
|
||||||
"node2comm": node2comm,
|
node2comm=node2comm,
|
||||||
"comms": comms,
|
comms=comms,
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
def cluster_leaves(G, leaves):
|
def cluster_leaves(G, leaves):
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -10,7 +10,7 @@ from agents.coders.steps.verify import handle_verify
|
|||||||
from agents.issue_triage.context_builder import YouTrackContextBuilder
|
from agents.issue_triage.context_builder import YouTrackContextBuilder
|
||||||
from agents.registry import agent, AgentRegistry
|
from agents.registry import agent, AgentRegistry
|
||||||
from common.gitea_mcp_client import GiteaMCPClient
|
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 common.youtrack_mcp_client import YouTrackMCPClient
|
||||||
from contracts.IssueContext import IssueContext
|
from contracts.IssueContext import IssueContext
|
||||||
from contracts.coders.RunContext import RunContext, new_run_id, AgentConfig
|
from contracts.coders.RunContext import RunContext, new_run_id, AgentConfig
|
||||||
@@ -60,10 +60,14 @@ class CoderAgent:
|
|||||||
gitea=self.gitea_mcp,
|
gitea=self.gitea_mcp,
|
||||||
state=gather_result.state,
|
state=gather_result.state,
|
||||||
config=AgentConfig(
|
config=AgentConfig(
|
||||||
youtrack_project=issue_ctx.issue_id.split(":")[0],
|
agent_dir=Path(__file__).resolve().parents[2] / "projects" / "agents" / self.id,
|
||||||
gitea_repo=""
|
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(
|
step = await handle_verify(
|
||||||
VerifyPossibilityInput(gather_result),
|
VerifyPossibilityInput(gather_result),
|
||||||
run_ctx
|
run_ctx
|
||||||
@@ -73,7 +77,7 @@ class CoderAgent:
|
|||||||
|
|
||||||
step = await handle_plan(
|
step = await handle_plan(
|
||||||
PlanInput(gather_result, step.output),
|
PlanInput(gather_result, step.output),
|
||||||
llm=self.llm,
|
run_ctx=run_ctx
|
||||||
)
|
)
|
||||||
print(step)
|
print(step)
|
||||||
|
|
||||||
|
|||||||
+1
-18
@@ -1,26 +1,9 @@
|
|||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from contracts.RepoContext import RepoContext
|
||||||
@dataclass
|
|
||||||
class RepoContext:
|
|
||||||
"""Result object carrying all info a coding agent needs."""
|
|
||||||
repo_dir: Path
|
|
||||||
remote_url: str
|
|
||||||
branch: str
|
|
||||||
base_branch: str
|
|
||||||
head_commit: str
|
|
||||||
is_new_clone: bool
|
|
||||||
is_new_branch: bool
|
|
||||||
original_branch: Optional[str] = None
|
|
||||||
uncommitted_changes: Optional[str] = None # git diff (tracked)
|
|
||||||
untracked_files: list[str] = field(default_factory=list)
|
|
||||||
stash_ref: Optional[str] = None
|
|
||||||
env: dict = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
|
def _run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
|
||||||
|
|||||||
+192
-78
@@ -3,54 +3,101 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import TypeAdapter, ValidationError
|
from pydantic import TypeAdapter, ValidationError
|
||||||
|
|
||||||
from agents.codebase_analyst.entrypoint import gather_repo_entrypoint
|
from agents.codebase_analyst.graph_tool import analyze_repo_with_cache
|
||||||
from agents.codebase_analyst.graph_tool import analyze_repository
|
from agents.codebase_analyst.select_files import format_table, select_files, SelectFilesInput
|
||||||
from agents.coders.repo import ensure_repo_ready, RepoContext
|
from agents.coders.repo import ensure_repo_ready
|
||||||
from common.llm_client import LLMClient
|
|
||||||
from contracts.RepoContext import AnalysisInput
|
|
||||||
from contracts.coders.GatherOutput import GatherOutput
|
from contracts.coders.GatherOutput import GatherOutput
|
||||||
from contracts.coders.PlanOutput import PlanOutput
|
from contracts.coders.PlanOutput import PlanOutput
|
||||||
|
from contracts.coders.RunContext import RunContext
|
||||||
from contracts.coders.VerifyOutput import FeasibleVerdict
|
from contracts.coders.VerifyOutput import FeasibleVerdict
|
||||||
|
|
||||||
PlanAdapter = TypeAdapter(PlanOutput)
|
PlanAdapter = TypeAdapter(PlanOutput)
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """\
|
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 \
|
## Inputs
|
||||||
repository context, the feasibility verdict from a prior triage, and a list \
|
|
||||||
of files linked from the issue.
|
|
||||||
|
|
||||||
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 \
|
The feasibility verdict is final. Do not re-derive it, do not argue with it, \
|
||||||
files must change together, that's two steps.
|
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 \
|
## Output
|
||||||
context and the files you plan to touch. Choose the test runner, formatter, \
|
|
||||||
and linter idiomatic to that language. Populate `language` accordingly.
|
|
||||||
|
|
||||||
3. **Target files, not file contents.** Describe *what* changes in each file, \
|
Return ONLY a JSON object matching the schema at the end of this message. No \
|
||||||
not the code itself. The Act step writes the code; you decide the shape.
|
prose, no markdown fences, no commentary outside the JSON.
|
||||||
|
|
||||||
4. **Be honest about breaking changes.** Set `breaks_existing=true` only if \
|
## Plan shape
|
||||||
the change modifies behavior that callers or users rely on: public API \
|
|
||||||
changes, schema changes, config default changes, removal of features. \
|
|
||||||
Pure additions, bug fixes, and internal refactors are NOT breaking.
|
|
||||||
|
|
||||||
5. **Risk notes.** If `breaks_existing=true`, list the specific risks. If not, \
|
A plan is an ordered list of steps. Each step targets exactly one file. After \
|
||||||
you may still add notes for reviewer attention (migrations, security, \
|
applying a step the repo should still parse and build — if it wouldn't, split \
|
||||||
performance), but this is optional.
|
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 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.
|
- 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
|
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:
|
if not inp.gather.issue_context.project or len(inp.gather.issue_context.project.repos) == 0:
|
||||||
raise "No project repo"
|
raise "No project repo"
|
||||||
|
|
||||||
@@ -74,23 +121,24 @@ async def handle_plan(inp: PlanInput, *, llm: LLMClient) -> PlanOutput:
|
|||||||
"zarch_" + inp.gather.issue_context.issue_id,
|
"zarch_" + inp.gather.issue_context.issue_id,
|
||||||
inp.gather.issue_context.project.repos[0].base_branch,
|
inp.gather.issue_context.project.repos[0].base_branch,
|
||||||
author_name="zarch",
|
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:
|
repo_analyze = analyze_repo_with_cache(run_ctx, repo)
|
||||||
raise repo_analyze.error
|
selected_files = select_files(
|
||||||
print(repo_analyze)
|
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()
|
schema = PlanAdapter.json_schema()
|
||||||
|
raw = run_ctx.llm.chat_with_schema(
|
||||||
raw = llm.chat_with_schema(
|
|
||||||
prompt,
|
prompt,
|
||||||
{
|
{
|
||||||
"type": "json_schema",
|
"type": "json_schema",
|
||||||
@@ -122,49 +170,115 @@ def _ensure_dict(raw):
|
|||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def _build_user_prompt(inp: PlanInput, repo: RepoContext) -> str:
|
MAX_FILE_LINES = 400
|
||||||
g = inp.gather
|
MAX_TOTAL_FILE_CHARS = 60_000
|
||||||
issue = g.issue_context
|
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:
|
if issue.acceptance_criteria:
|
||||||
for i, ac in enumerate(issue.acceptance_criteria, 1):
|
out += [f"{i}. {ac}" for i, ac in enumerate(issue.acceptance_criteria, 1)]
|
||||||
parts.append(f"{i}. {ac}")
|
|
||||||
else:
|
else:
|
||||||
parts.append("(none stated)")
|
out.append("(none stated)")
|
||||||
parts.append("")
|
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)")
|
def _comments_block(comments) -> str:
|
||||||
parts.append(f"Verdict: feasible")
|
lines = ["## Prior comments"]
|
||||||
parts.append(f"Reasoning: {inp.feasibility.reasoning}")
|
lines += [f"- [{c.created_at}] {c.author}: {c.body.strip()}" for c in comments]
|
||||||
parts.append(f"Confidence: {inp.feasibility.confidence}")
|
return "\n".join(lines)
|
||||||
parts.append("")
|
|
||||||
|
|
||||||
parts.append("## Repository context")
|
|
||||||
parts.append(f"- url: {repo.remote_url}")
|
|
||||||
parts.append(f"- default branch: {repo.base_branch}")
|
|
||||||
parts.append("")
|
|
||||||
|
|
||||||
parts.append("## Linked files")
|
def _feasibility_block(f) -> str:
|
||||||
if g.linked_files:
|
return "\n".join([
|
||||||
for f in g.linked_files:
|
"## Feasibility verdict (already confirmed)",
|
||||||
parts.append(f"- {f}")
|
f"Verdict: feasible",
|
||||||
else:
|
f"Reasoning: {f.reasoning}",
|
||||||
parts.append("(none)")
|
f"Confidence: {f.confidence}",
|
||||||
parts.append("")
|
])
|
||||||
|
|
||||||
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
@@ -1,12 +1,66 @@
|
|||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
from openai import OpenAI
|
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:
|
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.model = model
|
||||||
self.client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
|
self.client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
|
||||||
|
self.cache = cache
|
||||||
|
|
||||||
def chat_with_schema(self, prompt: str, schema: dict, system: str | None = None) -> str | None:
|
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)
|
assert_strict_mode_clean(schema)
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
@@ -19,6 +73,10 @@ class LLMClient:
|
|||||||
"response_format": schema,
|
"response_format": schema,
|
||||||
}
|
}
|
||||||
resp = self.client.chat.completions.create(**kwargs)
|
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
|
return resp.choices[0].message.content
|
||||||
|
|
||||||
def chat(self, system: str, user: str, json_mode: bool = False, images: list[str] | None = None) -> str | None:
|
def chat(self, system: str, user: str, json_mode: bool = False, images: list[str] | None = None) -> str | None:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
@@ -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
@@ -1,65 +1,40 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
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:
|
class RepoContext:
|
||||||
url: str
|
"""Result object carrying all info a coding agent needs."""
|
||||||
default_branch: str
|
remote_url: str
|
||||||
language_hint: str | None # "python" | "typescript" | "go" | None
|
branch: str
|
||||||
|
base_branch: str
|
||||||
class Language(str, Enum):
|
head_commit: str
|
||||||
PYTHON = "python"
|
is_new_clone: bool
|
||||||
JAVASCRIPT = "javascript"
|
is_new_branch: bool
|
||||||
TYPESCRIPT = "typescript"
|
repo_dir: Path = Field(..., description="Absolute path to the cloned repository")
|
||||||
JAVA = "java"
|
original_branch: Optional[str] = None
|
||||||
GO = "go"
|
uncommitted_changes: Optional[str] = None # git diff (tracked)
|
||||||
RUST = "rust"
|
untracked_files: list[str] = field(default_factory=list)
|
||||||
CPP = "cpp"
|
stash_ref: Optional[str] = None
|
||||||
C = "c"
|
env: dict = field(default_factory=dict)
|
||||||
|
|
||||||
LanguageLiteral = Literal[
|
|
||||||
Language.PYTHON,
|
|
||||||
Language.JAVASCRIPT,
|
|
||||||
Language.TYPESCRIPT,
|
|
||||||
Language.JAVA,
|
|
||||||
Language.GO,
|
|
||||||
Language.RUST,
|
|
||||||
Language.CPP,
|
|
||||||
Language.C,
|
|
||||||
]
|
|
||||||
|
|
||||||
class DependencyNode(BaseModel):
|
|
||||||
"""A single node in the dependency graph."""
|
|
||||||
id: str = Field(..., description="Unique identifier (file path or module name)")
|
|
||||||
label: str = Field(..., description="Human-readable label")
|
|
||||||
language: Language
|
|
||||||
is_external: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="True if the dependency is outside the repository",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DependencyEdge(BaseModel):
|
@field_validator("repo_path")
|
||||||
"""A directed edge from source → target (source depends on target)."""
|
@classmethod
|
||||||
source: str = Field(..., description="Node id of the dependant")
|
def repo_must_exist(cls, v: Path) -> Path:
|
||||||
target: str = Field(..., description="Node id of the dependency")
|
if not v.is_dir():
|
||||||
kind: str = Field(
|
raise ValueError(f"Repository path does not exist or is not a directory: {v}")
|
||||||
default="import",
|
return v.resolve()
|
||||||
description="Edge kind: import, include, inherit, call, etc.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DependencyGraph(BaseModel):
|
|
||||||
"""Complete dependency graph result."""
|
|
||||||
root: str = Field(..., description="Entrypoint node id")
|
|
||||||
nodes: list[DependencyNode]
|
|
||||||
edges: list[DependencyEdge]
|
|
||||||
metadata: dict[str, str] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class ToolRequirement(BaseModel):
|
class ToolRequirement(BaseModel):
|
||||||
@@ -81,33 +56,21 @@ class AnalysisError(BaseModel):
|
|||||||
|
|
||||||
class AnalysisInput(BaseModel):
|
class AnalysisInput(BaseModel):
|
||||||
"""Input schema for the analyzer."""
|
"""Input schema for the analyzer."""
|
||||||
repo_path: Path = Field(..., description="Absolute path to the cloned repository")
|
repo: RepoContext
|
||||||
language: Language
|
|
||||||
entrypoint: str = Field(
|
|
||||||
...,
|
|
||||||
description="Relative path from repo_path to the entrypoint file",
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator("repo_path")
|
|
||||||
@classmethod
|
|
||||||
def repo_must_exist(cls, v: Path) -> Path:
|
|
||||||
if not v.is_dir():
|
|
||||||
raise ValueError(f"Repository path does not exist or is not a directory: {v}")
|
|
||||||
return v.resolve()
|
|
||||||
|
|
||||||
@field_validator("entrypoint")
|
|
||||||
@classmethod
|
|
||||||
def entrypoint_must_exist(cls, v: str, info) -> str:
|
|
||||||
repo = info.data.get("repo_path")
|
|
||||||
if repo and not (repo / v).is_file():
|
|
||||||
raise ValueError(f"Entrypoint not found: {repo / v}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class AnalysisResult(BaseModel):
|
class AnalysisResult(BaseModel):
|
||||||
"""Top-level result — either a graph or an error."""
|
"""Top-level result — either a graph or an error."""
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
graph: Optional[DependencyGraph] = None
|
graph: Optional[DependencyGraph] = None
|
||||||
error: Optional[AnalysisError] = None
|
error: Optional[AnalysisError] = None
|
||||||
leaf_clusters: Any
|
leaf_clusters: Any = None
|
||||||
res_all: Any
|
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
|
||||||
@@ -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(...)
|
||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from agents.coders.state import AgentState
|
from agents.coders.state import AgentState
|
||||||
@@ -15,9 +16,13 @@ from common.youtrack_mcp_client import YouTrackMCPClient
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AgentConfig:
|
class AgentConfig:
|
||||||
|
agent_dir: Path
|
||||||
|
|
||||||
# ── project-level ──────────────────────────────────────────
|
# ── project-level ──────────────────────────────────────────
|
||||||
youtrack_project: str
|
youtrack_project: str
|
||||||
gitea_repo: str
|
git_name: str
|
||||||
|
git_email: str
|
||||||
|
gitea_repo: str = ""
|
||||||
gitea_target_branch: str = "main"
|
gitea_target_branch: str = "main"
|
||||||
|
|
||||||
# ── limits (snapshotted into state at run start) ───────────
|
# ── limits (snapshotted into state at run start) ───────────
|
||||||
@@ -39,6 +44,13 @@ class AgentConfig:
|
|||||||
# ── language hints (for Plan) ──────────────────────────────
|
# ── language hints (for Plan) ──────────────────────────────
|
||||||
language_markers: dict[str, list[str]] = None # filled from project
|
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
|
@dataclass
|
||||||
class RunContext:
|
class RunContext:
|
||||||
|
|||||||
Reference in New Issue
Block a user