Coder preparations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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.",
|
||||
)
|
||||
Reference in New Issue
Block a user