feat: add SDK session implementation with approval flow and audit logging

- Implement SDK session with secretary model for tool approval flow
- Add audit logging for tool usage and permission decisions
- Support Feishu card interactions for approval requests
- Add new commands for task interruption and progress checking
- Remove old test files and update documentation
This commit is contained in:
Yuyao Huang
2026-04-01 12:51:00 +08:00
parent ba1b5b76c6
commit eac90941ef
34 changed files with 2375 additions and 965 deletions
+54 -15
View File
@@ -12,7 +12,7 @@ from typing import Optional, Tuple
from agent.manager import manager
from agent.scheduler import scheduler
from agent.task_runner import task_runner
from agent.cc_runner import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
from agent.sdk_session import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
from orchestrator.agent import agent
from orchestrator.tools import set_current_user, get_current_chat
@@ -24,12 +24,14 @@ _PERM_ALIASES: dict[str, str] = {
"default": "default",
"edit": "acceptEdits",
"plan": "plan",
"auto": "dontAsk",
}
_PERM_LABELS: dict[str, str] = {
"default": "default",
"bypassPermissions": "bypass",
"acceptEdits": "edit",
"plan": "plan",
"dontAsk": "auto",
}
@@ -105,6 +107,10 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
return await _cmd_remind(args)
elif cmd == P+"perm":
return await _cmd_perm(user_id, args)
elif cmd in (P+"stop", P+"interrupt"):
return await _cmd_stop(user_id)
elif cmd in (P+"progress", P+"prog", P+"p"):
return await _cmd_progress(user_id)
elif cmd in (P+"nodes", P+"node"):
return await _cmd_nodes(user_id, args)
else:
@@ -114,26 +120,25 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
async def _cmd_new(user_id: str, args: str) -> str:
"""Create a new session."""
if not args:
return "Usage: /new <project_dir> [initial_message] [--timeout N] [--perm MODE]\nModes: default, edit, plan, bypass"
return "Usage: /new <project_dir> [initial_message] [--perm MODE]\nModes: default, edit, plan, bypass, auto"
parser = argparse.ArgumentParser()
parser.add_argument("working_dir", nargs="?", help="Project directory")
parser.add_argument("rest", nargs="*", help="Initial message")
parser.add_argument("--timeout", type=int, default=None, help="CC timeout in seconds")
parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds")
parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass")
parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass, auto")
try:
parsed = parser.parse_args(args.split())
except SystemExit:
return "Usage: /new <project_dir> [initial_message] [--timeout N] [--idle N] [--perm MODE]"
return "Usage: /new <project_dir> [initial_message] [--idle N] [--perm MODE]"
if not parsed.working_dir:
return "Error: project_dir is required"
permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE
if permission_mode is None:
return f"Invalid --perm. Valid modes: default, edit, plan, bypass"
return f"Invalid --perm. Valid modes: default, edit, plan, bypass, auto"
working_dir = parsed.working_dir
initial_msg = " ".join(parsed.rest) if parsed.rest else None
@@ -147,13 +152,14 @@ async def _cmd_new(user_id: str, args: str) -> str:
import uuid as _uuid
conv_id = str(_uuid.uuid4())[:8]
chat_id = get_current_chat()
await manager.create(
conv_id,
str(resolved),
owner_id=user_id,
idle_timeout=parsed.idle or 1800,
cc_timeout=float(parsed.timeout or 300),
permission_mode=permission_mode,
chat_id=chat_id,
)
agent._active_conv[user_id] = conv_id
@@ -161,7 +167,6 @@ async def _cmd_new(user_id: str, args: str) -> str:
if initial_msg:
response = await manager.send(conv_id, initial_msg, user_id=user_id)
chat_id = get_current_chat()
if chat_id:
from bot.feishu import send_card, send_text, build_sessions_card
sessions = manager.list_sessions(user_id=user_id)
@@ -174,8 +179,6 @@ async def _cmd_new(user_id: str, args: str) -> str:
perm_label = _perm_label(permission_mode)
reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]"
if parsed.timeout:
reply += f" (timeout: {parsed.timeout}s)"
if initial_msg and response:
reply += f"\n\n{response}"
return reply
@@ -289,11 +292,12 @@ async def _cmd_perm(user_id: str, args: str) -> str:
if not parts:
return (
"Usage: /perm <mode> [conv_id]\n"
"Modes: default, edit, plan, bypass\n"
"Modes: default, edit, plan, bypass, auto\n"
" default — default mode\n"
" edit — auto-accept file edits, confirm shell commands\n"
" plan — plan only, no writes\n"
" bypass — skip all permission checks"
" bypass — skip all permission checks\n"
" auto — allow all tools, don't ask"
)
alias = parts[0]
@@ -403,6 +407,38 @@ async def _cmd_remind(args: str) -> str:
return f"⏰ Reminder #{job_id} set for {value}{unit} from now"
async def _cmd_stop(user_id: str) -> str:
"""Interrupt the current task in the active session."""
conv_id = agent.get_active_conv(user_id)
if not conv_id:
return "No active session."
try:
success = await manager.interrupt(conv_id, user_id)
return "✓ Interrupted" if success else "No active task."
except Exception as e:
return f"Error: {e}"
async def _cmd_progress(user_id: str) -> str:
"""Show progress of the active session."""
conv_id = agent.get_active_conv(user_id)
if not conv_id:
return "No active session."
progress = manager.get_progress(conv_id, user_id)
if not progress:
return "Session not found."
if not progress.busy:
if progress.last_result:
return f"✅ 已完成\n\n{progress.last_result[:500]}"
return "空闲中,无正在执行的任务。"
elapsed = int(progress.elapsed_seconds)
tools = ", ".join(progress.tool_calls[-3:]) if progress.tool_calls else "none"
pending = ""
if progress.pending_approval:
pending = f"\n⚠️ 等待审批: {progress.pending_approval}"
return f"⏳ 执行中 ({elapsed}s)\n最近工具: {tools}{pending}"
async def _cmd_nodes(user_id: str, args: str) -> str:
"""List nodes or switch active node."""
from config import ROUTER_MODE
@@ -440,11 +476,13 @@ def _cmd_help() -> str:
"""Show help."""
from config import COMMAND_PREFIX as P
return f"""**Commands:** (prefix: `{P}`)
{P}new <dir> [msg] [--timeout N] [--idle N] [--perm MODE] - Create session
{P}new <dir> [msg] [--idle N] [--perm MODE] - Create session
{P}status - Show sessions and current mode
{P}close [n] - Close session (active or by number)
{P}switch <n> - Switch to session by number
{P}perm <mode> [conv_id] - Set permission mode (default/edit/plan/bypass)
{P}perm <mode> [conv_id] - Set permission mode (default/edit/plan/bypass/auto)
{P}stop - Interrupt the current task
{P}progress - Show task progress
{P}direct - Direct mode: messages → Claude Code (no LLM overhead)
{P}smart - Smart mode: messages → LLM routing (default)
{P}shell <cmd> - Run shell command (bypasses LLM)
@@ -462,4 +500,5 @@ def _cmd_help() -> str:
plan — 只规划、不执行任何写操作
适合:先预览 CC 的操作计划再决定是否执行
bypass — 跳过所有权限确认,CC 自动执行一切操作
适合:受信任的沙盒环境、自动化任务"""
适合:受信任的沙盒环境、自动化任务
auto — 允许所有工具,不询问(等效 bypass + dontAsk"""
+44 -2
View File
@@ -128,8 +128,8 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode:
lines = []
for i, s in enumerate(sessions, 1):
marker = "" if s["conv_id"] == active_conv_id else " "
started = "🟢" if s["started"] else "🟡"
lines.append(f"{marker} {i}. {started} `{s['conv_id']}` — `{s['cwd']}`")
status = "🔵" if s.get("busy") else ""
lines.append(f"{marker} {i}. {status} `{s['conv_id']}` — `{s['cwd']}`")
sessions_md = "\n".join(lines)
else:
sessions_md = "_No active sessions_"
@@ -148,6 +148,48 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode:
}
def build_approval_card(conv_id: str, tool_name: str, summary: str, timeout: int = 120) -> dict:
"""Build an approval card for a tool call (schema 2.0, with approve/deny buttons)."""
return {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
"template": "orange",
},
"body": {
"elements": [
{
"tag": "markdown",
"content": f"**工具:** `{tool_name}`\n**参数:** {summary}",
},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "✅ 批准"},
"type": "primary",
"value": {"action": "approve", "conv_id": conv_id},
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "❌ 拒绝"},
"type": "danger",
"value": {"action": "deny", "conv_id": conv_id},
},
],
},
{
"tag": "note",
"elements": [
{"tag": "plain_text", "content": f"超时 {timeout}s 自动拒绝 | 也可回复 y/n"},
],
},
],
},
}
async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_type: str = "stream") -> None:
"""
Upload a local file to Feishu and send it as a file message.
+55
View File
@@ -129,6 +129,25 @@ async def _process_message(user_id: str, chat_id: str, text: str) -> None:
await send_text(chat_id, "chat_id", "Sorry, you are not authorized to use this bot.")
return
# Text approval fallback: user replies y/n to a pending tool approval
if text.strip().lower() in ("y", "n", "yes", "no"):
approved = text.strip().lower() in ("y", "yes")
from orchestrator.agent import agent as _agent
from agent.manager import manager as _manager
conv_id = _agent.get_active_conv(user_id)
if conv_id:
session = _manager._sessions.get(conv_id)
if (
session
and session.sdk_session
and session.sdk_session._pending_approval
and not session.sdk_session._pending_approval.done()
):
await _manager.approve(conv_id, approved)
label = "✅ 已批准" if approved else "❌ 已拒绝"
await send_text(chat_id, "chat_id", label)
return
from config import ROUTER_MODE
if ROUTER_MODE:
from router.nodes import get_node_registry
@@ -196,12 +215,48 @@ def _handle_any(data: lark.CustomizedEvent) -> None:
logger.info("RAW CustomizedEvent: %s", marshaled[:500])
def _handle_card_action(data: lark.CustomizedEvent) -> None:
"""Handle Feishu card button clicks (approval approve/deny)."""
try:
marshaled = lark.JSON.marshal(data)
if not marshaled:
return
payload = json.loads(marshaled) if isinstance(marshaled, str) else marshaled
action = payload.get("event", {}).get("action", {})
value = action.get("value", {})
action_type = value.get("action") # "approve" or "deny"
conv_id = value.get("conv_id")
if not action_type or not conv_id:
logger.debug("Card action without action/conv_id: %s", value)
return
approved = action_type == "approve"
logger.info("Card action: %s for session %s", action_type, conv_id)
if _main_loop:
asyncio.run_coroutine_threadsafe(
_handle_approval_async(conv_id, approved), _main_loop
)
except Exception:
logger.exception("Error handling card action")
async def _handle_approval_async(conv_id: str, approved: bool) -> None:
"""Process a card approval action."""
from agent.manager import manager
await manager.approve(conv_id, approved)
def build_event_handler() -> lark.EventDispatcherHandler:
"""Construct the EventDispatcherHandler with all registered callbacks."""
handler = (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(_handle_message)
.register_p1_customized_event("im.message.receive_v1", _handle_any)
.register_p1_customized_event("card.action.trigger", _handle_card_action)
.build()
)
return handler