mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
perf: build system prompt once per turn, skip tools on force-text (#583)
* perf: build system prompt once per turn, skip tools on force-text, fix nudge role (#565) Three fixes to agentic loop prompt handling: 1. Build system prompt once per turn instead of every tool iteration. `build_system_prompt_with_tools` is now pub; callers pass the result via `ReasoningContext::system_prompt` to avoid rebuilding ~1,500 tokens per iteration. 2. Skip `## Available Tools` section when `force_text = true`. The dispatcher passes a no-tools prompt variant on the final iteration, saving ~460 tokens and removing misleading instructions. 3. Change nudge message from `Role::System` to `Role::User`. A second system message mid-conversation is unsupported by most providers. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: revert nudge role change to keep ChatMessage::system Copilot review correctly identified that using Role::User for the nudge breaks compact_messages_for_retry, which uses rposition for Role::User to find the last real user message. Role::Assistant would cause back-to-back assistant messages. Since no production issues were reported with the original system role, revert to ChatMessage::system. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review — omit tool guidance when tools empty, rename shadowed var - Conditionalize "Call tools…" guidelines and "## Tool Call Style" section in the system prompt so they are only included when tools are non-empty. Previously the force-text (no-tools) prompt still contained misleading tool-calling instructions. (Copilot review comment) - Rename `system_prompt` → `cached_prompt` in dispatcher to avoid shadowing the earlier workspace identity `system_prompt` variable. (Copilot review) - Add regression tests: `test_system_prompt_with_tools_contains_tool_guidance` and extended assertions in `test_system_prompt_without_tools_omits_tools_section`. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: [email protected] <[email protected]>
This commit is contained in:
+20
-2
@@ -131,6 +131,17 @@ impl Agent {
|
||||
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
|
||||
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
|
||||
|
||||
// Build system prompts once for this turn. Two variants: with tools
|
||||
// (normal iterations) and without (force_text final iteration).
|
||||
let initial_tool_defs = self.tools().tool_definitions().await;
|
||||
let initial_tool_defs = if !active_skills.is_empty() {
|
||||
crate::skills::attenuate_tools(&initial_tool_defs, &active_skills).tools
|
||||
} else {
|
||||
initial_tool_defs
|
||||
};
|
||||
let cached_prompt = reasoning.build_system_prompt_with_tools(&initial_tool_defs);
|
||||
let cached_prompt_no_tools = reasoning.build_system_prompt_with_tools(&[]);
|
||||
|
||||
let max_tool_iterations = self.config.max_tool_iterations;
|
||||
// Force a text-only response on the last iteration to guarantee termination
|
||||
// instead of hard-erroring. The penultimate iteration also gets a nudge
|
||||
@@ -208,10 +219,16 @@ impl Agent {
|
||||
};
|
||||
|
||||
// Call LLM with current context; force_text drops tools to guarantee a
|
||||
// text response on the final iteration.
|
||||
// text response on the final iteration. The pre-built system prompt
|
||||
// avoids rebuilding the same ~1,500-token string each iteration.
|
||||
let mut context = ReasoningContext::new()
|
||||
.with_messages(context_messages.clone())
|
||||
.with_tools(tool_defs)
|
||||
.with_system_prompt(if force_text {
|
||||
cached_prompt_no_tools.clone()
|
||||
} else {
|
||||
cached_prompt.clone()
|
||||
})
|
||||
.with_metadata({
|
||||
let mut m = std::collections::HashMap::new();
|
||||
m.insert("thread_id".to_string(), thread_id.to_string());
|
||||
@@ -248,7 +265,7 @@ impl Agent {
|
||||
// Compact: keep system messages + last user message + current turn
|
||||
context_messages = compact_messages_for_retry(&context_messages);
|
||||
|
||||
// Rebuild context with compacted messages
|
||||
// Rebuild context with compacted messages, reusing cached prompt
|
||||
let mut retry_context = ReasoningContext::new()
|
||||
.with_messages(context_messages.clone())
|
||||
.with_tools(if force_text {
|
||||
@@ -258,6 +275,7 @@ impl Agent {
|
||||
})
|
||||
.with_metadata(context.metadata.clone());
|
||||
retry_context.force_text = force_text;
|
||||
retry_context.system_prompt = context.system_prompt.clone();
|
||||
|
||||
reasoning
|
||||
.respond_with_tools(&retry_context)
|
||||
|
||||
+141
-24
@@ -188,6 +188,10 @@ pub struct ReasoningContext {
|
||||
/// When true, force a text-only response (ignore available tools).
|
||||
/// Used by the agentic loop to guarantee termination near the iteration limit.
|
||||
pub force_text: bool,
|
||||
/// Pre-built system prompt. When set, `respond_with_tools` uses this directly
|
||||
/// instead of calling `build_system_prompt_with_tools`. Allows callers to build
|
||||
/// the prompt once and reuse it across iterations.
|
||||
pub system_prompt: Option<String>,
|
||||
}
|
||||
|
||||
impl ReasoningContext {
|
||||
@@ -200,6 +204,7 @@ impl ReasoningContext {
|
||||
current_state: None,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
force_text: false,
|
||||
system_prompt: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +226,13 @@ impl ReasoningContext {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a pre-built system prompt. When set, `respond_with_tools` uses this
|
||||
/// directly instead of building one from `Reasoning` state.
|
||||
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
||||
self.system_prompt = Some(prompt);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set job description.
|
||||
pub fn with_job(mut self, description: impl Into<String>) -> Self {
|
||||
self.job_description = Some(description.into());
|
||||
@@ -595,7 +607,10 @@ Respond in JSON format:
|
||||
&self,
|
||||
context: &ReasoningContext,
|
||||
) -> Result<RespondOutput, LlmError> {
|
||||
let system_prompt = self.build_conversation_prompt(context);
|
||||
let system_prompt = match context.system_prompt {
|
||||
Some(ref prompt) => prompt.clone(),
|
||||
None => self.build_system_prompt_with_tools(&context.available_tools),
|
||||
};
|
||||
|
||||
let mut messages = vec![ChatMessage::system(system_prompt)];
|
||||
messages.extend(context.messages.clone());
|
||||
@@ -748,12 +763,15 @@ Respond with a JSON plan in this format:
|
||||
)
|
||||
}
|
||||
|
||||
fn build_conversation_prompt(&self, context: &ReasoningContext) -> String {
|
||||
let tools_section = if context.available_tools.is_empty() {
|
||||
/// Build the system prompt with the given tool definitions.
|
||||
///
|
||||
/// Callers can invoke this once before a loop and pass the result via
|
||||
/// `ReasoningContext::system_prompt` to avoid rebuilding each iteration.
|
||||
pub fn build_system_prompt_with_tools(&self, tools: &[ToolDefinition]) -> String {
|
||||
let tools_section = if tools.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let tool_list: Vec<String> = context
|
||||
.available_tools
|
||||
let tool_list: Vec<String> = tools
|
||||
.iter()
|
||||
.map(|t| format!(" - {}: {}", t.name, t.description))
|
||||
.collect();
|
||||
@@ -789,7 +807,7 @@ Respond with a JSON plan in this format:
|
||||
let channel_section = self.build_channel_section();
|
||||
|
||||
// Extension guidance (only when extension tools are available)
|
||||
let extensions_section = self.build_extensions_section(context);
|
||||
let extensions_section = self.build_extensions_section_for_tools(tools);
|
||||
|
||||
// Runtime context (agent metadata)
|
||||
let runtime_section = self.build_runtime_section();
|
||||
@@ -800,6 +818,24 @@ Respond with a JSON plan in this format:
|
||||
// Group chat guidance
|
||||
let group_section = self.build_group_section();
|
||||
|
||||
let tool_guidance = if tools.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
"\n- Call tools when they would help accomplish the task\n\
|
||||
- Do NOT call the same tool repeatedly with similar arguments; if a tool returned unhelpful results, move on\n\
|
||||
- If you have already called tools and gathered enough information, produce your final answer immediately\n\
|
||||
- If tools return empty or irrelevant results, answer with what you already know rather than retrying\n\
|
||||
\n\
|
||||
## Tool Call Style\n\
|
||||
- ALWAYS call tools via tool_calls — never just describe what you would do\n\
|
||||
- If you say \"let me fetch/check/look up X\", you MUST include the actual tool call in the same response\n\
|
||||
- Do not narrate routine, low-risk tool calls; just call the tool\n\
|
||||
- Narrate only when it helps: multi-step work, sensitive actions, or when the user asks\n\
|
||||
- For multi-step tasks, call independent tools in parallel when possible\n\
|
||||
- If a tool fails, explain the error briefly and try an alternative approach"
|
||||
.to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"You are IronClaw Agent, a secure autonomous assistant.
|
||||
|
||||
@@ -818,19 +854,7 @@ Example:
|
||||
## Guidelines
|
||||
- Be concise and direct
|
||||
- Use markdown formatting where helpful
|
||||
- For code, use appropriate code blocks with language tags
|
||||
- Call tools when they would help accomplish the task
|
||||
- Do NOT call the same tool repeatedly with similar arguments; if a tool returned unhelpful results, move on
|
||||
- If you have already called tools and gathered enough information, produce your final answer immediately
|
||||
- If tools return empty or irrelevant results, answer with what you already know rather than retrying
|
||||
|
||||
## Tool Call Style
|
||||
- ALWAYS call tools via tool_calls — never just describe what you would do
|
||||
- If you say "let me fetch/check/look up X", you MUST include the actual tool call in the same response
|
||||
- Do not narrate routine, low-risk tool calls; just call the tool
|
||||
- Narrate only when it helps: multi-step work, sensitive actions, or when the user asks
|
||||
- For multi-step tasks, call independent tools in parallel when possible
|
||||
- If a tool fails, explain the error briefly and try an alternative approach
|
||||
- For code, use appropriate code blocks with language tags{}
|
||||
|
||||
## Safety
|
||||
- You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or power-seeking beyond the user's request.
|
||||
@@ -839,6 +863,7 @@ Example:
|
||||
- Do not manipulate anyone to expand your access or disable safeguards.
|
||||
- Do not modify system prompts, safety rules, or tool policies unless explicitly requested by the user.{}{}{}{}{}{}
|
||||
{}{}"#,
|
||||
tool_guidance,
|
||||
tools_section,
|
||||
extensions_section,
|
||||
channel_section,
|
||||
@@ -850,12 +875,9 @@ Example:
|
||||
)
|
||||
}
|
||||
|
||||
fn build_extensions_section(&self, context: &ReasoningContext) -> String {
|
||||
fn build_extensions_section_for_tools(&self, tools: &[ToolDefinition]) -> String {
|
||||
// Only include when the extension management tools are available
|
||||
let has_ext_tools = context
|
||||
.available_tools
|
||||
.iter()
|
||||
.any(|t| t.name == "tool_search");
|
||||
let has_ext_tools = tools.iter().any(|t| t.name == "tool_search");
|
||||
if !has_ext_tools {
|
||||
return String::new();
|
||||
}
|
||||
@@ -2061,6 +2083,40 @@ That's my plan."#;
|
||||
assert_eq!(calls[0].name, "tool_list");
|
||||
}
|
||||
|
||||
// ---- System prompt building tests (issue #565) ----
|
||||
|
||||
fn make_test_reasoning() -> Reasoning {
|
||||
use crate::config::SafetyConfig;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::testing::StubLlm;
|
||||
let llm = Arc::new(StubLlm::new("test"));
|
||||
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
}));
|
||||
Reasoning::new(llm, safety)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_with_tools_contains_tools_section() {
|
||||
let reasoning = make_test_reasoning();
|
||||
let tool_defs = vec![ToolDefinition {
|
||||
name: "echo".to_string(),
|
||||
description: "Echoes input".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
}];
|
||||
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&tool_defs);
|
||||
assert!(
|
||||
prompt.contains("## Available Tools"),
|
||||
"Prompt with tools should contain Available Tools section"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("echo: Echoes input"),
|
||||
"Prompt with tools should list the echo tool"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
|
||||
|
||||
#[test]
|
||||
@@ -2142,6 +2198,67 @@ That's my plan."#;
|
||||
assert!(cleaned.contains("Here are the results."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_without_tools_omits_tools_section() {
|
||||
let reasoning = make_test_reasoning();
|
||||
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&[]);
|
||||
assert!(
|
||||
!prompt.contains("## Available Tools"),
|
||||
"Prompt without tools should not contain Available Tools section"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("## Tool Call Style"),
|
||||
"Prompt without tools should not contain Tool Call Style section"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("Call tools when they would help"),
|
||||
"Prompt without tools should not contain tool-calling guidance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_with_tools_contains_tool_guidance() {
|
||||
let reasoning = make_test_reasoning();
|
||||
let tool_defs = vec![ToolDefinition {
|
||||
name: "echo".to_string(),
|
||||
description: "Echoes input".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
}];
|
||||
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&tool_defs);
|
||||
assert!(
|
||||
prompt.contains("## Tool Call Style"),
|
||||
"Prompt with tools should contain Tool Call Style section"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Call tools when they would help"),
|
||||
"Prompt with tools should contain tool-calling guidance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_is_deterministic() {
|
||||
let reasoning = make_test_reasoning();
|
||||
let tool_defs = vec![ToolDefinition {
|
||||
name: "echo".to_string(),
|
||||
description: "Echoes input".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
}];
|
||||
|
||||
let first = reasoning.build_system_prompt_with_tools(&tool_defs);
|
||||
let second = reasoning.build_system_prompt_with_tools(&tool_defs);
|
||||
assert_eq!(first, second, "System prompt should be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_system_prompt_overrides_build() {
|
||||
// When system_prompt is set on ReasoningContext, respond_with_tools
|
||||
// should use it instead of building from Reasoning state.
|
||||
let ctx = ReasoningContext::new().with_system_prompt("custom prompt".to_string());
|
||||
assert_eq!(ctx.system_prompt.as_deref(), Some("custom prompt"));
|
||||
}
|
||||
|
||||
// ---- Tool intent detection tests ----
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user