crabmate 0.5.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
//! Web 服务进程内状态:会话存储、上传目录、任务队列句柄等(自 `lib.rs` 下沉)。

use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::time::Duration;

use tokio::sync::mpsc;

use crate::chat_job_queue::{ChatJobQueue, WebChatQueueDeps};
use crate::config::SharedAgentConfig;
use crate::cm_api_contract::chat::ConversationLayoutMeta;
use crate::conversation_store::{
    self, CONVERSATION_STORE_MAX_ENTRIES, CONVERSATION_STORE_TTL_SECS, SaveConversationOutcome,
};
use crate::memory::long_term_memory::LongTermMemoryRuntime;
use crate::types::{CommandApprovalDecision, Message};

use crate::sse::SseStreamHub;

/// 与 `chat_handlers::normalize_client_conversation_id` 及存储上限对齐。
pub(crate) use crate::conversation_store::CONVERSATION_ID_MAX_LEN;

const CONVERSATION_STORE_TTL: Duration = Duration::from_secs(CONVERSATION_STORE_TTL_SECS);

/// Web **`POST /chat/stream`** 携带 `approval_session_id` 时注册的 **`POST /chat/approval`** 投递通道;带创建时刻以便惰性淘汰陈旧条目。
pub(crate) struct ApprovalSessionSlot {
    pub(crate) tx: mpsc::Sender<CommandApprovalDecision>,
    pub(crate) created_at: std::time::Instant,
}

/// 未完成审批决策的会话在内存中的保留时长(与 worker 结束时 `remove` 互补)。
pub(crate) const APPROVAL_SESSION_TTL: Duration = Duration::from_secs(3600);

pub(crate) fn purge_expired_approval_sessions(
    map: &mut HashMap<String, ApprovalSessionSlot>,
    ttl: Duration,
) {
    let now = std::time::Instant::now();
    map.retain(|_, slot| now.duration_since(slot.created_at) <= ttl);
}

#[derive(Clone)]
pub(crate) struct MemoryConversationEntry {
    messages: Vec<Message>,
    /// 当前多角色工作台选用的命名角色 id;`None` 表示默认人格(与 Web 未持久化选用一致)。
    active_agent_role: Option<String>,
    /// 当前会话工作模式;`None` 表示未显式设置(回落配置默认)。
    active_session_mode: Option<String>,
    /// B2:与 SQLite `layout_meta_json` 同语义,每次按当前 `messages` 派生写入。
    layout: Option<ConversationLayoutMeta>,
    revision: u64,
    updated_at: std::time::Instant,
}

#[derive(Clone)]
pub(crate) struct ConversationTurnSeed {
    pub messages: Vec<Message>,
    pub expected_revision: Option<u64>,
    pub persisted_active_agent_role: Option<String>,
    /// 持久化的会话工作模式(ask/plan/act);`None` 表示未设置。
    pub persisted_active_session_mode: Option<String>,
    /// 可选布局元数据;缺省时 GET 省略。会话级,不随消息分页切片。
    pub layout: Option<ConversationLayoutMeta>,
}

fn nonempty_persisted_column(raw: String) -> Option<String> {
    let t = raw.trim();
    if t.is_empty() {
        None
    } else {
        Some(t.to_string())
    }
}

/// HTTP 客户端、共享配置快照与工作区覆盖(与队列 / 会话后端解耦)。
///
/// 根包保留本类型以满足 axum `FromRef` 孤儿规则(**不**在 `cm_web_host` 镜像同名类型)。
#[derive(Clone)]
pub(crate) struct AppStateHttpCore {
    pub(crate) cfg: SharedAgentConfig,
    /// 与启动时 `--config` / 默认探测一致,供 **`POST /config/reload`** 调用 [`load_config`]。
    pub(crate) config_path_for_reload: Option<String>,
    /// 进程级 Bearer 密钥快照(`Arc` 便于 handler facet 廉价共享,勿整串 `Clone`)。
    pub(crate) api_key: Arc<str>,
    pub(crate) client: reqwest::Client,
    pub(crate) tools: Vec<crate::types::Tool>,
    /// 前端设置的工作区路径覆盖;为 None 时使用 cfg.command_exec.run_command_working_dir
    pub(crate) workspace_override: Arc<tokio::sync::RwLock<Option<String>>>,
    pub(crate) uploads_dir: std::path::PathBuf,
}

/// 由工作区覆盖 + 共享配置解析当前有效工作区根(空串表示未设置)。
pub(crate) async fn effective_workspace_path_from_override(
    workspace_override: &tokio::sync::RwLock<Option<String>>,
    cfg: &SharedAgentConfig,
) -> String {
    let guard = workspace_override.read().await;
    match guard.as_deref() {
        None => String::new(),
        Some(s) if s.trim().is_empty() => {
            let cfg = cfg.read().await;
            cfg.command_exec.run_command_working_dir.clone()
        }
        Some(s) => s.to_string(),
    }
}

/// 前端是否已经设置过明确工作区路径(`Some(non-empty)`)。
pub(crate) async fn workspace_is_set_from_override(
    workspace_override: &tokio::sync::RwLock<Option<String>>,
) -> bool {
    let guard = workspace_override.read().await;
    guard.as_deref().is_some_and(|s| !s.trim().is_empty())
}

impl AppStateHttpCore {
    /// 当前 Web 会话选中的工作区根路径(**未**调用 `POST /workspace` 成功设置前返回空串)。
    pub(crate) async fn effective_workspace_path(&self) -> String {
        effective_workspace_path_from_override(&self.workspace_override, &self.cfg).await
    }
}

/// `/chat` / `/chat/stream` 进程内队列及其 worker 依赖。
#[derive(Clone)]
pub(crate) struct AppStateChatRuntime {
    pub(crate) chat_queue: ChatJobQueue,
    pub(crate) chat_queue_job_deps: Arc<WebChatQueueDeps>,
}

/// 会话持久化与默认 `conversation_id` 生成。
#[derive(Clone)]
pub(crate) struct AppStateConversationRuntime {
    /// `conversation_id` → 消息与 revision:内存或 SQLite(见配置 `conversation_store_sqlite_path`)。
    /// 外层 `RwLock` 供 Web **`POST /config/session/conversation-store`** 在进程内切换后端(与配置热重载不同轨)。
    pub(crate) conversation_backing: Arc<tokio::sync::RwLock<ConversationBacking>>,
    pub(crate) conversation_id_counter: Arc<AtomicU64>,
}

/// 审批表、任务侧栏、SSE hub、异步作业等 Web 辅助状态。
#[derive(Clone)]
pub(crate) struct AppStateWebAux {
    pub(crate) approval_sessions: Arc<tokio::sync::RwLock<HashMap<String, ApprovalSessionSlot>>>,
    pub(crate) long_term_memory: Option<Arc<LongTermMemoryRuntime>>,
    pub(crate) llm_models_health_cache:
        Arc<std::sync::Mutex<Option<crate::health::CachedLlmModelsHealthProbe>>>,
    pub(crate) sse_stream_hub: Arc<SseStreamHub>,
    pub(crate) process_handles: Arc<crate::process_handles::ProcessHandles>,
    pub(crate) async_chat_jobs: super::async_chat_job::AsyncChatJobsMap,
    /// 后台任务注册表(`run_command` 的 `async=true`);启动时按 `[tool_registry]` 配置构建。
    pub(crate) tool_job_registry: std::sync::Arc<crate::cm_internal::tool_jobs::ToolJobRegistry>,
    /// 是否挂载业务 UI 静态资源(`serve --with-web`)。为 false(默认)时 `/health` 不检查静态目录。
    pub(crate) mount_web_ui: bool,
}

#[derive(Clone)]
pub(crate) struct AppState {
    pub(crate) http: AppStateHttpCore,
    pub(crate) chat: AppStateChatRuntime,
    pub(crate) conversation: AppStateConversationRuntime,
    pub(crate) aux: AppStateWebAux,
}

/// 队列 worker 所需的 AppState **消费面**:会话落盘、审批表、`ProcessHandles`(不含 HTTP/上传/整包状态)。
///
/// 与 [`crate::chat_job_queue::WebChatQueueDeps`] 互补:后者管 LLM/工具/SSE hub;本类型管回合后落盘与审批清理。
#[derive(Clone)]
pub(crate) struct WebChatJobAppFacet {
    pub(crate) conversation: AppStateConversationRuntime,
    pub(crate) process_handles: Arc<crate::process_handles::TurnProcessHandles>,
    pub(crate) approval_sessions: Arc<tokio::sync::RwLock<HashMap<String, ApprovalSessionSlot>>>,
    /// 后台任务注册表(`run_command` 的 `async=true`)。
    pub(crate) tool_job_registry: std::sync::Arc<crate::cm_internal::tool_jobs::ToolJobRegistry>,
}

/// Web 会话存储后端。
#[derive(Clone)]
pub(crate) enum ConversationBacking {
    Memory(Arc<tokio::sync::RwLock<HashMap<String, MemoryConversationEntry>>>),
    Sqlite(Arc<std::sync::Mutex<rusqlite::Connection>>),
}

impl ConversationBacking {
    pub(crate) fn memory_default() -> Self {
        Self::Memory(Arc::new(tokio::sync::RwLock::new(HashMap::new())))
    }

    pub(crate) fn is_sqlite(&self) -> bool {
        matches!(self, Self::Sqlite(_))
    }
}

async fn sqlite_conversation_store_op(
    conn: Arc<std::sync::Mutex<rusqlite::Connection>>,
    id_log: String,
    op_zh: &'static str,
    run: impl FnOnce(&rusqlite::Connection) -> Result<SaveConversationOutcome, rusqlite::Error>
    + Send
    + 'static,
) -> SaveConversationOutcome {
    match tokio::task::spawn_blocking(move || {
        let g = conn
            .lock()
            .map_err(|e: std::sync::PoisonError<_>| e.to_string())?;
        run(&g).map_err(|e: rusqlite::Error| e.to_string())
    })
    .await
    {
        Ok(Ok(out)) => out,
        Ok(Err(e)) => {
            log::error!(
                target: "crabmate",
                "会话 SQLite {}失败 conversation_id={} error={}",
                op_zh,
                id_log,
                e
            );
            SaveConversationOutcome::Conflict
        }
        Err(e) => {
            log::error!(
                target: "crabmate",
                "会话 SQLite {}任务失败 conversation_id={} error={}",
                op_zh,
                id_log,
                e
            );
            SaveConversationOutcome::Conflict
        }
    }
}

impl AppStateConversationRuntime {
    pub(crate) async fn load_conversation_seed(
        &self,
        conversation_id: &str,
    ) -> Option<ConversationTurnSeed> {
        let backing = self.conversation_backing.read().await;
        match &*backing {
            ConversationBacking::Memory(map) => {
                let mut guard = map.write().await;
                let entry = guard.get_mut(conversation_id)?;
                if entry.updated_at.elapsed() > CONVERSATION_STORE_TTL {
                    guard.remove(conversation_id);
                    return None;
                }
                entry.updated_at = std::time::Instant::now();
                Some(ConversationTurnSeed {
                    messages: entry.messages.clone(),
                    expected_revision: Some(entry.revision),
                    persisted_active_agent_role: entry.active_agent_role.clone(),
                    persisted_active_session_mode: entry.active_session_mode.clone(),
                    layout: entry.layout.clone(),
                })
            }
            ConversationBacking::Sqlite(conn) => {
                let id = conversation_id.to_string();
                let c = Arc::clone(conn);
                let loaded = tokio::task::spawn_blocking(move || {
                    let g = match c.lock() {
                        Ok(g) => g,
                        Err(e) => {
                            log::error!(
                                target: "crabmate",
                                "会话 SQLite 锁失败: {}",
                                e
                            );
                            return None;
                        }
                    };
                    match conversation_store::load(&g, &id, CONVERSATION_STORE_TTL_SECS) {
                        Ok(o) => o,
                        Err(e) => {
                            log::warn!(
                                target: "crabmate",
                                "会话 SQLite 读取失败 id={} error={}",
                                id,
                                e
                            );
                            None
                        }
                    }
                })
                .await
                .ok()
                .flatten();
                loaded.map(|row| ConversationTurnSeed {
                    messages: row.messages,
                    expected_revision: Some(row.revision),
                    persisted_active_agent_role: nonempty_persisted_column(row.active_agent_role),
                    persisted_active_session_mode: nonempty_persisted_column(
                        row.active_session_mode,
                    ),
                    layout: row.layout,
                })
            }
        }
    }

    fn prune_memory_locked(
        guard: &mut HashMap<String, MemoryConversationEntry>,
        now: std::time::Instant,
    ) {
        guard.retain(|_, v| now.duration_since(v.updated_at) <= CONVERSATION_STORE_TTL);
        if guard.len() <= CONVERSATION_STORE_MAX_ENTRIES {
            return;
        }
        let mut order: Vec<(String, std::time::Instant)> = guard
            .iter()
            .map(|(k, v)| (k.clone(), v.updated_at))
            .collect();
        order.sort_by_key(|(_, t)| *t);
        let to_drop = guard.len() - CONVERSATION_STORE_MAX_ENTRIES;
        for (k, _) in order.into_iter().take(to_drop) {
            guard.remove(&k);
        }
    }

    pub(crate) async fn save_conversation_messages_if_revision(
        &self,
        conversation_id: String,
        messages: Vec<Message>,
        active_agent_role: Option<&str>,
        active_session_mode: Option<&str>,
        expected_revision: Option<u64>,
    ) -> SaveConversationOutcome {
        let backing = self.conversation_backing.read().await;
        match &*backing {
            ConversationBacking::Memory(map) => {
                let mut guard = map.write().await;
                let now = std::time::Instant::now();
                let role_owned = active_agent_role
                    .map(str::trim)
                    .filter(|s| !s.is_empty())
                    .map(str::to_string);
                let mode_owned = active_session_mode
                    .map(str::trim)
                    .filter(|s| !s.is_empty())
                    .map(str::to_string);
                if let Some(entry) = guard.get_mut(&conversation_id) {
                    match expected_revision {
                        Some(exp) if entry.revision == exp => {
                            entry.layout = Some(crate::cm_turn_layout::layout_meta_from_messages(
                                &messages,
                            ));
                            entry.messages = messages;
                            entry.active_agent_role = role_owned;
                            entry.active_session_mode = mode_owned;
                            entry.revision = entry.revision.saturating_add(1);
                            entry.updated_at = now;
                        }
                        _ => return SaveConversationOutcome::Conflict,
                    }
                } else if expected_revision.is_some() {
                    return SaveConversationOutcome::Conflict;
                } else {
                    let layout = Some(crate::cm_turn_layout::layout_meta_from_messages(&messages));
                    guard.insert(
                        conversation_id,
                        MemoryConversationEntry {
                            messages,
                            active_agent_role: role_owned,
                            active_session_mode: mode_owned,
                            layout,
                            revision: 1,
                            updated_at: now,
                        },
                    );
                }
                Self::prune_memory_locked(&mut guard, now);
                SaveConversationOutcome::Saved
            }
            ConversationBacking::Sqlite(conn) => {
                let id = conversation_id;
                let id_log = id.clone();
                let c = Arc::clone(conn);
                let exp = expected_revision;
                let active_for_sql = active_agent_role.map(|s| s.to_string());
                let mode_for_sql = active_session_mode.map(|s| s.to_string());
                sqlite_conversation_store_op(c, id_log, "保存", move |g| {
                    conversation_store::save_if_revision(
                        g,
                        &id,
                        messages,
                        active_for_sql.as_deref(),
                        mode_for_sql.as_deref(),
                        exp,
                    )
                })
                .await
            }
        }
    }

    /// 截断到第 `user_ordinal` 条**普通**用户消息之前(0-based,不含长期记忆/变更集/首轮工作区画像等注入),且仅当 `revision` 匹配时成功。
    pub(crate) async fn truncate_conversation_before_user_ordinal_if_revision(
        &self,
        conversation_id: String,
        user_ordinal: usize,
        expected_revision: u64,
    ) -> SaveConversationOutcome {
        let backing = self.conversation_backing.read().await;
        match &*backing {
            ConversationBacking::Memory(map) => {
                let mut guard = map.write().await;
                let Some(entry) = guard.get_mut(&conversation_id) else {
                    return SaveConversationOutcome::Conflict;
                };
                if entry.updated_at.elapsed() > CONVERSATION_STORE_TTL {
                    guard.remove(&conversation_id);
                    return SaveConversationOutcome::Conflict;
                }
                if entry.revision != expected_revision {
                    return SaveConversationOutcome::Conflict;
                }
                let mut u = 0usize;
                let mut cut = entry.messages.len();
                for (i, m) in entry.messages.iter().enumerate() {
                    if crate::types::user_message_counts_for_branch_truncation(m) {
                        if u == user_ordinal {
                            cut = i;
                            break;
                        }
                        u += 1;
                    }
                }
                if cut >= entry.messages.len() {
                    entry.updated_at = std::time::Instant::now();
                    return SaveConversationOutcome::Saved;
                }
                entry.messages.truncate(cut);
                entry.layout = Some(crate::cm_turn_layout::layout_meta_from_messages(
                    &entry.messages,
                ));
                entry.revision = entry.revision.saturating_add(1);
                entry.updated_at = std::time::Instant::now();
                Self::prune_memory_locked(&mut guard, std::time::Instant::now());
                SaveConversationOutcome::Saved
            }
            ConversationBacking::Sqlite(conn) => {
                let id = conversation_id;
                let id_log = id.clone();
                let c = Arc::clone(conn);
                sqlite_conversation_store_op(c, id_log, "截断", move |g| {
                    conversation_store::truncate_before_user_ordinal_if_revision(
                        g,
                        &id,
                        user_ordinal,
                        expected_revision,
                    )
                })
                .await
            }
        }
    }

    /// 会话正文里仍引用的 **`/uploads/<filename>`**(清理附图时跳过)。
    pub(crate) async fn referenced_upload_filenames(&self) -> HashSet<String> {
        use crate::web::chat_uploads_paths::collect_upload_filenames_from_text;
        let backing = self.conversation_backing.read().await;
        match &*backing {
            ConversationBacking::Memory(map) => {
                let guard = map.read().await;
                let mut out = HashSet::new();
                for entry in guard.values() {
                    if let Ok(s) = serde_json::to_string(&entry.messages) {
                        out.extend(collect_upload_filenames_from_text(&s));
                    }
                }
                out
            }
            ConversationBacking::Sqlite(conn) => {
                let c = Arc::clone(conn);
                tokio::task::spawn_blocking(move || {
                    let g = match c.lock() {
                        Ok(g) => g,
                        Err(_) => return HashSet::new(),
                    };
                    let Ok(blobs) = conversation_store::list_all_messages_json(&g) else {
                        return HashSet::new();
                    };
                    let mut out = HashSet::new();
                    for s in blobs {
                        out.extend(collect_upload_filenames_from_text(&s));
                    }
                    out
                })
                .await
                .unwrap_or_default()
            }
        }
    }

    pub(crate) async fn conversation_count(&self) -> usize {
        let backing = self.conversation_backing.read().await;
        match &*backing {
            ConversationBacking::Memory(map) => map.read().await.len(),
            ConversationBacking::Sqlite(conn) => {
                let c = Arc::clone(conn);
                tokio::task::spawn_blocking(move || {
                    let g = match c.lock() {
                        Ok(g) => g,
                        Err(_) => return 0usize,
                    };
                    conversation_store::count(&g).unwrap_or(0)
                })
                .await
                .unwrap_or(0)
            }
        }
    }

    /// 删除持久化会话行(仅 E2E 夹具 `replace` 等;不存在时视为成功)。
    pub(crate) async fn delete_conversation_record(&self, conversation_id: &str) {
        let backing = self.conversation_backing.read().await;
        match &*backing {
            ConversationBacking::Memory(map) => {
                let mut guard = map.write().await;
                guard.remove(conversation_id);
            }
            ConversationBacking::Sqlite(conn) => {
                let id = conversation_id.to_string();
                let c = Arc::clone(conn);
                let _ = tokio::task::spawn_blocking(move || {
                    if let Ok(g) = c.lock() {
                        let _ = conversation_store::delete_by_id(&g, &id);
                    }
                })
                .await;
            }
        }
    }
}

/// 打开 SQLite 会话库(`run()` 在 `--serve` 时调用)。
pub(crate) fn open_conversation_sqlite(
    path: &Path,
) -> Result<Arc<std::sync::Mutex<rusqlite::Connection>>, Box<dyn std::error::Error + Send + Sync>> {
    let conn = conversation_store::open_file(path)?;
    if let Err(e) = LongTermMemoryRuntime::migrate_on_connection(&conn) {
        return Err(format!("长期记忆表迁移失败: {e}").into());
    }
    Ok(Arc::new(std::sync::Mutex::new(conn)))
}