backend dev: create plan
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
projects
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
"""
|
||||||
|
Backend Developer Agent
|
||||||
|
This agent specializes in backend development tasks including:
|
||||||
|
- API development with FastAPI
|
||||||
|
- Database design and management
|
||||||
|
- Server-side programming
|
||||||
|
- System architecture
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
from agents.backend_dev.models import TaskPlan
|
||||||
|
from agents.backend_dev.decomposer import IssueDecomposer
|
||||||
|
from agents.backend_dev.validator import enforce
|
||||||
|
from common.youtrack_mcp_client import IssueNotFound
|
||||||
|
|
||||||
|
|
||||||
|
class BackendDeveloperAgent:
|
||||||
|
def __init__(self, context_builder, llm):
|
||||||
|
self.name = "Backend Developer"
|
||||||
|
self.ctx_builder = context_builder
|
||||||
|
self.decomposer = IssueDecomposer(llm)
|
||||||
|
|
||||||
|
async def plan_issue(self, issue_id: str) -> TaskPlan:
|
||||||
|
ctx = await self.ctx_builder.build(issue_id)
|
||||||
|
if ctx is None:
|
||||||
|
raise IssueNotFound(f"Issue {issue_id} was not found in YouTrack")
|
||||||
|
|
||||||
|
plan = self.decomposer.decompose(ctx)
|
||||||
|
plan.issue_id = ctx.issue_id
|
||||||
|
return enforce(plan)
|
||||||
|
|
||||||
|
def render(self, plan) -> str:
|
||||||
|
lines = [f"# Plan for {plan.issue_id}", plan.summary, ""]
|
||||||
|
for t in plan.tasks:
|
||||||
|
deps = f" (after {', '.join(t.depends_on)})" if t.depends_on else ""
|
||||||
|
lines.append(f"- [{t.kind.value}] {t.id}: {t.title}{deps}")
|
||||||
|
for ac in t.acceptance_criteria:
|
||||||
|
lines.append(f" ✓ {ac}")
|
||||||
|
for q in t.open_questions:
|
||||||
|
lines.append(f" ? {q}")
|
||||||
|
if plan.unknowns:
|
||||||
|
lines.append("\nUnknowns:")
|
||||||
|
lines += [f" - {u}" for u in plan.unknowns]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Не забыть: после изменений вести CONTRIBUTING.md
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# context_builder.py
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from agents.backend_dev.models import IssueContext
|
||||||
|
from agents.backend_dev.project_loader import load_project
|
||||||
|
from common.as_type import _as_dict, _as_list, _first, _extract_base64
|
||||||
|
from common.youtrack_mcp_client import IssueNotFound
|
||||||
|
|
||||||
|
logger = logging.getLogger("context_builder")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
IMAGE_MIME_PREFIX = "image/"
|
||||||
|
MARKDOWN_IMAGE_RE = re.compile(r"!\[[^]]*]\(([^)]+)\)")
|
||||||
|
|
||||||
|
class YouTrackContextBuilder:
|
||||||
|
"""Reads YouTrack issues via MCP tools."""
|
||||||
|
|
||||||
|
def __init__(self, mcp_client, http_client: httpx.AsyncClient):
|
||||||
|
# mcp_client exposes async methods like call_tool("get_issue", {...})
|
||||||
|
self.mcp = mcp_client
|
||||||
|
self.http = http_client
|
||||||
|
|
||||||
|
async def build(self, issue_id: str) -> IssueContext | None:
|
||||||
|
try:
|
||||||
|
issue_raw = await self.mcp.call_tool(
|
||||||
|
"get_issue",
|
||||||
|
{"issueId": issue_id, "briefOutput": False},
|
||||||
|
)
|
||||||
|
logger.debug("TYPE: %s", type(issue_raw)) # use %s placeholder
|
||||||
|
logger.debug("REPR: %s", repr(issue_raw)[:2000])
|
||||||
|
issue = _as_dict(issue_raw)
|
||||||
|
except IssueNotFound:
|
||||||
|
return None # caller decides what to do
|
||||||
|
|
||||||
|
images = await self._fetch_image_base64(issue)
|
||||||
|
|
||||||
|
# comments are optional — missing comments shouldn't kill the plan
|
||||||
|
try:
|
||||||
|
logger.debug(f"Lookup for comments")
|
||||||
|
comments_raw = await self.mcp.call_tool(
|
||||||
|
"get_issue_comments",
|
||||||
|
{"issueId": issue_id, "limit": 10},
|
||||||
|
)
|
||||||
|
logger.debug(f"Found {len(comments_raw)} comments")
|
||||||
|
|
||||||
|
comments = _as_list(comments_raw)
|
||||||
|
except IssueNotFound:
|
||||||
|
comments = []
|
||||||
|
|
||||||
|
project_key = issue_id.split("-", 1)[0]
|
||||||
|
project = load_project(project_key)
|
||||||
|
|
||||||
|
return IssueContext(
|
||||||
|
issue_id=_first(issue, "idReadable", "id") or issue_id,
|
||||||
|
title=_first(issue, "summary", "title", "name", default=""),
|
||||||
|
body=_first(issue, "description", "body", default=""),
|
||||||
|
comments=[
|
||||||
|
{
|
||||||
|
"author": (c.get("author") or {}).get("login", "unknown"),
|
||||||
|
"body": c.get("text") or c.get("body") or "",
|
||||||
|
"created_at": str(c.get("created") or c.get("created_at") or ""),
|
||||||
|
}
|
||||||
|
for c in comments
|
||||||
|
],
|
||||||
|
labels=[
|
||||||
|
t["name"]
|
||||||
|
for t in issue.get("tags", [])
|
||||||
|
if isinstance(t, dict) and t.get("name")
|
||||||
|
],
|
||||||
|
repo=_first(
|
||||||
|
(_first(issue, "project", default={}) or {}),
|
||||||
|
"shortName", "id", "key"
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"custom_fields": _first(issue, "customFields", "fields", default=[]),
|
||||||
|
"url": _first(issue, "url", "selfUrl"),
|
||||||
|
"state": self._extract_field(issue, "State"),
|
||||||
|
},
|
||||||
|
images=images,
|
||||||
|
project=project
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fetch_image_base64(self, issue: dict) -> list[str]:
|
||||||
|
"""Download image attachments referenced by the issue."""
|
||||||
|
attachments = issue.get("attachments") or []
|
||||||
|
if not isinstance(attachments, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
referenced = self._extract_markdown_image_names(issue.get("description") or "")
|
||||||
|
|
||||||
|
images: list[str] = []
|
||||||
|
for att in attachments:
|
||||||
|
if not isinstance(att, dict):
|
||||||
|
continue
|
||||||
|
mime = str(att.get("mimeType", ""))
|
||||||
|
if not mime.startswith("image/"):
|
||||||
|
continue
|
||||||
|
name = att.get("name", "")
|
||||||
|
# Only fetch if it's referenced in the body, or fetch all if body has no refs
|
||||||
|
if referenced and name not in referenced:
|
||||||
|
continue
|
||||||
|
url = att.get("url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
resp = await self.http.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
print(f"Failed to fetch attachment {name}: {e}", flush=True)
|
||||||
|
continue
|
||||||
|
images.append(base64.b64encode(resp.content).decode("ascii"))
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
async def _download_attachment(self, issue_id: str, attachment_id: str) -> str | None:
|
||||||
|
try:
|
||||||
|
result = await self.mcp.call_tool(
|
||||||
|
"issue_attachment_download",
|
||||||
|
{
|
||||||
|
"issueId": issue_id,
|
||||||
|
"attachmentId": attachment_id,
|
||||||
|
"downloadToFile": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
# Server may return base64 directly, or a text block containing base64/URL
|
||||||
|
return _extract_base64(result)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_markdown_image_names(body: str) -> set[str]:
|
||||||
|
return {m.group(1) for m in MARKDOWN_IMAGE_RE.finditer(body)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_field(issue: dict, name: str):
|
||||||
|
fields = issue.get("customFields") or issue.get("custom_fields") or []
|
||||||
|
for f in fields:
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue # skip strings, numbers, None
|
||||||
|
if f.get("name") != name:
|
||||||
|
continue
|
||||||
|
value = f.get("value")
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value.get("name") or value.get("presentation")
|
||||||
|
return value
|
||||||
|
return None
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# decomposer.py
|
||||||
|
import json
|
||||||
|
|
||||||
|
from openai import images
|
||||||
|
|
||||||
|
from agents.backend_dev.models import IssueContext, Task, TaskPlan, TaskKind, Atomicity
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are a senior backend engineer.
|
||||||
|
Your job: given a YouTrack issue, its comments, and the project context,
|
||||||
|
produce the SMALLEST possible sequence of tasks to resolve it.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- The Project Context section is AUTHORITATIVE. Never ask about language,
|
||||||
|
framework, UI library, database, test framework, or conventions — they
|
||||||
|
are already known. If a task would normally need that info, use the
|
||||||
|
values from Project Context directly.
|
||||||
|
- Only include an entry in "unknowns" or "open_questions" if the answer
|
||||||
|
is NOT in the Project Context and NOT in the issue/comments.
|
||||||
|
- Each task must be ATOMIC: one clear action, one owner, doable in <= 1 day.
|
||||||
|
- Cover the full lifecycle: investigation -> design -> schema/API -> implementation -> tests -> docs -> review -> deploy.
|
||||||
|
- Prefer splitting over lumping. If a task contains the word "and", consider splitting.
|
||||||
|
- Order tasks by dependency; use depends_on referencing earlier task ids.
|
||||||
|
- Include open_questions for anything ambiguous from the issue.
|
||||||
|
- Return ONLY JSON matching the schema below.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
{
|
||||||
|
"summary": "<one paragraph>",
|
||||||
|
"unknowns": ["..."],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "t1",
|
||||||
|
"title": "...",
|
||||||
|
"kind": "investigate|design|implement|migrate|test|docs|review|ops",
|
||||||
|
"description": "...",
|
||||||
|
"acceptance_criteria": ["..."],
|
||||||
|
"depends_on": ["t0"],
|
||||||
|
"atomicity": "atomic|needs_split",
|
||||||
|
"estimate_hours": 4,
|
||||||
|
"files_hint": ["src/foo.py"],
|
||||||
|
"open_questions": ["..."]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class IssueDecomposer:
|
||||||
|
def __init__(self, llm, max_depth: int = 3):
|
||||||
|
self.llm = llm # any chat-completions style client
|
||||||
|
self.max_depth = max_depth
|
||||||
|
|
||||||
|
def decompose(self, ctx: IssueContext) -> TaskPlan:
|
||||||
|
plan = self._decompose_once(ctx.to_prompt_text(), image_list=ctx.images)
|
||||||
|
plan = self._recursively_split(plan, depth=0)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
# ---- internals -------------------------------------------------
|
||||||
|
def _decompose_once(self, issue_text: str, extra: str = "", image_list: list[str] | None = None) -> TaskPlan:
|
||||||
|
user = f"{issue_text}\n\n{extra}\n\nProduce the JSON plan."
|
||||||
|
raw = self.llm.chat(system=SYSTEM_PROMPT, user=user, json_mode=True, images=image_list or [])
|
||||||
|
data = json.loads(raw)
|
||||||
|
tasks = [
|
||||||
|
Task(
|
||||||
|
id=t["id"],
|
||||||
|
title=t["title"],
|
||||||
|
kind=TaskKind(t["kind"]),
|
||||||
|
description=t["description"],
|
||||||
|
acceptance_criteria=t.get("acceptance_criteria", []),
|
||||||
|
depends_on=t.get("depends_on", []),
|
||||||
|
atomicity=Atomicity(t.get("atomicity", "atomic")),
|
||||||
|
estimate_hours=t.get("estimate_hours"),
|
||||||
|
files_hint=t.get("files_hint", []),
|
||||||
|
open_questions=t.get("open_questions", []),
|
||||||
|
)
|
||||||
|
for t in data["tasks"]
|
||||||
|
]
|
||||||
|
return TaskPlan(
|
||||||
|
issue_id="", # filled by caller
|
||||||
|
summary=data["summary"],
|
||||||
|
tasks=tasks,
|
||||||
|
unknowns=data.get("unknowns", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _recursively_split(self, plan: TaskPlan, depth: int) -> TaskPlan:
|
||||||
|
if depth >= self.max_depth:
|
||||||
|
return plan
|
||||||
|
|
||||||
|
split_needed = [t for t in plan.tasks if t.atomicity == Atomicity.NEEDS_SPLIT]
|
||||||
|
if not split_needed:
|
||||||
|
return plan
|
||||||
|
|
||||||
|
new_tasks: list[Task] = []
|
||||||
|
for task in plan.tasks:
|
||||||
|
if task.atomicity != Atomicity.NEEDS_SPLIT:
|
||||||
|
new_tasks.append(task)
|
||||||
|
continue
|
||||||
|
|
||||||
|
sub = self._split_task(task, plan)
|
||||||
|
new_tasks.extend(sub)
|
||||||
|
|
||||||
|
plan.tasks = new_tasks
|
||||||
|
return self._recursively_split(plan, depth + 1)
|
||||||
|
|
||||||
|
def _split_task(self, task: Task, plan: TaskPlan) -> list[Task]:
|
||||||
|
prompt = (
|
||||||
|
f"You previously produced a task that is NOT atomic:\n"
|
||||||
|
f"Title: {task.title}\nDescription: {task.description}\n\n"
|
||||||
|
f"Split it into 2–6 atomic subtasks. Preserve dependency order. "
|
||||||
|
f"Return JSON: {{\"tasks\": [...]}} with the same schema."
|
||||||
|
)
|
||||||
|
raw = self.llm.chat(system=SYSTEM_PROMPT, user=prompt, json_mode=True)
|
||||||
|
data = json.loads(raw)
|
||||||
|
return [
|
||||||
|
Task(
|
||||||
|
id=f"{task.id}.{i}",
|
||||||
|
title=t["title"],
|
||||||
|
kind=TaskKind(t["kind"]),
|
||||||
|
description=t["description"],
|
||||||
|
acceptance_criteria=t.get("acceptance_criteria", []),
|
||||||
|
depends_on=t.get("depends_on", []),
|
||||||
|
atomicity=Atomicity(t.get("atomicity", "atomic")),
|
||||||
|
estimate_hours=t.get("estimate_hours"),
|
||||||
|
files_hint=t.get("files_hint", []),
|
||||||
|
open_questions=t.get("open_questions", []),
|
||||||
|
)
|
||||||
|
for i, t in enumerate(data["tasks"])
|
||||||
|
]
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# models.py
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class TaskKind(str, Enum):
|
||||||
|
INVESTIGATE = "investigate" # read code, reproduce, research
|
||||||
|
DESIGN = "design" # API/schema decisions
|
||||||
|
IMPLEMENT = "implement" # write code
|
||||||
|
MIGRATE = "migrate" # DB / data changes
|
||||||
|
TEST = "test" # unit/integration/e2e
|
||||||
|
DOCS = "docs" # docs, changelog
|
||||||
|
REVIEW = "review" # code review, verification
|
||||||
|
OPS = "ops" # deploy, config, feature flag
|
||||||
|
|
||||||
|
|
||||||
|
class Atomicity(str, Enum):
|
||||||
|
ATOMIC = "atomic" # single clear action, one owner, < 1 day
|
||||||
|
NEEDS_SPLIT = "needs_split" # still too big, recurse
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IssueContext:
|
||||||
|
issue_id: str
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
comments: list[dict] # [{"author": ..., "body": ..., "created_at": ...}]
|
||||||
|
labels: list[str] = field(default_factory=list)
|
||||||
|
repo: Optional[str] = None
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
images: list[str] = field(default_factory=list)
|
||||||
|
project: ProjectContext | None = None
|
||||||
|
|
||||||
|
def to_prompt_text(self) -> str:
|
||||||
|
parts = [f"# Issue {self.issue_id}: {self.title}", "", self.body]
|
||||||
|
|
||||||
|
if self.project:
|
||||||
|
parts.append("\n## Project info")
|
||||||
|
parts.append(self.project.to_prompt_text())
|
||||||
|
|
||||||
|
meta_lines = []
|
||||||
|
for key in ("state", "url"):
|
||||||
|
if self.metadata.get(key):
|
||||||
|
meta_lines.append(f"- {key.capitalize()}: {self.metadata[key]}")
|
||||||
|
|
||||||
|
if meta_lines:
|
||||||
|
parts.append("\n## Issue Metadata")
|
||||||
|
parts.extend(meta_lines)
|
||||||
|
|
||||||
|
if self.comments:
|
||||||
|
parts.append("\n## Discussion / Comments")
|
||||||
|
for c in self.comments:
|
||||||
|
parts.append(f"- @{c['author']} ({c['created_at']}): {c['body']}")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Task:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
kind: TaskKind
|
||||||
|
description: str
|
||||||
|
acceptance_criteria: list[str] = field(default_factory=list)
|
||||||
|
depends_on: list[str] = field(default_factory=list)
|
||||||
|
atomicity: Atomicity = Atomicity.ATOMIC
|
||||||
|
estimate_hours: Optional[float] = None
|
||||||
|
files_hint: list[str] = field(default_factory=list) # probable touch points
|
||||||
|
open_questions: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new(**kw) -> "Task":
|
||||||
|
return Task(id=str(uuid.uuid4())[:8], **kw)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskPlan:
|
||||||
|
issue_id: str
|
||||||
|
summary: str
|
||||||
|
tasks: list[Task]
|
||||||
|
unknowns: list[str] = field(default_factory=list)
|
||||||
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectContext:
|
||||||
|
"""Static, per-project knowledge injected into every decomposition."""
|
||||||
|
project_key: str # e.g. "ARCH"
|
||||||
|
language: str = ""
|
||||||
|
backend_framework: str = "" # "FastAPI", "Django", "Spring Boot"
|
||||||
|
frontend_framework: str = "" # "React + Vite", "Vue 3"
|
||||||
|
ui_library: str = "" # "shadcn/ui", "MUI", "Ant Design"
|
||||||
|
database: str = "" # "PostgreSQL 16 via SQLAlchemy 2"
|
||||||
|
orm: str = ""
|
||||||
|
test_framework: str = "" # "pytest + httpx.AsyncClient"
|
||||||
|
package_manager: str = "" # "uv", "poetry", "npm"
|
||||||
|
auth: str = "" # "JWT via fastapi-users"
|
||||||
|
deployment: str = "" # "Docker Compose on Hetzner"
|
||||||
|
conventions: list[str] = field(default_factory=list) # free-form notes
|
||||||
|
extra: dict[str, str] = field(default_factory=dict) # anything else
|
||||||
|
|
||||||
|
def to_prompt_text(self) -> str:
|
||||||
|
lines = [f"## Project Context ({self.project_key})"]
|
||||||
|
fields = [
|
||||||
|
("Language", self.language),
|
||||||
|
("Backend framework", self.backend_framework),
|
||||||
|
("Frontend framework", self.frontend_framework),
|
||||||
|
("UI library", self.ui_library),
|
||||||
|
("Database", self.database),
|
||||||
|
("ORM", self.orm),
|
||||||
|
("Test framework", self.test_framework),
|
||||||
|
("Package manager", self.package_manager),
|
||||||
|
("Auth", self.auth),
|
||||||
|
("Deployment", self.deployment),
|
||||||
|
]
|
||||||
|
for label, value in fields:
|
||||||
|
if value:
|
||||||
|
lines.append(f"- {label}: {value}")
|
||||||
|
for k, v in self.extra.items():
|
||||||
|
if v:
|
||||||
|
lines.append(f"- {k}: {v}")
|
||||||
|
if self.conventions:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Conventions:")
|
||||||
|
lines.extend(f"- {c}" for c in self.conventions)
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import yaml
|
||||||
|
from agents.backend_dev.models import ProjectContext
|
||||||
|
|
||||||
|
|
||||||
|
PROJECTS_DIR = Path(__file__).parent.parent.parent / "projects"
|
||||||
|
|
||||||
|
|
||||||
|
def load_project(project_key: str) -> ProjectContext:
|
||||||
|
path = PROJECTS_DIR / f"{project_key}.yaml"
|
||||||
|
if not path.exists():
|
||||||
|
return ProjectContext(project_key=project_key)
|
||||||
|
data = yaml.safe_load(path.read_text()) or {}
|
||||||
|
return ProjectContext(**data)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# validator.py
|
||||||
|
from agents.backend_dev.models import Task, TaskPlan, Atomicity
|
||||||
|
|
||||||
|
MAX_HOURS = 8
|
||||||
|
VAGUE_VERBS = ("handle", "support", "improve", "refactor", "manage", "deal with")
|
||||||
|
|
||||||
|
|
||||||
|
def validate(task: Task) -> list[str]:
|
||||||
|
issues = []
|
||||||
|
if task.estimate_hours and task.estimate_hours > MAX_HOURS:
|
||||||
|
issues.append(f"estimate {task.estimate_hours}h > {MAX_HOURS}h")
|
||||||
|
if " and " in task.title.lower():
|
||||||
|
issues.append("title contains 'and' — likely two tasks")
|
||||||
|
if any(v in task.title.lower() for v in VAGUE_VERBS):
|
||||||
|
issues.append("vague verb — needs concrete action")
|
||||||
|
if not task.acceptance_criteria:
|
||||||
|
issues.append("missing acceptance criteria")
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def enforce(plan: TaskPlan) -> TaskPlan:
|
||||||
|
for t in plan.tasks:
|
||||||
|
problems = validate(t)
|
||||||
|
if problems:
|
||||||
|
t.atomicity = Atomicity.NEEDS_SPLIT
|
||||||
|
t.open_questions.extend(problems)
|
||||||
|
return plan
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp_types import TextContent
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict(result: Any) -> dict:
|
||||||
|
"""Normalize MCP call_tool output to a dict."""
|
||||||
|
if result is None:
|
||||||
|
raise ValueError("MCP returned None")
|
||||||
|
|
||||||
|
# Direct dict
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not isinstance(result, list):
|
||||||
|
raise ValueError(f"Unexpected MCP response shape: {type(result)}")
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise ValueError("MCP returned an empty list")
|
||||||
|
|
||||||
|
# Find the first text block (dict or TextContent object)
|
||||||
|
text: str | None = None
|
||||||
|
for block in result:
|
||||||
|
if isinstance(block, TextContent):
|
||||||
|
text = block.text
|
||||||
|
break
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
text = block.get("text")
|
||||||
|
break
|
||||||
|
# Last-resort: the block itself is structured data
|
||||||
|
if isinstance(block, dict) and "id" in block:
|
||||||
|
return block
|
||||||
|
|
||||||
|
if text is None:
|
||||||
|
raise ValueError(f"No text block found in {repr(result)[:300]}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Text block is not JSON: {text[:300]}") from e
|
||||||
|
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
|
||||||
|
return parsed[0]
|
||||||
|
raise ValueError(f"Parsed JSON is neither dict nor list[dict]: {type(parsed)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(result: Any) -> list[dict]:
|
||||||
|
if result is None:
|
||||||
|
return []
|
||||||
|
if isinstance(result, list) and result and isinstance(result[0], TextContent):
|
||||||
|
text = result[0].text
|
||||||
|
elif isinstance(result, list) and result and isinstance(result[0], dict) and "type" in result[0]:
|
||||||
|
text = result[0].get("text", "")
|
||||||
|
elif isinstance(result, list):
|
||||||
|
return result # already a list of dicts
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unexpected MCP response shape: {type(result)}")
|
||||||
|
|
||||||
|
parsed = json.loads(text)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return parsed
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
for key in ("items", "comments", "issues", "results"):
|
||||||
|
if isinstance(parsed.get(key), list):
|
||||||
|
return parsed[key]
|
||||||
|
return [parsed]
|
||||||
|
raise ValueError(f"Parsed JSON is not a list: {type(parsed)}")
|
||||||
|
|
||||||
|
def _first(d: dict, *keys, default=None):
|
||||||
|
for k in keys:
|
||||||
|
if k in d and d[k] is not None:
|
||||||
|
return d[k]
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _extract_base64(result: Any) -> str | None:
|
||||||
|
"""Pull a base64 image payload out of an MCP call_tool result.
|
||||||
|
|
||||||
|
Handles all the shapes a YouTrack MCP server might return:
|
||||||
|
- ImageContent block (has .data and .mimeType)
|
||||||
|
- TextContent block with raw base64 or a JSON wrapper
|
||||||
|
- dict with {"data": "..."} or {"content": "..."}
|
||||||
|
- plain string of base64
|
||||||
|
"""
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Plain string
|
||||||
|
if isinstance(result, str):
|
||||||
|
return result if _looks_like_base64(result) else None
|
||||||
|
|
||||||
|
# Unwrap CallToolResult-like objects
|
||||||
|
content = getattr(result, "content", None)
|
||||||
|
if content is not None:
|
||||||
|
return _extract_base64(content)
|
||||||
|
|
||||||
|
# List of content blocks
|
||||||
|
if isinstance(result, list):
|
||||||
|
for block in result:
|
||||||
|
got = _extract_base64(block)
|
||||||
|
if got:
|
||||||
|
return got
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Dict wrapper
|
||||||
|
if isinstance(result, dict):
|
||||||
|
# Image content: {"type": "image", "data": "...", "mimeType": "..."}
|
||||||
|
if result.get("type") == "image" and result.get("data"):
|
||||||
|
return result["data"]
|
||||||
|
|
||||||
|
# Direct data field
|
||||||
|
for key in ("data", "content", "base64", "image"):
|
||||||
|
if isinstance(result.get(key), str):
|
||||||
|
val = result[key]
|
||||||
|
if _looks_like_base64(val):
|
||||||
|
return val
|
||||||
|
|
||||||
|
# Text wrapper whose text is base64 or JSON containing base64
|
||||||
|
text = result.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
return _extract_base64(parsed)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return text if _looks_like_base64(text) else None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# SDK TextContent / ImageContent objects (attribute access)
|
||||||
|
if isinstance(result, TextContent):
|
||||||
|
text = result.text
|
||||||
|
try:
|
||||||
|
return _extract_base64(json.loads(text))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return text if _looks_like_base64(text) else None
|
||||||
|
|
||||||
|
# ImageContent has .data and .mime_type (or .mimeType)
|
||||||
|
if hasattr(result, "data") and hasattr(result, "type") and getattr(result, "type") == "image":
|
||||||
|
return getattr(result, "data")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
B64_RE = re.compile(r"^[A-Za-z0-9+/=\s]+$")
|
||||||
|
def _looks_like_base64(s: str) -> bool:
|
||||||
|
"""Very cheap check — base64 has no whitespace-heavy content and uses a limited alphabet."""
|
||||||
|
s = s.strip()
|
||||||
|
if len(s) < 32:
|
||||||
|
return False
|
||||||
|
return bool(B64_RE.match(s))
|
||||||
|
|
||||||
|
async def _fetch_image_base64(self, issue_id: str, attachment_id: str) -> str:
|
||||||
|
result = await self.mcp.call_tool(
|
||||||
|
"issue_attachment_download",
|
||||||
|
{"issueId": issue_id, "attachmentId": attachment_id, "downloadToFile": False},
|
||||||
|
)
|
||||||
|
# Result contains base64 or a URL you can fetch
|
||||||
|
return result # base64 string
|
||||||
|
|
||||||
|
def _extract_text(result: Any) -> str:
|
||||||
|
"""Pull a single string out of an MCP result, whichever shape it is."""
|
||||||
|
content = getattr(result, "content", None) or result
|
||||||
|
if isinstance(content, list) and content:
|
||||||
|
block = content[0]
|
||||||
|
if isinstance(block, dict):
|
||||||
|
return block.get("text") or json.dumps(block)
|
||||||
|
if isinstance(content, dict):
|
||||||
|
return content.get("text") or json.dumps(content)
|
||||||
|
return str(content)
|
||||||
|
|
||||||
|
|
||||||
|
NOT_FOUND_HINTS = (
|
||||||
|
"not found",
|
||||||
|
"does not exist",
|
||||||
|
"doesn't exist",
|
||||||
|
"no such",
|
||||||
|
"unknown issue",
|
||||||
|
"404",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_not_found(text: str) -> bool:
|
||||||
|
lowered = text.lower()
|
||||||
|
return any(h in lowered for h in NOT_FOUND_HINTS)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import json
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
def __init__(self, base_url, api_key: str | None = None, model: str = "gpt-4o-mini"):
|
||||||
|
self.model = model
|
||||||
|
self.client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
|
||||||
|
|
||||||
|
def chat(self, system: str, user: str, json_mode: bool = False, images: list[str] | None = None) -> str | None:
|
||||||
|
images = images or []
|
||||||
|
if images:
|
||||||
|
content = [{"type": "text", "text": user}]
|
||||||
|
for b64 in images:
|
||||||
|
content.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/png;base64,{b64}"},
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
content = user
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": content},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if json_mode:
|
||||||
|
kwargs["response_format"] = {"type": "json_object"}
|
||||||
|
resp = self.client.chat.completions.create(**kwargs)
|
||||||
|
return resp.choices[0].message.content
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from contextlib import AsyncExitStack
|
||||||
|
from mcp import Client, MCPError
|
||||||
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
|
from common.as_type import _extract_text, _looks_like_not_found
|
||||||
|
|
||||||
|
logger = logging.getLogger("youtrack_mcp")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
class IssueNotFound(Exception):
|
||||||
|
"""Raised when a YouTrack issue doesn't exist or isn't accessible."""
|
||||||
|
|
||||||
|
class YouTrackMCPClient:
|
||||||
|
"""Wraps an MCP connection to YouTrack, owning the lifecycle in a dedicated task."""
|
||||||
|
|
||||||
|
def __init__(self, endpoint_url: str, token: str, proxy: str | None = None):
|
||||||
|
self.endpoint_url = endpoint_url
|
||||||
|
self.token = token
|
||||||
|
self.proxy = proxy
|
||||||
|
self._client = None
|
||||||
|
self._lifecycle_task = None
|
||||||
|
self._ready = asyncio.Event()
|
||||||
|
self._stop = asyncio.Event()
|
||||||
|
|
||||||
|
async def _run(self):
|
||||||
|
"""Single task that owns the entire MCP client lifecycle."""
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
http = await stack.enter_async_context(
|
||||||
|
httpx.AsyncClient(
|
||||||
|
headers={"Authorization": f"Bearer {self.token}"},
|
||||||
|
proxy=self.proxy,
|
||||||
|
timeout=httpx.Timeout(30.0, connect=10.0, read=300.0),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport = streamable_http_client(
|
||||||
|
self.endpoint_url,
|
||||||
|
http_client=http,
|
||||||
|
)
|
||||||
|
self._client = await stack.enter_async_context(Client(transport))
|
||||||
|
self._ready.set()
|
||||||
|
await self._stop.wait() # keep the context alive
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Start the lifecycle task and wait until the client is ready."""
|
||||||
|
self._lifecycle_task = asyncio.create_task(self._run())
|
||||||
|
await self._ready.wait()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Signal the lifecycle task to exit and wait for it to finish."""
|
||||||
|
if self._lifecycle_task is None:
|
||||||
|
return
|
||||||
|
self._stop.set()
|
||||||
|
await self._lifecycle_task
|
||||||
|
self._lifecycle_task = None
|
||||||
|
|
||||||
|
async def call_tool(self, name: str, arguments: dict):
|
||||||
|
logger.debug("MCP CALL: %s with %s", name, arguments)
|
||||||
|
result = await self._call_tool_or_raise(name, arguments)
|
||||||
|
logger.debug("MCP RAW RESULT: %s", repr(result)[:500])
|
||||||
|
return result.structured_content or result.content
|
||||||
|
|
||||||
|
async def _call_tool_or_raise(self, name: str, arguments: dict):
|
||||||
|
try:
|
||||||
|
result = await self._client.call_tool(name, arguments)
|
||||||
|
logger.debug("call RESULT: %s", result)
|
||||||
|
except MCPError as e:
|
||||||
|
# Drill into cause chain for HTTP status
|
||||||
|
cause = e.__cause__
|
||||||
|
while cause:
|
||||||
|
if isinstance(cause, httpx.HTTPStatusError):
|
||||||
|
if cause.response.status_code == 404:
|
||||||
|
raise IssueNotFound(f"{arguments} not found") from e
|
||||||
|
raise
|
||||||
|
cause = cause.__cause__
|
||||||
|
raise
|
||||||
|
|
||||||
|
# MCP-level error flag
|
||||||
|
if getattr(result, "is_error", False):
|
||||||
|
text = _extract_text(result)
|
||||||
|
if _looks_like_not_found(text):
|
||||||
|
raise IssueNotFound(text)
|
||||||
|
raise RuntimeError(f"MCP tool {name} failed: {text}")
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -1,7 +1,53 @@
|
|||||||
from fastapi import FastAPI
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
app = FastAPI()
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from agents.backend_dev.BackendDeveloperAgent import BackendDeveloperAgent
|
||||||
|
from agents.backend_dev.context_builder import YouTrackContextBuilder
|
||||||
|
from common.llm_client import LLMClient
|
||||||
|
from common.youtrack_mcp_client import YouTrackMCPClient, IssueNotFound
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
def env(key: str) -> str:
|
||||||
|
value = os.environ.get(key)
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(f"Missing required env var: {key}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
mcp = await YouTrackMCPClient(
|
||||||
|
str(os.getenv('YOUTRACK_MCP_SERVER')),
|
||||||
|
str(os.getenv('YOUTRACK_MCP_TOKEN')),
|
||||||
|
).connect()
|
||||||
|
|
||||||
|
http_for_attachments = httpx.AsyncClient(
|
||||||
|
headers={"Authorization": f"Bearer {env('YOUTRACK_MCP_TOKEN')}"},
|
||||||
|
proxy=env("HTTPS_PROXY"),
|
||||||
|
timeout=httpx.Timeout(30.0, connect=10.0, read=60.0),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.backend_agent = BackendDeveloperAgent(
|
||||||
|
YouTrackContextBuilder(mcp, http_for_attachments),
|
||||||
|
LLMClient(
|
||||||
|
base_url=env("LLM_ADDRESS"),
|
||||||
|
api_key=env("LLM_API_KEY"),
|
||||||
|
model=env("LLM_MODEL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# ---- shutdown ----
|
||||||
|
await mcp.close()
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
@@ -11,3 +57,20 @@ async def root():
|
|||||||
@app.get("/hello/{name}")
|
@app.get("/hello/{name}")
|
||||||
async def say_hello(name: str):
|
async def say_hello(name: str):
|
||||||
return {"message": f"Hello {name}"}
|
return {"message": f"Hello {name}"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/backend")
|
||||||
|
async def backend_task(task: str):
|
||||||
|
agent: BackendDeveloperAgent = app.state.backend_agent
|
||||||
|
try:
|
||||||
|
logger.info(f"Received task: {task}")
|
||||||
|
plan = await agent.plan_issue(task)
|
||||||
|
logger.info("Task planning successful.")
|
||||||
|
print(agent.render(plan))
|
||||||
|
return {"plan": plan}
|
||||||
|
except IssueNotFound:
|
||||||
|
logger.error(f"Issue {task} not found.")
|
||||||
|
raise HTTPException(status_code=404, detail=f"Issue {task} not found")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("An unexpected error occurred")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
project_key: ARCH
|
||||||
|
language: typescript + javascript
|
||||||
|
backend_framework: node js + supabase
|
||||||
|
frontend_framework: React typescript
|
||||||
|
test_framework: puppeteer
|
||||||
|
package_manager: npm
|
||||||
|
auth: JWT via supabase
|
||||||
|
deployment: standalone node script start.js and two node servers (one for screenshots and one for backend)
|
||||||
|
conventions:
|
||||||
|
- For styles styled-components only is used
|
||||||
Reference in New Issue
Block a user