Coder preparations

This commit is contained in:
2026-09-22 23:21:59 +03:00
parent 3d1df7fee5
commit 1f2dc25e0b
32 changed files with 3205 additions and 26 deletions
+13 -3
View File
@@ -2,6 +2,8 @@ import hashlib
from dataclasses import dataclass, field
from typing import Optional
from pydantic import BaseModel
from contracts.ProjectContext import ProjectContext
@@ -10,8 +12,8 @@ class IssueContext:
issue_id: str
title: str
body: str
acceptance_criteria: str | None
comments: list[dict]
acceptance_criteria: str | None = None
comments: list[IssueComment] = field(default_factory=list)
labels: list[str] = field(default_factory=list)
repo: Optional[str] = None
metadata: dict = field(default_factory=dict)
@@ -44,6 +46,14 @@ class IssueContext:
if self.comments:
parts.append("\n## Discussion / Comments")
for c in self.comments:
parts.append(f"- @{c['author']} ({c['created_at']}): {c['body']}")
parts.append(f"- @{c.author} ({c.created_at}): {c.body}")
return "\n".join(parts)
class IssueComment(BaseModel):
id: str
author: str
body: str
created_at: int
is_agent: bool = False
marker: dict[str, str] = field(default_factory=dict)
+6
View File
@@ -7,6 +7,11 @@ class TeamMemberModel(BaseModel):
role: str
nick: str
class Repo(BaseModel):
id: str
remote_url: str
base_branch: str = "main"
class ProjectContext(BaseModel):
"""Static, per-project knowledge injected into every decomposition."""
project_key: str # e.g. "ARCH"
@@ -22,6 +27,7 @@ class ProjectContext(BaseModel):
deployment: str = "" # "Docker Compose on Hetzner"
project_language: str = "english"
default_responder: str | None = None
repos: list[Repo] = field(default_factory=list)
team: list[TeamMemberModel] = field(default_factory=list)
conventions: list[str] = field(default_factory=list) # free-form notes
extra: dict[str, str] = field(default_factory=dict) # anything else
+113
View File
@@ -0,0 +1,113 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional, Literal, Any
from pydantic import field_validator, BaseModel, Field
@dataclass(frozen=True)
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",
)
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 ToolRequirement(BaseModel):
"""Describes a required external tool and how to install it."""
tool_name: str
command: str = Field(..., description="CLI command that must be on PATH")
install_guide: str = Field(..., description="Markdown installation instructions")
docs_url: Optional[str] = None
class AnalysisError(BaseModel):
"""Structured error returned when analysis cannot proceed."""
error_code: str
message: str
missing_tools: list[ToolRequirement] = Field(default_factory=list)
language: Optional[Language] = None
entrypoint: Optional[str] = None
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
class AnalysisResult(BaseModel):
"""Top-level result — either a graph or an error."""
success: bool
graph: Optional[DependencyGraph] = None
error: Optional[AnalysisError] = None
leaf_clusters: Any
res_all: Any
+8 -1
View File
@@ -1,6 +1,13 @@
from abc import abstractmethod, ABC
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
class _StrictModel(BaseModel):
# Required for OpenAI strict JSON-schema mode: forbids extra keys,
# no defaults, every field required.
model_config = ConfigDict(extra="forbid")
'''
A contract between agents
+16
View File
@@ -0,0 +1,16 @@
from dataclasses import dataclass, field
from typing import Optional, List
from pydantic import BaseModel, Field
from agents.coders.state import AgentState
from contracts.IssueContext import IssueContext
class GatherOutput(BaseModel):
state: AgentState
issue_context: IssueContext
pending_questions: List[str] = Field(default_factory=list)
run_id: str = ""
attempt: int = 0
+89
View File
@@ -0,0 +1,89 @@
from typing import Literal
from pydantic import Field, model_validator
from contracts.base import _StrictModel
class LanguageSnapshot(_StrictModel):
"""
The language the plan commits to, plus the toolchain it will use.
Chosen by Plan from the target files; consumed by Act and Verify.
"""
name: str = Field(
min_length=1,
description="Language name, e.g. 'python', 'typescript', 'go'.",
)
version: str | None = Field(
description="Language version if known, else null.",
)
test_runner: str = Field(
min_length=1,
description="How tests are run, e.g. 'pytest', 'vitest', 'go test'.",
)
formatter: str | None = Field(
description="Formatter command, or null if none.",
)
linter: str | None = Field(
description="Linter command, or null if none.",
)
detected_from: list[str] = Field(
description="Marker files used to detect, e.g. ['pyproject.toml'].",
)
class PlanStep(_StrictModel):
order: int = Field(ge=1, description="1-based ordering of this step.")
file: str = Field(
min_length=1,
description="Repo-relative path (no leading '/', no '..').",
)
change_kind: Literal["create", "modify", "delete"]
description: str = Field(
min_length=1,
description="What changes in this file and why, 1–3 sentences.",
)
class PlanOutput(_StrictModel):
summary: str = Field(
min_length=1, description="1–3 sentence summary of the whole change."
)
steps: list[PlanStep] = Field(
min_length=1, description="Ordered steps. Each step is one file."
)
target_files: list[str] = Field(
min_length=1, description="Unique repo-relative files this plan touches."
)
target_tests: list[str] = Field(
description="Tests that must pass. May be empty for non-code changes."
)
language: LanguageSnapshot
breaks_existing: bool = Field(
description="True if this changes existing behavior a user or caller "
"could rely on.",
)
risk_notes: list[str] = Field(
description="Reasons this might be risky. Non-empty if breaks_existing.",
)
@model_validator(mode="after")
def _consistency(self) -> "PlanOutput":
# 1. target_files must equal the set of files in steps.
step_files = {s.file for s in self.steps}
if set(self.target_files) != step_files:
raise ValueError(
f"target_files {sorted(self.target_files)} must equal "
f"the set of step files {sorted(step_files)}"
)
# 2. breaks_existing implies risk_notes non-empty.
if self.breaks_existing and not self.risk_notes:
raise ValueError("breaks_existing=True requires at least one risk note")
# 3. No absolute paths, no traversal.
for f in self.target_files:
if f.startswith("/") or ".." in f.split("/"):
raise ValueError(f"unsafe path: {f!r}")
return self
+91
View File
@@ -0,0 +1,91 @@
# orchestrator/run_context.py
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from agents.coders.state import AgentState
from common.gitea_mcp_client import GiteaMCPClient
from common.llm_client import LLMClient
from common.time import now_iso, now_iso_plus
from common.youtrack_mcp_client import YouTrackMCPClient
@dataclass(frozen=True)
class AgentConfig:
# ── project-level ──────────────────────────────────────────
youtrack_project: str
gitea_repo: str
gitea_target_branch: str = "main"
# ── limits (snapshotted into state at run start) ───────────
max_plan_attempts: int = 3
max_act_attempts: int = 5
max_ci_self_resolve_attempts: int = 2
max_clarification_rounds: int = 3
max_rework_rounds: int = 2
wall_clock_minutes: int = 60
token_budget: int = 500_000
# ── human-await timers ─────────────────────────────────────
re_ping_after_days: int = 3
abandon_after_days: int = 5
# ── reviewer policy ────────────────────────────────────────
reviewer_fallback: str = "codeowners" # codeowners | blame | on_call
# ── language hints (for Plan) ──────────────────────────────
language_markers: dict[str, list[str]] = None # filled from project
@dataclass
class RunContext:
"""
Everything a handler needs to do its job: the ports it talks to, the
persisted state for this issue, and the identifiers it must stamp into
comments and state.
"""
# ── identifiers ────────────────────────────────────────────
agent_id: str
issue_id: str
run_id: str # one logical attempt; stable across calls
attempt: int # retry counter within the run
# ── ports (outbound systems) ───────────────────────────────
llm: LLMClient
youtrack: YouTrackMCPClient
gitea: GiteaMCPClient
# ── persisted pipeline state for this issue ────────────────
state: AgentState
# ── static config for this run ─────────────────────────────
config: AgentConfig
# ── convenience (thin, no hidden behavior) ─────────────────
def now(self) -> str:
return now_iso()
def now_plus(self, **delta) -> str:
return now_iso_plus(**delta)
def new_run_id() -> str:
"""
Generate a run id: one logical attempt at a task.
Format: run-YYYYMMDD-HHMMSS-<6 hex chars>
Example: run-20260921-114500-a3f1c2
- Timestamp prefix makes ids sortable and human-readable in logs.
- Random suffix prevents collisions when two runs start in the same
second (e.g., parallel issues, fast retries, tests).
- The "run-" prefix makes it obvious what kind of id this is when it
appears next to a comment marker or in a state file.
"""
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
suffix = secrets.token_hex(3) # 3 bytes -> 6 hex chars
return f"run-{ts}-{suffix}"
+56
View File
@@ -0,0 +1,56 @@
# orchestrator/step_result.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
# Terminal reasons are strings, not an enum, so you can add domain-specific
# reasons (e.g. "escalated:ci_infra") without touching this module.
TerminalReason = str
AwaitState = str # must be one of the AWAITING_* names, or "ABANDONED"
@dataclass(frozen=True)
class StepResult:
"""
The outcome of a step handler.
This is control flow, not data. The step's contract *output* lives in
`output`; the fields here tell the runner what to do next. The runner
is the only thing that reads these fields; handlers only construct them.
"""
kind: Literal["continue", "await", "terminal"]
next_step: str | None = None # set iff kind == "continue"
await_state: AwaitState | None = None # set iff kind == "await"
terminal_reason: TerminalReason | None = None # set iff kind == "terminal"
output: Any = None # the step's contract output
# ── constructors ─────────────────────────────────────────────
@classmethod
def continue_to(cls, step: str, output: Any = None) -> "StepResult":
"""Advance to another step in the pipeline."""
return cls(kind="continue", next_step=step, output=output)
@classmethod
def awaiting(cls, state: AwaitState, output: Any = None) -> "StepResult":
"""Stop this call; resume when the await trigger fires."""
if not (state.startswith("AWAITING_") or state == "ABANDONED"):
raise ValueError(f"not an await state: {state!r}")
return cls(kind="await", await_state=state, output=output)
@classmethod
def complete(cls, output: Any = None) -> "StepResult":
"""Terminal: MR merged and task closed."""
return cls(kind="terminal", terminal_reason="completed", output=output)
@classmethod
def abandoned(cls, output: Any = None) -> "StepResult":
"""Terminal: no response within the timeout window."""
return cls(kind="terminal", terminal_reason="abandoned", output=output)
@classmethod
def escalated(cls, reason: str, output: Any = None) -> "StepResult":
"""Terminal: stopping because a human must intervene."""
return cls(kind="terminal", terminal_reason=f"escalated:{reason}",
output=output)
+121
View File
@@ -0,0 +1,121 @@
# contracts/verify_possibility.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal, Protocol, Annotated, Union, Any
from pydantic import BaseModel, ConfigDict, Field
from contracts.base import _StrictModel
from contracts.coders.GatherOutput import GatherOutput
@dataclass(frozen=True)
class VerifyPossibilityInput:
gather: GatherOutput
def sanitize_for_openai(schema: dict[str, Any]) -> dict[str, Any]:
"""
Recursively rewrite a Pydantic-generated JSON schema into the subset
OpenAI strict mode accepts.
- oneOf -> anyOf (OpenAI rejects oneOf)
- discriminator -> removed (OpenAI rejects it; the const fields
already make branches disjoint)
- adds additionalProperties: false to every object missing it
- strips None defaults
"""
return _walk(schema)
def _walk(node: Any) -> Any:
if isinstance(node, list):
return [_walk(x) for x in node]
if not isinstance(node, dict):
return node
# Recurse into every nested schema container first.
for key in ("properties", "$defs", "definitions"):
if key in node and isinstance(node[key], dict):
node[key] = {k: _walk(v) for k, v in node[key].items()}
for key in ("items", "additionalProperties"):
if key in node and isinstance(node[key], (dict, list)):
node[key] = _walk(node[key])
for key in ("anyOf", "allOf"):
if key in node and isinstance(node[key], list):
node[key] = [_walk(v) for v in node[key]]
# ── the two rewrites that matter ──────────────────────────
if "oneOf" in node and isinstance(node["oneOf"], list):
existing = node.get("anyOf", [])
if not isinstance(existing, list):
existing = []
node["anyOf"] = existing + [_walk(v) for v in node["oneOf"]]
node.pop("oneOf")
node.pop("discriminator", None) # OpenAI doesn't accept it
# ── strict-mode hygiene ───────────────────────────────────
if node.get("type") == "object":
node.setdefault("additionalProperties", False)
props = node.get("properties")
if isinstance(props, dict):
# Strict mode: every property must be in required.
node["required"] = list(props.keys())
if node.get("default", object()) is None:
node.pop("default", None)
return node
class Option(_StrictModel):
label: str = Field(min_length=1, max_length=80)
description: str = Field(min_length=1)
tradeoff: str | None = Field(
description="Optional trade-off; null if none.",
)
class _BaseVerdict(_StrictModel):
reasoning: str = Field(
min_length=1,
description="2–5 sentences explaining the verdict.",
)
confidence: float = Field(
ge=0.0, le=1.0,
description="Your honest probability that the verdict is correct.",
)
class FeasibleVerdict(_BaseVerdict):
verdict: Literal["feasible"]
class AmbiguousVerdict(_BaseVerdict):
verdict: Literal["ambiguous"]
question: str = Field(
min_length=1,
description="Exactly one clarifying question that unblocks planning.",
)
class InfeasibleVerdict(_BaseVerdict):
verdict: Literal["infeasible"]
options: list[Option] = Field(
min_length=1, max_length=3,
description="1–3 concrete alternatives.",
)
Verdict = Annotated[
Union[FeasibleVerdict, AmbiguousVerdict, InfeasibleVerdict],
Field(discriminator="verdict"),
]
class VerifyPossibilityOutput(_StrictModel):
"""Top-level object required by OpenAI strict mode."""
result: Verdict = Field(
description="The feasibility verdict.",
)