Skip to content

Commit f6a007a

Browse files
committed
Refactor(core): split built-in command handlers
1 parent 9213665 commit f6a007a

12 files changed

Lines changed: 1754 additions & 1567 deletions

File tree

facio/command/builtin.py

Lines changed: 88 additions & 1565 deletions
Large diffs are not rendered by default.

facio/command/builtins/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Built-in slash command handler modules."""
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Guardrail slash command handlers."""
2+
3+
from __future__ import annotations
4+
5+
from facio.bus.events import OutboundMessage
6+
from facio.command.router import CommandContext
7+
8+
9+
async def cmd_guard(ctx: CommandContext) -> OutboundMessage:
10+
"""Manage guardrails: enable/disable and banned topics."""
11+
loop = ctx.loop
12+
guardrail = getattr(loop, "_invariant_guardrail", None)
13+
scanner = getattr(loop, "_llm_guard", None)
14+
15+
args = ctx.args.strip()
16+
17+
# /guard on — enable guardrails at runtime
18+
if args == "on":
19+
if guardrail is None or scanner is None:
20+
# First-time enable — instantiate from config
21+
from facio.config.loader import get_config_path, load_config, save_config
22+
from facio.security.guardrails import InvariantGuardrail
23+
from facio.security.llm_guard import LLMGuardScanner
24+
25+
config = load_config(get_config_path())
26+
config.tools.guardrails.enabled = True
27+
save_config(config, get_config_path())
28+
guardrail = InvariantGuardrail(config.tools.guardrails)
29+
scanner = LLMGuardScanner(config.tools.guardrails, getattr(loop, "workspace", None))
30+
loop._invariant_guardrail = guardrail
31+
loop._llm_guard = scanner
32+
else:
33+
guardrail._enabled = True
34+
scanner._enabled = True
35+
from facio.config.loader import get_config_path, load_config, save_config
36+
config = load_config(get_config_path())
37+
config.tools.guardrails.enabled = True
38+
save_config(config, get_config_path())
39+
return OutboundMessage(
40+
channel=ctx.msg.channel,
41+
chat_id=ctx.msg.chat_id,
42+
content="✅ Guardrails **enabled**.",
43+
metadata={"render_as": "text"},
44+
)
45+
46+
# /guard off — disable guardrails at runtime
47+
if args == "off":
48+
if guardrail is not None:
49+
guardrail._enabled = False
50+
if scanner is not None:
51+
scanner._enabled = False
52+
from facio.config.loader import get_config_path, load_config, save_config
53+
config = load_config(get_config_path())
54+
config.tools.guardrails.enabled = False
55+
save_config(config, get_config_path())
56+
return OutboundMessage(
57+
channel=ctx.msg.channel,
58+
chat_id=ctx.msg.chat_id,
59+
content="⛔ Guardrails **disabled**.",
60+
metadata={"render_as": "text"},
61+
)
62+
63+
# /guard status — show current state
64+
if args == "status":
65+
enabled = (guardrail is not None and guardrail.available) or (scanner is not None and scanner.available)
66+
return OutboundMessage(
67+
channel=ctx.msg.channel,
68+
chat_id=ctx.msg.chat_id,
69+
content=f"Guardrails: **{'enabled' if enabled else 'disabled'}**",
70+
metadata={"render_as": "text"},
71+
)
72+
73+
# Everything below requires guardrails to be active
74+
if scanner is None or not scanner.available:
75+
return OutboundMessage(
76+
channel=ctx.msg.channel,
77+
chat_id=ctx.msg.chat_id,
78+
content="Guardrails are disabled. Use `/guard on` to enable.",
79+
metadata={"render_as": "text"},
80+
)
81+
82+
if not args or args == "list":
83+
topics = scanner.ban_topics
84+
if topics:
85+
lines = "\n".join(f"- {t}" for t in topics)
86+
content = f"**Banned topics ({len(topics)}):**\n{lines}"
87+
else:
88+
content = "No banned topics configured."
89+
return OutboundMessage(
90+
channel=ctx.msg.channel,
91+
chat_id=ctx.msg.chat_id,
92+
content=content,
93+
metadata={"render_as": "text"},
94+
)
95+
96+
parts = args.split(None, 1)
97+
action = parts[0].lower()
98+
topic = parts[1].strip() if len(parts) > 1 else ""
99+
100+
if action == "add":
101+
if not topic:
102+
return OutboundMessage(
103+
channel=ctx.msg.channel,
104+
chat_id=ctx.msg.chat_id,
105+
content="Usage: `/guard add <topic>`",
106+
metadata={"render_as": "text"},
107+
)
108+
scanner.add_topic(topic)
109+
return OutboundMessage(
110+
channel=ctx.msg.channel,
111+
chat_id=ctx.msg.chat_id,
112+
content=f"Added banned topic: **{topic}**",
113+
metadata={"render_as": "text"},
114+
)
115+
116+
if action == "remove":
117+
if not topic:
118+
return OutboundMessage(
119+
channel=ctx.msg.channel,
120+
chat_id=ctx.msg.chat_id,
121+
content="Usage: `/guard remove <topic>`",
122+
metadata={"render_as": "text"},
123+
)
124+
if topic.lower() in [t.lower() for t in scanner.ban_topics]:
125+
scanner.remove_topic(topic)
126+
return OutboundMessage(
127+
channel=ctx.msg.channel,
128+
chat_id=ctx.msg.chat_id,
129+
content=f"Removed banned topic: **{topic}**",
130+
metadata={"render_as": "text"},
131+
)
132+
return OutboundMessage(
133+
channel=ctx.msg.channel,
134+
chat_id=ctx.msg.chat_id,
135+
content=f"Topic not found: **{topic}**",
136+
metadata={"render_as": "text"},
137+
)
138+
139+
return OutboundMessage(
140+
channel=ctx.msg.channel,
141+
chat_id=ctx.msg.chat_id,
142+
content="Usage: `/guard [on|off|status|list|add|remove] [topic]`",
143+
metadata={"render_as": "text"},
144+
)

facio/command/builtins/history.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Conversation history slash command handlers."""
2+
3+
from __future__ import annotations
4+
5+
from facio.bus.events import OutboundMessage
6+
from facio.command import responses
7+
from facio.command.router import CommandContext
8+
9+
_HISTORY_DEFAULT_COUNT = 10
10+
_HISTORY_MAX_COUNT = 50
11+
_HISTORY_MAX_CONTENT_CHARS = 200
12+
13+
14+
def _format_history_message(msg: dict) -> str | None:
15+
"""Format a single history message for display. Returns None to skip."""
16+
role = msg.get("role")
17+
if role not in ("user", "assistant"):
18+
return None
19+
content = msg.get("content") or ""
20+
if isinstance(content, list):
21+
parts = [
22+
b.get("text", "")
23+
for b in content
24+
if isinstance(b, dict) and b.get("type") == "text"
25+
]
26+
content = " ".join(parts)
27+
content = str(content).strip()
28+
if not content:
29+
return None
30+
if len(content) > _HISTORY_MAX_CONTENT_CHARS:
31+
content = content[:_HISTORY_MAX_CONTENT_CHARS] + "…"
32+
label = "👤 You" if role == "user" else "🗿 facio"
33+
return f"{label}: {content}"
34+
35+
36+
async def cmd_history(ctx: CommandContext) -> OutboundMessage:
37+
"""Show the last N messages of the current session (default 10, max 50).
38+
39+
Usage: /history [count]
40+
"""
41+
count = _HISTORY_DEFAULT_COUNT
42+
if ctx.args.strip():
43+
try:
44+
count = max(1, min(int(ctx.args.strip()), _HISTORY_MAX_COUNT))
45+
except ValueError:
46+
return OutboundMessage(
47+
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
48+
content=responses.HISTORY_USAGE,
49+
metadata=dict(ctx.msg.metadata or {}),
50+
)
51+
52+
session = ctx.session or ctx.loop.sessions.get_or_create(ctx.key)
53+
history = session.get_history(max_messages=_HISTORY_MAX_COUNT)
54+
visible = [_format_history_message(m) for m in history]
55+
visible = [m for m in visible if m is not None]
56+
recent = visible[-count:]
57+
58+
if not recent:
59+
return OutboundMessage(
60+
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
61+
content=responses.NO_CONVERSATION_HISTORY,
62+
metadata=dict(ctx.msg.metadata or {}),
63+
)
64+
65+
header = responses.history_header(len(recent))
66+
return OutboundMessage(
67+
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
68+
content=header + "\n".join(recent),
69+
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
70+
)

facio/command/builtins/policy.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Tool policy slash command handlers."""
2+
3+
from __future__ import annotations
4+
5+
import shlex
6+
7+
from facio.bus.events import OutboundMessage
8+
from facio.command.router import CommandContext
9+
10+
11+
def _policy_text(ctx: CommandContext, content: str) -> OutboundMessage:
12+
return OutboundMessage(
13+
channel=ctx.msg.channel,
14+
chat_id=ctx.msg.chat_id,
15+
content=content,
16+
metadata={"render_as": "text"},
17+
)
18+
19+
20+
def _parse_policy_params(rest: str) -> dict[str, str]:
21+
"""Parse ``key=value`` tokens via :mod:`shlex` so values may be quoted.
22+
23+
Examples
24+
--------
25+
>>> _parse_policy_params('command="git status" cwd=/tmp')
26+
{'command': 'git status', 'cwd': '/tmp'}
27+
"""
28+
out: dict[str, str] = {}
29+
if not rest.strip():
30+
return out
31+
try:
32+
tokens = shlex.split(rest)
33+
except ValueError:
34+
# Unbalanced quotes — fall back to whitespace split so the user still
35+
# gets a (best-effort) result instead of a hard crash.
36+
tokens = rest.split()
37+
for token in tokens:
38+
if "=" not in token:
39+
continue
40+
k, _, v = token.partition("=")
41+
k = k.strip()
42+
if k:
43+
out[k] = v.strip()
44+
return out
45+
46+
47+
async def cmd_policy(ctx: CommandContext) -> OutboundMessage:
48+
"""Inspect and manage the tool-call policy (R11)."""
49+
gate = getattr(ctx.loop, "policy_gate", None)
50+
if gate is None:
51+
return _policy_text(ctx, "Tool policy is disabled.")
52+
53+
args = ctx.args.strip()
54+
parts = args.split(None, 1) if args else []
55+
action = parts[0].lower() if parts else "list"
56+
rest = parts[1] if len(parts) > 1 else ""
57+
58+
engine = gate.engine
59+
60+
if action in ("", "list", "status"):
61+
rules = engine.rules
62+
if not rules:
63+
return _policy_text(ctx, "No policy rules configured.")
64+
lines = [f"**Policy rules ({len(rules)}):**"]
65+
for i, r in enumerate(rules, 1):
66+
params = ", ".join(f"{k}={v}" for k, v in r.params.items()) or "*"
67+
lines.append(f"{i}. `{r.action}` `{r.tool}` ({params})")
68+
return _policy_text(ctx, "\n".join(lines))
69+
70+
if action == "clear":
71+
engine.clear()
72+
return _policy_text(ctx, "🗑️ Cleared all policy rules.")
73+
74+
if action in ("allow", "deny"):
75+
sub = rest.split(None, 1)
76+
if not sub:
77+
return _policy_text(ctx, f"Usage: `/policy {action} <tool> [key=value ...]`")
78+
tool = sub[0]
79+
params = _parse_policy_params(sub[1] if len(sub) > 1 else "")
80+
engine.add(action, tool, params, added_by="slash")
81+
return _policy_text(ctx, f"✅ Added `{action}` rule for `{tool}`.")
82+
83+
if action == "remove":
84+
sub = rest.split(None, 1)
85+
if not sub:
86+
return _policy_text(ctx, "Usage: `/policy remove <tool> [key=value ...]`")
87+
tool = sub[0]
88+
params = _parse_policy_params(sub[1] if len(sub) > 1 else "")
89+
# Need an action — look up matching rule(s)
90+
removed = 0
91+
for r in list(engine.rules):
92+
if r.tool == tool and r.params == params:
93+
if engine.remove(r.action, r.tool, r.params):
94+
removed += 1
95+
return _policy_text(ctx, f"Removed {removed} rule(s).")
96+
97+
return _policy_text(
98+
ctx,
99+
"Usage: `/policy [list|allow|deny|remove|clear] <tool> [key=value ...]`",
100+
)
101+
102+
103+
async def cmd_allow(ctx: CommandContext) -> OutboundMessage:
104+
"""Shortcut: `/allow <tool> [key=value ...]`."""
105+
ctx.args = f"allow {ctx.args}".strip()
106+
return await cmd_policy(ctx)
107+
108+
109+
async def cmd_deny(ctx: CommandContext) -> OutboundMessage:
110+
"""Shortcut: `/deny <tool> [key=value ...]`."""
111+
ctx.args = f"deny {ctx.args}".strip()
112+
return await cmd_policy(ctx)

0 commit comments

Comments
 (0)