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
+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.",
)