返回 CodeWhale
session.rs
根目录 / crates / tui / src / rlm / session.rs
1 //! Persistent RLM session state for the v0.8.33 head/hands tool surface.
2
3 use std::collections::HashMap;
4 use std::path::{Path, PathBuf};
5 use std::sync::Arc;
6 use std::time::{Duration, Instant};
7
8 use serde::{Deserialize, Serialize};
9 use serde_json::{Value, json};
10 use tokio::sync::Mutex;
11 use uuid::Uuid;
12
13 use crate::models::{ContentBlock, Message, SystemPrompt};
14 use crate::repl::PythonRuntime;
15
16 pub type SharedRlmSessionStore = Arc<Mutex<HashMap<String, Arc<Mutex<RlmSession>>>>>;
17
18 #[must_use]
19 pub fn new_shared_rlm_session_store() -> SharedRlmSessionStore {
20 Arc::new(Mutex::new(HashMap::new()))
21 }
22
23 #[derive(Debug)]
24 pub struct RlmSession {
25 pub name: String,
26 pub id: String,
27 pub kernel: Option<PythonRuntime>,
28 pub context_meta: ContextMeta,
29 pub config: RlmSessionConfig,
30 pub rpc_count: u32,
31 pub total_duration: Duration,
32 pub peak_var_count: usize,
33 pub final_count: usize,
34 pub created_at: Instant,
35 pub last_used_at: Instant,
36 pub context_path: PathBuf,
37 }
38
39 impl RlmSession {
40 #[must_use]
41 pub fn new(
42 name: String,
43 kernel: PythonRuntime,
44 context_meta: ContextMeta,
45 context_path: PathBuf,
46 ) -> Self {
47 let now = Instant::now();
48 Self {
49 name,
50 id: format!("rlm:{}", Uuid::new_v4().simple()),
51 kernel: Some(kernel),
52 context_meta,
53 config: RlmSessionConfig::default(),
54 rpc_count: 0,
55 total_duration: Duration::ZERO,
56 peak_var_count: 0,
57 final_count: 0,
58 created_at: now,
59 last_used_at: now,
60 context_path,
61 }
62 }
63 }
64
65 #[derive(Debug, Clone, Serialize, Deserialize)]
66 pub struct ContextMeta {
67 pub length: usize,
68 #[serde(rename = "type")]
69 pub type_name: String,
70 pub preview_500: String,
71 pub sha256: String,
72 }
73
74 impl ContextMeta {
75 #[must_use]
76 pub fn from_body(body: &str, type_name: impl Into<String>) -> Self {
77 Self {
78 length: body.chars().count(),
79 type_name: type_name.into(),
80 preview_500: body.chars().take(500).collect(),
81 sha256: sha256_hex(body.as_bytes()),
82 }
83 }
84 }
85
86 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
87 #[serde(rename_all = "snake_case")]
88 pub enum OutputFeedback {
89 Full,
90 Metadata,
91 }
92
93 #[derive(Debug, Clone, Serialize, Deserialize)]
94 pub struct RlmSessionConfig {
95 pub output_feedback: OutputFeedback,
96 pub sub_query_timeout_secs: u64,
97 pub sub_rlm_max_depth: u32,
98 pub share_session: bool,
99 }
100
101 impl Default for RlmSessionConfig {
102 fn default() -> Self {
103 Self {
104 output_feedback: OutputFeedback::Full,
105 sub_query_timeout_secs: 120,
106 sub_rlm_max_depth: 1,
107 share_session: false,
108 }
109 }
110 }
111
112 pub fn write_context_file(body: &str) -> std::io::Result<PathBuf> {
113 let dir = std::env::temp_dir().join("deepseek_rlm_ctx");
114 std::fs::create_dir_all(&dir)?;
115 let path = dir.join(format!(
116 "session_{}_{}.txt",
117 std::process::id(),
118 Uuid::new_v4().simple()
119 ));
120 std::fs::write(&path, body)?;
121 Ok(path)
122 }
123
124 #[derive(Debug, Clone)]
125 pub struct SessionObjectSnapshot {
126 pub session_id: String,
127 pub model: String,
128 pub workspace: PathBuf,
129 pub system_prompt: Option<SystemPrompt>,
130 pub messages: Vec<Message>,
131 }
132
133 impl SessionObjectSnapshot {
134 #[must_use]
135 pub fn new(
136 session_id: String,
137 model: String,
138 workspace: PathBuf,
139 system_prompt: Option<SystemPrompt>,
140 messages: Vec<Message>,
141 ) -> Self {
142 Self {
143 session_id,
144 model,
145 workspace,
146 system_prompt,
147 messages,
148 }
149 }
150
151 #[must_use]
152 pub fn object_cards(&self) -> Vec<SessionObjectCard> {
153 let mut cards = Vec::new();
154 for object in self.base_objects() {
155 cards.push(SessionObjectCard::from_resolved(&object));
156 }
157 for index in 0..self.messages.len() {
158 if let Some(object) = self.resolve(&format!("session://active/messages/{index}")) {
159 cards.push(SessionObjectCard::from_resolved(&object));
160 }
161 }
162 cards
163 }
164
165 #[must_use]
166 pub fn resolve(&self, object_ref: &str) -> Option<ResolvedSessionObject> {
167 let normalized = normalize_session_object_ref(object_ref);
168 match normalized.as_str() {
169 "session://active/session" => Some(self.session_metadata_object()),
170 "session://active/system_prompt" => self.system_prompt_object(),
171 "session://active/transcript" => Some(self.transcript_object()),
172 "session://active/latest_user" => self.latest_user_object(),
173 _ => self.message_object(&normalized),
174 }
175 }
176
177 fn base_objects(&self) -> Vec<ResolvedSessionObject> {
178 let mut objects = vec![self.session_metadata_object()];
179 if let Some(object) = self.system_prompt_object() {
180 objects.push(object);
181 }
182 objects.push(self.transcript_object());
183 if let Some(object) = self.latest_user_object() {
184 objects.push(object);
185 }
186 objects
187 }
188
189 fn session_metadata_object(&self) -> ResolvedSessionObject {
190 let body = json!({
191 "session_id": self.session_id,
192 "model": self.model,
193 "workspace": self.workspace.display().to_string(),
194 "message_count": self.messages.len(),
195 "object_refs": {
196 "system_prompt": "session://active/system_prompt",
197 "transcript": "session://active/transcript",
198 "latest_user": "session://active/latest_user",
199 "message_prefix": "session://active/messages/"
200 }
201 })
202 .to_string();
203 ResolvedSessionObject::new(
204 "session://active/session",
205 "session_metadata",
206 "Active session metadata",
207 body,
208 )
209 }
210
211 fn system_prompt_object(&self) -> Option<ResolvedSessionObject> {
212 let prompt = self.system_prompt.as_ref()?;
213 Some(ResolvedSessionObject::new(
214 "session://active/system_prompt",
215 "system_prompt",
216 "Active system prompt",
217 render_system_prompt(prompt),
218 ))
219 }
220
221 fn transcript_object(&self) -> ResolvedSessionObject {
222 let body = self
223 .messages
224 .iter()
225 .enumerate()
226 .map(|(index, message)| compact_message_json(index, message).to_string())
227 .collect::<Vec<_>>()
228 .join("\n");
229 ResolvedSessionObject::new(
230 "session://active/transcript",
231 "transcript",
232 "Active transcript as JSONL",
233 body,
234 )
235 }
236
237 fn latest_user_object(&self) -> Option<ResolvedSessionObject> {
238 self.messages
239 .iter()
240 .enumerate()
241 .rev()
242 .find(|(_, message)| message.role == "user")
243 .map(|(index, message)| message_resolved_object(index, message, "Latest user message"))
244 }
245
246 fn message_object(&self, normalized: &str) -> Option<ResolvedSessionObject> {
247 let index = normalized
248 .strip_prefix("session://active/messages/")?
249 .parse::<usize>()
250 .ok()?;
251 self.messages
252 .get(index)
253 .map(|message| message_resolved_object(index, message, "Transcript message"))
254 }
255 }
256
257 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
258 pub struct SessionObjectCard {
259 pub id: String,
260 pub kind: String,
261 pub title: String,
262 pub length: usize,
263 pub preview_500: String,
264 pub sha256: String,
265 }
266
267 impl SessionObjectCard {
268 #[must_use]
269 pub fn from_resolved(object: &ResolvedSessionObject) -> Self {
270 Self {
271 id: object.id.clone(),
272 kind: object.kind.clone(),
273 title: object.title.clone(),
274 length: object.body.chars().count(),
275 preview_500: object.body.chars().take(500).collect(),
276 sha256: sha256_hex(object.body.as_bytes()),
277 }
278 }
279 }
280
281 #[derive(Debug, Clone)]
282 pub struct ResolvedSessionObject {
283 pub id: String,
284 pub kind: String,
285 pub title: String,
286 pub body: String,
287 }
288
289 impl ResolvedSessionObject {
290 fn new(
291 id: impl Into<String>,
292 kind: impl Into<String>,
293 title: impl Into<String>,
294 body: impl Into<String>,
295 ) -> Self {
296 Self {
297 id: id.into(),
298 kind: kind.into(),
299 title: title.into(),
300 body: body.into(),
301 }
302 }
303 }
304
305 fn normalize_session_object_ref(object_ref: &str) -> String {
306 let trimmed = object_ref.trim();
307 if trimmed.starts_with("session://") {
308 trimmed.to_string()
309 } else {
310 format!("session://active/{}", trimmed.trim_start_matches('/'))
311 }
312 }
313
314 fn render_system_prompt(prompt: &SystemPrompt) -> String {
315 match prompt {
316 SystemPrompt::Text(text) => text.clone(),
317 SystemPrompt::Blocks(blocks) => blocks
318 .iter()
319 .map(|block| block.text.as_str())
320 .collect::<Vec<_>>()
321 .join("\n\n"),
322 }
323 }
324
325 fn message_resolved_object(index: usize, message: &Message, title: &str) -> ResolvedSessionObject {
326 ResolvedSessionObject::new(
327 format!("session://active/messages/{index}"),
328 "message",
329 format!("{title} {index} ({})", message.role),
330 compact_message_json(index, message).to_string(),
331 )
332 }
333
334 fn compact_message_json(index: usize, message: &Message) -> Value {
335 json!({
336 "index": index,
337 "role": message.role,
338 "content": message.content.iter().map(compact_content_block).collect::<Vec<_>>(),
339 })
340 }
341
342 fn compact_content_block(block: &ContentBlock) -> Value {
343 match block {
344 ContentBlock::Text { text, .. } => json!({
345 "type": "text",
346 "text": text,
347 }),
348 ContentBlock::Thinking { thinking, .. } => json!({
349 "type": "thinking",
350 "redacted": true,
351 "chars": thinking.chars().count(),
352 "sha256": sha256_hex(thinking.as_bytes()),
353 "preview_240": truncate_chars(thinking, 240),
354 }),
355 ContentBlock::ToolUse {
356 id,
357 name,
358 input,
359 caller,
360 } => json!({
361 "type": "tool_use",
362 "id": id,
363 "name": name,
364 "input": input,
365 "caller": caller,
366 }),
367 ContentBlock::ToolResult {
368 tool_use_id,
369 content,
370 is_error,
371 content_blocks,
372 } => {
373 let chars = content.chars().count();
374 let large = chars > 2_000;
375 json!({
376 "type": "tool_result",
377 "tool_use_id": tool_use_id,
378 "is_error": is_error,
379 "content": if large { Value::Null } else { Value::String(content.clone()) },
380 "content_preview": truncate_chars(content, 500),
381 "content_chars": chars,
382 "content_sha256": sha256_hex(content.as_bytes()),
383 "content_redacted": large,
384 "content_blocks": content_blocks,
385 })
386 }
387 ContentBlock::ServerToolUse { id, name, input } => json!({
388 "type": "server_tool_use",
389 "id": id,
390 "name": name,
391 "input": input,
392 }),
393 ContentBlock::ToolSearchToolResult {
394 tool_use_id,
395 content,
396 } => json!({
397 "type": "tool_search_tool_result",
398 "tool_use_id": tool_use_id,
399 "content": content,
400 }),
401 ContentBlock::CodeExecutionToolResult {
402 tool_use_id,
403 content,
404 } => json!({
405 "type": "code_execution_tool_result",
406 "tool_use_id": tool_use_id,
407 "content": content,
408 }),
409 ContentBlock::ImageUrl { .. } => serde_json::Value::Null,
410 }
411 }
412
413 fn truncate_chars(text: &str, max_chars: usize) -> String {
414 if text.chars().count() <= max_chars {
415 return text.to_string();
416 }
417 let take = max_chars.saturating_sub(3);
418 let mut out: String = text.chars().take(take).collect();
419 out.push_str("...");
420 out
421 }
422
423 #[must_use]
424 pub fn derive_session_name(source_hint: Option<&str>) -> String {
425 let hint = source_hint
426 .and_then(|raw| {
427 Path::new(raw)
428 .file_name()
429 .and_then(|name| name.to_str())
430 .or(Some(raw))
431 })
432 .unwrap_or("context");
433 let mut out = String::new();
434 for ch in hint.chars() {
435 if ch.is_ascii_alphanumeric() {
436 out.push(ch.to_ascii_lowercase());
437 } else if !out.ends_with('_') {
438 out.push('_');
439 }
440 if out.len() >= 48 {
441 break;
442 }
443 }
444 let out = out.trim_matches('_');
445 if out.is_empty() {
446 "context".to_string()
447 } else {
448 out.to_string()
449 }
450 }
451
452 fn sha256_hex(bytes: &[u8]) -> String {
453 crate::hashing::sha256_hex(bytes)
454 }
455
456 #[cfg(test)]
457 mod tests {
458 use super::*;
459
460 #[test]
461 fn derive_session_name_slugifies_path() {
462 assert_eq!(
463 derive_session_name(Some("src/Big File.rs")),
464 "big_file_rs".to_string()
465 );
466 }
467
468 #[test]
469 fn context_meta_hashes_and_previews_body() {
470 let meta = ContextMeta::from_body("abcdef", "text");
471 assert_eq!(meta.length, 6);
472 assert_eq!(meta.preview_500, "abcdef");
473 assert_eq!(
474 meta.sha256,
475 "bef57ec7f53a6d40beb640a780a639c83bc29ac8a9816f1fc6c5c6dcd93c4721"
476 );
477 }
478
479 #[test]
480 fn session_objects_expose_prompt_and_transcript_cards() {
481 let snapshot = SessionObjectSnapshot::new(
482 "session-1".to_string(),
483 "deepseek-v4-pro".to_string(),
484 PathBuf::from("/tmp/work"),
485 Some(SystemPrompt::Text("system body".to_string())),
486 vec![Message {
487 role: "user".to_string(),
488 content: vec![ContentBlock::Text {
489 text: "hello RLM".to_string(),
490 cache_control: None,
491 }],
492 }],
493 );
494
495 let cards = snapshot.object_cards();
496 assert!(
497 cards
498 .iter()
499 .any(|card| card.id == "session://active/system_prompt")
500 );
501 assert!(
502 cards
503 .iter()
504 .any(|card| card.id == "session://active/messages/0")
505 );
506
507 let transcript = snapshot
508 .resolve("session://active/transcript")
509 .expect("transcript object");
510 assert!(transcript.body.contains("hello RLM"));
511 }
512
513 #[test]
514 fn session_object_transcript_keeps_large_tool_results_compact() {
515 let large = "tool output\n".repeat(400);
516 let snapshot = SessionObjectSnapshot::new(
517 "session-1".to_string(),
518 "deepseek-v4-pro".to_string(),
519 PathBuf::from("/tmp/work"),
520 None,
521 vec![Message {
522 role: "user".to_string(),
523 content: vec![ContentBlock::ToolResult {
524 tool_use_id: "call_1".to_string(),
525 content: large.clone(),
526 is_error: None,
527 content_blocks: None,
528 }],
529 }],
530 );
531
532 let object = snapshot
533 .resolve("session://active/messages/0")
534 .expect("message object");
535 assert!(object.body.contains("\"content_redacted\":true"));
536 assert!(object.body.len() < large.len());
537 }
538 }
539
539 lines RUST