Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions prompts/en/fragments/tool_use_enforcement.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Tool-Use Enforcement

You MUST use your tools to take action — do not describe what you would do or plan to do without actually doing it. When you say you will perform an action (e.g. "I will run the tests", "Let me check the file", "I will create the project"), you MUST immediately make the corresponding tool call in the same response. Never end your turn with a promise of future action — execute it now.

Keep working until the task is actually complete. Do not stop with a summary of what you plan to do next time. If you have tools available that can accomplish the task, use them instead of telling the user what you would do.

Every response should either (a) contain tool calls that make progress, or (b) deliver a final result to the user. Responses that only describe intentions without acting are not acceptable.
23 changes: 21 additions & 2 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1637,7 +1637,11 @@ impl Channel {
}
};

prompt_engine.render_channel_prompt_with_links(
let routing = rc.routing.load();
let model_name = routing.resolve(ProcessType::Channel, None).to_string();
let tool_use_enforcement = rc.tool_use_enforcement.load();

let system_prompt = prompt_engine.render_channel_prompt_with_links(
empty_to_none(identity_context),
memory_bulletin_text,
empty_to_none(skills_prompt),
Expand All @@ -1653,6 +1657,12 @@ impl Channel {
self.backfill_transcript.clone(),
empty_to_none(working_memory),
empty_to_none(channel_activity_map),
)?;

prompt_engine.maybe_append_tool_use_enforcement(
system_prompt,
tool_use_enforcement.as_ref(),
&model_name,
)
}

Expand Down Expand Up @@ -2346,8 +2356,11 @@ impl Channel {
};

let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) };
let routing = rc.routing.load();
let model_name = routing.resolve(ProcessType::Channel, None).to_string();
let tool_use_enforcement = rc.tool_use_enforcement.load();

prompt_engine.render_channel_prompt_with_links(
let system_prompt = prompt_engine.render_channel_prompt_with_links(
empty_to_none(identity_context),
memory_bulletin_text,
empty_to_none(skills_prompt),
Expand All @@ -2363,6 +2376,12 @@ impl Channel {
self.backfill_transcript.clone(),
empty_to_none(working_memory),
empty_to_none(channel_activity_map),
)?;

prompt_engine.maybe_append_tool_use_enforcement(
system_prompt,
tool_use_enforcement.as_ref(),
&model_name,
)
}

Expand Down
47 changes: 45 additions & 2 deletions src/agent/channel_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::agent::worker::Worker;
use crate::conversation::settings::{WorkerContextMode, WorkerHistoryMode};
use crate::error::{AgentError, Error as SpacebotError};
use crate::tools::{BranchToolProfile, MemoryPersistenceContractState};
use crate::{AgentDeps, BranchId, ChannelId, ProcessEvent, WorkerId};
use crate::{AgentDeps, BranchId, ChannelId, ProcessEvent, ProcessType, WorkerId};
use futures::FutureExt as _;
use std::sync::Arc;
use tokio::sync::broadcast;
Expand Down Expand Up @@ -127,11 +127,21 @@ pub async fn spawn_branch_from_state(
let description = description.into();
let rc = &state.deps.runtime_config;
let prompt_engine = rc.prompts.load();
let routing = rc.routing.load();
let model_name = routing.resolve(ProcessType::Branch, None).to_string();
let tool_use_enforcement = rc.tool_use_enforcement.load();
let system_prompt = prompt_engine
.render_branch_prompt(
&rc.instance_dir.display().to_string(),
&rc.workspace_dir.display().to_string(),
)
.and_then(|prompt| {
prompt_engine.maybe_append_tool_use_enforcement(
prompt,
tool_use_enforcement.as_ref(),
&model_name,
)
})
.map_err(|e| AgentError::Other(anyhow::anyhow!("{e}")))?;

spawn_branch(
Expand Down Expand Up @@ -160,8 +170,18 @@ pub(crate) async fn spawn_memory_persistence_branch(
let contract_state = Arc::new(MemoryPersistenceContractState::default());

let prompt_engine = deps.runtime_config.prompts.load();
let routing = deps.runtime_config.routing.load();
let model_name = routing.resolve(ProcessType::Branch, None).to_string();
let tool_use_enforcement = deps.runtime_config.tool_use_enforcement.load();
let system_prompt = prompt_engine
.render_static("memory_persistence")
.and_then(|prompt| {
prompt_engine.maybe_append_tool_use_enforcement(
prompt,
tool_use_enforcement.as_ref(),
&model_name,
)
})
.map_err(|e| AgentError::Other(anyhow::anyhow!("{e}")))?;
let prompt = prompt_engine
.render_system_memory_persistence()
Expand Down Expand Up @@ -481,6 +501,9 @@ async fn spawn_worker_inner(
};

let browser_config = (**rc.browser_config.load()).clone();
let routing = rc.routing.load();
let model_name = routing.resolve(ProcessType::Worker, None).to_string();
let tool_use_enforcement = rc.tool_use_enforcement.load();
let worker_system_prompt = prompt_engine
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now the enforcement fragment gets appended before the skills listing, so the final lines of the worker preamble are the skills section. If the goal is “last instruction wins”, it might be more effective to append enforcement after skills.

Suggested change
let worker_system_prompt = prompt_engine
let worker_system_prompt = prompt_engine
.render_worker_prompt(
&rc.instance_dir.display().to_string(),
&rc.workspace_dir.display().to_string(),
sandbox_enabled,
sandbox_containment_active,
sandbox_read_allowlist,
sandbox_write_allowlist,
&tool_secret_names,
browser_config.persist_session,
worker_status_text,
)
.map_err(|e| AgentError::Other(anyhow::anyhow!("{e}")))?;
let skills = rc.skills.load();
let brave_search_key = (**rc.brave_search_key.load()).clone();
// Append skills listing to worker system prompt. Suggested skills are
// flagged so the worker knows the channel's intent, but it can read any
// skill it decides is relevant via the read_skill tool.
let system_prompt = match skills.render_worker_skills(suggested_skills, &prompt_engine) {
Ok(skills_prompt) if !skills_prompt.is_empty() => {
format!("{worker_system_prompt}\n\n{skills_prompt}")
}
Ok(_) => worker_system_prompt,
Err(error) => {
tracing::warn!(%error, "failed to render worker skills listing, spawning without skills context");
worker_system_prompt
}
};
let system_prompt = prompt_engine
.maybe_append_tool_use_enforcement(system_prompt, tool_use_enforcement.as_ref(), &model_name)
.map_err(|e| AgentError::Other(anyhow::anyhow!("{e}")))?;

.render_worker_prompt(
&rc.instance_dir.display().to_string(),
Expand All @@ -500,7 +523,7 @@ async fn spawn_worker_inner(
// Append skills listing to worker system prompt. Suggested skills are
// flagged so the worker knows the channel's intent, but it can read any
// skill it decides is relevant via the read_skill tool.
let mut system_prompt = match skills.render_worker_skills(suggested_skills, &prompt_engine) {
let system_prompt = match skills.render_worker_skills(suggested_skills, &prompt_engine) {
Ok(skills_prompt) if !skills_prompt.is_empty() => {
format!("{worker_system_prompt}\n\n{skills_prompt}")
}
Expand All @@ -511,6 +534,16 @@ async fn spawn_worker_inner(
}
};

// Append tool-use enforcement after skills so it's the last instruction
// in the preamble ("last instruction wins").
let mut system_prompt = prompt_engine
.maybe_append_tool_use_enforcement(
system_prompt,
tool_use_enforcement.as_ref(),
&model_name,
)
.map_err(|e| AgentError::Other(anyhow::anyhow!("{e}")))?;

// Inject memory context based on worker_context settings
if worker_context.memory.ambient_enabled() {
// Get knowledge synthesis and working memory
Expand Down Expand Up @@ -1141,6 +1174,9 @@ pub async fn resume_idle_worker_into_state(
None => Vec::new(),
};
let browser_config = (**rc.browser_config.load()).clone();
let routing = rc.routing.load();
let model_name = routing.resolve(ProcessType::Worker, None).to_string();
let tool_use_enforcement = rc.tool_use_enforcement.load();
let system_prompt = prompt_engine
.render_worker_prompt(
&rc.instance_dir.display().to_string(),
Expand All @@ -1153,6 +1189,13 @@ pub async fn resume_idle_worker_into_state(
browser_config.persist_session,
worker_status_text,
)
.and_then(|prompt| {
prompt_engine.maybe_append_tool_use_enforcement(
prompt,
tool_use_enforcement.as_ref(),
&model_name,
)
})
.map_err(|error| format!("failed to render worker prompt: {error}"))?;
let brave_search_key = (**rc.brave_search_key.load()).clone();

Expand Down
4 changes: 3 additions & 1 deletion src/agent/compactor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,10 @@ impl Compactor {
let deps = self.deps.clone();
let model_override = self.model_override.clone();
let prompt_engine = deps.runtime_config.prompts.load();
// The compactor is a toolless agent (summary-only), so tool-use
// enforcement is skipped — there are no tools to enforce.
let compactor_prompt = match prompt_engine.render_static("compactor") {
Ok(p) => p,
Ok(prompt) => prompt,
Err(error) => {
tracing::error!(%error, "failed to render compactor prompt");
let mut flag = is_compacting.write().await;
Expand Down
25 changes: 24 additions & 1 deletion src/agent/cortex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1497,8 +1497,21 @@ fn handle_cortex_receiver_result(
pub fn spawn_cortex_loop(deps: AgentDeps, logger: CortexLogger) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let prompt_engine = deps.runtime_config.prompts.load();
let routing = deps.runtime_config.routing.load();
let model_name = routing.resolve(ProcessType::Cortex, None).to_string();
let tool_use_enforcement = deps.runtime_config.tool_use_enforcement.load();
let system_prompt = match prompt_engine.render_static("cortex") {
Ok(prompt) => prompt,
Ok(prompt) => match prompt_engine.maybe_append_tool_use_enforcement(
prompt.clone(),
tool_use_enforcement.as_ref(),
&model_name,
) {
Ok(prompt) => prompt,
Err(error) => {
tracing::warn!(%error, "failed to append tool-use enforcement, using base cortex prompt");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor robustness: on append failure you can just fall back to the already-rendered base prompt instead of re-rendering the template (and potentially failing twice).

Suggested change
tracing::warn!(%error, "failed to append tool-use enforcement, using base cortex prompt");
Ok(prompt) => match prompt_engine.maybe_append_tool_use_enforcement(
prompt.clone(),
tool_use_enforcement.as_ref(),
&model_name,
) {
Ok(prompt) => prompt,
Err(error) => {
tracing::warn!(%error, "failed to append tool-use enforcement, using base cortex prompt");
prompt
}
},

prompt
}
},
Err(error) => {
tracing::warn!(%error, "failed to render cortex prompt, using empty preamble");
String::new()
Expand Down Expand Up @@ -3276,6 +3289,9 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho
let current_time_line = temporal_context.current_time_line();
let worker_status_text = Some(system_info.render_for_worker(&current_time_line));

let routing = deps.runtime_config.routing.load();
let model_name = routing.resolve(ProcessType::Worker, None).to_string();
let tool_use_enforcement = deps.runtime_config.tool_use_enforcement.load();
let worker_system_prompt = prompt_engine
.render_worker_prompt(
&deps.runtime_config.instance_dir.display().to_string(),
Expand All @@ -3288,6 +3304,13 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho
browser_config.persist_session,
worker_status_text,
)
.and_then(|prompt| {
prompt_engine.maybe_append_tool_use_enforcement(
prompt,
tool_use_enforcement.as_ref(),
&model_name,
)
})
.map_err(|error| anyhow::anyhow!("failed to render worker prompt: {error}"))?;

let mut task_prompt = format!("Execute task #{}: {}", task.task_number, task.title);
Expand Down
11 changes: 10 additions & 1 deletion src/agent/cortex_chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -821,8 +821,11 @@ impl CortexChatSession {
};

let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) };
let routing = runtime_config.routing.load();
let model_name = routing.resolve(ProcessType::Cortex, None).to_string();
let tool_use_enforcement = runtime_config.tool_use_enforcement.load();

prompt_engine.render_cortex_chat_prompt(
let system_prompt = prompt_engine.render_cortex_chat_prompt(
empty_to_none(identity_context),
empty_to_none(memory_bulletin.to_string()),
channel_transcript,
Expand All @@ -831,6 +834,12 @@ impl CortexChatSession {
empty_to_none(runtime_config_snapshot),
worker_capabilities,
self.factory_enabled,
)?;

prompt_engine.maybe_append_tool_use_enforcement(
system_prompt,
tool_use_enforcement.as_ref(),
&model_name,
)
}

Expand Down
16 changes: 9 additions & 7 deletions src/agent/ingestion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,10 +474,14 @@ async fn process_chunk(
deps: &AgentDeps,
) -> anyhow::Result<()> {
let prompt_engine = deps.runtime_config.prompts.load();
let ingestion_prompt = prompt_engine.render_static("ingestion")?;

let routing = deps.runtime_config.routing.load();
let model_name = routing.resolve(ProcessType::Branch, None).to_string();
let tool_use_enforcement = deps.runtime_config.tool_use_enforcement.load();
let ingestion_prompt = prompt_engine.maybe_append_tool_use_enforcement(
prompt_engine.render_static("ingestion")?,
tool_use_enforcement.as_ref(),
&model_name,
)?;
let model = SpacebotModel::make(&deps.llm_manager, &model_name)
.with_context(&*deps.agent_id, "branch")
.with_worker_type("ingestion")
Expand Down Expand Up @@ -526,11 +530,9 @@ async fn process_chunk(
classify_chunk_prompt_result(result, filename, chunk_number, total_chunks)?;

if !contract_state.has_terminal_outcome() {
tracing::warn!(
file = %filename,
chunk = %format!("{chunk_number}/{total_chunks}"),
"ingestion chunk completed without memory_persistence_complete signal"
);
return Err(anyhow::anyhow!(
"ingestion chunk {chunk_number}/{total_chunks} for {filename} completed without memory_persistence_complete signal"
));
}

Ok(())
Expand Down
1 change: 1 addition & 0 deletions src/api/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@ pub async fn create_agent_internal(
max_turns: None,
branch_max_turns: None,
context_window: None,
tool_use_enforcement: None,
compaction: None,
memory_persistence: None,
coalesce: None,
Expand Down
43 changes: 43 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2170,4 +2170,47 @@ command = "/usr/bin/test"
// The mcp_servers data is silently dropped — verify it's not accessible
assert!(parsed.defaults.mcp.is_empty());
}

#[test]
fn tool_use_enforcement_parses_and_resolves() {
let toml = r#"
[defaults]
tool_use_enforcement = "always"

[[agents]]
id = "main"
tool_use_enforcement = ["gemini", "deepseek"]
"#;

let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML");
let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config");

assert_eq!(
config.defaults.tool_use_enforcement,
ToolUseEnforcement::Always
);
assert_eq!(
config.agents[0].tool_use_enforcement,
Some(ToolUseEnforcement::Custom(vec![
"gemini".to_string(),
"deepseek".to_string(),
]))
);

let resolved = config.resolve_agents();
assert_eq!(
resolved[0].tool_use_enforcement,
ToolUseEnforcement::Custom(vec!["gemini".to_string(), "deepseek".to_string()])
);
assert!(
resolved[0]
.tool_use_enforcement
.should_inject("google/gemini-2.5-pro")
);
assert!(
!resolved[0]
.tool_use_enforcement
.should_inject("anthropic/claude-sonnet-4")
);
}
}
7 changes: 7 additions & 0 deletions src/config/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,7 @@ impl Config {
max_turns: None,
branch_max_turns: None,
context_window: None,
tool_use_enforcement: None,
compaction: None,
memory_persistence: None,
coalesce: None,
Expand Down Expand Up @@ -1533,6 +1534,10 @@ impl Config {
.defaults
.context_window
.unwrap_or(base_defaults.context_window),
tool_use_enforcement: toml
.defaults
.tool_use_enforcement
.unwrap_or_else(|| base_defaults.tool_use_enforcement.clone()),
compaction: toml
.defaults
.compaction
Expand Down Expand Up @@ -1781,6 +1786,7 @@ impl Config {
max_turns: a.max_turns,
branch_max_turns: a.branch_max_turns,
context_window: a.context_window,
tool_use_enforcement: a.tool_use_enforcement,
compaction: a.compaction.map(|c| CompactionConfig {
background_threshold: c
.background_threshold
Expand Down Expand Up @@ -1922,6 +1928,7 @@ impl Config {
max_turns: None,
branch_max_turns: None,
context_window: None,
tool_use_enforcement: None,
compaction: None,
memory_persistence: None,
coalesce: None,
Expand Down
Loading
Loading