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$/agents/issue_triage/tests" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/projects/agents" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.14 (Agents)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
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
|
||||
1. Add verification step - ensure the issue can be solved by coding
|
||||
2. Add repo download step
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.registry import agent, AgentRegistry
|
||||
from common.gitea_mcp_client import GiteaMCPClient
|
||||
from common.llm_client import LLMClient
|
||||
from common.llm_client import LLMClient, ResponseCache
|
||||
from common.youtrack_mcp_client import YouTrackMCPClient
|
||||
from contracts.IssueContext import IssueContext
|
||||
from contracts.coders.RunContext import RunContext, new_run_id, AgentConfig
|
||||
@@ -60,10 +60,14 @@ class CoderAgent:
|
||||
gitea=self.gitea_mcp,
|
||||
state=gather_result.state,
|
||||
config=AgentConfig(
|
||||
youtrack_project=issue_ctx.issue_id.split(":")[0],
|
||||
gitea_repo=""
|
||||
agent_dir=Path(__file__).resolve().parents[2] / "projects" / "agents" / self.id,
|
||||
youtrack_project=issue_ctx.issue_id.split("-")[0],
|
||||
git_name = "zarch",
|
||||
git_email = "zarch@zaek.eu"
|
||||
),
|
||||
)
|
||||
run_ctx.llm.cache = ResponseCache(run_ctx.config.project_mem_dir() / "init.json")
|
||||
|
||||
step = await handle_verify(
|
||||
VerifyPossibilityInput(gather_result),
|
||||
run_ctx
|
||||
@@ -73,7 +77,7 @@ class CoderAgent:
|
||||
|
||||
step = await handle_plan(
|
||||
PlanInput(gather_result, step.output),
|
||||
llm=self.llm,
|
||||
run_ctx=run_ctx
|
||||
)
|
||||
print(step)
|
||||
|
||||
|
||||
+1
-18
@@ -1,26 +1,9 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepoContext:
|
||||
"""Result object carrying all info a coding agent needs."""
|
||||
repo_dir: Path
|
||||
remote_url: str
|
||||
branch: str
|
||||
base_branch: str
|
||||
head_commit: str
|
||||
is_new_clone: bool
|
||||
is_new_branch: bool
|
||||
original_branch: Optional[str] = None
|
||||
uncommitted_changes: Optional[str] = None # git diff (tracked)
|
||||
untracked_files: list[str] = field(default_factory=list)
|
||||
stash_ref: Optional[str] = None
|
||||
env: dict = field(default_factory=dict)
|
||||
from contracts.RepoContext import RepoContext
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
|
||||
|
||||
+192
-78
@@ -3,54 +3,101 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from agents.codebase_analyst.entrypoint import gather_repo_entrypoint
|
||||
from agents.codebase_analyst.graph_tool import analyze_repository
|
||||
from agents.coders.repo import ensure_repo_ready, RepoContext
|
||||
from common.llm_client import LLMClient
|
||||
from contracts.RepoContext import AnalysisInput
|
||||
from agents.codebase_analyst.graph_tool import analyze_repo_with_cache
|
||||
from agents.codebase_analyst.select_files import format_table, select_files, SelectFilesInput
|
||||
from agents.coders.repo import ensure_repo_ready
|
||||
from contracts.coders.GatherOutput import GatherOutput
|
||||
from contracts.coders.PlanOutput import PlanOutput
|
||||
from contracts.coders.RunContext import RunContext
|
||||
from contracts.coders.VerifyOutput import FeasibleVerdict
|
||||
|
||||
PlanAdapter = TypeAdapter(PlanOutput)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are a senior engineer writing an implementation plan for a coding agent.
|
||||
You are a senior engineer writing an implementation plan that a coding agent \
|
||||
will execute file-by-file.
|
||||
|
||||
You will receive an issue (title, body, acceptance criteria, comments), a \
|
||||
repository context, the feasibility verdict from a prior triage, and a list \
|
||||
of files linked from the issue.
|
||||
## Inputs
|
||||
|
||||
Produce a plan with these properties:
|
||||
You receive:
|
||||
- The issue: title, body, acceptance criteria, comments.
|
||||
- Repository context: remote URL, default branch.
|
||||
- A feasibility verdict from a prior triage step, with reasoning and confidence.
|
||||
- Files linked from the issue, with line numbers and per-file confidence.
|
||||
|
||||
1. **One step per file.** Each step describes a single file change. If two \
|
||||
files must change together, that's two steps.
|
||||
The feasibility verdict is final. Do not re-derive it, do not argue with it, \
|
||||
do not mention it in the plan. It is context, not a question.
|
||||
|
||||
2. **Commit to a language.** Infer the primary language from the repository \
|
||||
context and the files you plan to touch. Choose the test runner, formatter, \
|
||||
and linter idiomatic to that language. Populate `language` accordingly.
|
||||
## Output
|
||||
|
||||
3. **Target files, not file contents.** Describe *what* changes in each file, \
|
||||
not the code itself. The Act step writes the code; you decide the shape.
|
||||
Return ONLY a JSON object matching the schema at the end of this message. No \
|
||||
prose, no markdown fences, no commentary outside the JSON.
|
||||
|
||||
4. **Be honest about breaking changes.** Set `breaks_existing=true` only if \
|
||||
the change modifies behavior that callers or users rely on: public API \
|
||||
changes, schema changes, config default changes, removal of features. \
|
||||
Pure additions, bug fixes, and internal refactors are NOT breaking.
|
||||
## Plan shape
|
||||
|
||||
5. **Risk notes.** If `breaks_existing=true`, list the specific risks. If not, \
|
||||
you may still add notes for reviewer attention (migrations, security, \
|
||||
performance), but this is optional.
|
||||
A plan is an ordered list of steps. Each step targets exactly one file. After \
|
||||
applying a step the repo should still parse and build — if it wouldn't, split \
|
||||
the step.
|
||||
|
||||
Order steps so no step depends on code a later step will write. Independent \
|
||||
steps go smallest-blast-radius first.
|
||||
|
||||
Each step must contain:
|
||||
- `file`: repo-relative path. For a new file, mark it as such.
|
||||
- `change`: 1–4 sentences a reviewer would understand. Name the symbols being \
|
||||
added, modified, or removed. Do NOT paste code — the Act step writes it. You \
|
||||
may name signatures, types, or schema fields where ambiguity matters.
|
||||
- `breaks_existing`: see below.
|
||||
- `risk_notes`: required when `breaks_existing=true`; otherwise optional \
|
||||
reviewer notes (migrations, security, perf).
|
||||
- `verifies`: how we'll know this step worked — a test to run, a file to check, \
|
||||
a command. If nothing automated applies, write `"manual review"`.
|
||||
|
||||
## Acceptance-criteria coverage
|
||||
|
||||
Every acceptance criterion must be satisfied by at least one step. Before \
|
||||
finalizing, walk the AC list and confirm coverage. If an AC cannot be satisfied \
|
||||
by a code change (e.g. "get sign-off"), record it in `uncoverable_criteria` \
|
||||
rather than inventing a step for it.
|
||||
|
||||
## Language and tooling
|
||||
|
||||
Infer the primary language from the repo context and the files you plan to \
|
||||
touch. Set `language` accordingly, and pick the test runner, formatter, and \
|
||||
linter idiomatic to that language. In a polyglot repo, set `language` to the \
|
||||
majority language and use a per-step language override where steps differ.
|
||||
|
||||
## Breaking changes
|
||||
|
||||
Set `breaks_existing=true` only for:
|
||||
- public API changes (signature, return type, thrown errors)
|
||||
- schema or data-format changes
|
||||
- config default changes
|
||||
- feature removal
|
||||
|
||||
Pure additions, bug fixes, and internal refactors are NOT breaking. When in \
|
||||
doubt, prefer `false` and add a `risk_notes` entry. A false positive here costs \
|
||||
more than a false negative.
|
||||
|
||||
## Gaps and ambiguity
|
||||
|
||||
If the issue is ambiguous or the linked files don't cover the surface area, do \
|
||||
not invent. Add a step whose `change` is "investigate X before proceeding" with \
|
||||
`file: null`, or populate `open_questions`. A plan that flags uncertainty is \
|
||||
more useful than one that looks complete.
|
||||
|
||||
## Constraints
|
||||
|
||||
Rules:
|
||||
- Do not plan to modify files outside the repository.
|
||||
- Do not invent files that clearly don't exist unless the plan is to create them.
|
||||
- Do not reference files you haven't seen, unless the step creates them.
|
||||
- Prefer the smallest plan that satisfies the acceptance criteria.
|
||||
- Return ONLY valid JSON matching the schema.
|
||||
- Do not restate the issue. The reader has it.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -64,7 +111,7 @@ class PlanInput:
|
||||
feasibility: FeasibleVerdict
|
||||
|
||||
|
||||
async def handle_plan(inp: PlanInput, *, llm: LLMClient) -> PlanOutput:
|
||||
async def handle_plan(inp: PlanInput, *, run_ctx: RunContext) -> PlanOutput:
|
||||
if not inp.gather.issue_context.project or len(inp.gather.issue_context.project.repos) == 0:
|
||||
raise "No project repo"
|
||||
|
||||
@@ -74,23 +121,24 @@ async def handle_plan(inp: PlanInput, *, llm: LLMClient) -> PlanOutput:
|
||||
"zarch_" + inp.gather.issue_context.issue_id,
|
||||
inp.gather.issue_context.project.repos[0].base_branch,
|
||||
author_name="zarch",
|
||||
author_email="zarch@zaek.eu"
|
||||
author_email="zarch@zaek.eu",
|
||||
)
|
||||
entrypoint_analyze = gather_repo_entrypoint(llm, repo)
|
||||
repo_analyze = analyze_repository(AnalysisInput(
|
||||
repo_path=repo.repo_dir,
|
||||
language=entrypoint_analyze.language,
|
||||
entrypoint=entrypoint_analyze.entrypoint,
|
||||
))
|
||||
|
||||
if repo_analyze.error:
|
||||
raise repo_analyze.error
|
||||
print(repo_analyze)
|
||||
repo_analyze = analyze_repo_with_cache(run_ctx, repo)
|
||||
selected_files = select_files(
|
||||
SelectFilesInput(
|
||||
inp.gather,
|
||||
format_table(repo_analyze.res_all.df.drop(columns=[c for c in {"leaf_no_deps", "isolated", "entry_no_dependents", "language"} if
|
||||
c in repo_analyze.res_all.df.columns])),
|
||||
0
|
||||
),
|
||||
llm=run_ctx.llm
|
||||
)
|
||||
print(selected_files)
|
||||
|
||||
prompt = _build_user_prompt(inp, repo)
|
||||
prompt = _build_user_prompt(inp, repo, selected_files)
|
||||
schema = PlanAdapter.json_schema()
|
||||
|
||||
raw = llm.chat_with_schema(
|
||||
raw = run_ctx.llm.chat_with_schema(
|
||||
prompt,
|
||||
{
|
||||
"type": "json_schema",
|
||||
@@ -122,49 +170,115 @@ def _ensure_dict(raw):
|
||||
return raw
|
||||
|
||||
|
||||
def _build_user_prompt(inp: PlanInput, repo: RepoContext) -> str:
|
||||
g = inp.gather
|
||||
issue = g.issue_context
|
||||
MAX_FILE_LINES = 400
|
||||
MAX_TOTAL_FILE_CHARS = 60_000
|
||||
MIN_FILE_CONFIDENCE = 0.7
|
||||
|
||||
parts: list[str] = []
|
||||
parts.append(f"# Issue {issue.issue_id}: {issue.title}")
|
||||
parts.append("")
|
||||
parts.append("## Description")
|
||||
parts.append(issue.body.strip() or "(empty)")
|
||||
parts.append("")
|
||||
|
||||
parts.append("## Acceptance criteria")
|
||||
def _numbered(path: Path) -> str:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as e:
|
||||
return f"(could not read {path}: {e})"
|
||||
|
||||
lines = text.splitlines()
|
||||
if len(lines) <= MAX_FILE_LINES:
|
||||
return "\n".join(f"{i:>4} {l}" for i, l in enumerate(lines, 1))
|
||||
|
||||
head_n = MAX_FILE_LINES * 2 // 3
|
||||
tail_n = MAX_FILE_LINES - head_n
|
||||
head = lines[:head_n]
|
||||
tail = lines[-tail_n:]
|
||||
elided = len(lines) - head_n - tail_n
|
||||
body = [f"{i:>4} {l}" for i, l in enumerate(head, 1)]
|
||||
body.append(f" ... ({elided} lines elided) ...")
|
||||
body += [f"{i:>4} {l}" for i, l in enumerate(tail, len(lines) - tail_n + 1)]
|
||||
return "\n".join(body)
|
||||
|
||||
|
||||
def _issue_block(issue) -> str:
|
||||
out = [f"# Issue {issue.issue_id}: {issue.title}", "", "## Description",
|
||||
issue.body.strip() or "(empty)", "", "## Acceptance criteria"]
|
||||
if issue.acceptance_criteria:
|
||||
for i, ac in enumerate(issue.acceptance_criteria, 1):
|
||||
parts.append(f"{i}. {ac}")
|
||||
out += [f"{i}. {ac}" for i, ac in enumerate(issue.acceptance_criteria, 1)]
|
||||
else:
|
||||
parts.append("(none stated)")
|
||||
parts.append("")
|
||||
out.append("(none stated)")
|
||||
return "\n".join(out)
|
||||
|
||||
if issue.comments:
|
||||
parts.append("## Prior comments")
|
||||
for c in issue.comments:
|
||||
parts.append(f"- [{c.created_at}] {c.author}: {c.body.strip()}")
|
||||
parts.append("")
|
||||
|
||||
parts.append("## Feasibility verdict (already confirmed)")
|
||||
parts.append(f"Verdict: feasible")
|
||||
parts.append(f"Reasoning: {inp.feasibility.reasoning}")
|
||||
parts.append(f"Confidence: {inp.feasibility.confidence}")
|
||||
parts.append("")
|
||||
def _comments_block(comments) -> str:
|
||||
lines = ["## Prior comments"]
|
||||
lines += [f"- [{c.created_at}] {c.author}: {c.body.strip()}" for c in comments]
|
||||
return "\n".join(lines)
|
||||
|
||||
parts.append("## Repository context")
|
||||
parts.append(f"- url: {repo.remote_url}")
|
||||
parts.append(f"- default branch: {repo.base_branch}")
|
||||
parts.append("")
|
||||
|
||||
parts.append("## Linked files")
|
||||
if g.linked_files:
|
||||
for f in g.linked_files:
|
||||
parts.append(f"- {f}")
|
||||
else:
|
||||
parts.append("(none)")
|
||||
parts.append("")
|
||||
def _feasibility_block(f) -> str:
|
||||
return "\n".join([
|
||||
"## Feasibility verdict (already confirmed)",
|
||||
f"Verdict: feasible",
|
||||
f"Reasoning: {f.reasoning}",
|
||||
f"Confidence: {f.confidence}",
|
||||
])
|
||||
|
||||
parts.append("Produce the plan. Return ONLY the JSON object.")
|
||||
return "\n".join(parts)
|
||||
|
||||
def _repo_block(repo) -> str:
|
||||
return "\n".join([
|
||||
"## Repository context",
|
||||
f"- url: {repo.remote_url}",
|
||||
f"- default branch: {repo.base_branch}",
|
||||
])
|
||||
|
||||
|
||||
def _files_block(repo, selected_files) -> str:
|
||||
kept = [f for f in selected_files.selections if f.confidence >= MIN_FILE_CONFIDENCE]
|
||||
kept.sort(key=lambda f: f.confidence, reverse=True)
|
||||
|
||||
lines = ["## Linked files"]
|
||||
if not kept:
|
||||
lines.append("(none selected above confidence threshold)")
|
||||
return "\n".join(lines)
|
||||
|
||||
budget = MAX_TOTAL_FILE_CHARS
|
||||
for f in kept:
|
||||
if budget <= 0:
|
||||
lines.append(f"- {f.path} (omitted: file budget exhausted)")
|
||||
continue
|
||||
content = _numbered(repo.repo_dir / f.path)
|
||||
if len(content) > budget:
|
||||
content = content[:budget] + "\n ... (truncated) ..."
|
||||
budget -= len(content)
|
||||
lines += [
|
||||
"",
|
||||
f"### `{f.path}` (confidence {f.confidence:.2f})",
|
||||
"```",
|
||||
content,
|
||||
"```",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _instructions_block() -> str:
|
||||
return "\n".join([
|
||||
"## Task",
|
||||
"Produce an implementation plan for the issue above.",
|
||||
"",
|
||||
"Rules:",
|
||||
"- Reference concrete file paths and line numbers from the linked files.",
|
||||
"- Each step must be independently verifiable.",
|
||||
"- Call out any assumptions you're making.",
|
||||
"",
|
||||
"Return ONLY the JSON object matching the plan schema. No prose, no markdown fence.",
|
||||
])
|
||||
|
||||
|
||||
def _build_user_prompt(inp, repo, selected_files) -> str:
|
||||
g = inp.gather
|
||||
blocks = [
|
||||
_issue_block(g.issue_context),
|
||||
_comments_block(g.issue_context.comments) if g.issue_context.comments else None,
|
||||
_feasibility_block(inp.feasibility),
|
||||
_repo_block(repo),
|
||||
_files_block(repo, selected_files),
|
||||
_instructions_block(),
|
||||
]
|
||||
return "\n\n".join(b for b in blocks if b)
|
||||
+59
-1
@@ -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:
|
||||
|
||||
@@ -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 pathlib import Path
|
||||
from typing import Optional, Literal, Any
|
||||
from typing import Optional, Literal, Any, Generic
|
||||
|
||||
from pydantic import field_validator, BaseModel, Field
|
||||
import pandas as pd
|
||||
from pydantic import field_validator, BaseModel, Field, ConfigDict, field_serializer
|
||||
|
||||
from contracts.CodeLanguages import Language
|
||||
from contracts.GraphAnalyzer import AnalyzeResult, DependencyGraph
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass
|
||||
class RepoContext:
|
||||
url: str
|
||||
default_branch: str
|
||||
language_hint: str | None # "python" | "typescript" | "go" | None
|
||||
|
||||
class Language(str, Enum):
|
||||
PYTHON = "python"
|
||||
JAVASCRIPT = "javascript"
|
||||
TYPESCRIPT = "typescript"
|
||||
JAVA = "java"
|
||||
GO = "go"
|
||||
RUST = "rust"
|
||||
CPP = "cpp"
|
||||
C = "c"
|
||||
|
||||
LanguageLiteral = Literal[
|
||||
Language.PYTHON,
|
||||
Language.JAVASCRIPT,
|
||||
Language.TYPESCRIPT,
|
||||
Language.JAVA,
|
||||
Language.GO,
|
||||
Language.RUST,
|
||||
Language.CPP,
|
||||
Language.C,
|
||||
]
|
||||
|
||||
class DependencyNode(BaseModel):
|
||||
"""A single node in the dependency graph."""
|
||||
id: str = Field(..., description="Unique identifier (file path or module name)")
|
||||
label: str = Field(..., description="Human-readable label")
|
||||
language: Language
|
||||
is_external: bool = Field(
|
||||
default=False,
|
||||
description="True if the dependency is outside the repository",
|
||||
)
|
||||
"""Result object carrying all info a coding agent needs."""
|
||||
remote_url: str
|
||||
branch: str
|
||||
base_branch: str
|
||||
head_commit: str
|
||||
is_new_clone: bool
|
||||
is_new_branch: bool
|
||||
repo_dir: Path = Field(..., description="Absolute path to the cloned repository")
|
||||
original_branch: Optional[str] = None
|
||||
uncommitted_changes: Optional[str] = None # git diff (tracked)
|
||||
untracked_files: list[str] = field(default_factory=list)
|
||||
stash_ref: Optional[str] = None
|
||||
env: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class DependencyEdge(BaseModel):
|
||||
"""A directed edge from source → target (source depends on target)."""
|
||||
source: str = Field(..., description="Node id of the dependant")
|
||||
target: str = Field(..., description="Node id of the dependency")
|
||||
kind: str = Field(
|
||||
default="import",
|
||||
description="Edge kind: import, include, inherit, call, etc.",
|
||||
)
|
||||
@field_validator("repo_path")
|
||||
@classmethod
|
||||
def repo_must_exist(cls, v: Path) -> Path:
|
||||
if not v.is_dir():
|
||||
raise ValueError(f"Repository path does not exist or is not a directory: {v}")
|
||||
return v.resolve()
|
||||
|
||||
|
||||
class DependencyGraph(BaseModel):
|
||||
"""Complete dependency graph result."""
|
||||
root: str = Field(..., description="Entrypoint node id")
|
||||
nodes: list[DependencyNode]
|
||||
edges: list[DependencyEdge]
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ToolRequirement(BaseModel):
|
||||
@@ -81,33 +56,21 @@ class AnalysisError(BaseModel):
|
||||
|
||||
class AnalysisInput(BaseModel):
|
||||
"""Input schema for the analyzer."""
|
||||
repo_path: Path = Field(..., description="Absolute path to the cloned repository")
|
||||
language: Language
|
||||
entrypoint: str = Field(
|
||||
...,
|
||||
description="Relative path from repo_path to the entrypoint file",
|
||||
)
|
||||
|
||||
@field_validator("repo_path")
|
||||
@classmethod
|
||||
def repo_must_exist(cls, v: Path) -> Path:
|
||||
if not v.is_dir():
|
||||
raise ValueError(f"Repository path does not exist or is not a directory: {v}")
|
||||
return v.resolve()
|
||||
|
||||
@field_validator("entrypoint")
|
||||
@classmethod
|
||||
def entrypoint_must_exist(cls, v: str, info) -> str:
|
||||
repo = info.data.get("repo_path")
|
||||
if repo and not (repo / v).is_file():
|
||||
raise ValueError(f"Entrypoint not found: {repo / v}")
|
||||
return v
|
||||
repo: RepoContext
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
"""Top-level result — either a graph or an error."""
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
success: bool
|
||||
graph: Optional[DependencyGraph] = None
|
||||
error: Optional[AnalysisError] = None
|
||||
leaf_clusters: Any
|
||||
res_all: Any
|
||||
leaf_clusters: Any = None
|
||||
res_all: AnalyzeResult | None = None
|
||||
|
||||
@field_serializer("leaf_clusters")
|
||||
def _ser_leaf_clusters(self, v: Any, _info):
|
||||
if isinstance(v, pd.DataFrame):
|
||||
return v.to_dict(orient="records")
|
||||
return v # already JSON-safe
|
||||
@@ -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
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user