From 012c735ff52ab985f7f95264225903ab22fb7fda Mon Sep 17 00:00:00 2001 From: za-ek <1@zaek.eu> Date: Sat, 19 Sep 2026 23:47:45 +0300 Subject: [PATCH] backend dev: create plan --- .aiignore | 1 + .gitignore | 2 + CoderAgent.py => agents/CoderAgent.py | 0 PlannerAgent.py => agents/PlannerAgent.py | 0 TesterAgent.py => agents/TesterAgent.py | 0 agents/backend_dev/BackendDeveloperAgent.py | 47 +++++ agents/backend_dev/README.md | 1 + agents/backend_dev/__init__.py | 0 agents/backend_dev/context_builder.py | 150 ++++++++++++++++ agents/backend_dev/decomposer.py | 129 ++++++++++++++ agents/backend_dev/models.py | 128 ++++++++++++++ agents/backend_dev/project_loader.py | 14 ++ agents/backend_dev/validator.py | 27 +++ common/__init__.py | 0 common/as_type.py | 187 ++++++++++++++++++++ common/llm_client.py | 31 ++++ common/youtrack_mcp_client.py | 90 ++++++++++ main.py | 67 ++++++- projects/zArch.yaml | 10 ++ 19 files changed, 882 insertions(+), 2 deletions(-) create mode 100644 .aiignore create mode 100644 .gitignore rename CoderAgent.py => agents/CoderAgent.py (100%) rename PlannerAgent.py => agents/PlannerAgent.py (100%) rename TesterAgent.py => agents/TesterAgent.py (100%) create mode 100644 agents/backend_dev/BackendDeveloperAgent.py create mode 100644 agents/backend_dev/README.md create mode 100644 agents/backend_dev/__init__.py create mode 100644 agents/backend_dev/context_builder.py create mode 100644 agents/backend_dev/decomposer.py create mode 100644 agents/backend_dev/models.py create mode 100644 agents/backend_dev/project_loader.py create mode 100644 agents/backend_dev/validator.py create mode 100644 common/__init__.py create mode 100644 common/as_type.py create mode 100644 common/llm_client.py create mode 100644 common/youtrack_mcp_client.py create mode 100644 projects/zArch.yaml diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.aiignore @@ -0,0 +1 @@ +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0555f7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +projects \ No newline at end of file diff --git a/CoderAgent.py b/agents/CoderAgent.py similarity index 100% rename from CoderAgent.py rename to agents/CoderAgent.py diff --git a/PlannerAgent.py b/agents/PlannerAgent.py similarity index 100% rename from PlannerAgent.py rename to agents/PlannerAgent.py diff --git a/TesterAgent.py b/agents/TesterAgent.py similarity index 100% rename from TesterAgent.py rename to agents/TesterAgent.py diff --git a/agents/backend_dev/BackendDeveloperAgent.py b/agents/backend_dev/BackendDeveloperAgent.py new file mode 100644 index 0000000..4ffc82e --- /dev/null +++ b/agents/backend_dev/BackendDeveloperAgent.py @@ -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) diff --git a/agents/backend_dev/README.md b/agents/backend_dev/README.md new file mode 100644 index 0000000..fa55a82 --- /dev/null +++ b/agents/backend_dev/README.md @@ -0,0 +1 @@ +- Не забыть: после изменений вести CONTRIBUTING.md \ No newline at end of file diff --git a/agents/backend_dev/__init__.py b/agents/backend_dev/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/backend_dev/context_builder.py b/agents/backend_dev/context_builder.py new file mode 100644 index 0000000..fa3a1ad --- /dev/null +++ b/agents/backend_dev/context_builder.py @@ -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 \ No newline at end of file diff --git a/agents/backend_dev/decomposer.py b/agents/backend_dev/decomposer.py new file mode 100644 index 0000000..81751db --- /dev/null +++ b/agents/backend_dev/decomposer.py @@ -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": "", + "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"]) + ] \ No newline at end of file diff --git a/agents/backend_dev/models.py b/agents/backend_dev/models.py new file mode 100644 index 0000000..a933b44 --- /dev/null +++ b/agents/backend_dev/models.py @@ -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) \ No newline at end of file diff --git a/agents/backend_dev/project_loader.py b/agents/backend_dev/project_loader.py new file mode 100644 index 0000000..59e5a61 --- /dev/null +++ b/agents/backend_dev/project_loader.py @@ -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) \ No newline at end of file diff --git a/agents/backend_dev/validator.py b/agents/backend_dev/validator.py new file mode 100644 index 0000000..64a5b4d --- /dev/null +++ b/agents/backend_dev/validator.py @@ -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 \ No newline at end of file diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/as_type.py b/common/as_type.py new file mode 100644 index 0000000..d1e79dd --- /dev/null +++ b/common/as_type.py @@ -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) \ No newline at end of file diff --git a/common/llm_client.py b/common/llm_client.py new file mode 100644 index 0000000..3d6e3d6 --- /dev/null +++ b/common/llm_client.py @@ -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 \ No newline at end of file diff --git a/common/youtrack_mcp_client.py b/common/youtrack_mcp_client.py new file mode 100644 index 0000000..d968509 --- /dev/null +++ b/common/youtrack_mcp_client.py @@ -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 \ No newline at end of file diff --git a/main.py b/main.py index 6d7c6d9..1beeee2 100644 --- a/main.py +++ b/main.py @@ -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)} diff --git a/projects/zArch.yaml b/projects/zArch.yaml new file mode 100644 index 0000000..5c20b27 --- /dev/null +++ b/projects/zArch.yaml @@ -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 \ No newline at end of file