backend dev: create plan

This commit is contained in:
2026-09-19 23:47:45 +03:00
parent eb84fb96af
commit 012c735ff5
19 changed files with 882 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
.env
+2
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
- Не забыть: после изменений вести CONTRIBUTING.md
View File
+150
View File
@@ -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
+129
View File
@@ -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"])
]
+128
View File
@@ -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)
+14
View File
@@ -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)
+27
View File
@@ -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
View File
+187
View File
@@ -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)
+31
View File
@@ -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
+90
View File
@@ -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
+65 -2
View File
@@ -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)}
+10
View File
@@ -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