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