Phase 3: approval gate, execute intent, approve/reject routes

- New: app/approval.py — HMAC-signed tokens, pending action store, 15-min TTL
- New: /approve/{token} and /reject/{token} GET routes (public, for Discord links)
- New: /internal/queue-action POST (for Citadel propose_file_change)
- New: execute intent in classifier — restart/rebuild + agent name queues action
- Updated: tools.py — notify_raven_with_actions, call_qyburn_rebuild/restart
- Updated: intent.py — EXECUTE_VERBS, STACK_NAMES, extract_execute_target
- Updated: SYSTEM_PROMPT reflects Phase 3 capabilities
- Updated: docker-compose.yml — JON_SECRET, JON_PUBLIC_URL, RAVEN_URL, QYBURN_URL
This commit is contained in:
2026-05-27 12:26:52 +00:00
parent a25deeb8f4
commit 53b52a2337
5 changed files with 710 additions and 16 deletions
+284 -13
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import logging
import os
import re
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -12,17 +13,39 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from .approval import cleanup_expired, generate_token, pop_action, queue_action, verify_token
from .brain import stream_completion
from .intent import classify_intent, extract_agent_name, extract_project_name
from .tools import create_plane_issue, get_agent_output, get_all_agent_status
from .intent import (
classify_intent,
extract_agent_name,
extract_compound_task,
extract_execute_target,
extract_new_project_name,
extract_project_name,
extract_task_destination,
extract_task_title,
is_issue_query,
)
from .tools import (
call_qyburn_rebuild,
call_qyburn_restart,
create_plane_issue,
create_plane_project,
get_agent_output,
get_all_agent_status,
get_plane_issues,
get_plane_projects,
notify_raven_with_actions,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger("jon-snow")
AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os"))
SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites"))
JON_PUBLIC_URL = os.getenv("JON_PUBLIC_URL", "https://jon.nxm.co.za")
app = FastAPI(title="Jon Snow", version="0.2.0")
app = FastAPI(title="Jon Snow", version="0.3.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
SYSTEM_PROMPT = """You are Jon Snow, chief of staff for the NxM home lab agent ecosystem on a self-hosted Linux server (172.27.40.3).
@@ -34,17 +57,19 @@ Live agents you coordinate:
- sam (8500): Research agent — SearXNG + Ollama synthesis
- raven (8400): Notifications — Discord webhook + Gmail SMTP
- qyburn (8700): LLM coding agent — qwen2.5-coder:14b, approve/reject workflow
- citadel (8300): MCP tool registry — 16 tools including Plane integration
- citadel (8300): MCP tool registry — 21 tools including Plane integration
- hermes: Cloud intelligence agent — claude-sonnet-4-6 brain, connected to Citadel read-only
Infrastructure: Ubuntu Docker host 172.27.40.3, Ollama at 172.27.40.20:11434, Netbird VPN (100.119.x.x), Plane project management, Gitea at git.nxm.co.za.
Infrastructure: Ubuntu Docker host 172.27.40.3, Ollama at 172.27.40.20:11434, Headscale VPN, Plane project management, Gitea at git.nxm.co.za.
Your current capabilities (Phase 2):
Your capabilities (Phase 3):
1. Report agent status — last run time, success/failure, output summary
2. Log tasks to Plane project management
2. List Plane projects and log tasks to Plane project management
3. Answer questions about the infrastructure
4. Route complex questions to your SMART_MODEL brain
5. Queue execution actions (restart/rebuild agents) — sends approval request via Discord before executing
You cannot yet execute tasks autonomously — that is Phase 3. When a user submits a task, log it to Plane and confirm.
For execution requests (restart/rebuild/redeploy), always queue for approval — never execute directly.
Be concise — the user is often on mobile. Use short markdown lists, not long paragraphs."""
@@ -63,6 +88,12 @@ class ChatRequest(BaseModel):
max_tokens: int | None = None
class QueueActionRequest(BaseModel):
description: str
action_type: str
params: dict
# --- Output helpers ---
def _write_status(intent: str, summary: str, status: str = "success") -> None:
@@ -142,7 +173,7 @@ async def _stream_llm(messages: list[dict], use_smart: bool = False) -> AsyncGen
@app.get("/health")
async def health():
return {"status": "ok", "agent": "jon-snow", "version": "0.2.0"}
return {"status": "ok", "agent": "jon-snow", "version": "0.3.0"}
@app.get("/v1/models")
@@ -153,6 +184,123 @@ async def list_models():
}
@app.get("/approve/{token}")
async def approve_action(token: str):
"""Public endpoint — called when user clicks Approve in Discord. Executes the queued action."""
cleanup_expired()
try:
action_id, purpose = verify_token(token)
except ValueError as e:
return JSONResponse(
{"error": str(e), "message": "Approval failed — token may be expired or already used."},
status_code=400,
)
if purpose != "approve":
return JSONResponse({"error": "wrong token purpose"}, status_code=400)
action = pop_action(action_id)
if not action:
return JSONResponse(
{"error": "action not found", "message": "Action not found — it may have already been executed or expired."},
status_code=404,
)
logger.info(f"approved: {action.action_type}{action.description}")
try:
if action.action_type == "docker_rebuild":
result = await call_qyburn_rebuild(action.params["stack_name"])
return JSONResponse({
"ok": True,
"message": f"✅ Rebuilding {action.params['stack_name']}. Check container logs for progress.",
"result": result,
})
elif action.action_type == "docker_restart":
result = await call_qyburn_restart(action.params["stack_name"])
return JSONResponse({
"ok": True,
"message": f"✅ Restarting {action.params['stack_name']}.",
"result": result,
})
elif action.action_type == "file_write":
path = action.params.get("path", "")
content = action.params.get("content", "")
safe_path = re.sub(r"\.\.", "", path).lstrip("/")
target = SITES_DIR / safe_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
return JSONResponse({
"ok": True,
"message": f"✅ File written: /opt/sites/{safe_path}",
})
else:
return JSONResponse(
{"error": f"Unknown action type: {action.action_type}"},
status_code=400,
)
except Exception as e:
logger.error(f"action execution failed: {e}")
return JSONResponse({"error": f"Execution failed: {e}"}, status_code=500)
@app.get("/reject/{token}")
async def reject_action(token: str):
"""Public endpoint — called when user clicks Reject in Discord. Discards the queued action."""
cleanup_expired()
try:
action_id, purpose = verify_token(token)
except ValueError as e:
return JSONResponse(
{"error": str(e), "message": "Rejection failed — token may be expired or already used."},
status_code=400,
)
if purpose != "reject":
return JSONResponse({"error": "wrong token purpose"}, status_code=400)
action = pop_action(action_id)
if not action:
return JSONResponse(
{"error": "action not found", "message": "Action not found — it may have already been handled."},
status_code=404,
)
logger.info(f"rejected: {action.action_type}{action.description}")
return JSONResponse({
"ok": True,
"message": f"❌ Action rejected: {action.description}",
})
@app.post("/internal/queue-action")
async def internal_queue_action(req: QueueActionRequest):
"""Internal endpoint — called by Citadel's propose_file_change tool to queue a file write for approval."""
action = queue_action(
description=req.description,
action_type=req.action_type,
params=req.params,
)
approve_url = f"{JON_PUBLIC_URL}/approve/{generate_token(action.id, 'approve')}"
reject_url = f"{JON_PUBLIC_URL}/reject/{generate_token(action.id, 'reject')}"
await notify_raven_with_actions(
description=req.description,
approve_url=approve_url,
reject_url=reject_url,
action_type=req.action_type,
)
logger.info(f"queued from citadel: {req.action_type}{req.description}")
return {
"ok": True,
"action_id": action.id,
"approve_url": approve_url,
"reject_url": reject_url,
}
@app.post("/v1/chat/completions")
async def chat_completions(req: ChatRequest):
user_message = next((m.content for m in reversed(req.messages) if m.role == "user"), "")
@@ -178,16 +326,139 @@ async def chat_completions(req: ChatRequest):
yield chunk
summary = f"Status query: {user_message[:100]}"
elif intent == "execute":
target = extract_execute_target(user_message)
if not target:
response_text = "I couldn't identify what to execute. Try: _restart citadel_ or _rebuild raven_."
summary = "execute: no target extracted"
else:
action_type, stack_name = target
verb = "Restart" if action_type == "docker_restart" else "Rebuild"
description = f"{verb} {stack_name}"
action = queue_action(
description=description,
action_type=action_type,
params={"stack_name": stack_name},
)
approve_url = f"{JON_PUBLIC_URL}/approve/{generate_token(action.id, 'approve')}"
reject_url = f"{JON_PUBLIC_URL}/reject/{generate_token(action.id, 'reject')}"
await notify_raven_with_actions(
description=description,
approve_url=approve_url,
reject_url=reject_url,
action_type=action_type,
)
response_text = (
f"⚡ Approval requested for: **{description}**\n\n"
f"A Discord notification has been sent with approve/reject links.\n"
f"Token expires in 15 minutes."
)
summary = f"execute queued: {description}"
async for chunk in _stream_text(response_text):
yield chunk
elif intent == "create_project":
name = extract_new_project_name(user_message)
if not name:
response_text = "What should the project be called?"
summary = "create_project: no name extracted"
else:
try:
project = await create_plane_project(name)
response_text = (
f"Project created.\n\n"
f"**{project['name']}** (`{project['identifier']}`)"
)
summary = f"Created project: {project['name']}"
# Handle compound: "create project X and add [task]"
task_title = extract_compound_task(user_message)
if task_title:
issue = await create_plane_issue(task_title, project["name"])
response_text += (
f"\n\n**{issue['title']}** added.\n"
f"Project: *{issue['project']}* | #{issue['sequence_id']}"
)
summary += f" + issue: {task_title[:60]}"
else:
response_text += f"\n\nAdd issues with: _add [task] to {project['name']}_"
except Exception as e:
response_text = f"Couldn't create project: {e}"
summary = f"create_project error: {e}"
async for chunk in _stream_text(response_text):
yield chunk
elif intent == "query":
try:
if is_issue_query(user_message):
# Match against live project list — most robust, no static keywords needed
projects = await get_plane_projects()
msg_lower = user_message.lower()
project = None
# Longest-name-first so "General / Admin" beats "General"
for p in sorted(projects, key=lambda x: len(x["name"]), reverse=True):
name_words = p["name"].lower().replace("/", " ").replace("-", " ")
# Match if all significant words of the project name appear in the message
sig_words = [w for w in name_words.split() if len(w) > 2]
if sig_words and all(w in msg_lower for w in sig_words):
project = p
break
# Or if the identifier appears
if p.get("identifier", "").lower() in msg_lower:
project = p
break
if not project:
# Fall back to static keyword map
hint = extract_project_name(user_message)
if hint:
_, _ = await get_plane_issues(hint) # resolve via tools
project_name_fb, issues_fb = await get_plane_issues(hint)
if project_name_fb:
project = {"id": None, "name": project_name_fb}
if not project:
names = "\n".join(f"- {p['name']}" for p in projects)
response_text = f"Which project? Available:\n\n{names}"
else:
project_name, issues = await get_plane_issues(project["name"])
if not issues:
response_text = f"**{project['name']}** — no issues found."
else:
PRIORITY_ICON = {"urgent": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "none": ""}
lines = "\n".join(
f"- [{i['state']}] {PRIORITY_ICON.get(i['priority'], '')} {i['title']}"
for i in issues
)
response_text = f"**{project['name']}{len(issues)} issue(s):**\n\n{lines}"
summary = f"Issues query: {user_message[:80]}"
else:
projects = await get_plane_projects()
lines = "\n".join(
f"- **{p.get('identifier', '?')}** — {p['name']}"
for p in projects
)
response_text = f"**Plane Projects ({len(projects)}):**\n\n{lines}"
summary = f"Listed {len(projects)} Plane projects"
except Exception as e:
response_text = f"Couldn't reach Plane: {e}"
summary = f"Plane query error: {e}"
async for chunk in _stream_text(response_text):
yield chunk
elif intent == "task":
project_hint = extract_project_name(user_message)
title = user_message.strip()
# Live destination match first, static keyword map as fallback
project_hint = extract_task_destination(user_message) or extract_project_name(user_message)
title = extract_task_title(user_message)
try:
issue = await create_plane_issue(title, project_hint)
response_text = (
f"Task logged to Plane.\n\n"
f"**{issue['title']}** \n"
f"Project: *{issue['project']}* | #{issue['sequence_id']}\n\n"
f"I can't execute tasks yet (Phase 3). It's in the backlog."
f"Project: *{issue['project']}* | #{issue['sequence_id']}"
)
summary = f"Task created: {issue['title']}"
except Exception as e: