Coder preparations
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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