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("/")
|
||||
async def root():
|
||||
@@ -11,3 +57,20 @@ async def root():
|
||||
@app.get("/hello/{name}")
|
||||
async def say_hello(name: str):
|
||||
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