mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +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);
|
||||
|
||||
Reference in New Issue
Block a user