Skip to content

Commit e537c17

Browse files
Alexclaude
andcommitted
fix: overhaul tinyclaw context compression and fix 8 bugs
Critical fixes: - compress() now uses proxy-first fallback (proxy -> Ollama -> extractive) instead of always bypassing proxy and going to Ollama directly - Summary is now injected into LLM context after compression via build_proxy_messages() helper with user+assistant pair - proxy_messages built from post-compression session state, not stale pre-compression snapshot that caused context loss Additional fixes: - tools_available() no longer auto-resets failure flag (was defeating the circuit breaker set by tool_call() on error) - Removed dead summarize_at_token_ratio config field - AnthropicUsage uses Anthropic-canonical field names with OpenAI aliases - Removed all #[allow(dead_code)] violations - Made test_text_only_fallback deterministic (unreachable port, not default) 7 new tests covering all changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8fa27f6 commit e537c17

4 files changed

Lines changed: 308 additions & 88 deletions

File tree

.beads/issues.jsonl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
{"id":"terraphim-ai-061","title":"Fix tools_available() auto-reset side effect","status":"closed","priority":2,"issue_type":"bug","owner":"alex@example.com","created_at":"2026-02-26T16:28:08.006176745Z","created_by":"Alex","updated_at":"2026-02-26T19:07:04.472260702Z","closed_at":"2026-02-26T19:07:04.472260702Z","close_reason":"Closed"}
12
{"id":"terraphim-ai-2sz","title":"Add embedded device settings fallback to terraphim-cli","description":"Evaluate and implement an embedded DeviceSettings fallback (similar to terraphim-agent) so terraphim-cli doesn't fail on missing settings.","status":"open","priority":2,"issue_type":"task","owner":"alex@metacortex.engineer","created_at":"2026-02-10T08:23:48.689656434Z","created_by":"AlexMikhalev","updated_at":"2026-02-10T08:23:48.689656434Z"}
3+
{"id":"terraphim-ai-8ld","title":"Rewrite compress() with proxy-first fallback","status":"closed","priority":1,"issue_type":"bug","owner":"alex@example.com","created_at":"2026-02-26T16:28:28.794633105Z","created_by":"Alex","updated_at":"2026-02-26T19:07:04.510972897Z","closed_at":"2026-02-26T19:07:04.510972897Z","close_reason":"Closed"}
24
{"id":"terraphim-ai-cbm","title":"Clarify terraphim-agent TUI offline/server requirement","description":"Determine whether terraphim-agent TUI is expected to work fully offline or requires a running server; document requirement and adjust behavior if needed.","design":"Phase 1/2 docs: docs/plans/terraphim-agent-tui-offline-server-research-2026-02-13.md and docs/plans/terraphim-agent-tui-offline-server-design-2026-02-13.md","acceptance_criteria":"Contract for fullscreen TUI vs REPL/offline is explicit in help/docs; actionable messaging when fullscreen TUI server is unreachable; tests cover mode behavior to prevent regressions.","notes":"Implemented on 2026-02-13: mode-contract wording in CLI/docs, fullscreen TUI server preflight with actionable repl fallback, and regression tests for help/non-TTY/server-failure paths. Validation: cargo fmt --package terraphim_agent; cargo clippy -p terraphim_agent --all-targets -- -D warnings; cargo test -p terraphim_agent --test offline_mode_tests; cargo test -p terraphim_agent --test server_mode_tests test_server_mode_config_show; targeted unit tests in main.rs for URL resolution and error messaging.","status":"closed","priority":2,"issue_type":"task","owner":"alex@metacortex.engineer","created_at":"2026-02-10T08:23:40.310825316Z","created_by":"AlexMikhalev","updated_at":"2026-02-23T10:46:13.066528719Z","closed_at":"2026-02-13T14:41:42.09313609Z"}
35
{"id":"terraphim-ai-iwy","title":"Add knowledge graph ranking example and guide","status":"closed","priority":2,"issue_type":"feature","owner":"alex@example.com","created_at":"2026-02-15T11:54:58.063432151Z","created_by":"Alex","updated_at":"2026-02-15T11:58:22.282021161Z","closed_at":"2026-02-15T11:58:22.282021161Z","close_reason":"Created knowledge graph ranking example and guide article"}
6+
{"id":"terraphim-ai-ou6","title":"Make test_text_only_fallback deterministic","status":"closed","priority":2,"issue_type":"bug","owner":"alex@example.com","created_at":"2026-02-26T16:28:23.598516653Z","created_by":"Alex","updated_at":"2026-02-26T19:07:04.509756346Z","closed_at":"2026-02-26T19:07:04.509756346Z","close_reason":"Closed"}
7+
{"id":"terraphim-ai-pdl","title":"Remove all #[allow(dead_code)] annotations","status":"closed","priority":3,"issue_type":"task","owner":"alex@example.com","created_at":"2026-02-26T16:28:39.215892144Z","created_by":"Alex","updated_at":"2026-02-26T19:07:04.513163031Z","closed_at":"2026-02-26T19:07:04.513163031Z","close_reason":"Closed"}
8+
{"id":"terraphim-ai-q63","title":"Remove dead summarize_at_token_ratio config","status":"closed","priority":3,"issue_type":"bug","owner":"alex@example.com","created_at":"2026-02-26T16:28:13.176563141Z","created_by":"Alex","updated_at":"2026-02-26T19:07:04.506195356Z","closed_at":"2026-02-26T19:07:04.506195356Z","close_reason":"Closed"}
9+
{"id":"terraphim-ai-rv5","title":"Fix AnthropicUsage field naming to Anthropic convention","status":"closed","priority":3,"issue_type":"bug","owner":"alex@example.com","created_at":"2026-02-26T16:28:18.370689359Z","created_by":"Alex","updated_at":"2026-02-26T19:07:04.50862237Z","closed_at":"2026-02-26T19:07:04.50862237Z","close_reason":"Closed"}
410
{"id":"terraphim-ai-tcw","title":"Define required feature parity between terraphim-agent and terraphim-cli","description":"Decide which commands/features must exist in both CLIs and document any intentional gaps for automation vs interactive use.","status":"open","priority":3,"issue_type":"task","owner":"alex@metacortex.engineer","created_at":"2026-02-10T08:23:51.548645229Z","created_by":"AlexMikhalev","updated_at":"2026-02-10T08:23:51.548645229Z"}
11+
{"id":"terraphim-ai-yuk","title":"Fix stale data + inject summary into LLM context","status":"closed","priority":0,"issue_type":"bug","owner":"alex@example.com","created_at":"2026-02-26T16:28:34.015795558Z","created_by":"Alex","updated_at":"2026-02-26T19:07:04.512036157Z","closed_at":"2026-02-26T19:07:04.512036157Z","close_reason":"Closed"}

crates/terraphim_tinyclaw/src/agent/agent_loop.rs

Lines changed: 212 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,9 @@ use tokio_util::sync::CancellationToken;
1616

1717
/// Configuration for the tool-calling loop.
1818
#[derive(Debug, Clone)]
19-
#[allow(dead_code)]
2019
pub struct ToolCallingConfig {
2120
/// Maximum tool-calling iterations per message.
2221
pub max_iterations: usize,
23-
/// Token ratio at which to trigger compression.
24-
pub summarize_at_token_ratio: f32,
2522
/// Number of messages to keep after summarization.
2623
pub keep_last_messages: usize,
2724
}
@@ -30,7 +27,6 @@ impl Default for ToolCallingConfig {
3027
fn default() -> Self {
3128
Self {
3229
max_iterations: 20,
33-
summarize_at_token_ratio: 0.75,
3430
keep_last_messages: 4,
3531
}
3632
}
@@ -160,46 +156,78 @@ impl HybridLlmRouter {
160156
}
161157
}
162158

163-
/// Compress context via direct LLM (cheap/local).
164-
/// Never goes through proxy - uses Ollama or configured direct LLM.
159+
/// Compress context via LLM summarization.
160+
/// Tries proxy first (Claude/OpenAI), falls back to direct LLM (Ollama),
161+
/// then to extractive summary.
165162
pub async fn compress(
166163
&self,
167164
messages: Vec<ChatMessage>,
168165
_system: String,
169166
) -> anyhow::Result<String> {
170-
let base_url = self
171-
.direct_config
172-
.base_url
173-
.clone()
174-
.unwrap_or_else(|| "http://127.0.0.1:11434".to_string());
175-
let model = &self.direct_config.model;
176-
177167
// Format conversation for summarization
178168
let conversation = messages
179169
.iter()
180170
.map(|m| format!("{:?}: {}", m.role, m.content))
181171
.collect::<Vec<_>>()
182172
.join("\n");
183173

184-
let prompt = format!(
174+
let summarization_prompt = format!(
185175
"Summarize the following conversation concisely, \
186176
preserving key facts, decisions, and context:\n\n{}",
187177
conversation
188178
);
189179

180+
let summarization_system = "You are a conversation summarizer. \
181+
Summarize concisely, preserving key facts, decisions, and context."
182+
.to_string();
183+
184+
log::info!("Context compression - {} messages", messages.len());
185+
186+
// Tier 1: Try proxy (Claude/OpenAI via terraphim-llm-proxy)
187+
if self.proxy.is_available() {
188+
let proxy_messages = vec![Message::user(&summarization_prompt)];
189+
match self
190+
.proxy
191+
.chat(proxy_messages, Some(summarization_system.clone()))
192+
.await
193+
{
194+
Ok(response) => {
195+
log::info!(
196+
"Context compressed via proxy (model: {}, tokens: {}/{})",
197+
response.model,
198+
response.usage.input_tokens,
199+
response.usage.output_tokens
200+
);
201+
if let Some(content) = response.content {
202+
return Ok(content);
203+
}
204+
}
205+
Err(e) => {
206+
log::warn!("Proxy unavailable for compression: {}", e);
207+
}
208+
}
209+
}
210+
211+
// Tier 2: Try direct LLM (Ollama)
212+
let base_url = self
213+
.direct_config
214+
.base_url
215+
.clone()
216+
.unwrap_or_else(|| "http://127.0.0.1:11434".to_string());
217+
let model = &self.direct_config.model;
218+
190219
log::info!(
191-
"Context compression via {} ({}) - {} messages",
220+
"Compression fallback to {} ({})",
192221
self.direct_config.provider,
193222
model,
194-
messages.len()
195223
);
196224

197225
let client = reqwest::Client::new();
198226
let response = client
199227
.post(format!("{}/api/generate", base_url))
200228
.json(&serde_json::json!({
201229
"model": model,
202-
"prompt": prompt,
230+
"prompt": summarization_prompt,
203231
"stream": false
204232
}))
205233
.send()
@@ -245,6 +273,36 @@ impl HybridLlmRouter {
245273
}
246274
}
247275

276+
/// Build proxy messages from session messages, optionally prepending a summary.
277+
///
278+
/// If a summary exists, it is injected as a user+assistant pair at the start
279+
/// to maintain the alternating message pattern required by the Anthropic API.
280+
fn build_proxy_messages(messages: &[ChatMessage], summary: Option<&str>) -> Vec<Message> {
281+
let mut proxy_messages: Vec<Message> = Vec::new();
282+
283+
// Inject summary as context if it exists
284+
if let Some(summary) = summary {
285+
proxy_messages.push(Message::user(format!(
286+
"[Previous conversation summary]: {}",
287+
summary
288+
)));
289+
proxy_messages.push(Message::assistant(
290+
"Understood, I have the context from our previous conversation.",
291+
));
292+
}
293+
294+
// Add current messages
295+
for m in messages {
296+
proxy_messages.push(match m.role {
297+
MessageRole::User => Message::user(&m.content),
298+
MessageRole::Assistant => Message::assistant(&m.content),
299+
_ => Message::user(&m.content),
300+
});
301+
}
302+
303+
proxy_messages
304+
}
305+
248306
/// The main tool-calling agent loop.
249307
pub struct ToolCallingLoop {
250308
config: ToolCallingConfig,
@@ -335,23 +393,27 @@ impl ToolCallingLoop {
335393
};
336394
session.add_message(user_msg.clone());
337395

338-
// Store messages for LLM conversion
339-
let session_messages: Vec<_> = session.messages.clone();
340-
let session_clone = session.clone();
341-
342396
// Save session before releasing lock
397+
let session_clone = session.clone();
398+
let message_count = session.messages.len();
343399
sessions_guard.save(&session_clone)?;
344400
drop(sessions_guard);
345401

346402
// Check if we need compression using configured ratio
347-
let needs_compress = session_messages.len() > self.config.keep_last_messages * 2;
403+
let needs_compress = message_count > self.config.keep_last_messages * 2;
348404
if needs_compress {
349405
// Keep the last N messages, compress the rest
350406
let keep_count = self.config.keep_last_messages;
351-
let messages_to_compress: Vec<_> = if session_messages.len() > keep_count {
352-
session_messages[..session_messages.len() - keep_count].to_vec()
353-
} else {
354-
session_messages.clone()
407+
408+
// Re-acquire lock to read messages for compression
409+
let messages_to_compress = {
410+
let mut sessions_guard = self.sessions.lock().await;
411+
let session = sessions_guard.get_or_create(&session_key);
412+
if session.messages.len() > keep_count {
413+
session.messages[..session.messages.len() - keep_count].to_vec()
414+
} else {
415+
session.messages.clone()
416+
}
355417
};
356418

357419
let summary = self
@@ -380,15 +442,12 @@ impl ToolCallingLoop {
380442
drop(sessions_guard);
381443
}
382444

383-
// Convert to proxy message format
384-
let proxy_messages: Vec<Message> = session_messages
385-
.iter()
386-
.map(|m| match m.role {
387-
MessageRole::User => Message::user(&m.content),
388-
MessageRole::Assistant => Message::assistant(&m.content),
389-
_ => Message::user(&m.content),
390-
})
391-
.collect();
445+
// Build proxy messages from CURRENT session state (post-compression)
446+
let proxy_messages = {
447+
let mut sessions_guard = self.sessions.lock().await;
448+
let session = sessions_guard.get_or_create(&session_key);
449+
build_proxy_messages(&session.messages, session.summary.as_deref())
450+
};
392451

393452
// Get tool definitions
394453
let tool_definitions: Vec<ToolDefinition> = self
@@ -469,6 +528,14 @@ impl ToolCallingLoop {
469528
}
470529
};
471530

531+
log::debug!(
532+
"LLM response (model: {}, reason: {}, tokens: {}/{})",
533+
response.model,
534+
response.stop_reason,
535+
response.usage.input_tokens,
536+
response.usage.output_tokens
537+
);
538+
472539
// Check if there are tool calls
473540
if response.tool_calls.is_empty() {
474541
// No tool calls - return the content
@@ -554,12 +621,6 @@ impl ToolCallingLoop {
554621
None
555622
}
556623
}
557-
558-
/// Trigger graceful shutdown.
559-
#[allow(dead_code)]
560-
pub fn shutdown(&self) {
561-
self.shutdown.cancel();
562-
}
563624
}
564625

565626
#[cfg(test)]
@@ -581,16 +642,34 @@ mod tests {
581642
assert!(router.tools_available.load(Ordering::SeqCst));
582643
}
583644

645+
#[test]
646+
fn test_tools_available_no_auto_reset() {
647+
let router = create_test_router();
648+
// Simulate a tool call failure by setting flag to false
649+
router.tools_available.store(false, Ordering::SeqCst);
650+
// The getter should NOT auto-reset
651+
assert!(!router.tools_available());
652+
// Flag should still be false
653+
assert!(!router.tools_available.load(Ordering::SeqCst));
654+
}
655+
584656
#[tokio::test]
585657
async fn test_text_only_fallback() {
586-
let router = create_test_router();
658+
let proxy_config = ProxyClientConfig {
659+
base_url: "http://127.0.0.1:1".to_string(),
660+
..Default::default()
661+
};
662+
let direct_config = DirectLlmConfig {
663+
base_url: Some("http://127.0.0.1:1".to_string()),
664+
..Default::default()
665+
};
666+
let router = HybridLlmRouter::new(proxy_config, direct_config);
587667
let messages = vec![Message::user("Hello")];
588668

589669
let response = router.text_only(messages, None).await.unwrap();
590-
// When both proxy and direct LLM are unavailable, returns unavailable message
591670
assert!(
592-
response.contains("unavailable") || response.contains("Hello"),
593-
"Expected unavailable message or echoed input, got: {}",
671+
response.contains("unavailable"),
672+
"Expected unavailable message when both proxy and direct LLM are unreachable, got: {}",
594673
response
595674
);
596675
}
@@ -642,4 +721,92 @@ mod tests {
642721
assert!(response.is_some());
643722
assert!(response.unwrap().content.contains("Available commands"));
644723
}
724+
725+
#[tokio::test]
726+
async fn test_compress_fallback_to_extractive() {
727+
// Both proxy and Ollama unreachable (port 1 is unreachable)
728+
let proxy_config = ProxyClientConfig {
729+
base_url: "http://127.0.0.1:1".to_string(),
730+
..Default::default()
731+
};
732+
let direct_config = DirectLlmConfig {
733+
base_url: Some("http://127.0.0.1:1".to_string()),
734+
..Default::default()
735+
};
736+
let router = HybridLlmRouter::new(proxy_config, direct_config);
737+
738+
let messages = vec![
739+
ChatMessage {
740+
role: MessageRole::User,
741+
content: "Hello there".to_string(),
742+
sender_id: None,
743+
timestamp: chrono::Utc::now(),
744+
metadata: std::collections::HashMap::new(),
745+
},
746+
ChatMessage {
747+
role: MessageRole::Assistant,
748+
content: "Hi! How can I help?".to_string(),
749+
sender_id: None,
750+
timestamp: chrono::Utc::now(),
751+
metadata: std::collections::HashMap::new(),
752+
},
753+
];
754+
755+
let result = router.compress(messages, "system".to_string()).await;
756+
assert!(
757+
result.is_ok(),
758+
"compress should never fail, got: {:?}",
759+
result
760+
);
761+
let summary = result.unwrap();
762+
assert!(
763+
summary.contains("2 messages"),
764+
"Expected extractive summary, got: {}",
765+
summary
766+
);
767+
}
768+
769+
#[test]
770+
fn test_build_proxy_messages_with_summary() {
771+
let messages = vec![ChatMessage {
772+
role: MessageRole::User,
773+
content: "What was the decision?".to_string(),
774+
sender_id: None,
775+
timestamp: chrono::Utc::now(),
776+
metadata: HashMap::new(),
777+
}];
778+
779+
let result = build_proxy_messages(&messages, Some("We decided to use Rust."));
780+
// Summary user message + assistant ack + 1 user message = 3
781+
assert_eq!(result.len(), 3);
782+
assert!(result[0].content.contains("We decided to use Rust."));
783+
assert_eq!(result[0].role, "user");
784+
assert_eq!(result[1].role, "assistant");
785+
assert_eq!(result[2].content, "What was the decision?");
786+
}
787+
788+
#[test]
789+
fn test_build_proxy_messages_without_summary() {
790+
let messages = vec![
791+
ChatMessage {
792+
role: MessageRole::User,
793+
content: "Hello".to_string(),
794+
sender_id: None,
795+
timestamp: chrono::Utc::now(),
796+
metadata: HashMap::new(),
797+
},
798+
ChatMessage {
799+
role: MessageRole::Assistant,
800+
content: "Hi!".to_string(),
801+
sender_id: None,
802+
timestamp: chrono::Utc::now(),
803+
metadata: HashMap::new(),
804+
},
805+
];
806+
807+
let result = build_proxy_messages(&messages, None);
808+
assert_eq!(result.len(), 2);
809+
assert_eq!(result[0].content, "Hello");
810+
assert_eq!(result[1].content, "Hi!");
811+
}
645812
}

0 commit comments

Comments
 (0)