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:
+54
-15
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user