Initial commit: Agentic OS troubleshooting platform

Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend +
LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini ->
DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials,
RBAC, audit log, project-memory + Obsidian integrations, and editable
troubleshooting decision rules tuned for the GeneseasX vessel stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 22:11:07 +03:00
commit 6185b9b85a
126 changed files with 14565 additions and 0 deletions

View File

@@ -0,0 +1,294 @@
"""Agent-driven MCP extension and bug-fix workflow."""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from langchain_core.messages import HumanMessage, SystemMessage
from app.agent.json_utils import parse_json as _parse_json
from app.config import settings
from app.services import mcp_workspace
from app.services.model_config import get_routing_config, resolve_tier_model
from app.services.model_router import build_chat_model
logger = logging.getLogger(__name__)
EmitFn = Callable[[int, str, str, dict | None], Awaitable[None]]
_REPAIRABLE = (
"unknown tool",
"traceback",
"nameerror",
"attributeerror",
"typeerror",
"not defined",
"target' is not defined",
"missing required",
"unexpected keyword",
"has no attribute",
)
def is_mcp_bug_or_gap(tool: str, error: str | None, output: str | None) -> bool:
text = f"{error or ''} {output or ''}".lower()
if "unknown tool" in text and tool.lower() in text:
return True
return any(sig in text for sig in _REPAIRABLE)
async def _load_context_files(server: str) -> dict[str, str]:
files: dict[str, str] = {}
for rel in await mcp_workspace.list_source_files(server):
if rel == "server.py" or rel.endswith("server.py") or rel == "pyproject.toml":
try:
files[rel] = await mcp_workspace.read_source_file(server, rel)
except OSError:
pass
if "server.py" not in files:
listed = await mcp_workspace.list_source_files(server, max_files=5)
for rel in listed[:3]:
try:
files[rel] = await mcp_workspace.read_source_file(server, rel, max_chars=40_000)
except OSError:
pass
return files
async def propose_mcp_patch(
*,
server: str,
tool: str,
issue: str,
error: str | None,
output: str | None,
task_title: str = "",
) -> dict[str, Any]:
"""Use LLM to propose MCP source file edits."""
cfg = await get_routing_config()
tier = settings.mcp_dev_model_tier if hasattr(settings, "mcp_dev_model_tier") else "premium"
entry = resolve_tier_model(cfg, tier) or resolve_tier_model(cfg, "economy")
if not entry:
return {"ok": False, "error": "No LLM available for MCP development"}
files = await _load_context_files(server)
tools = mcp_workspace.list_registered_tools(server)
prompt = (
"You are an expert Python MCP (Model Context Protocol) developer using FastMCP patterns. "
"Given a failing tool call or missing capability, patch the MCP server source. "
"Add new @mcp.tool() functions when a capability is missing; fix bugs when tools crash. "
"Match existing code style. Keep changes minimal and safe (read-only tools unless clearly needed). "
'Respond ONLY as JSON: {"summary":"...", "commit_message":"fix: ...", '
'"files":[{"path":"server.py","content":"FULL file content after edit"}]}'
)
ctx = (
f"MCP server: {server}\n"
f"Registered tools: {', '.join(tools) or 'unknown'}\n"
f"Task: {task_title}\n"
f"Issue: {issue}\n"
f"Tool involved: {tool}\n"
f"Error: {error or 'none'}\n"
f"Output: {(output or '')[:2000]}\n\n"
f"Source files:\n"
+ "\n\n".join(f"--- {p} ---\n{c[:25000]}" for p, c in files.items())
)
model = build_chat_model(entry, temperature=0.1)
resp = await model.ainvoke([SystemMessage(content=prompt), HumanMessage(content=ctx)])
data = _parse_json(str(resp.content))
if not data.get("files"):
return {"ok": False, "error": "LLM did not return file edits", "raw": str(resp.content)[:500]}
return {"ok": True, **data}
async def apply_and_reload_mcp(
server: str,
edits: list[dict],
manager,
) -> dict[str, Any]:
written = await mcp_workspace.apply_file_edits(server, edits)
if not written:
return {"ok": False, "error": "No files written"}
await manager.reload_server(server)
return {"ok": True, "files": written, "tools": mcp_workspace.list_registered_tools(server)}
async def attempt_mcp_repair(
*,
task_id: int,
server: str,
tool: str,
args: dict,
issue: str,
error: str | None,
output: str | None,
manager,
emit: EmitFn,
task_title: str = "",
push: bool | None = None,
) -> dict[str, Any]:
"""Full repair loop: analyze → patch → reload → optional git push."""
if not settings.mcp_development_enabled:
return {"ok": False, "skipped": True, "reason": "MCP development disabled"}
await emit(
task_id,
"mcp_dev",
f"Analyzing MCP '{server}' to fix or add capability for {tool}",
{"server": server, "tool": tool, "phase": "analyze"},
)
proposal = await propose_mcp_patch(
server=server,
tool=tool,
issue=issue,
error=error,
output=output,
task_title=task_title,
)
if not proposal.get("ok"):
await emit(task_id, "mcp_dev", f"MCP patch proposal failed: {proposal.get('error')}", proposal)
return proposal
await emit(
task_id,
"mcp_dev",
proposal.get("summary", "Applying MCP patch…"),
{"server": server, "phase": "patch", "files": [f.get("path") for f in proposal.get("files", [])]},
)
await emit(
task_id,
"mcp_dev",
f"Reloading MCP '{server}' with patched source…",
{"server": server, "phase": "reload"},
)
reload_result = await apply_and_reload_mcp(server, proposal.get("files") or [], manager)
if not reload_result.get("ok"):
await emit(task_id, "error", reload_result.get("error", "MCP reload failed"), reload_result)
return reload_result
await emit(
task_id,
"mcp_dev",
f"MCP '{server}' reloaded — {len(reload_result.get('tools') or [])} tools registered",
{"server": server, "phase": "reload_done", "tools": reload_result.get("tools")},
)
should_push = push if push is not None else settings.mcp_dev_auto_push
push_result: dict[str, Any] | None = None
commit_msg = proposal.get("commit_message") or f"agentic-os: fix {tool} on {server}"
written = reload_result.get("files") or []
if should_push and settings.gitea_token:
push_result = await mcp_workspace.commit_and_push(server, commit_msg, written)
await emit(
task_id,
"mcp_dev",
f"Git push {'ok' if push_result.get('ok') else 'failed'}: {push_result.get('commit') or push_result.get('error')}",
{"server": server, "phase": "push", **push_result},
)
elif settings.gitea_token and written and task_id > 0:
from app.database import SessionLocal
from app.models.enums import ApprovalStatus
from app.models.task import ApprovalRequest
async with SessionLocal() as db:
db.add(
ApprovalRequest(
task_id=task_id,
tool_name="mcp_git_push",
tool_args={
"server": server,
"files": written,
"commit_message": commit_msg,
},
risk=proposal.get("summary"),
status=ApprovalStatus.pending,
)
)
await db.commit()
await emit(
task_id,
"approval",
f"MCP patch ready — approve to push {server} to Gitea",
{"server": server, "tool_name": "mcp_git_push", "files": written},
)
return {
"ok": True,
"summary": proposal.get("summary"),
"reload": reload_result,
"push": push_result,
"tools": reload_result.get("tools"),
}
async def maybe_repair_after_tool_failure(
*,
task_id: int,
device: str,
server: str,
tool: str,
args: dict,
issue: str,
error: str | None,
output: str | None,
manager,
emit: EmitFn,
task_title: str = "",
) -> tuple[dict, bool] | None:
"""If failure looks MCP-related, try repair once and return retry (res, ok) or None."""
if not settings.mcp_development_enabled:
return None
if not is_mcp_bug_or_gap(tool, error, output):
return None
result = await attempt_mcp_repair(
task_id=task_id,
server=server,
tool=tool,
args=args,
issue=issue,
error=error,
output=output,
manager=manager,
emit=emit,
task_title=task_title,
push=settings.mcp_dev_auto_push,
)
if not result.get("ok"):
return None
if result.get("push") and not result["push"].get("ok") and result["push"].get("runtime_only"):
await emit(
task_id,
"log",
"MCP patched in runtime — no git remote; copy changes to mcp-servers or Gitea manually.",
result["push"],
)
from app.agent.mcp_helpers import tool_result_ok
from app.agent.tool_events import tool_event_payload
from app.mcp_manager.recovery import call_tool_resilient
await emit(task_id, "log", f"Retrying {tool} after MCP patch…", {"server": server, "tool": tool})
res = await call_tool_resilient(manager, server, tool, args, task_id=task_id, emit=emit)
ok = tool_result_ok(res)
await emit(
task_id,
"tool_call",
f"{device} {tool} (after MCP patch)",
tool_event_payload(
device=device,
tool=tool,
args=args,
status="done",
ok=ok,
output=res.get("text", ""),
),
)
return res, ok