Deployment
This commit is contained in:
@@ -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
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
projects
|
projects
|
||||||
|
role_id
|
||||||
|
wrapped_secret_id
|
||||||
Generated
+2
-1
@@ -6,8 +6,9 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$/agents/issue_triage/tests" isTestSource="true" />
|
<sourceFolder url="file://$MODULE_DIR$/agents/issue_triage/tests" isTestSource="true" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/projects/agents" />
|
<excludeFolder url="file://$MODULE_DIR$/projects/agents" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.14 (Agents)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.10 (agents) (2)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
Generated
+1
-1
@@ -3,5 +3,5 @@
|
|||||||
<component name="Black">
|
<component name="Black">
|
||||||
<option name="sdkName" value="Python 3.14 (Agents)" />
|
<option name="sdkName" value="Python 3.14 (Agents)" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (Agents)" project-jdk-type="Python SDK" />
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (agents) (2)" project-jdk-type="Python SDK" />
|
||||||
</project>
|
</project>
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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)
|
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.
|
Convert a DependencyGraph into a networkx DiGraph.
|
||||||
|
|
||||||
@@ -792,7 +792,7 @@ COLS = [
|
|||||||
|
|
||||||
|
|
||||||
def analyze(
|
def analyze(
|
||||||
G: nx.DiGraph[DependencyNode],
|
G: "nx.DiGraph[DependencyNode]",
|
||||||
graph: DependencyGraph, # NOTE: currently unused in the body
|
graph: DependencyGraph, # NOTE: currently unused in the body
|
||||||
label: str = "",
|
label: str = "",
|
||||||
) -> AnalyzeResult:
|
) -> AnalyzeResult:
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
import requests
|
import requests
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
from agents.architect_ts.analyzer import CodeAnalyzer
|
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"
|
CONFIG_FILE = "config.json"
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import os
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from agents.codebase_analyst.start import Project
|
||||||
|
|
||||||
|
|
||||||
class CoderAgent:
|
class CoderAgent:
|
||||||
"""Агент-исполнитель: выполняет подзадачу, генерирует код и применяет изменения."""
|
"""Агент-исполнитель: выполняет подзадачу, генерирует код и применяет изменения."""
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class AgentState(BaseModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@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 = {
|
known = {
|
||||||
"schema_version", "issue_id", "agent_id", "state", "updated_at",
|
"schema_version", "issue_id", "agent_id", "state", "updated_at",
|
||||||
"last_processed_comment_id", "pending_question_comment_id",
|
"last_processed_comment_id", "pending_question_comment_id",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from agents.issue_triage.triage import render_validation_failure, render_plan_co
|
|||||||
from agents.registry import agent, AgentRegistry, Capability
|
from agents.registry import agent, AgentRegistry, Capability
|
||||||
from common.llm_client import LLMClient
|
from common.llm_client import LLMClient
|
||||||
from common.youtrack_mcp_client import IssueNotFound, YouTrackMCPClient
|
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 = logging.getLogger("triage_agent")
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
@@ -23,7 +23,7 @@ logger.setLevel(logging.DEBUG)
|
|||||||
class TriageContext:
|
class TriageContext:
|
||||||
issue: IssueContext
|
issue: IssueContext
|
||||||
registry: AgentRegistry
|
registry: AgentRegistry
|
||||||
triage_agent: IssueTriageAgent
|
triage_agent: "IssueTriageAgent"
|
||||||
|
|
||||||
|
|
||||||
async def run_triage(ctx: TriageContext) -> TriageMarker:
|
async def run_triage(ctx: TriageContext) -> TriageMarker:
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import json
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
from agents.codebase_analyst.start import Project
|
||||||
|
|
||||||
|
|
||||||
class PlannerAgent:
|
class PlannerAgent:
|
||||||
"""Агент-планировщик: анализирует проект и создаёт план подзадач."""
|
"""Агент-планировщик: анализирует проект и создаёт план подзадач."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from agents.codebase_analyst.start import Project
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -7,6 +7,15 @@ from pydantic import BaseModel
|
|||||||
from contracts.ProjectContext import ProjectContext
|
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
|
@dataclass
|
||||||
class IssueContext:
|
class IssueContext:
|
||||||
issue_id: str
|
issue_id: str
|
||||||
@@ -49,11 +58,3 @@ class IssueContext:
|
|||||||
parts.append(f"- @{c.author} ({c.created_at}): {c.body}")
|
parts.append(f"- @{c.author} ({c.created_at}): {c.body}")
|
||||||
|
|
||||||
return "\n".join(parts)
|
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)
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional, Literal
|
||||||
|
|
||||||
from contracts.base import BaseContract
|
from contracts.base import BaseContract
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ class Task(BaseContract):
|
|||||||
lines = [
|
lines = [
|
||||||
f"# Task: {self.description}",
|
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"# 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)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
Executable
+36
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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")
|
||||||
@@ -2,23 +2,35 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import httpx
|
from fastapi import FastAPI, HTTPException, Depends
|
||||||
from dotenv import load_dotenv
|
|
||||||
from fastapi import FastAPI, HTTPException
|
|
||||||
|
|
||||||
from agents.coders.BaseCoder import CoderAgent
|
from agents.coders.BaseCoder import CoderAgent
|
||||||
from agents.issue_triage.IssueTriageAgent import IssueTriageAgent
|
from agents.issue_triage.IssueTriageAgent import IssueTriageAgent
|
||||||
from agents.issue_triage.context_builder import YouTrackContextBuilder
|
from agents.issue_triage.context_builder import YouTrackContextBuilder
|
||||||
from agents.registry import AgentRegistry
|
from agents.registry import AgentRegistry
|
||||||
from common.gitea_mcp_client import GiteaMCPClient
|
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.llm_client import LLMClient
|
||||||
|
from common.singleton import AgentSingleton
|
||||||
from common.youtrack_mcp_client import YouTrackMCPClient, IssueNotFound
|
from common.youtrack_mcp_client import YouTrackMCPClient, IssueNotFound
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def env(key: str) -> str:
|
||||||
|
reload_if_changed()
|
||||||
value = os.environ.get(key)
|
value = os.environ.get(key)
|
||||||
if not value:
|
if not value:
|
||||||
raise RuntimeError(f"Missing required env var: {key}")
|
raise RuntimeError(f"Missing required env var: {key}")
|
||||||
@@ -26,42 +38,43 @@ def env(key: str) -> str:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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(
|
app.state.youtrack_mcp = await YouTrackMCPClient(
|
||||||
str(os.getenv('YOUTRACK_MCP_SERVER')),
|
str(os.getenv('YOUTRACK_MCP_SERVER')),
|
||||||
str(os.getenv('YOUTRACK_MCP_TOKEN')),
|
str(os.getenv('YOUTRACK_MCP_TOKEN')),
|
||||||
).connect()
|
).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
|
yield
|
||||||
|
|
||||||
# ---- shutdown ----
|
# ---- shutdown ----
|
||||||
|
watcher.cancel()
|
||||||
await app.state.youtrack_mcp.close()
|
await app.state.youtrack_mcp.close()
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_agent() -> IssueTriageAgent:
|
||||||
|
singleton = await AgentSingleton.get()
|
||||||
|
await singleton.ensure_initialized()
|
||||||
|
return singleton.agent
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "Hello World"}
|
return {"message": "Hello World"}
|
||||||
@@ -73,8 +86,7 @@ async def say_hello(name: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/consume_task")
|
@app.get("/consume_task")
|
||||||
async def decomposing_issue(issue: str):
|
async def decomposing_issue(issue: str, agent: IssueTriageAgent = Depends(get_agent)):
|
||||||
agent: IssueTriageAgent = app.state.issue_reader_agent
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Received an issue: {issue}")
|
logger.info(f"Received an issue: {issue}")
|
||||||
plan = await agent.plan_issue(issue)
|
plan = await agent.plan_issue(issue)
|
||||||
@@ -89,8 +101,7 @@ async def decomposing_issue(issue: str):
|
|||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
@app.get("/code_issue")
|
@app.get("/code_issue")
|
||||||
async def code_issue(issue: str):
|
async def code_issue(issue: str, issue_agent: IssueTriageAgent = Depends(get_agent)):
|
||||||
issue_agent: IssueTriageAgent = app.state.issue_reader_agent
|
|
||||||
gitea_mcp_client = GiteaMCPClient()
|
gitea_mcp_client = GiteaMCPClient()
|
||||||
coder_agent: CoderAgent = CoderAgent(
|
coder_agent: CoderAgent = CoderAgent(
|
||||||
str(os.getenv("CODER_ID")),
|
str(os.getenv("CODER_ID")),
|
||||||
|
|||||||
+14
-99
@@ -1,103 +1,18 @@
|
|||||||
a2wsgi==1.10.10
|
fastapi==0.141.1
|
||||||
adbc_driver_manager==1.12.0
|
httpx==0.28.1
|
||||||
adbc_driver_postgresql==1.12.0
|
mcp==2.2.0
|
||||||
adbc_driver_sqlite==1.12.0
|
mcp_types==2.2.0
|
||||||
aiohttp==3.14.3
|
networkx==3.7
|
||||||
AppKit==0.2.8
|
openai==3.19.2
|
||||||
atheris==3.1.0
|
pandas==3.0.6
|
||||||
beautifulsoup4==4.15.0
|
playwright==1.63.0
|
||||||
botocore==1.43.102
|
pydantic==2.13.5
|
||||||
brotli==1.2.0
|
python-dotenv==1.2.3
|
||||||
brotlicffi==1.2.0.2
|
PyYAML==6.0.3
|
||||||
cffi==2.1.1
|
Requests==2.34.2
|
||||||
chardet==7.6.0
|
tree_sitter==0.26.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
|
|
||||||
tree_sitter_html==0.23.2
|
tree_sitter_html==0.23.2
|
||||||
tree_sitter_javascript==0.25.0
|
tree_sitter_javascript==0.25.0
|
||||||
tree_sitter_python==0.25.0
|
tree_sitter_python==0.25.0
|
||||||
tree_sitter_typescript==0.23.2
|
tree_sitter_typescript==0.23.2
|
||||||
typer==0.27.2
|
uvicorn==0.54.0
|
||||||
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user