diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eaab2b6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.aiassistant +.env +.git/ +.gitignore +.idea/ +.vscode/ +.venv/ +venv/ +projects-data +CHANGELOG.md +__pycache__ +deployment +projects +test_main.http +role_id +wrapped_secret_id \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0555f7b..ad421e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .env -projects \ No newline at end of file +projects +role_id +wrapped_secret_id \ No newline at end of file diff --git a/.idea/Agents.iml b/.idea/Agents.iml index 9d65d31..e0d6de8 100644 --- a/.idea/Agents.iml +++ b/.idea/Agents.iml @@ -6,8 +6,9 @@ + - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index c1fc2a9..1bec7e5 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,5 +3,5 @@ - + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f18c515 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +### Сборка +```shell +docker build -t triage_agent -f deployment/Dockerfile . +``` + +### Запуск +```shell +./deployment/generate-wrapped-secret-id.sh && docker compose -f deployment/docker-compose.yml up --build +``` \ No newline at end of file diff --git a/agents/codebase_analyst/graph_tool.py b/agents/codebase_analyst/graph_tool.py index eda997c..dadcb74 100644 --- a/agents/codebase_analyst/graph_tool.py +++ b/agents/codebase_analyst/graph_tool.py @@ -751,7 +751,7 @@ def analyze_repository(inp: AnalysisInput, run_ctx: RunContext) -> AnalysisResul return AnalysisResult(success=True, graph=graph, res_all=res_all, leaf_clusters=leaf_clusters) -def to_networkx(dep_graph: "DependencyGraph", include_external: bool = False) -> nx.DiGraph[DependencyNode]: +def to_networkx(dep_graph: "DependencyGraph", include_external: bool = False) -> "nx.DiGraph[DependencyNode]": """ Convert a DependencyGraph into a networkx DiGraph. @@ -792,7 +792,7 @@ COLS = [ def analyze( - G: nx.DiGraph[DependencyNode], + G: "nx.DiGraph[DependencyNode]", graph: DependencyGraph, # NOTE: currently unused in the body label: str = "", ) -> AnalyzeResult: diff --git a/agents/codebase_analyst/start.py b/agents/codebase_analyst/start.py index e77d8f3..c5a436f 100644 --- a/agents/codebase_analyst/start.py +++ b/agents/codebase_analyst/start.py @@ -13,8 +13,10 @@ import sys from pathlib import Path from typing import List, Dict, Any import requests -from playwright.sync_api import sync_playwright from agents.architect_ts.analyzer import CodeAnalyzer +from agents.coder.CoderAgent import CoderAgent +from agents.solution_architect.PlannerAgent import PlannerAgent +from agents.tester_web.TesterAgent import TesterAgent # ---------- Конфигурация ---------- CONFIG_FILE = "config.json" diff --git a/agents/coder/CoderAgent.py b/agents/coder/CoderAgent.py index 40ee545..e962b83 100644 --- a/agents/coder/CoderAgent.py +++ b/agents/coder/CoderAgent.py @@ -1,3 +1,9 @@ +import os +from typing import List + +from agents.codebase_analyst.start import Project + + class CoderAgent: """Агент-исполнитель: выполняет подзадачу, генерирует код и применяет изменения.""" diff --git a/agents/coders/state.py b/agents/coders/state.py index 98a711a..ca1fcb5 100644 --- a/agents/coders/state.py +++ b/agents/coders/state.py @@ -108,7 +108,7 @@ class AgentState(BaseModel): } @classmethod - def from_dict(cls, data: Dict[str, Any], state_file: Path) -> AgentState: + def from_dict(cls, data: Dict[str, Any], state_file: Path) -> "AgentState": known = { "schema_version", "issue_id", "agent_id", "state", "updated_at", "last_processed_comment_id", "pending_question_comment_id", diff --git a/agents/issue_triage/IssueTriageAgent.py b/agents/issue_triage/IssueTriageAgent.py index 8dd37bd..c272228 100644 --- a/agents/issue_triage/IssueTriageAgent.py +++ b/agents/issue_triage/IssueTriageAgent.py @@ -14,7 +14,7 @@ from agents.issue_triage.triage import render_validation_failure, render_plan_co from agents.registry import agent, AgentRegistry, Capability from common.llm_client import LLMClient from common.youtrack_mcp_client import IssueNotFound, YouTrackMCPClient -from contracts.IssueContext import IssueContext, IssueComment +from contracts.IssueContext import IssueContext logger = logging.getLogger("triage_agent") logger.setLevel(logging.DEBUG) @@ -23,7 +23,7 @@ logger.setLevel(logging.DEBUG) class TriageContext: issue: IssueContext registry: AgentRegistry - triage_agent: IssueTriageAgent + triage_agent: "IssueTriageAgent" async def run_triage(ctx: TriageContext) -> TriageMarker: diff --git a/agents/solution_architect/PlannerAgent.py b/agents/solution_architect/PlannerAgent.py index d6cc321..35f5c1f 100644 --- a/agents/solution_architect/PlannerAgent.py +++ b/agents/solution_architect/PlannerAgent.py @@ -1,3 +1,9 @@ +import json +from typing import List, Dict + +from agents.codebase_analyst.start import Project + + class PlannerAgent: """Агент-планировщик: анализирует проект и создаёт план подзадач.""" diff --git a/agents/tester_web/TesterAgent.py b/agents/tester_web/TesterAgent.py index 6e64c5c..615f72f 100644 --- a/agents/tester_web/TesterAgent.py +++ b/agents/tester_web/TesterAgent.py @@ -1,3 +1,8 @@ +import time +from typing import List, Dict, Any + +from playwright.sync_api import sync_playwright + from agents.codebase_analyst.start import Project diff --git a/common/hot_env.py b/common/hot_env.py new file mode 100644 index 0000000..5abaa1a --- /dev/null +++ b/common/hot_env.py @@ -0,0 +1,82 @@ +import asyncio +import hashlib +import logging +import os +import time +from pathlib import Path + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +ENV_PATH = Path(os.environ.get("VAULT_ENV_PATH", "/vault-secrets/.env")) +MAX_WAIT_SECONDS = 30 +WATCH_INTERVAL_SECONDS = 5 + +def _file_hash() -> str | None: + if not ENV_PATH.exists(): + return None + try: + return hashlib.sha256(ENV_PATH.read_bytes()).hexdigest() + except OSError: + return None + + +def _parse_env_file(path: Path) -> dict[str, str]: + result: dict[str, str] = {} + with path.open() as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + result[key.strip()] = value.strip().strip('"').strip("'") + return result + + +def wait_for_env_file(timeout: int = MAX_WAIT_SECONDS) -> None: + """Блокирующее ожидание появления .env (для entrypoint до старта uvicorn).""" + start = time.time() + while not ENV_PATH.exists(): + if time.time() - start > timeout: + raise RuntimeError( + f"Vault Agent did not render {ENV_PATH} within {timeout}s" + ) + logger.info("Waiting for %s ...", ENV_PATH) + time.sleep(0.5) + time.sleep(1.5) + + +def load_env(force: bool = False) -> bool: + if not ENV_PATH.exists(): + return False + + try: + values = _parse_env_file(ENV_PATH) + except OSError as e: + logger.warning("Failed to read %s: %s", ENV_PATH, e) + return False + + for key, value in values.items(): + if force or key not in os.environ: + os.environ[key] = value + + global _last_hash + _last_hash = _file_hash() + return True + + +def reload_if_changed() -> bool: + current = _file_hash() + if current is None or current == _last_hash: + return False + logger.info("Detected change in %s, reloading secrets", ENV_PATH) + return load_env(force=True) + + +async def watch_env_file(interval: int = WATCH_INTERVAL_SECONDS) -> None: + while True: + try: + reload_if_changed() + except Exception: + logger.exception("env watcher failed") + await asyncio.sleep(interval) diff --git a/common/singleton.py b/common/singleton.py new file mode 100644 index 0000000..bfdbeb5 --- /dev/null +++ b/common/singleton.py @@ -0,0 +1,107 @@ +import asyncio +import logging +from typing import Optional + +import httpx + +from agents.issue_triage.IssueTriageAgent import IssueTriageAgent +from agents.issue_triage.context_builder import YouTrackContextBuilder +from agents.registry import AgentRegistry +from common.llm_client import LLMClient +from common.youtrack_mcp_client import YouTrackMCPClient + +logger = logging.getLogger(__name__) + + +class AgentSingleton: + """ + Синглтон для IssueTriageAgent и его зависимостей. + Потокобезопасен через asyncio.Lock — инициализация не гоняется. + """ + + _instance: Optional["AgentSingleton"] = None + _lock = asyncio.Lock() + + def __init__(self) -> None: + self._agent: Optional[IssueTriageAgent] = None + self._youtrack_mcp: Optional[YouTrackMCPClient] = None + self._http_for_attachments: Optional[httpx.AsyncClient] = None + self._initialized = False + + @classmethod + async def get(cls) -> "AgentSingleton": + if cls._instance is None: + async with cls._lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + async def ensure_initialized(self) -> None: + """Инициализация при первом обращении или после reset().""" + if self._initialized: + return + async with self._lock: + if self._initialized: + return + await self._initialize() + self._initialized = True + + async def _initialize(self) -> None: + from main import env, env_optional # локальный импорт, чтобы не плодить циклы + + logger.info("Initializing AgentSingleton (or re-initializing)") + + self._youtrack_mcp = await YouTrackMCPClient( + env("YOUTRACK_MCP_SERVER"), + env("YOUTRACK_MCP_TOKEN"), + ).connect() + + self._http_for_attachments = httpx.AsyncClient( + headers={"Authorization": f"Bearer {env('YOUTRACK_MCP_TOKEN')}"}, + proxy=env_optional("HTTPS_PROXY"), + timeout=httpx.Timeout(30.0, connect=10.0, read=60.0), + follow_redirects=True, + ) + + self._agent = IssueTriageAgent( + YouTrackContextBuilder(self._youtrack_mcp, self._http_for_attachments), + LLMClient( + base_url=env("LLM_ADDRESS"), + api_key=env("LLM_API_KEY"), + model=env("LLM_MODEL"), + ), + AgentRegistry(), + self._youtrack_mcp, + ) + + @property + def agent(self) -> IssueTriageAgent: + if self._agent is None: + raise RuntimeError("AgentSingleton not initialized. Call ensure_initialized() first.") + return self._agent + + @property + def youtrack_mcp(self) -> YouTrackMCPClient: + if self._youtrack_mcp is None: + raise RuntimeError("AgentSingleton not initialized.") + return self._youtrack_mcp + + async def reset(self) -> None: + """Закрывает старые ресурсы и помечает синглтон как неинициализированный.""" + async with self._lock: + logger.info("Resetting AgentSingleton (secrets rotated?)") + if self._http_for_attachments is not None: + await self._http_for_attachments.aclose() + if self._youtrack_mcp is not None: + await self._youtrack_mcp.close() + self._agent = None + self._youtrack_mcp = None + self._http_for_attachments = None + self._initialized = False + + @classmethod + async def destroy(cls) -> None: + """Полное уничтожение синглтона — для shutdown.""" + if cls._instance is not None: + await cls._instance.reset() + cls._instance = None \ No newline at end of file diff --git a/contracts/IssueContext.py b/contracts/IssueContext.py index ed5e290..da467bb 100644 --- a/contracts/IssueContext.py +++ b/contracts/IssueContext.py @@ -7,6 +7,15 @@ from pydantic import BaseModel from contracts.ProjectContext import ProjectContext +class IssueComment(BaseModel): + id: str + author: str + body: str + created_at: int + is_agent: bool = False + marker: dict[str, str] = field(default_factory=dict) + + @dataclass class IssueContext: issue_id: str @@ -49,11 +58,3 @@ class IssueContext: parts.append(f"- @{c.author} ({c.created_at}): {c.body}") return "\n".join(parts) - -class IssueComment(BaseModel): - id: str - author: str - body: str - created_at: int - is_agent: bool = False - marker: dict[str, str] = field(default_factory=dict) diff --git a/contracts/Task.py b/contracts/Task.py index 5bfbc5d..73d5096 100644 --- a/contracts/Task.py +++ b/contracts/Task.py @@ -1,7 +1,7 @@ import uuid from dataclasses import dataclass, field from enum import Enum -from typing import Optional +from typing import Optional, Literal from contracts.base import BaseContract @@ -58,7 +58,7 @@ class Task(BaseContract): lines = [ f"# Task: {self.description}", f"# Before start you must ensure that another tasks have been done: {', '.join(self.depends_on)}" if self.depends_on else "", - f"# Acceptance criteria: \n{"\n - ".join(self.acceptance_criteria)}", + f"# Acceptance criteria: \n{"\n - ".join(self.acceptance_criteria)}" ] return "\n".join(lines) diff --git a/deployment/Dockerfile b/deployment/Dockerfile new file mode 100644 index 0000000..6ba89d2 --- /dev/null +++ b/deployment/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13-slim-trixie AS builder +WORKDIR /build +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir --target=/app/deps -r requirements.txt + +### Runtime +FROM gcr.io/distroless/python3-debian13:nonroot +WORKDIR /app +COPY --from=builder /app/deps /app/deps +ENV PYTHONPATH="/app/deps" +ENV PYTHONUNBUFFERED=1 +COPY . . +USER nonroot +EXPOSE 8000 +CMD ["entrypoint.py"] \ No newline at end of file diff --git a/deployment/agent.hcl b/deployment/agent.hcl new file mode 100644 index 0000000..bd28458 --- /dev/null +++ b/deployment/agent.hcl @@ -0,0 +1,29 @@ +pid_file = "/tmp/pidfile" + +vault { + address = "https://10.6.2.1:8200" + tls_skip_verify = true + retry { + num_retries = 5 + } +} + +auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "/vault-auth/role_id" + secret_id_file_path = "/vault-auth/wrapped_secret_id" + secret_id_response_wrapping_path = "auth/approle/role/triage_agent/secret-id" + remove_secret_id_file_after_reading = false + } + } + # sink не нужен, если приложению не требуется сам токен Vault +} + +template { + source = "/vault-template/template.ctmpl" + destination = "/vault-secrets/.env" + error_on_missing_key = true + perms = "0644" +} \ No newline at end of file diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml new file mode 100644 index 0000000..27a451d --- /dev/null +++ b/deployment/docker-compose.yml @@ -0,0 +1,48 @@ +volumes: + vault-secrets: + driver_opts: + type: tmpfs + device: tmpfs + o: "size=1m,mode=0777,uid=65532,gid=65532" + projects-data: + +services: + vault-agent: + image: hashicorp/vault:1.17 + command: agent -config=/vault-agent/agent.hcl + cap_add: + - IPC_LOCK + volumes: + - ./agent.hcl:/vault-agent/agent.hcl:ro + - ./env_template.ctmpl:/vault-template/template.ctmpl:ro + - ./../role_id:/vault-auth/role_id:ro + - ./../wrapped_secret_id:/vault-auth/wrapped_secret_id:ro + - vault-secrets:/vault-secrets + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - agent-net + + api: + build: + context: .. + dockerfile: deployment/Dockerfile + ports: + - "8173:8000" + group_add: + - "100" + volumes: + - ./../projects-data:/projects + - vault-secrets:/vault-secrets + depends_on: + vault-agent: + condition: service_started + networks: + - agent-net + +networks: + agent-net: + driver: bridge + ipam: + config: + - subnet: 10.6.7.0/24 \ No newline at end of file diff --git a/deployment/env_template.ctmpl b/deployment/env_template.ctmpl new file mode 100644 index 0000000..bd5458a --- /dev/null +++ b/deployment/env_template.ctmpl @@ -0,0 +1,10 @@ +{{ with secret "internal_secrets/data/agents" }} +YOUTRACK_MCP_SERVER={{ .Data.data.youtrack_mcp_server }} +YOUTRACK_MCP_TOKEN={{ .Data.data.youtrack_agent_mcp_token }} +YOUTRACK_TRIAGE_AUTHOR={{ .Data.data.youtrack_triage_author }} + +LLM_ADDRESS={{ .Data.data.llm_address }} +LLM_API_KEY={{ .Data.data.llm_api_key }} +LLM_MODEL={{ .Data.data.llm_model }} + +{{ end }} diff --git a/deployment/generate-wrapped-secret-id.sh b/deployment/generate-wrapped-secret-id.sh new file mode 100755 index 0000000..97a7e24 --- /dev/null +++ b/deployment/generate-wrapped-secret-id.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +: ' +vault write auth/approle/role/triage_agent \ + token_policies="agent_policy" \ + token_ttl=1h \ + token_max_ttl=4h \ + secret_id_ttl=24h \ + token_bound_cidrs="10.6.0.0/16" +' + +VAULT_ADDR="https://10.6.2.1:8200" +if [ -z "${VAULT_TOKEN:-}" ]; then + read -r -s -p "VAULT_TOKEN: " VAULT_TOKEN + echo +fi + +TARGET_DIR=${1:-'.'} + +ROLE_NAME="triage_agent" +WRAP_TTL="600s" +CONFIG=$(mktemp) +chmod 600 "$CONFIG" +trap 'rm -f "$CONFIG"' EXIT + +{ + printf 'header = "X-Vault-Token: %s"\n' "$VAULT_TOKEN" + printf 'header = "X-Vault-Wrap-TTL: %s"\n' "$WRAP_TTL" +} > "$CONFIG" + +curl -s -X POST -k -K "$CONFIG" \ + "$VAULT_ADDR/v1/auth/approle/role/${ROLE_NAME}/secret-id" \ + | jq -r '.wrap_info.token // .errors[0]' | tr -d '\n' > wrapped_secret_id + + diff --git a/entrypoint.py b/entrypoint.py new file mode 100644 index 0000000..8da356c --- /dev/null +++ b/entrypoint.py @@ -0,0 +1,17 @@ +import logging +import sys + +from common.hot_env import wait_for_env_file, load_env + +logging.basicConfig(level=logging.INFO) + +if __name__ == "__main__": + try: + wait_for_env_file() + load_env(force=True) + except RuntimeError as e: + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) + + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info") \ No newline at end of file diff --git a/main.py b/main.py index 09fb744..7d5b414 100644 --- a/main.py +++ b/main.py @@ -2,23 +2,35 @@ import logging import os from contextlib import asynccontextmanager -import httpx -from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Depends from agents.coders.BaseCoder import CoderAgent from agents.issue_triage.IssueTriageAgent import IssueTriageAgent from agents.issue_triage.context_builder import YouTrackContextBuilder from agents.registry import AgentRegistry from common.gitea_mcp_client import GiteaMCPClient +from common.hot_env import load_env, reload_if_changed from common.llm_client import LLMClient +from common.singleton import AgentSingleton from common.youtrack_mcp_client import YouTrackMCPClient, IssueNotFound logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -load_dotenv() +_last_hash: str | None = None + +if not load_env(force=False): + logger.warning("No .env file found at startup, will rely on os.environ") + + +def env_optional(key: str, default: str | None = None) -> str: + value = os.environ.get(key) + if value is None: + return default + return value + def env(key: str) -> str: + reload_if_changed() value = os.environ.get(key) if not value: raise RuntimeError(f"Missing required env var: {key}") @@ -26,42 +38,43 @@ def env(key: str) -> str: @asynccontextmanager async def lifespan(app: FastAPI): + import asyncio + + singleton = await AgentSingleton.get() + await singleton.ensure_initialized() + + async def watch_and_reset(): + while True: + try: + if reload_if_changed(): + await singleton.reset() + await singleton.ensure_initialized() + except Exception: + logger.exception("watch_and_reset failed") + await asyncio.sleep(5) + + watcher = asyncio.create_task(watch_and_reset()) + app.state.youtrack_mcp = await YouTrackMCPClient( str(os.getenv('YOUTRACK_MCP_SERVER')), str(os.getenv('YOUTRACK_MCP_TOKEN')), ).connect() - # - # - # res = await mcp.call_tool( - # "add_issue_comment", - # {"issueId": "ARCH-229", "text": "test"} - # ) - # logger.info(res) - - 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.issue_reader_agent = IssueTriageAgent( - YouTrackContextBuilder(app.state.youtrack_mcp, http_for_attachments), - LLMClient( - base_url=env("LLM_ADDRESS"), - api_key=env("LLM_API_KEY"), - model=env("LLM_MODEL"), - ), - AgentRegistry(), - app.state.youtrack_mcp - ) yield # ---- shutdown ---- + watcher.cancel() await app.state.youtrack_mcp.close() + app = FastAPI(lifespan=lifespan) + +async def get_agent() -> IssueTriageAgent: + singleton = await AgentSingleton.get() + await singleton.ensure_initialized() + return singleton.agent + + @app.get("/") async def root(): return {"message": "Hello World"} @@ -73,8 +86,7 @@ async def say_hello(name: str): @app.get("/consume_task") -async def decomposing_issue(issue: str): - agent: IssueTriageAgent = app.state.issue_reader_agent +async def decomposing_issue(issue: str, agent: IssueTriageAgent = Depends(get_agent)): try: logger.info(f"Received an issue: {issue}") plan = await agent.plan_issue(issue) @@ -89,8 +101,7 @@ async def decomposing_issue(issue: str): return {"error": str(e)} @app.get("/code_issue") -async def code_issue(issue: str): - issue_agent: IssueTriageAgent = app.state.issue_reader_agent +async def code_issue(issue: str, issue_agent: IssueTriageAgent = Depends(get_agent)): gitea_mcp_client = GiteaMCPClient() coder_agent: CoderAgent = CoderAgent( str(os.getenv("CODER_ID")), diff --git a/requirements.txt b/requirements.txt index 8a5e5c9..b3f2a29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,103 +1,18 @@ -a2wsgi==1.10.10 -adbc_driver_manager==1.12.0 -adbc_driver_postgresql==1.12.0 -adbc_driver_sqlite==1.12.0 -aiohttp==3.14.3 -AppKit==0.2.8 -atheris==3.1.0 -beautifulsoup4==4.15.0 -botocore==1.43.102 -brotli==1.2.0 -brotlicffi==1.2.0.2 -cffi==2.1.1 -chardet==7.6.0 -checks==0.2 -ConfigParser==7.2.0 -curio==1.6 -cycler==0.12.1 -Cython==3.3.0 -docutils==0.23 -email_validator==2.3.0 -eval_type_backport==0.4.0 -exceptiongroup==1.3.1 -fastapi_cli==0.0.32 -fastparquet==2026.5.0 -filelock==4.0.3 -Foundation==0.1.0a0.dev1 -fqdn==1.5.1 -fsspec==2026.9.0 -gunicorn==26.2.0 -h2==4.4.1 -HTMLParser==0.0.2 -httptools==0.8.0 -httpx2_jsfetch==1.0 -hypothesis==6.168.1 -importlib_resources==7.1.0 -ipython==9.17.1 -ipywidgets==8.1.9 -isoduration==20.11.0 -itsdangerous==2.2.0 -Jinja2==3.1.6 -jnius==1.1.0 -jsonpath_ng==1.8.0 -jsonpointer==3.1.1 -keyring==25.7.0 -lxml==6.1.3 -MarkupSafe==3.0.3 -matplotlib==3.11.2 -mtrand==0.1 -numexpr==2.14.2 -odfpy==1.4.1 -openpyxl==3.1.5 -outcome==1.3.0.post0 -Pillow==12.3.0 -protobuf==7.36.2 -psutil==7.2.2 -pydantic_extra_types==2.11.1 -pydot==4.0.1 -pygraphviz==2.0.2 -PyInstaller==6.22.3 -pyodide==0.0.2 -pyOpenSSL==26.4.0 -pyperf==2.10.0 -PyQt4==4.11.4 -PyQt5==5.15.11 -pytest==9.1.1 -pytest_run_parallel==0.10.0 -python_bcrypt==0.3.2 -python_calamine==0.8.2 -pyxlsb==1.0.10 -qtpy==2.4.3 -redis==8.1.0 -rfc3339_validator==0.1.4 -rfc3986_validator==0.1.1 -rfc3987==1.3.8 -rfc3987_syntax==1.1.0 -s3fs==2026.9.0 -scipy==1.18.1 -scipy_doctest==2.2.0 -setuptools==84.0.0 -simplejson==4.1.2 -sounddevice==0.5.6 -Sphinx==9.1.0 -SQLAlchemy==2.1.0 -sympy==1.14.0 -threadpoolctl==3.7.0 -toml==0.10.2 -tomlkit==0.15.1 -traitlets==5.16.1 +fastapi==0.141.1 +httpx==0.28.1 +mcp==2.2.0 +mcp_types==2.2.0 +networkx==3.7 +openai==3.19.2 +pandas==3.0.6 +playwright==1.63.0 +pydantic==2.13.5 +python-dotenv==1.2.3 +PyYAML==6.0.3 +Requests==2.34.2 +tree_sitter==0.26.0 tree_sitter_html==0.23.2 tree_sitter_javascript==0.25.0 tree_sitter_python==0.25.0 tree_sitter_typescript==0.23.2 -typer==0.27.2 -urllib3_secure_extra==0.1.0 -watchfiles==1.3.0 -webcolors==25.10.0 -winloop==0.6.4 -wsproto==1.3.2 -xlrd==2.0.2 -xlsxwriter==3.2.9 -xmlrpclib==1.0.1 -zstandard==0.25.0 -zttp==0.0.34 +uvicorn==0.54.0