//! ask_uclaw AI 工具(u-claw 委托:经 u-relay commander 协议下发指令给常驻 claude 网关)。 //! //! ## 协议(2026-10-05 三方源码终核:u-claw wsch.go 405 行 / u-relay relay.rs+conn.rs / u-ppt relay.ts) //! //! commander 侧线格式(全部裸应用层帧,与 df-tunnel 的 TunnelMessage{dir} 包装不兼容): //! 1. 连 `{relay}/ws/commander`,10s 内首帧 hello `{kind:"commander", channel_id, token}`; //! 2. relay 回 `{"kind":"control","payload":{"control_kind":"hello_ack"}}` = 握手成功 //! (错误帧 `{"kind":"control","error":"..."}` = 被拒); //! 3. 心跳:裸顶层帧 `{"control_kind":"ping"}`(⚠️不能包 kind/payload——会被 relay 当 //! 普通消息转发给对端,pong 永远不来);relay 回 `{"control_kind":"pong"}` 本地响应; //! 4. 发指令:裸帧 `{"type":"agent_request","request_id":"","prompt":"..."}` → //! relay 自动包装 BroadcastMessage(kind=command) 路由到同 channel 的 agent; //! 5. 收下行:relay 包装帧 `{kind:"command",from:"agent",payload:{...}}`,payload 内 //! `{type:"agent_progress"|"agent_result","request_id":"...","text":"..."}`; //! progress 为 u-claw 侧 2s 节流块级(assistant 阶段叙述 + 🔧tool 名),result 为全文; //! 6. 帧上限 1MiB(relay SetReadLimit + 入站断开);channel_id 格式:非空 ≤128B 无控制字符。 //! //! ## 执行语义(u-claw 侧实证) //! - 单 channel 单链串行 worker(reqCh 缓冲 4,满静默丢)→ 本工具进程级互斥:同一时刻 //! 仅允许 1 个在飞请求,第二个直接报「渠道忙」(对齐串行事实,避免排队后丢帧不可感知); //! - u-claw 单条指令 15min 硬上限(wsExecTimeout,超时 AbortRunning 回「处理超时」文本)→ //! 本工具客户端超时 16min(>硬上限,让 u-claw 自己的超时结果先回来);外层兜底超时取 //! 16min+60s 缓冲(audit/mod.rs outer_secs); //! - 队列满静默丢无回执 → 超时后最多重发 2 次,文案「u-claw 渠道忙」; //! - 空闲链回收后 --resume 冷启动 ~7s 属正常(16min 超时覆盖足够); //! - 会话锚定:channel 级单链粘性会话,所有 ask_uclaw 共享同一 claude 上下文(多轮追问 //! 天然连续;不同 devflow 会话共享同一 u-claw 上下文,属已知 MVP 语义,工具描述注明)。 //! //! ## 连接模型:每请求独立短连接 //! 常驻连接需管理跨会话生命周期/重连/分发路由(AppHandle 级 spawn),短连接把生命周期 //! 收敛到单次工具调用内(connect→hello→探活→request→progress/result→close),无全局态、 //! 无泄漏面。探活 ping 在同一连接内完成(连接前探活无意义——relay 与 agent 可独立断)。 //! 代价是每请求 ~3 次 WS 握手 RTT,相对 15min 级任务量级可忽略。 //! //! ## 配置(KV,前端设置页可改;默认关) //! - uclaw_relay_url:如 `wss://relay.example.com`(自动补 /ws/commander); //! - uclaw_channel_id:配对 channel(须与 u-claw ~/.u-claw/u-claw.yml ws.channel_id 一致); //! - uclaw_token:U_RELAY_TOKEN 同源。 //! 三项任一为空 → 工具返回明确错误「未配置」,LLM 可向用户澄清。 //! //! ## 流式 //! handler 内经 task-local sink(command_stream 同款新槽)把 agent_progress 块级文本 //! emit AiCommandOutput(前端工具卡 Started→Completed 间实时展示);audit/mod.rs 对 //! ask_uclaw 分支注入 sink + outer_secs 分档(与 run_command/spawn_agent 同模式)。 use std::sync::Arc; use std::time::Duration; use df_ai::ai_tools::{AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta}; use df_ai::declare_tool; use df_storage::db::Database; use futures_util::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message; use crate::state::SettingsNorm; // ── 协议常量(与 u-relay/u-claw 源码对齐,勿凭记忆改) ── /// 握手看门狗:10s 内未收 hello_ack 视为失败(u-ppt relay.ts HANDSHAKE_TIMEOUT 对齐)。 const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); /// 探活 ping → pong 等待窗(30s 内无 pong 判渠道不可达,不进请求,D-5)。 const PROBE_TIMEOUT: Duration = Duration::from_secs(30); /// 单请求硬超时:16min(> u-claw wsExecTimeout 15min,让对端超时结果先回来)。 const REQUEST_TIMEOUT: Duration = Duration::from_secs(16 * 60); /// 超时重发上限(对齐任务描述 D-4「最多 2 次重试」;含首发共 3 次尝试)。 const MAX_ATTEMPTS: u32 = 3; /// 帧上限 1MiB(relay 入站断开线,客户端先拒省一次白连)。 const MAX_FRAME_BYTES: usize = 1024 * 1024; // ── KV 键(前端设置页同键写入) ── const KV_RELAY_URL: &str = "uclaw_relay_url"; const KV_CHANNEL_ID: &str = "uclaw_channel_id"; const KV_TOKEN: &str = "uclaw_token"; /// 从 settings 读三项配置并校验(任一空 → Err 明确提示,LLM 可澄清)。 /// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号,SettingsNorm 正典同款)。 async fn load_config(db: &Database) -> anyhow::Result<(String, String, String)> { use df_storage::crud::SettingsRepo; let settings = SettingsRepo::new(db); let url = settings .get_string_norm(KV_RELAY_URL) .await .unwrap_or_default(); let channel = settings .get_string_norm(KV_CHANNEL_ID) .await .unwrap_or_default(); let token = settings .get_string_norm(KV_TOKEN) .await .unwrap_or_default(); if url.trim().is_empty() || channel.trim().is_empty() || token.trim().is_empty() { anyhow::bail!( "u-claw 集成未配置:需在设置页填写 uclaw_relay_url / uclaw_channel_id / uclaw_token(三项缺一不可)" ); } Ok((url.trim().to_string(), channel.trim().to_string(), token.trim().to_string())) } /// relay URL 归一:去掉尾斜杠,无路径时补 /ws/commander(用户可填裸域名或完整端点)。 fn normalize_relay_url(url: &str) -> String { let u = url.trim_end_matches('/'); if u.ends_with("/ws/commander") { u.to_string() } else { format!("{u}/ws/commander") } } /// channel_id 格式校验(对齐 u-relay validate_channel_id:非空 ≤128B 无控制字符, /// 客户端先拒省一次握手往返)。 fn validate_channel_id(channel: &str) -> bool { !channel.is_empty() && channel.len() <= 128 && !channel.chars().any(|c| c.is_control()) } /// URL 协议校验:仅 ws:// 与 wss://(拒 http/https/其他 scheme,防误配置成 relay 的 HTTP 面板)。 fn validate_ws_url(url: &str) -> bool { url.starts_with("ws://") || url.starts_with("wss://") } // ── task-local 进度 sink(audit/mod.rs 对 ask_uclaw 分支注入,handler 内读取) ── tokio::task_local! { static UCLAW_SINK: Option; } /// 进度 sink:emit 所需上下文(AppHandle + tool_call_id + conv_id)。 /// 结构对齐 command_stream::CommandSink(无合批——u-claw 侧已 2s 节流,双端节流叠加无必要)。 pub struct UclawProgressSink { app: tauri::AppHandle, tool_call_id: String, conversation_id: Option, } impl UclawProgressSink { pub fn new(app: tauri::AppHandle, tool_call_id: String, conversation_id: Option) -> Self { Self { app, tool_call_id, conversation_id } } fn emit_progress(&self, text: &str) { let ev = crate::commands::ai::AiChatEvent::AiCommandOutput { id: self.tool_call_id.clone(), stream: "stdout".to_string(), line: text.to_string(), conversation_id: self.conversation_id.clone(), }; crate::commands::ai::event_bus::emit_ai_chat_event(&self.app, ev); } } /// 在 sink 作用域内执行 future(audit/mod.rs 调用;作用域结束自动清理)。 pub async fn scope_progress_sink(sink: Option, fut: F) -> R where F: std::future::Future, { UCLAW_SINK.scope(sink, fut).await } /// handler 读取 sink(task-local 未注入时 noop,兜底不报错)。 fn emit_progress_line(text: &str) { let _ = UCLAW_SINK.with(|maybe| { if let Some(sink) = maybe { sink.emit_progress(text); } }); } // ── WS 客户端(每请求短连接) ── /// 单连接会话:connect → hello → hello_ack → (探活 ping) → agent_request → 收帧直到 result。 /// 返回 Ok(result_text) 或 Err(带语义错误)。progress 帧经 emit_progress_line 流式透出。 async fn run_once( ws_url: &str, channel: &str, token: &str, prompt: &str, request_id: &str, ) -> anyhow::Result { // 帧体预校验(prompt 序列化后超 1MiB 直接拒,relay 会断连不如本地先拒) let request_frame = serde_json::json!({ "type": "agent_request", "request_id": request_id, "prompt": prompt, }); let request_text = serde_json::to_string(&request_frame)?; if request_text.len() > MAX_FRAME_BYTES { anyhow::bail!( "prompt 序列化后 {} 字节超过 relay 1MiB 帧上限,请精简任务描述", request_text.len() ); } let (ws, _resp) = tokio_tungstenite::connect_async(ws_url) .await .map_err(|e| anyhow::anyhow!("连接 relay 失败({ws_url}): {e}"))?; let (mut sink, mut stream) = ws.split(); // hello 握手(10s 看门狗) let hello = serde_json::json!({ "kind": "commander", "channel_id": channel, "token": token, }); tokio::time::timeout(HANDSHAKE_TIMEOUT, sink.send(Message::Text(hello.to_string()))) .await .map_err(|_| anyhow::anyhow!("hello 发送超时(10s)"))? .map_err(|e| anyhow::anyhow!("hello 发送失败: {e}"))?; // 等 hello_ack(容错跳过 pong 帧——理论上 pong 只在 ping 后,防御性跳过) let ack_deadline = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { loop { let msg = stream.next().await.ok_or_else(|| anyhow::anyhow!("连接在握手期关闭"))?; match msg? { Message::Text(t) => return Ok::(t), _ => continue, } } }) .await .map_err(|_| anyhow::anyhow!("hello_ack 超时(10s,relay 未响应)"))??; let ack: serde_json::Value = serde_json::from_str(&ack_deadline) .map_err(|e| anyhow::anyhow!("hello_ack 非 JSON: {e}"))?; if ack.get("kind").and_then(|v| v.as_str()) == Some("control") { if let Some(err) = ack.get("error").and_then(|v| v.as_str()) { anyhow::bail!("relay 握手被拒: {err}(检查 token/channel_id)"); } let ck = ack.pointer("/payload/control_kind").and_then(|v| v.as_str()); if ck != Some("hello_ack") { anyhow::bail!("relay 握手异常控制帧: control_kind={ck:?}"); } } else if ack.get("kind").is_some() { // 带 kind 但非 control:非预期(应为 hello_ack),报错退出 anyhow::bail!("relay 握手返回非 ack 帧: {}", ack_deadline); } // 无 kind 字段的帧也当 ack 放行(协议演进容错);走到这里 = 握手成功。 // 探活 ping:确认 agent 在线(channel 无任何连接时发请求=石沉大海,relay 无离线缓存)。 // ping 必须是裸顶层 {"control_kind":"ping"}(包 kind/payload 会被当普通消息转发)。 sink.send(Message::Text(r#"{"control_kind":"ping"}"#.to_string())) .await .map_err(|e| anyhow::anyhow!("探活 ping 发送失败: {e}"))?; tokio::time::timeout(PROBE_TIMEOUT, async { loop { let msg = stream.next().await.ok_or_else(|| anyhow::anyhow!("探活期连接关闭"))?; match msg? { Message::Text(t) => { let v: serde_json::Value = serde_json::from_str(&t) .map_err(|e| anyhow::anyhow!("探活期非 JSON 帧: {e}"))?; let ck = v.get("control_kind").and_then(|x| x.as_str()); match ck { Some("pong") => return Ok::<(), anyhow::Error>(()), Some("hello_ack") => continue, // 迟到的 ack,忽略 _ => anyhow::bail!("探活期收到非控制帧(协议异常): {}", &t[..t.len().min(120)]), } } _ => continue, } } }) .await .map_err(|_| { anyhow::anyhow!( "u-claw 探活超时({}s 无 pong):渠道不可达(agent 离线或 relay 故障),不进入队列", PROBE_TIMEOUT.as_secs() ) })??; // 发指令(裸应用层帧,relay 自动包装 kind=command 路由) sink.send(Message::Text(request_text)) .await .map_err(|e| anyhow::anyhow!("指令发送失败: {e}"))?; // 收循环:按 request_id 配对,直到 agent_result;progress 流式透出。 // 超时总闸 16min(u-claw 15min 硬上限会先回超时文本,此闸只兜协议级静默)。 let mut last_progress = String::new(); let result = tokio::time::timeout(REQUEST_TIMEOUT, async { loop { let msg = stream .next() .await .ok_or_else(|| anyhow::anyhow!("连接在等待结果期关闭(agent 可能已下线)"))?; match msg? { Message::Text(t) => { let v: serde_json::Value = match serde_json::from_str(&t) { Ok(v) => v, Err(_) => continue, // 非 JSON 忽略(协议前向兼容) }; // control 帧:pong/其他,忽略 if v.get("kind").and_then(|x| x.as_str()) == Some("control") { continue; } // relay 包装帧取 payload;裸帧(u-relay 演进直透)兜底取自身 let body = v.get("payload").unwrap_or(&v); let rtype = body.get("type").and_then(|x| x.as_str()); let rid = body.get("request_id").and_then(|x| x.as_str()); if rid != Some(request_id) { continue; // 不配对的帧忽略(u-ppt 同语义) } let text = body.get("text").and_then(|x| x.as_str()).unwrap_or(""); match rtype { Some("agent_progress") => { if !text.is_empty() && text != last_progress { emit_progress_line(text); last_progress = text.to_string(); } } Some("agent_result") => { return Ok::(text.to_string()); } _ => {} // 未知 type 静默忽略(前向兼容,对齐 u-claw handleFrame) } } Message::Ping(p) => { let _ = sink.send(Message::Pong(p)).await; } Message::Close(_) => { return Err(anyhow::anyhow!("relay 主动关闭连接(帧上限超限或服务重启)")); } _ => {} } } }) .await .map_err(|_| anyhow::anyhow!("等待 u-claw 结果超时({}s):渠道静默,可能队列已满被丢弃", REQUEST_TIMEOUT.as_secs()))??; let _ = sink.close().await; Ok(result) } /// 带互斥与重试的顶层调用:进程级串行锁(对齐 u-claw 单 worker 事实)+ 超时重发。 async fn ask_uclaw_inner( db: &Database, prompt: &str, ) -> anyhow::Result { let (url, channel, token) = load_config(db).await?; let ws_url = normalize_relay_url(&url); if !validate_ws_url(&ws_url) { anyhow::bail!("uclaw_relay_url 非法(须 ws:// 或 wss:// 开头): {ws_url}"); } if !validate_channel_id(&channel) { anyhow::bail!("uclaw_channel_id 非法(非空 ≤128B 无控制字符): {channel}"); } let request_id = uuid::Uuid::new_v4().to_string(); let mut last_err = String::new(); for attempt in 1..=MAX_ATTEMPTS { if attempt > 1 { tracing::info!(attempt, request_id = %request_id, "[u-claw] 重试(上次: {last_err})"); emit_progress_line(&format!("(重试 {attempt}/{MAX_ATTEMPTS}: {last_err})")); } match run_once(&ws_url, &channel, &token, prompt, &request_id).await { Ok(text) => { return Ok(serde_json::json!({ "status": "completed", "request_id": request_id, "result": text, })); } Err(e) => { let msg = e.to_string(); // 不可重试类:配置错/握手被拒/帧超限 —— 重发必然同败,直接返回 if msg.contains("未配置") || msg.contains("握手被拒") || msg.contains("帧上限") || msg.contains("非法") { anyhow::bail!(msg); } // agent_result 已带回的超时/失败文本是 u-claw 的明确答复,不重发 if msg.contains("处理超时") || msg.contains("处理失败") { anyhow::bail!(msg); } last_err = msg; } } } anyhow::bail!("u-claw 渠道忙:连续 {} 次尝试失败({last_err}),请稍后重试", MAX_ATTEMPTS); } /// 注册 ask_uclaw 工具(RiskLevel::Medium,委托外部 agent 执行需用户审批,与 spawn_agent 同档)。 pub fn register(registry: &mut AiToolRegistry, db: &Arc) { let schema = serde_json::json!({ "type": "object", "properties": { "prompt": { "type": "string", "description": "下发给 u-claw(常驻 Claude Code 网关)的任务指令。注意:u-claw 是 channel 级单链粘性会话——所有调用共享同一上下文,多轮追问天然连续,但跨 devflow 会话也会串上下文;长任务(u-claw 侧 15min 硬上限)超时会被 u-claw 主动中止并回超时文本" } }, "required": ["prompt"] }); declare_tool!( registry, db: Arc, "ask_uclaw", "委托 u-claw(常驻 Claude Code 能力网关,经 u-relay commander 协议)执行任务并等待结果。适用:需要另一路 Claude Code 会话独立完成的任务(如远端环境操作/长任务委托)。prompt 为任务指令;u-claw 侧会话粘性(同一 channel 所有调用共享上下文),多轮追问无需重复背景;进度实时流式回传(2s 块级)。超时 16min,u-claw 侧单任务 15min 硬上限,队列忙/离线会明确报错", RiskLevel::Medium, meta: ToolMeta { approval_hint: Some(("委托 u-claw: {}", &["prompt"])), display_verb: Some("委托"), goal: Some(GoalMeta { verb: "委托 u-claw", keys: &["prompt"], style: GoalStyle::Truncate, fallback: Some("委托 u-claw 执行任务"), }), sig: Some(SigMeta { groups: &[(&["prompt"], SigValKind::Str)], }), ..Default::default() }, schema: schema, args => { let prompt = args["prompt"] .as_str() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("参数 prompt 不能为空:请传下发给 u-claw 的任务指令"))?; if prompt.chars().count() > 100_000 { anyhow::bail!("prompt 过长({} 字符),请精简后重试", prompt.chars().count()); } // 进程级互斥:u-claw 单 channel 单 worker 串行,并发请求只会在 reqCh(缓冲4) // 排队后可能被静默丢弃(D-4)。锁失败=已有请求在飞,直接报忙(LLM 可稍后重试)。 static IN_FLIGHT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); let _guard = match IN_FLIGHT.try_lock() { Ok(g) => g, Err(_) => anyhow::bail!("u-claw 渠道忙:已有任务在执行(u-claw 串行处理,同一时刻仅一个在飞请求),请稍后重试"), }; ask_uclaw_inner(&db, &prompt).await } ); } #[cfg(test)] mod tests { use super::*; #[test] fn normalize_relay_url_appends_endpoint() { assert_eq!( normalize_relay_url("wss://relay.example.com"), "wss://relay.example.com/ws/commander" ); assert_eq!( normalize_relay_url("wss://relay.example.com/"), "wss://relay.example.com/ws/commander" ); assert_eq!( normalize_relay_url("ws://localhost:9180/ws/commander"), "ws://localhost:9180/ws/commander" ); } #[test] fn validate_ws_url_schemes() { assert!(validate_ws_url("wss://relay.example.com/ws/commander")); assert!(validate_ws_url("ws://localhost:9180/ws/commander")); assert!(!validate_ws_url("https://relay.example.com"), "https 拒绝"); assert!(!validate_ws_url("http://localhost:9180"), "http 拒绝"); assert!(!validate_ws_url("relay.example.com"), "无 scheme 拒绝"); } #[test] fn validate_channel_id_rules() { assert!(validate_channel_id("devflow-conv-1")); assert!(validate_channel_id(&"a".repeat(128)), "128 上限放行"); assert!(!validate_channel_id(""), "空拒绝"); assert!(!validate_channel_id(&"a".repeat(129)), "超长拒绝"); assert!(!validate_channel_id("dev\u{0001}flow"), "控制字符拒绝"); } #[tokio::test] async fn config_missing_fails_with_hint() { let db = Database::open_in_memory().await.expect("db"); let err = load_config(&db).await.unwrap_err(); assert!(err.to_string().contains("未配置"), "应提示未配置: {err}"); } #[tokio::test] async fn config_partial_fails() { let db = Database::open_in_memory().await.expect("db"); use df_storage::crud::SettingsRepo; let settings = SettingsRepo::new(&db); settings.set(KV_RELAY_URL, "wss://relay.example.com").await.unwrap(); let err = load_config(&db).await.unwrap_err(); assert!(err.to_string().contains("未配置"), "缺 channel/token 仍应拒绝: {err}"); } #[tokio::test] async fn config_complete_passes() { let db = Database::open_in_memory().await.expect("db"); use df_storage::crud::SettingsRepo; let settings = SettingsRepo::new(&db); // 模拟前端 JSON.stringify 落库(带引号),验证 get_string_norm 归一 settings.set(KV_RELAY_URL, "\"wss://relay.example.com\"").await.unwrap(); settings.set(KV_CHANNEL_ID, "\"u-ppt-dev-01\"").await.unwrap(); settings.set(KV_TOKEN, "\"tok-123\"").await.unwrap(); let (url, channel, token) = load_config(&db).await.expect("完整配置应通过"); assert_eq!(url, "wss://relay.example.com"); assert_eq!(channel, "u-ppt-dev-01"); assert_eq!(token, "tok-123"); } }