mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
fix: preserve text before tool-call XML in forced-text responses (#852)
* fix: preserve text before tool-call XML in forced-text responses (#789) Local models (Qwen3, DeepSeek, GLM) emit <tool_call> XML even when no tools are available (force_text mode). The existing strip_xml_tag() discards everything from an unclosed opening tag onward, producing an empty string that triggers the "I'm not sure how to respond" fallback. Add truncate_at_tool_tags() — a code-region-aware pre-processing step that truncates at the first tool-call XML tag BEFORE clean_response() runs, preserving all useful text before the tag. Protect all 7 clean_response() call sites. Case-insensitive matching handles models that emit <TOOL_CALL> or <Tool_Call> variants. Secondary fix: add has_native_thinking() model detection to skip <think>/<final> system prompt injection for models with built-in reasoning (Qwen3, QwQ, DeepSeek-R1, GLM-Z1, etc.), preventing thinking-only responses that clean to empty. Wire with_model_name(active_model_name()) at all 9 production sites that construct Reasoning, so the runtime model name (not static config) drives system prompt generation. 126 new/updated tests covering truncation edge cases, code-block awareness, Unicode, case-insensitivity, StubLlm integration for complete/plan/evaluate_success/respond_with_tools paths, model detection, and conditional system prompt generation. Closes #789 Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address Copilot review — unclosed-only truncation, ASCII case folding - truncate_at_tool_tags() now only truncates at UNCLOSED tool tags; properly closed tags (e.g. <tool_call>...</tool_call>) are left intact for clean_response() to strip normally, preserving any text after them - Switch from to_lowercase() to to_ascii_lowercase() to prevent byte offset misalignment with non-ASCII characters whose lowercase form has different byte length (e.g. Kelvin sign U+212A) - Add closing_tag_for() helper to derive closing tags from open patterns - Fix doc comment: "fenced markdown code blocks or inline code spans" (not "indented", which find_code_regions() doesn't detect) - Add regression tests: closed vs unclosed for each tag variant, Unicode + case-insensitive offset safety, and mixed closed/unclosed Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: minor review items — consistent ascii_lowercase, closing_tag_for tests - Switch has_native_thinking() from to_lowercase() to to_ascii_lowercase() for consistency with truncate_at_tool_tags() approach - Add unit tests for closing_tag_for(): standard tags, space-suffixed patterns, pipe-delimited tags, and exhaustive coverage of all TOOL_TAG_PATTERNS entries - Add test for mixed closed+unclosed tags of different types Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
60881d6888
commit
46c01cb841
@@ -405,7 +405,8 @@ impl Agent {
|
||||
.with_max_tokens(512)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm().clone());
|
||||
let reasoning = Reasoning::new(self.llm().clone())
|
||||
.with_model_name(self.llm().active_model_name());
|
||||
match reasoning.complete(request).await {
|
||||
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
|
||||
"Thread Summary:\n\n{}",
|
||||
@@ -453,7 +454,8 @@ impl Agent {
|
||||
.with_max_tokens(512)
|
||||
.with_temperature(0.5);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm().clone());
|
||||
let reasoning = Reasoning::new(self.llm().clone())
|
||||
.with_model_name(self.llm().active_model_name());
|
||||
match reasoning.complete(request).await {
|
||||
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
|
||||
"Suggested Next Steps:\n\n{}",
|
||||
|
||||
@@ -227,7 +227,8 @@ Be brief but capture all important details. Use bullet points."#,
|
||||
.with_max_tokens(1024)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm.clone());
|
||||
let reasoning = Reasoning::new(self.llm.clone())
|
||||
.with_model_name(self.llm.active_model_name());
|
||||
let (text, _) = reasoning.complete(request).await?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
@@ -303,7 +303,8 @@ impl HeartbeatRunner {
|
||||
.with_max_tokens(max_tokens)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm.clone());
|
||||
let reasoning = Reasoning::new(self.llm.clone())
|
||||
.with_model_name(self.llm.active_model_name());
|
||||
let (content, _usage) = match reasoning.complete(request).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
|
||||
|
||||
+2
-1
@@ -212,7 +212,8 @@ impl Worker {
|
||||
let job_ctx = self.context_manager().get_context(self.job_id).await?;
|
||||
|
||||
// Create reasoning engine
|
||||
let reasoning = Reasoning::new(self.llm().clone());
|
||||
let reasoning = Reasoning::new(self.llm().clone())
|
||||
.with_model_name(self.llm().active_model_name());
|
||||
|
||||
// Build initial reasoning context (tool definitions refreshed each iteration in execution_loop)
|
||||
let mut reason_ctx = ReasoningContext::new().with_job(&job_ctx.description);
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod session;
|
||||
pub mod smart_routing;
|
||||
|
||||
pub mod image_models;
|
||||
pub mod reasoning_models;
|
||||
pub mod vision_models;
|
||||
|
||||
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
||||
|
||||
+700
-18
@@ -450,7 +450,8 @@ impl Reasoning {
|
||||
cache_read_input_tokens: response.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: response.cache_creation_input_tokens,
|
||||
};
|
||||
Ok((clean_response(&response.content), usage))
|
||||
let pre_truncated = truncate_at_tool_tags(&response.content);
|
||||
Ok((clean_response(&pre_truncated), usage))
|
||||
}
|
||||
|
||||
/// Generate a plan for completing a goal.
|
||||
@@ -480,8 +481,11 @@ impl Reasoning {
|
||||
|
||||
let response = self.llm.complete(request).await?;
|
||||
|
||||
// Clean reasoning model artifacts before parsing JSON
|
||||
let cleaned = clean_response(&response.content);
|
||||
// Clean reasoning model artifacts before parsing JSON.
|
||||
// Pre-truncate at tool tags to avoid strip_xml_tag discarding
|
||||
// content after unclosed tags (issue #789).
|
||||
let pre_truncated = truncate_at_tool_tags(&response.content);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
self.parse_plan(&cleaned)
|
||||
}
|
||||
|
||||
@@ -575,8 +579,11 @@ Respond in JSON format:
|
||||
|
||||
let response = self.llm.complete(request).await?;
|
||||
|
||||
// Clean reasoning model artifacts before parsing JSON
|
||||
let cleaned = clean_response(&response.content);
|
||||
// Clean reasoning model artifacts before parsing JSON.
|
||||
// Pre-truncate at tool tags to avoid strip_xml_tag discarding
|
||||
// content after unclosed tags (issue #789).
|
||||
let pre_truncated = truncate_at_tool_tags(&response.content);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
self.parse_evaluation(&cleaned)
|
||||
}
|
||||
|
||||
@@ -653,7 +660,10 @@ Respond in JSON format:
|
||||
return Ok(RespondOutput {
|
||||
result: RespondResult::ToolCalls {
|
||||
tool_calls: response.tool_calls,
|
||||
content: response.content.map(|c| clean_response(&c)),
|
||||
content: response.content.map(|c| {
|
||||
let pre_truncated = truncate_at_tool_tags(&c);
|
||||
clean_response(&pre_truncated)
|
||||
}),
|
||||
},
|
||||
usage,
|
||||
});
|
||||
@@ -666,9 +676,13 @@ Respond in JSON format:
|
||||
// Some models (e.g. GLM-4.7) emit tool calls as XML tags in content
|
||||
// instead of using the structured tool_calls field. Try to recover
|
||||
// them before giving up and returning plain text.
|
||||
// NOTE: Recovery runs on the raw content (before truncation) so it can
|
||||
// parse tool-call JSON from the XML tags. Truncation only applies to the
|
||||
// remaining *text* content returned alongside the recovered tool calls.
|
||||
let recovered = recover_tool_calls_from_content(&content, &context.available_tools);
|
||||
if !recovered.is_empty() {
|
||||
let cleaned = clean_response(&content);
|
||||
let pre_truncated = truncate_at_tool_tags(&content);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
return Ok(RespondOutput {
|
||||
result: RespondResult::ToolCalls {
|
||||
tool_calls: recovered,
|
||||
@@ -682,12 +696,16 @@ Respond in JSON format:
|
||||
});
|
||||
}
|
||||
|
||||
// Guard against empty text after cleaning. This can happen
|
||||
// when reasoning models (e.g. GLM-5) return chain-of-thought
|
||||
// in reasoning_content wrapped in <think> tags and content is
|
||||
// null — the .or(reasoning_content) fallback picks it up, then
|
||||
// clean_response strips the think tags leaving an empty string.
|
||||
let cleaned = clean_response(&content);
|
||||
// Guard against empty text after cleaning. This can happen when:
|
||||
// 1. Reasoning models (e.g. GLM-5) return chain-of-thought in
|
||||
// reasoning_content wrapped in <think> tags — clean_response
|
||||
// strips the think tags leaving an empty string.
|
||||
// 2. Local models (Qwen3, DeepSeek) emit <tool_call> XML in text
|
||||
// responses even in force_text mode — strip_xml_tag discards
|
||||
// from unclosed opening tag onward (issue #789).
|
||||
// Pre-truncate at tool tags to preserve text before the tag.
|
||||
let pre_truncated = truncate_at_tool_tags(&content);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
let final_text = if cleaned.trim().is_empty() {
|
||||
tracing::warn!(
|
||||
"LLM response was empty after cleaning (original len={}), using fallback",
|
||||
@@ -709,7 +727,8 @@ Respond in JSON format:
|
||||
request.metadata = context.metadata.clone();
|
||||
|
||||
let response = self.llm.complete(request).await?;
|
||||
let cleaned = clean_response(&response.content);
|
||||
let pre_truncated = truncate_at_tool_tags(&response.content);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
let final_text = if cleaned.trim().is_empty() {
|
||||
tracing::warn!(
|
||||
"LLM response was empty after cleaning (original len={}), using fallback",
|
||||
@@ -847,10 +866,22 @@ Respond with a JSON plan in this format:
|
||||
.to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"You are IronClaw Agent, a secure autonomous assistant.
|
||||
// Models with native thinking (Qwen3, DeepSeek-R1, etc.) produce their
|
||||
// own <think> tags or reasoning_content. Injecting our <think>/<final>
|
||||
// format collides with their native behavior, causing thinking-only
|
||||
// responses that clean to empty strings. See issue #789.
|
||||
let has_native_thinking = self
|
||||
.model_name
|
||||
.as_ref()
|
||||
.is_some_and(|n| crate::llm::reasoning_models::has_native_thinking(n));
|
||||
|
||||
## Response Format — CRITICAL
|
||||
let response_format = if has_native_thinking {
|
||||
r#"## Response Format
|
||||
|
||||
Respond directly with your answer. Do not wrap your response in any special tags.
|
||||
Your reasoning process is handled natively — just provide the final user-facing answer."#
|
||||
} else {
|
||||
r#"## Response Format — CRITICAL
|
||||
|
||||
ALL internal reasoning MUST be inside <think>...</think> tags.
|
||||
Do not output any analysis, planning, or self-talk outside <think>.
|
||||
@@ -860,7 +891,13 @@ Only text inside <final> is shown to the user; everything else is discarded.
|
||||
|
||||
Example:
|
||||
<think>The user is asking about X.</think>
|
||||
<final>Here is the answer about X.</final>
|
||||
<final>Here is the answer about X.</final>"#
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"You are IronClaw Agent, a secure autonomous assistant.
|
||||
|
||||
{response_format}
|
||||
|
||||
## Guidelines
|
||||
- Be concise and direct
|
||||
@@ -1442,6 +1479,99 @@ fn strip_bracket_tool_calls(text: &str) -> String {
|
||||
/// Tool-related tags stripped with simple string matching (no code-awareness needed).
|
||||
const TOOL_TAGS: &[&str] = &["tool_call", "function_call", "tool_calls"];
|
||||
|
||||
/// Patterns that indicate tool-call XML in model output.
|
||||
const TOOL_TAG_PATTERNS: &[&str] = &[
|
||||
"<tool_call>",
|
||||
"<tool_call ",
|
||||
"<function_call>",
|
||||
"<function_call ",
|
||||
"<tool_calls>",
|
||||
"<tool_calls ",
|
||||
"<|tool_call|>",
|
||||
"<|function_call|>",
|
||||
"<|tool_calls|>",
|
||||
];
|
||||
|
||||
/// Truncate text at the first **unclosed** tool-call XML tag, preserving content
|
||||
/// before it.
|
||||
///
|
||||
/// Local models (Qwen3, DeepSeek, etc.) often emit `<tool_call>` XML in text
|
||||
/// responses even when no tools are available. The downstream `clean_response()`
|
||||
/// → `strip_xml_tag()` pipeline discards everything from an unclosed opening
|
||||
/// tag onward, which can leave an empty string and trigger the fallback message.
|
||||
///
|
||||
/// This function truncates at the first *unclosed* tool tag BEFORE
|
||||
/// `clean_response()` runs, so the useful text before the tag is preserved.
|
||||
/// Properly closed tags (e.g. `<tool_call>...</tool_call>`) are left intact for
|
||||
/// `clean_response()` to strip normally. Tags inside fenced markdown code blocks
|
||||
/// or inline code spans are ignored. See issue #789.
|
||||
fn truncate_at_tool_tags(text: &str) -> String {
|
||||
let code_regions = find_code_regions(text);
|
||||
// Use ASCII-only lowercasing so byte offsets stay valid for the original
|
||||
// string. Full `to_lowercase()` can change byte lengths for non-ASCII
|
||||
// chars (e.g. the Kelvin sign), making positions unreliable.
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let first_unclosed = TOOL_TAG_PATTERNS
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let mut search_from = 0;
|
||||
loop {
|
||||
match lower[search_from..].find(p) {
|
||||
Some(offset) => {
|
||||
let pos = search_from + offset;
|
||||
if is_inside_code(pos, &code_regions) {
|
||||
search_from = pos + 1;
|
||||
continue;
|
||||
}
|
||||
// Check if this tag has a matching closing tag after it.
|
||||
// If so, clean_response() can handle it — skip to next.
|
||||
let after_open = pos + p.len();
|
||||
if closing_tag_for(p)
|
||||
.is_some_and(|close| lower[after_open..].contains(close.as_str()))
|
||||
{
|
||||
search_from = after_open;
|
||||
continue;
|
||||
}
|
||||
// Unclosed tag — truncate here
|
||||
return Some(pos);
|
||||
}
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.min();
|
||||
match first_unclosed {
|
||||
Some(pos) => {
|
||||
tracing::debug!(
|
||||
original_len = text.len(),
|
||||
truncated_at = pos,
|
||||
"Truncated response at unclosed tool-call XML tag (issue #789)"
|
||||
);
|
||||
text[..pos].to_string()
|
||||
}
|
||||
None => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the closing tag for a tool-call opening pattern.
|
||||
///
|
||||
/// Examples: `<tool_call>` → `</tool_call>`, `<|tool_call|>` → `<|/tool_call|>`.
|
||||
fn closing_tag_for(open_pattern: &str) -> Option<String> {
|
||||
if let Some(name) = open_pattern
|
||||
.strip_prefix("<|")
|
||||
.and_then(|s| s.strip_suffix("|>"))
|
||||
{
|
||||
// Pipe-delimited: <|tool_call|> → <|/tool_call|>
|
||||
Some(format!("<|/{name}|>"))
|
||||
} else if let Some(rest) = open_pattern.strip_prefix('<') {
|
||||
// Standard XML: <tool_call> or <tool_call → </tool_call>
|
||||
let name = rest.trim_end_matches('>').trim();
|
||||
Some(format!("</{name}>"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip thinking/reasoning tags using regex, respecting code regions.
|
||||
///
|
||||
/// Strict mode: an unclosed opening tag discards all trailing text after it.
|
||||
@@ -2414,4 +2544,556 @@ That's my plan."#;
|
||||
let text = "I said let me be clear, then let me fetch the data.";
|
||||
assert!(llm_signals_tool_intent(text));
|
||||
}
|
||||
|
||||
// ---- Issue #789: truncate_at_tool_tags tests ----
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preserves_text_before_tool_tag() {
|
||||
let input = "Here is my answer about the topic.\n<tool_call>{\"name\": \"search\"}";
|
||||
assert_eq!(
|
||||
truncate_at_tool_tags(input),
|
||||
"Here is my answer about the topic.\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_no_tool_tags_unchanged() {
|
||||
let input = "Just a normal response with no tool tags.";
|
||||
assert_eq!(truncate_at_tool_tags(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_empty_string() {
|
||||
assert_eq!(truncate_at_tool_tags(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_tool_tag_at_start() {
|
||||
assert_eq!(
|
||||
truncate_at_tool_tags("<tool_call>{\"name\": \"search\"}"),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_picks_earliest_unclosed_tag() {
|
||||
// <function_call>...</function_call> is closed — skipped.
|
||||
// <tool_call>second is unclosed — truncated here.
|
||||
let input = "Text before <function_call>first</function_call> and <tool_call>second";
|
||||
assert_eq!(truncate_at_tool_tags(input), "Text before <function_call>first</function_call> and ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_pipe_delimited_tags() {
|
||||
let input = "Answer here\n<|tool_call|>{\"name\": \"fetch\"}";
|
||||
assert_eq!(truncate_at_tool_tags(input), "Answer here\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_closed_tag_with_attributes_preserved() {
|
||||
// Closed tag (even with attributes) is left for clean_response()
|
||||
let input = "Some text <tool_call id=\"123\">{\"name\": \"test\"}</tool_call>";
|
||||
assert_eq!(truncate_at_tool_tags(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_unclosed_tag_with_attributes() {
|
||||
let input = "Some text <tool_call id=\"123\">{\"name\": \"test\"}";
|
||||
assert_eq!(truncate_at_tool_tags(input), "Some text ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_whitespace_only_before_tag() {
|
||||
assert_eq!(truncate_at_tool_tags(" \n\n<tool_call>{}"), " \n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_ignores_tags_inside_code_blocks() {
|
||||
let input = "Here's the XML format:\n\n```xml\n<tool_call>{\"name\": \"search\"}</tool_call>\n```\n\nYou can use this to call tools.";
|
||||
assert_eq!(truncate_at_tool_tags(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_finds_tag_after_code_block() {
|
||||
let input = "Example:\n\n```\n<tool_call>example</tool_call>\n```\n\nReal output:\n<tool_call>{\"name\": \"x\"}";
|
||||
assert_eq!(
|
||||
truncate_at_tool_tags(input),
|
||||
"Example:\n\n```\n<tool_call>example</tool_call>\n```\n\nReal output:\n"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Issue #789: full pipeline (truncate + clean_response) tests ----
|
||||
|
||||
#[test]
|
||||
fn test_issue_789_force_text_unclosed_tool_tag() {
|
||||
let model_output = "The file contains a main function that initializes the server.\n<tool_call>{\"name\": \"read_file\", \"arguments\": {\"path\": \"src/main.rs\"}}";
|
||||
let pre_truncated = truncate_at_tool_tags(model_output);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
assert_eq!(
|
||||
cleaned,
|
||||
"The file contains a main function that initializes the server."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_789_only_tool_tag_produces_empty() {
|
||||
let model_output = "<tool_call>{\"name\": \"search\", \"arguments\": {\"q\": \"test\"}}";
|
||||
let pre_truncated = truncate_at_tool_tags(model_output);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
assert!(cleaned.trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_789_thinking_then_tool_tag() {
|
||||
let model_output =
|
||||
"<think>I should search for this</think>Let me help you.\n<tool_call>{\"name\": \"s\"}";
|
||||
let pre_truncated = truncate_at_tool_tags(model_output);
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
assert_eq!(cleaned, "Let me help you.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_789_closed_tool_tag_preserved_for_clean_response() {
|
||||
// Closed tags are left intact — clean_response() strips them normally,
|
||||
// preserving any text after the tag.
|
||||
let model_output = "Info here.\n<tool_call>{\"name\": \"x\"}</tool_call>\nMore text.";
|
||||
let pre_truncated = truncate_at_tool_tags(model_output);
|
||||
assert_eq!(pre_truncated, model_output, "Closed tag should not be truncated");
|
||||
let cleaned = clean_response(&pre_truncated);
|
||||
assert_eq!(cleaned, "Info here.\n\nMore text.");
|
||||
}
|
||||
|
||||
// ---- Issue #789: conditional system prompt tests ----
|
||||
|
||||
fn make_reasoning_with_model(model: &str) -> Reasoning {
|
||||
use crate::testing::StubLlm;
|
||||
Reasoning::new(Arc::new(StubLlm::new("test"))).with_model_name(model.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_skips_think_final_for_native_thinking() {
|
||||
let reasoning = make_reasoning_with_model("qwen3-8b");
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&[]);
|
||||
assert!(
|
||||
!prompt.contains("<think>"),
|
||||
"Native thinking model should NOT have <think> in system prompt"
|
||||
);
|
||||
assert!(prompt.contains("Respond directly with your answer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_includes_think_final_for_regular_model() {
|
||||
let reasoning = make_reasoning_with_model("llama-3.1-70b");
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&[]);
|
||||
assert!(prompt.contains("<think>"));
|
||||
assert!(prompt.contains("<final>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_defaults_to_think_final_when_no_model() {
|
||||
use crate::testing::StubLlm;
|
||||
let reasoning = Reasoning::new(Arc::new(StubLlm::new("test")));
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&[]);
|
||||
assert!(prompt.contains("<think>"));
|
||||
assert!(prompt.contains("<final>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_deepseek_r1_skips_think_final() {
|
||||
let reasoning = make_reasoning_with_model("deepseek-r1-distill-qwen-32b");
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&[]);
|
||||
assert!(!prompt.contains("CRITICAL"));
|
||||
assert!(prompt.contains("Respond directly"));
|
||||
}
|
||||
|
||||
// ---- Issue #789: additional edge case tests for truncate_at_tool_tags ----
|
||||
|
||||
#[test]
|
||||
fn test_truncate_unicode_content_before_tool_tag() {
|
||||
let input = "こんにちは世界!素晴らしい結果です。\n<tool_call>{\"name\": \"search\"}";
|
||||
assert_eq!(
|
||||
truncate_at_tool_tags(input),
|
||||
"こんにちは世界!素晴らしい結果です。\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_emoji_content_preserved() {
|
||||
let input = "The answer is 42 🎉🚀\n<function_call>{\"name\": \"x\"}";
|
||||
assert_eq!(truncate_at_tool_tags(input), "The answer is 42 🎉🚀\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_very_long_text_before_tag() {
|
||||
let long_text = "A".repeat(10_000);
|
||||
let input = format!("{}\n<tool_call>{{\"name\": \"x\"}}", long_text);
|
||||
let result = truncate_at_tool_tags(&input);
|
||||
assert_eq!(result.len(), long_text.len() + 1); // +1 for \n
|
||||
assert!(result.starts_with("AAAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_multiple_code_blocks_with_tags() {
|
||||
let input = "Explanation:\n\n```python\n# <tool_call> in comment\nprint('hi')\n```\n\nAnd also:\n\n```xml\n<function_call>example</function_call>\n```\n\nFinal answer here.";
|
||||
// Both tags are inside code blocks, so nothing is truncated
|
||||
assert_eq!(truncate_at_tool_tags(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_inline_code_with_tool_tag() {
|
||||
let input = "Use `<tool_call>` to invoke tools.\n<tool_call>{\"name\": \"real\"}";
|
||||
// First occurrence is in inline code, second is real
|
||||
assert_eq!(
|
||||
truncate_at_tool_tags(input),
|
||||
"Use `<tool_call>` to invoke tools.\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_tag_immediately_after_code_block() {
|
||||
let input = "```\nexample\n```\n<tool_call>{\"name\": \"x\"}";
|
||||
assert_eq!(truncate_at_tool_tags(input), "```\nexample\n```\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_interleaved_thinking_and_tool_tags() {
|
||||
// Simulate: thinking tag + text + tool tag
|
||||
let input = "<think>reasoning</think>Here's the answer.\n<tool_call>{\"name\": \"y\"}";
|
||||
let truncated = truncate_at_tool_tags(input);
|
||||
let cleaned = clean_response(&truncated);
|
||||
assert_eq!(cleaned, "Here's the answer.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_closed_tool_calls_plural_preserved() {
|
||||
// Closed <tool_calls>...</tool_calls> left for clean_response()
|
||||
let input = "Answer.\n<tool_calls>[{\"name\": \"a\"}, {\"name\": \"b\"}]</tool_calls>";
|
||||
assert_eq!(truncate_at_tool_tags(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_unclosed_tool_calls_plural() {
|
||||
let input = "Answer.\n<tool_calls>[{\"name\": \"a\"}, {\"name\": \"b\"}]";
|
||||
assert_eq!(truncate_at_tool_tags(input), "Answer.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_closed_pipe_function_call_preserved() {
|
||||
let input = "Done!\n<|function_call|>{\"name\": \"x\"}<|/function_call|>";
|
||||
assert_eq!(truncate_at_tool_tags(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_unclosed_pipe_function_call() {
|
||||
let input = "Done!\n<|function_call|>{\"name\": \"x\"}";
|
||||
assert_eq!(truncate_at_tool_tags(input), "Done!\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_adversarial_nested_code_blocks() {
|
||||
// Adversarial: code block inside another structure
|
||||
let input = "```\nouter\n```\n\nReal text.\n\n```\n<tool_call>inside</tool_call>\n```\n\n<tool_call>{\"name\": \"real\"}";
|
||||
let result = truncate_at_tool_tags(input);
|
||||
assert!(result.contains("Real text."));
|
||||
assert!(!result.contains("{\"name\": \"real\"}"));
|
||||
}
|
||||
|
||||
// ---- Issue #789: StubLlm integration tests ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_complete_truncates_tool_tags_from_response() {
|
||||
use crate::testing::StubLlm;
|
||||
let response = "The server has 3 endpoints.\n<tool_call>{\"name\": \"read_file\"}";
|
||||
let llm = Arc::new(StubLlm::new(response));
|
||||
let reasoning = Reasoning::new(llm);
|
||||
|
||||
let request = CompletionRequest::new(vec![ChatMessage::user("describe the server")]);
|
||||
let (result, _usage) = reasoning.complete(request).await.unwrap();
|
||||
assert_eq!(result, "The server has 3 endpoints.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_complete_with_only_tool_tag_returns_empty() {
|
||||
use crate::testing::StubLlm;
|
||||
let response = "<tool_call>{\"name\": \"search\", \"arguments\": {}}";
|
||||
let llm = Arc::new(StubLlm::new(response));
|
||||
let reasoning = Reasoning::new(llm);
|
||||
|
||||
let request = CompletionRequest::new(vec![ChatMessage::user("hello")]);
|
||||
let (result, _usage) = reasoning.complete(request).await.unwrap();
|
||||
assert!(result.trim().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_respond_with_tools_force_text_truncates_tool_tags() {
|
||||
use crate::testing::StubLlm;
|
||||
let response =
|
||||
"Here is my analysis of the code.\n<tool_call>{\"name\": \"read_file\", \"arguments\": {\"path\": \"main.rs\"}}";
|
||||
let llm = Arc::new(StubLlm::new(response));
|
||||
let reasoning = Reasoning::new(llm);
|
||||
|
||||
let mut context = ReasoningContext::new()
|
||||
.with_message(ChatMessage::user("analyze the code"));
|
||||
context.force_text = true;
|
||||
|
||||
let output = reasoning.respond_with_tools(&context).await.unwrap();
|
||||
match output.result {
|
||||
RespondResult::Text(text) => {
|
||||
assert_eq!(text, "Here is my analysis of the code.");
|
||||
}
|
||||
RespondResult::ToolCalls { .. } => {
|
||||
panic!("Expected text result in force_text mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_respond_with_tools_force_text_only_tag_uses_fallback() {
|
||||
use crate::testing::StubLlm;
|
||||
let response = "<tool_call>{\"name\": \"search\"}";
|
||||
let llm = Arc::new(StubLlm::new(response));
|
||||
let reasoning = Reasoning::new(llm);
|
||||
|
||||
let mut context = ReasoningContext::new()
|
||||
.with_message(ChatMessage::user("hi"));
|
||||
context.force_text = true;
|
||||
|
||||
let output = reasoning.respond_with_tools(&context).await.unwrap();
|
||||
match output.result {
|
||||
RespondResult::Text(text) => {
|
||||
assert_eq!(text, "I'm not sure how to respond to that.");
|
||||
}
|
||||
RespondResult::ToolCalls { .. } => {
|
||||
panic!("Expected fallback text, not tool calls");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plan_truncates_tool_tags_before_json() {
|
||||
use crate::testing::StubLlm;
|
||||
let response = r#"<think>Let me plan</think>{"goal": "Test goal", "actions": [{"tool_name": "search", "parameters": {}, "reasoning": "find files", "expected_outcome": "results"}], "confidence": 0.9}
|
||||
<tool_call>{"name": "search"}"#;
|
||||
let llm = Arc::new(StubLlm::new(response));
|
||||
let reasoning = Reasoning::new(llm);
|
||||
|
||||
let context = ReasoningContext::new()
|
||||
.with_message(ChatMessage::user("plan a search"))
|
||||
.with_job("Search for relevant files");
|
||||
|
||||
let plan = reasoning.plan(&context).await.unwrap();
|
||||
assert_eq!(plan.goal, "Test goal");
|
||||
assert!(!plan.actions.is_empty());
|
||||
}
|
||||
|
||||
// ---- Issue #789: model name propagation test ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_with_model_name_affects_system_prompt() {
|
||||
use crate::testing::StubLlm;
|
||||
// StubLlm model_name is "stub-model" by default, but Reasoning.model_name
|
||||
// is what matters for system prompt building.
|
||||
let llm = Arc::new(StubLlm::new("test").with_model_name("qwen3-8b"));
|
||||
let reasoning = Reasoning::new(llm.clone()).with_model_name("qwen3-8b".to_string());
|
||||
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&[]);
|
||||
assert!(
|
||||
!prompt.contains("<think>"),
|
||||
"Qwen3 model should get native thinking system prompt"
|
||||
);
|
||||
assert!(prompt.contains("Respond directly"));
|
||||
|
||||
// Now create reasoning WITHOUT with_model_name — should get default prompt
|
||||
let reasoning_no_model = Reasoning::new(llm);
|
||||
let prompt2 = reasoning_no_model.build_system_prompt_with_tools(&[]);
|
||||
assert!(
|
||||
prompt2.contains("<think>"),
|
||||
"Without model name, should get default think/final prompt"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Issue #789: case-insensitive truncation ----
|
||||
|
||||
#[test]
|
||||
fn test_truncate_case_insensitive_upper() {
|
||||
let input = "Some answer.\n<TOOL_CALL>{\"name\": \"search\"}";
|
||||
assert_eq!(truncate_at_tool_tags(input), "Some answer.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_case_insensitive_mixed() {
|
||||
let input = "Result here.\n<Tool_Call>{\"name\": \"x\"}";
|
||||
assert_eq!(truncate_at_tool_tags(input), "Result here.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_unicode_before_case_insensitive_tag_no_panic() {
|
||||
// Regression: to_lowercase() can change byte lengths for non-ASCII chars
|
||||
// (e.g. Kelvin sign U+212A is 3 bytes, lowercases to 'k' which is 1 byte).
|
||||
// Using to_ascii_lowercase() keeps byte offsets stable.
|
||||
let input = "Ответ: 42\n<TOOL_CALL>{\"name\": \"x\"}";
|
||||
assert_eq!(truncate_at_tool_tags(input), "Ответ: 42\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_case_insensitive_function_call_closed() {
|
||||
// Closed tag (case-insensitive) preserved for clean_response()
|
||||
let input = "Done.\n<FUNCTION_CALL>{\"name\": \"y\"}</FUNCTION_CALL>";
|
||||
assert_eq!(truncate_at_tool_tags(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_case_insensitive_function_call_unclosed() {
|
||||
let input = "Done.\n<FUNCTION_CALL>{\"name\": \"y\"}";
|
||||
assert_eq!(truncate_at_tool_tags(input), "Done.\n");
|
||||
}
|
||||
|
||||
// ---- Issue #789: evaluate_success integration test ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_evaluate_success_truncates_tool_tags() {
|
||||
use crate::testing::StubLlm;
|
||||
let response = r#"<think>evaluating</think>{"success": true, "confidence": 0.85, "reasoning": "Task completed", "issues": [], "suggestions": []}
|
||||
<tool_call>{"name": "verify"}"#;
|
||||
let llm = Arc::new(StubLlm::new(response));
|
||||
let reasoning = Reasoning::new(llm);
|
||||
|
||||
let context = ReasoningContext::new().with_job("Test task");
|
||||
let eval = reasoning
|
||||
.evaluate_success(&context, "The job is done")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(eval.success);
|
||||
assert_eq!(eval.confidence, 0.85);
|
||||
}
|
||||
|
||||
// ---- Issue #789: respond_with_tools recovered tool calls path ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_respond_with_tools_recovered_tool_calls_preserves_text() {
|
||||
use crate::testing::StubLlm;
|
||||
// StubLlm returns empty tool_calls + content with XML tool tags.
|
||||
// The recovery path should parse the tool call AND preserve text before it.
|
||||
let response =
|
||||
"Let me search for that.\n<tool_call>{\"name\": \"tool_list\", \"arguments\": {}}</tool_call>";
|
||||
let llm = Arc::new(StubLlm::new(response));
|
||||
let reasoning = Reasoning::new(llm);
|
||||
|
||||
let context = ReasoningContext::new()
|
||||
.with_message(ChatMessage::user("list tools"))
|
||||
.with_tools(vec![ToolDefinition {
|
||||
name: "tool_list".to_string(),
|
||||
description: "Lists tools".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
}]);
|
||||
|
||||
let output = reasoning.respond_with_tools(&context).await.unwrap();
|
||||
match output.result {
|
||||
RespondResult::ToolCalls {
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
assert_eq!(tool_calls[0].name, "tool_list");
|
||||
// Text before the tag should be preserved
|
||||
assert_eq!(content.as_deref(), Some("Let me search for that."));
|
||||
}
|
||||
RespondResult::Text(_) => {
|
||||
panic!("Expected recovered tool calls, got text");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_respond_with_tools_recovered_only_tag_content_is_none() {
|
||||
use crate::testing::StubLlm;
|
||||
// Content is ONLY a tool call tag — after truncation+cleaning, content should be None
|
||||
let response =
|
||||
"<tool_call>{\"name\": \"tool_list\", \"arguments\": {}}</tool_call>";
|
||||
let llm = Arc::new(StubLlm::new(response));
|
||||
let reasoning = Reasoning::new(llm);
|
||||
|
||||
let context = ReasoningContext::new()
|
||||
.with_message(ChatMessage::user("list tools"))
|
||||
.with_tools(vec![ToolDefinition {
|
||||
name: "tool_list".to_string(),
|
||||
description: "Lists tools".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
}]);
|
||||
|
||||
let output = reasoning.respond_with_tools(&context).await.unwrap();
|
||||
match output.result {
|
||||
RespondResult::ToolCalls {
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
assert_eq!(tool_calls[0].name, "tool_list");
|
||||
assert!(content.is_none(), "Content should be None when only tool tags present");
|
||||
}
|
||||
RespondResult::Text(_) => {
|
||||
panic!("Expected recovered tool calls, got text");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Issue #789: OpenAI reasoning models negative test ----
|
||||
|
||||
#[test]
|
||||
fn test_openai_reasoning_models_not_detected() {
|
||||
use crate::llm::reasoning_models::has_native_thinking;
|
||||
assert!(!has_native_thinking("o1"));
|
||||
assert!(!has_native_thinking("o1-mini"));
|
||||
assert!(!has_native_thinking("o1-preview"));
|
||||
assert!(!has_native_thinking("o3-mini"));
|
||||
assert!(!has_native_thinking("o4-mini"));
|
||||
}
|
||||
|
||||
// ---- closing_tag_for() unit tests ----
|
||||
|
||||
#[test]
|
||||
fn test_closing_tag_for_standard_tags() {
|
||||
assert_eq!(closing_tag_for("<tool_call>").as_deref(), Some("</tool_call>"));
|
||||
assert_eq!(closing_tag_for("<function_call>").as_deref(), Some("</function_call>"));
|
||||
assert_eq!(closing_tag_for("<tool_calls>").as_deref(), Some("</tool_calls>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_closing_tag_for_space_suffixed_patterns() {
|
||||
// Patterns with trailing space (for attribute matching)
|
||||
assert_eq!(closing_tag_for("<tool_call ").as_deref(), Some("</tool_call>"));
|
||||
assert_eq!(closing_tag_for("<function_call ").as_deref(), Some("</function_call>"));
|
||||
assert_eq!(closing_tag_for("<tool_calls ").as_deref(), Some("</tool_calls>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_closing_tag_for_pipe_delimited() {
|
||||
assert_eq!(closing_tag_for("<|tool_call|>").as_deref(), Some("<|/tool_call|>"));
|
||||
assert_eq!(closing_tag_for("<|function_call|>").as_deref(), Some("<|/function_call|>"));
|
||||
assert_eq!(closing_tag_for("<|tool_calls|>").as_deref(), Some("<|/tool_calls|>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_closing_tag_for_covers_all_patterns() {
|
||||
// Every entry in TOOL_TAG_PATTERNS must produce a closing tag
|
||||
for pattern in TOOL_TAG_PATTERNS {
|
||||
assert!(
|
||||
closing_tag_for(pattern).is_some(),
|
||||
"closing_tag_for({:?}) returned None",
|
||||
pattern
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- truncation with multiple tags: first closed, second unclosed ----
|
||||
|
||||
#[test]
|
||||
fn test_truncate_mixed_closed_then_unclosed_different_types() {
|
||||
let input = "Text <function_call>{}</function_call> middle <tool_call>{\"name\": \"x\"}";
|
||||
// function_call is closed → skipped. tool_call is unclosed → truncated.
|
||||
assert_eq!(
|
||||
truncate_at_tool_tags(input),
|
||||
"Text <function_call>{}</function_call> middle "
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Reasoning/thinking model detection utilities.
|
||||
//!
|
||||
//! Models with native thinking support produce structured chain-of-thought
|
||||
//! via `reasoning_content` fields or built-in `<think>` tags. Injecting
|
||||
//! IronClaw's own `<think>/<final>` format instructions into the system
|
||||
//! prompt collides with these models' native behavior, causing:
|
||||
//! - Thinking-only responses with no visible content
|
||||
//! - Double-wrapped thinking tags that confuse response cleaning
|
||||
//!
|
||||
//! When a model has native thinking, we skip the `<think>/<final>` prompt
|
||||
//! injection and let the model use its own format. The response cleaning
|
||||
//! pipeline already handles stripping all known thinking tag variants.
|
||||
//!
|
||||
//! ## Design note: why match broadly (e.g. all Qwen3)?
|
||||
//!
|
||||
//! Some families (Qwen3) have ALL variants trained with native `<think>` tags,
|
||||
//! even tiny models like 0.6B. Thinking can be disabled at inference time via
|
||||
//! `enable_thinking=false`, but we can't detect that from the model name alone.
|
||||
//! We err on the safe side: skip injection for all variants because:
|
||||
//! - False negative (inject when model thinks natively) = broken responses
|
||||
//! - False positive (skip injection for non-thinking model) = less structured
|
||||
//! but working responses
|
||||
//!
|
||||
//! For families where only SOME variants reason (GLM-4), we match specific
|
||||
//! sub-families (glm-z1, glm-4-plus) to avoid false positives.
|
||||
|
||||
/// Known model families with native thinking/reasoning support.
|
||||
///
|
||||
/// These models produce chain-of-thought reasoning either via a dedicated
|
||||
/// `reasoning_content` response field or via built-in `<think>` tags that
|
||||
/// the model was trained to emit without prompt injection.
|
||||
const NATIVE_THINKING_PATTERNS: &[&str] = &[
|
||||
// Qwen3 family — ALL variants (0.6B through 235B) emit native <think> tags
|
||||
// by default. Thinking can be toggled via `enable_thinking` parameter or
|
||||
// `/think` `/no_think` soft switches, but the default is ON and we can't
|
||||
// detect the runtime setting from the model name.
|
||||
"qwen3",
|
||||
// QwQ is Qwen's dedicated reasoning model (based on Qwen2.5-32B + RL).
|
||||
// Always thinks, no disable toggle.
|
||||
"qwq",
|
||||
// DeepSeek reasoning models — native reasoning_content field
|
||||
"deepseek-r1",
|
||||
"deepseek-reasoner",
|
||||
// GLM reasoning variants only (glm-4-flash, glm-4-air, glm-4v do NOT reason)
|
||||
"glm-z1",
|
||||
"glm-4-plus",
|
||||
"glm-5",
|
||||
// Nanbeige reasoning models
|
||||
"nanbeige",
|
||||
// Step reasoning models (3.5+ have native thinking; step-3 base does not)
|
||||
"step-3.5",
|
||||
// MiniMax reasoning models
|
||||
"minimax-m2",
|
||||
];
|
||||
|
||||
/// Check if a model name indicates native thinking/reasoning support.
|
||||
///
|
||||
/// Models that return `true` should NOT have IronClaw's `<think>/<final>`
|
||||
/// format instructions injected into their system prompt, as this collides
|
||||
/// with their built-in reasoning behavior.
|
||||
///
|
||||
/// Note: this is a best-effort heuristic based on model name. Some models
|
||||
/// support toggling thinking at runtime (e.g. Qwen3's `enable_thinking`),
|
||||
/// which we cannot detect here. We default to assuming thinking is ON for
|
||||
/// models that have it, since that's the default behavior.
|
||||
pub fn has_native_thinking(model: &str) -> bool {
|
||||
let lower = model.to_ascii_lowercase();
|
||||
NATIVE_THINKING_PATTERNS.iter().any(|p| lower.contains(p))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_qwen3_models() {
|
||||
// All Qwen3 variants have native thinking (even small ones)
|
||||
assert!(has_native_thinking("qwen3-coder-next-80b"));
|
||||
assert!(has_native_thinking("Qwen3.5-35B"));
|
||||
assert!(has_native_thinking("qwen3-0.6b"));
|
||||
assert!(has_native_thinking("qwen3:8b"));
|
||||
assert!(has_native_thinking("qwen3-30b-a3b"));
|
||||
// Ollama-style tag format
|
||||
assert!(has_native_thinking("qwen3-coder:latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_qwq() {
|
||||
assert!(has_native_thinking("qwq-32b"));
|
||||
assert!(has_native_thinking("QwQ-32B-Preview"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_deepseek_reasoning() {
|
||||
assert!(has_native_thinking("deepseek-r1-distill-qwen-32b"));
|
||||
assert!(has_native_thinking("deepseek-reasoner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_glm_reasoning_variants() {
|
||||
assert!(has_native_thinking("glm-z1-airx"));
|
||||
assert!(has_native_thinking("glm-4-plus"));
|
||||
assert!(has_native_thinking("GLM-5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_other_reasoning_models() {
|
||||
assert!(has_native_thinking("nanbeige-4.1-3b"));
|
||||
assert!(has_native_thinking("step-3.5-flash-197b"));
|
||||
assert!(has_native_thinking("minimax-m2.5-139b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_reasoning_models() {
|
||||
assert!(!has_native_thinking("gpt-4o"));
|
||||
assert!(!has_native_thinking("claude-3-5-sonnet"));
|
||||
assert!(!has_native_thinking("llama-3.1-70b"));
|
||||
assert!(!has_native_thinking("mistral-7b"));
|
||||
assert!(!has_native_thinking("gemini-2.0-flash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_reasoning_variants_in_same_family() {
|
||||
// Qwen2.5 does NOT have native thinking (only Qwen3/QwQ do)
|
||||
assert!(!has_native_thinking("qwen2.5:7b"));
|
||||
assert!(!has_native_thinking("qwen2.5-instruct"));
|
||||
// GLM-4 base variants do NOT have reasoning_content
|
||||
assert!(!has_native_thinking("glm-4-flash"));
|
||||
assert!(!has_native_thinking("glm-4-air"));
|
||||
assert!(!has_native_thinking("glm-4v"));
|
||||
// step-3 base does not reason (only 3.5+)
|
||||
assert!(!has_native_thinking("step-3-mini"));
|
||||
}
|
||||
}
|
||||
@@ -509,7 +509,8 @@ Create alongside the .wasm file to grant capabilities:
|
||||
let mut iteration = 0;
|
||||
|
||||
// Create reasoning engine
|
||||
let reasoning = Reasoning::new(self.llm.clone());
|
||||
let reasoning = Reasoning::new(self.llm.clone())
|
||||
.with_model_name(self.llm.active_model_name());
|
||||
|
||||
// Build initial context
|
||||
let tool_defs = self.get_build_tools().await;
|
||||
@@ -810,7 +811,8 @@ Create alongside the .wasm file to grant capabilities:
|
||||
impl SoftwareBuilder for LlmSoftwareBuilder {
|
||||
async fn analyze(&self, description: &str) -> Result<BuildRequirement, AgentToolError> {
|
||||
// Use LLM to parse the description
|
||||
let reasoning = Reasoning::new(self.llm.clone());
|
||||
let reasoning = Reasoning::new(self.llm.clone())
|
||||
.with_model_name(self.llm.active_model_name());
|
||||
|
||||
let prompt = format!(
|
||||
r#"Analyze this software requirement and extract structured information.
|
||||
|
||||
@@ -133,7 +133,8 @@ impl WorkerRuntime {
|
||||
.await?;
|
||||
|
||||
// Create reasoning engine
|
||||
let reasoning = Reasoning::new(self.llm.clone());
|
||||
let reasoning = Reasoning::new(self.llm.clone())
|
||||
.with_model_name(self.llm.active_model_name());
|
||||
|
||||
// Build initial context
|
||||
let mut reason_ctx = ReasoningContext::new().with_job(&job.description);
|
||||
|
||||
Reference in New Issue
Block a user