Enhance MCP development features and introduce skills management
- 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.
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
from app.agent.graph import run_task_agent
|
||||
"""Agent package — import submodules directly (e.g. app.agent.graph)."""
|
||||
|
||||
__all__ = ["run_task_agent"]
|
||||
__all__: list[str] = []
|
||||
|
||||
@@ -37,6 +37,7 @@ from app.services.device_defaults import resolve_slot_defaults
|
||||
from app.services.investigation_context import gather_investigation_context
|
||||
from app.services.approval_filter import split_proposed_fixes
|
||||
from app.services.troubleshooting_rules import rule_guidance_block
|
||||
from app.services.skill_registry import match_skills, skill_guidance_block
|
||||
from app.services.llm import triage_model, triage_model_info
|
||||
from app.services.llm_usage import LlmUsageTracker, merge_run_history
|
||||
from app.services.model_config import get_routing_config
|
||||
@@ -75,6 +76,7 @@ class AgentState(TypedDict, total=False):
|
||||
prior_context: dict | None
|
||||
investigation_context: dict | None
|
||||
matched_rule: dict | None
|
||||
matched_skills: list[dict] | None
|
||||
follow_up: str | None
|
||||
run_number: int
|
||||
prior_runs: list[dict]
|
||||
@@ -348,6 +350,7 @@ async def triage_node(state: AgentState) -> AgentState:
|
||||
f"Onboard devices to check: {json.dumps(device_summary)}"
|
||||
f"{_investigation_context_block(state.get('investigation_context'))}"
|
||||
f"{rule_guidance_block(state.get('matched_rule'))}"
|
||||
f"{skill_guidance_block(state.get('matched_skills'))}"
|
||||
f"{_prior_context_block(state.get('prior_context'), state.get('follow_up'))}"
|
||||
)
|
||||
triage_info = await triage_model_info()
|
||||
@@ -449,6 +452,7 @@ async def reason_node(state: AgentState) -> AgentState:
|
||||
f"Title: {state['title']}\nIssue: {state['issue']}\nTriage: {json.dumps(triage)}\n\n{diag_text}"
|
||||
f"{_investigation_context_block(state.get('investigation_context'))}"
|
||||
f"{rule_guidance_block(state.get('matched_rule'))}"
|
||||
f"{skill_guidance_block(state.get('matched_skills'))}"
|
||||
f"{_prior_context_block(state.get('prior_context'), state.get('follow_up'))}"
|
||||
)
|
||||
messages = [SystemMessage(content=prompt), HumanMessage(content=ctx)]
|
||||
@@ -604,6 +608,10 @@ async def report_node(state: AgentState) -> AgentState:
|
||||
}
|
||||
if state.get("matched_rule"):
|
||||
report["matched_rule"] = state["matched_rule"]
|
||||
if state.get("matched_skills"):
|
||||
report["matched_skills"] = [
|
||||
{k: v for k, v in s.items() if k != "body"} for s in state["matched_skills"]
|
||||
]
|
||||
|
||||
report = merge_run_history(state.get("prior_runs") or [], run_number, report, llm_summary)
|
||||
|
||||
@@ -786,6 +794,7 @@ async def run_task_agent(task_id: int) -> None:
|
||||
|
||||
devices: list[dict] = []
|
||||
matched_rule: dict | None = None
|
||||
matched_skills: list[dict] = []
|
||||
vessel_name: str | None = None
|
||||
vessel_site: str | None = None
|
||||
vessel_public_ip: str | None = None
|
||||
@@ -795,6 +804,18 @@ async def run_task_agent(task_id: int) -> None:
|
||||
all_devices, routing_text, title=task.title
|
||||
)
|
||||
matched_rule = rule.as_dict() if rule else None
|
||||
rule_id = matched_rule.get("id") if matched_rule else None
|
||||
matched_skills = [
|
||||
{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"body": s.body,
|
||||
"score": s.score,
|
||||
"priority": s.priority,
|
||||
}
|
||||
for s in match_skills(task.title, routing_text, matched_rule_id=rule_id)
|
||||
]
|
||||
vres = await db.execute(select(Vessel).where(Vessel.id == task.vessel_id))
|
||||
vessel = vres.scalar_one_or_none()
|
||||
if vessel:
|
||||
@@ -807,6 +828,20 @@ async def run_task_agent(task_id: int) -> None:
|
||||
if d:
|
||||
devices = [_device_dict(d)]
|
||||
|
||||
if not matched_skills:
|
||||
rule_id = matched_rule.get("id") if matched_rule else None
|
||||
matched_skills = [
|
||||
{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"body": s.body,
|
||||
"score": s.score,
|
||||
"priority": s.priority,
|
||||
}
|
||||
for s in match_skills(task.title, routing_text, matched_rule_id=rule_id)
|
||||
]
|
||||
|
||||
task.status = TaskStatus.running
|
||||
task.details = details
|
||||
await db.commit()
|
||||
@@ -842,6 +877,14 @@ async def run_task_agent(task_id: int) -> None:
|
||||
f"Matched rule: {matched_rule.get('name')} → {', '.join(matched_rule.get('devices') or [])}",
|
||||
matched_rule,
|
||||
)
|
||||
if matched_skills:
|
||||
names = ", ".join(s["name"] for s in matched_skills)
|
||||
await _emit(
|
||||
task_id,
|
||||
"skill",
|
||||
f"Matched skills: {names}",
|
||||
{"skills": [{k: v for k, v in s.items() if k != "body"} for s in matched_skills]},
|
||||
)
|
||||
|
||||
remaining = await balance.latest_remaining_usd()
|
||||
from app.config import settings
|
||||
@@ -873,6 +916,7 @@ async def run_task_agent(task_id: int) -> None:
|
||||
"run_number": run_number,
|
||||
"prior_runs": prior_runs if continue_mode else [],
|
||||
"matched_rule": matched_rule,
|
||||
"matched_skills": matched_skills or None,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
@@ -10,6 +10,10 @@ 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
|
||||
|
||||
@@ -103,16 +107,67 @@ async def propose_mcp_patch(
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
async def apply_and_reload_mcp(
|
||||
async def _queue_apply_patch_approval(
|
||||
*,
|
||||
task_id: int,
|
||||
server: str,
|
||||
edits: list[dict],
|
||||
manager,
|
||||
proposal: dict,
|
||||
tool: str,
|
||||
args: dict,
|
||||
device: str | None,
|
||||
emit: EmitFn,
|
||||
) -> 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)}
|
||||
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(
|
||||
@@ -128,8 +183,9 @@ async def attempt_mcp_repair(
|
||||
emit: EmitFn,
|
||||
task_title: str = "",
|
||||
push: bool | None = None,
|
||||
device: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full repair loop: analyze → patch → reload → optional git push."""
|
||||
"""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"}
|
||||
|
||||
@@ -152,6 +208,17 @@ async def attempt_mcp_repair(
|
||||
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",
|
||||
@@ -166,66 +233,25 @@ async def attempt_mcp_repair(
|
||||
{"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")},
|
||||
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,
|
||||
)
|
||||
|
||||
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(
|
||||
*,
|
||||
@@ -259,10 +285,14 @@ async def maybe_repair_after_tool_failure(
|
||||
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,
|
||||
@@ -271,6 +301,10 @@ async def maybe_repair_after_tool_failure(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user