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:
+189
-3
@@ -1,4 +1,4 @@
|
||||
AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon"}
|
||||
AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon", "hermes"}
|
||||
|
||||
STATUS_PHRASES = {
|
||||
"status", "health", "running", "last run", "what did", "when did",
|
||||
@@ -9,7 +9,17 @@ STATUS_PHRASES = {
|
||||
TASK_PHRASES = {
|
||||
"create task", "add task", "add issue", "create issue", "log task",
|
||||
"log this", "new task", "new issue", "add to plane", "add to backlog",
|
||||
"plan", "schedule", "remind", "track", "todo", "to do",
|
||||
"schedule", "remind", "track", "todo", "to do",
|
||||
}
|
||||
|
||||
# "plan" removed from TASK_PHRASES — it substring-matches "plane" (the tool name)
|
||||
TASK_WORDS = {"plan"}
|
||||
|
||||
QUERY_PHRASES = {
|
||||
"list", "list all", "show all", "what are", "give me", "get",
|
||||
"my projects", "all projects", "list projects", "show projects",
|
||||
"issues", "tasks", "backlog", "what's in", "whats in", "show issues",
|
||||
"list issues", "issues in", "tasks in", "work items", "work item",
|
||||
}
|
||||
|
||||
RESEARCH_PHRASES = {
|
||||
@@ -17,12 +27,61 @@ RESEARCH_PHRASES = {
|
||||
"how does", "documentation", "docs",
|
||||
}
|
||||
|
||||
CREATE_PROJECT_PHRASES = {
|
||||
"create project", "new project", "make project", "add project",
|
||||
"create a project", "new project called", "create project called",
|
||||
"start a project", "set up a project",
|
||||
"create a plane project", "create plane project",
|
||||
"create a new project", "make a new project",
|
||||
}
|
||||
|
||||
# Execute intent — words that signal a request to run/deploy a container
|
||||
EXECUTE_VERBS = {"restart", "rebuild", "redeploy", "reload", "deploy"}
|
||||
|
||||
# Short agent name → Docker stack directory name
|
||||
STACK_NAMES = {
|
||||
"hodor": "hodor-gateway",
|
||||
"bran": "bran-changelog",
|
||||
"varys": "varys-monitor",
|
||||
"sam": "sam-research",
|
||||
"raven": "raven-notify",
|
||||
"qyburn": "qyburn-coder",
|
||||
"citadel": "citadel-mcp",
|
||||
"jon": "jon-snow",
|
||||
"hermes": "hermes-cloud",
|
||||
"monitoring": "monitoring",
|
||||
"netbox": "netbox",
|
||||
"plane": "plane",
|
||||
"gitea": "gitea",
|
||||
"searxng": "searxng",
|
||||
}
|
||||
|
||||
|
||||
def classify_intent(message: str) -> str:
|
||||
msg = message.lower()
|
||||
words = set(msg.split())
|
||||
|
||||
# Agent name + query word → status
|
||||
# Project creation — check before task/query
|
||||
if any(p in msg for p in CREATE_PROJECT_PHRASES):
|
||||
return "create_project"
|
||||
|
||||
# Execute intent — agent/stack name + action verb → queue for approval
|
||||
if words & EXECUTE_VERBS and words & set(STACK_NAMES.keys()):
|
||||
return "execute"
|
||||
if any(v in msg for v in EXECUTE_VERBS) and any(s in msg for s in STACK_NAMES):
|
||||
return "execute"
|
||||
|
||||
# Plane query — list/show projects or issues (check before task to avoid "plan" ⊂ "plane" bug)
|
||||
if ("project" in msg or "projects" in msg) and any(p in msg for p in QUERY_PHRASES):
|
||||
return "query"
|
||||
if "plane" in words and any(p in msg for p in QUERY_PHRASES):
|
||||
return "query"
|
||||
# Issue list query — "show issues in X", "list tasks for X", "work items for X"
|
||||
has_issue_word = bool(words & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES)
|
||||
if has_issue_word and any(p in msg for p in {"show", "list", "get", "what", "give", "in", "for"}):
|
||||
return "query"
|
||||
|
||||
# Agent name + status word → status
|
||||
if words & AGENT_NAMES:
|
||||
if any(p in msg for p in STATUS_PHRASES) or words & {"status", "check", "output", "run"}:
|
||||
return "status"
|
||||
@@ -30,6 +89,12 @@ def classify_intent(message: str) -> str:
|
||||
# Explicit task phrases → task
|
||||
if any(p in msg for p in TASK_PHRASES):
|
||||
return "task"
|
||||
# Word-boundary match for single-word task terms (avoids "plan" matching "plane")
|
||||
if words & TASK_WORDS:
|
||||
return "task"
|
||||
# Natural task creation: "add X to [project]"
|
||||
if msg.startswith("add ") and " to " in msg:
|
||||
return "task"
|
||||
|
||||
# Generic status signal words
|
||||
if any(p in msg for p in STATUS_PHRASES) and words & AGENT_NAMES:
|
||||
@@ -54,6 +119,36 @@ def extract_agent_name(message: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def extract_execute_target(message: str) -> tuple[str, str] | None:
|
||||
"""Extract (action_type, stack_dir_name) from an execute message.
|
||||
|
||||
Examples:
|
||||
'restart citadel' → ('docker_restart', 'citadel-mcp')
|
||||
'rebuild raven' → ('docker_rebuild', 'raven-notify')
|
||||
'redeploy hermes' → ('docker_rebuild', 'hermes-cloud')
|
||||
|
||||
Returns None if action type or stack name cannot be determined.
|
||||
"""
|
||||
msg = message.lower()
|
||||
words = set(msg.split())
|
||||
|
||||
action_type = None
|
||||
if words & {"restart", "reload"}:
|
||||
action_type = "docker_restart"
|
||||
elif words & {"rebuild", "redeploy", "deploy"}:
|
||||
action_type = "docker_rebuild"
|
||||
|
||||
stack_dir = None
|
||||
for short, full in STACK_NAMES.items():
|
||||
if short in words:
|
||||
stack_dir = full
|
||||
break
|
||||
|
||||
if action_type and stack_dir:
|
||||
return (action_type, stack_dir)
|
||||
return None
|
||||
|
||||
|
||||
PROJECT_KEYWORDS = {
|
||||
"bni": "BNI Scheduler",
|
||||
"scheduler": "BNI Scheduler",
|
||||
@@ -67,9 +162,100 @@ PROJECT_KEYWORDS = {
|
||||
"portal": "Nexum Portal",
|
||||
"authelia": "Nexum Portal",
|
||||
"nexum": "Nexum Portal",
|
||||
"general": "General / Admin",
|
||||
"admin": "General / Admin",
|
||||
"agent": "Agent Ecosystem",
|
||||
"ecosystem": "Agent Ecosystem",
|
||||
"milkwood": "Milkwood Primary",
|
||||
"grgl": "GRGL",
|
||||
"private": "Private Clients",
|
||||
}
|
||||
|
||||
|
||||
ISSUE_KEYWORDS = {"issue", "issues", "task", "tasks", "backlog", "ticket", "tickets"}
|
||||
ISSUE_PHRASES = {"work items", "work item"}
|
||||
|
||||
|
||||
def is_issue_query(message: str) -> bool:
|
||||
msg = message.lower()
|
||||
return bool(set(msg.split()) & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES)
|
||||
|
||||
|
||||
def extract_task_title(message: str) -> str:
|
||||
"""Extract clean task title from natural phrasing like 'add buy milk to General'."""
|
||||
msg = message.strip()
|
||||
lower = msg.lower()
|
||||
if lower.startswith("add ") and " to " in lower:
|
||||
parts = msg.rsplit(" to ", 1)
|
||||
title = parts[0][4:].strip()
|
||||
return title if title else msg
|
||||
for prefix in ("create task:", "add task:", "log task:", "new task:", "log this:"):
|
||||
if lower.startswith(prefix):
|
||||
return msg[len(prefix):].strip()
|
||||
return msg
|
||||
|
||||
|
||||
def extract_compound_task(message: str) -> str | None:
|
||||
"""Extract a task title from compound messages like 'create project X and add [todo item,] Y'.
|
||||
Returns the task title or None if no compound task found."""
|
||||
lower = message.lower()
|
||||
# Split on compound markers
|
||||
for marker in ("and add to do item,", "and add todo item,", "and add a task,",
|
||||
"and add task,", "and add issue,", "and add to do,",
|
||||
"and add to-do,", "and add,"):
|
||||
idx = lower.find(marker)
|
||||
if idx != -1:
|
||||
return message[idx + len(marker):].strip().strip('"\'')
|
||||
# Simpler: "and add [title]" — only if no comma separator
|
||||
idx = lower.find(" and add ")
|
||||
if idx != -1:
|
||||
candidate = message[idx + 9:].strip()
|
||||
# Skip if it looks like a project destination ("and add to X")
|
||||
if not candidate.lower().startswith("to "):
|
||||
return candidate.strip('"\'')
|
||||
return None
|
||||
|
||||
|
||||
def _trim_at_compound(text: str) -> str:
|
||||
"""Trim text at compound boundaries like 'and add'."""
|
||||
lower = text.lower()
|
||||
for stop in (" and add", " then add", ", and", " also add"):
|
||||
idx = lower.find(stop)
|
||||
if idx != -1:
|
||||
text = text[:idx]
|
||||
return text.strip().strip('"\',.')
|
||||
|
||||
|
||||
def extract_new_project_name(message: str) -> str | None:
|
||||
"""Extract project name from 'create project called X', 'new project: X', etc."""
|
||||
msg = message.strip()
|
||||
lower = msg.lower()
|
||||
for marker in ("called ", "named ", "name ", ": ", "- "):
|
||||
idx = lower.find(marker)
|
||||
if idx != -1:
|
||||
name = _trim_at_compound(msg[idx + len(marker):])
|
||||
if name:
|
||||
return name
|
||||
for prefix in ("create a new project ", "create a plane project ", "create plane project ",
|
||||
"create a project ", "create project ", "new project ", "make a new project ",
|
||||
"make project ", "add project ", "start a project ", "set up a project "):
|
||||
if lower.startswith(prefix):
|
||||
name = _trim_at_compound(msg[len(prefix):])
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def extract_task_destination(message: str) -> str | None:
|
||||
"""Extract the destination project from 'add X to Y' → returns Y, or None."""
|
||||
lower = message.strip().lower()
|
||||
if lower.startswith("add ") and " to " in lower:
|
||||
parts = message.strip().rsplit(" to ", 1)
|
||||
if len(parts) == 2:
|
||||
return parts[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_project_name(message: str) -> str | None:
|
||||
msg = message.lower()
|
||||
for kw, project in PROJECT_KEYWORDS.items():
|
||||
|
||||
Reference in New Issue
Block a user