- Added configuration options for requiring human approval before applying LLM-generated MCP patches. - Updated Docker setup to include skills directory. - Integrated skills management into the backend, allowing for procedural guides and skill matching. - Refactored database initialization to apply Alembic migrations. - Enhanced task approval process to handle MCP patch applications with optional approval. - Introduced new schemas for skills and updated existing APIs to support skills functionality. This commit lays the groundwork for improved agent capabilities and better management of MCP development processes.
329 lines
9.8 KiB
Python
329 lines
9.8 KiB
Python
"""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.mcp_approvals import (
|
||
backup_proposal_files,
|
||
execute_approved_patch,
|
||
)
|
||
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 _queue_apply_patch_approval(
|
||
*,
|
||
task_id: int,
|
||
server: str,
|
||
proposal: dict,
|
||
tool: str,
|
||
args: dict,
|
||
device: str | None,
|
||
emit: EmitFn,
|
||
) -> dict[str, Any]:
|
||
from app.database import SessionLocal
|
||
from app.models.enums import ApprovalStatus
|
||
from app.models.task import ApprovalRequest
|
||
|
||
edits = proposal.get("files") or []
|
||
backups = await backup_proposal_files(server, edits)
|
||
commit_msg = proposal.get("commit_message") or f"agentic-os: fix {tool} on {server}"
|
||
|
||
approval_args = {
|
||
"server": server,
|
||
"files": edits,
|
||
"backups": backups,
|
||
"commit_message": commit_msg,
|
||
"summary": proposal.get("summary"),
|
||
"retry_context": {
|
||
"device": device,
|
||
"server": server,
|
||
"tool": tool,
|
||
"args": args,
|
||
},
|
||
}
|
||
|
||
async with SessionLocal() as db:
|
||
db.add(
|
||
ApprovalRequest(
|
||
task_id=task_id,
|
||
tool_name="mcp_apply_patch",
|
||
tool_args=approval_args,
|
||
risk=proposal.get("summary"),
|
||
status=ApprovalStatus.pending,
|
||
)
|
||
)
|
||
await db.commit()
|
||
|
||
await emit(
|
||
task_id,
|
||
"approval",
|
||
f"MCP patch proposed for {server} — approve to apply and reload",
|
||
{
|
||
"server": server,
|
||
"tool_name": "mcp_apply_patch",
|
||
"files": [e.get("path") for e in edits],
|
||
"summary": proposal.get("summary"),
|
||
},
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"pending_approval": True,
|
||
"approval_type": "mcp_apply_patch",
|
||
"summary": proposal.get("summary"),
|
||
}
|
||
|
||
|
||
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,
|
||
device: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Full repair loop: analyze → (approval?) → 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
|
||
|
||
if settings.mcp_dev_require_approval and task_id > 0:
|
||
return await _queue_apply_patch_approval(
|
||
task_id=task_id,
|
||
server=server,
|
||
proposal=proposal,
|
||
tool=tool,
|
||
args=args,
|
||
device=device,
|
||
emit=emit,
|
||
)
|
||
|
||
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"},
|
||
)
|
||
|
||
return await execute_approved_patch(
|
||
task_id=task_id,
|
||
tool_args={
|
||
"server": server,
|
||
"files": proposal.get("files") or [],
|
||
"commit_message": proposal.get("commit_message") or f"agentic-os: fix {tool} on {server}",
|
||
"summary": proposal.get("summary"),
|
||
"retry_context": {
|
||
"device": device,
|
||
"server": server,
|
||
"tool": tool,
|
||
"args": args,
|
||
},
|
||
},
|
||
manager=manager,
|
||
emit=emit,
|
||
push=push,
|
||
)
|
||
|
||
|
||
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,
|
||
device=device,
|
||
)
|
||
if not result.get("ok"):
|
||
return None
|
||
|
||
if result.get("pending_approval"):
|
||
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"],
|
||
)
|
||
|
||
retry = result.get("retry")
|
||
if retry and retry.get("result") is not None:
|
||
return retry["result"], bool(retry.get("ok"))
|
||
|
||
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
|