Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
11 changes: 10 additions & 1 deletion src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2265,8 +2265,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),
empty_to_none(memory_bulletin.to_string()),
empty_to_none(skills_prompt),
Expand All @@ -2282,6 +2285,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
32 changes: 31 additions & 1 deletion src/agent/channel_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::agent::channel_prompt::TemporalContext;
use crate::agent::worker::Worker;
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 @@ -126,11 +126,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 @@ -159,8 +169,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 @@ -473,6 +493,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 @@ -485,6 +508,13 @@ async fn spawn_worker_inner(
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(|e| AgentError::Other(anyhow::anyhow!("{e}")))?;
let skills = rc.skills.load();
let brave_search_key = (**rc.brave_search_key.load()).clone();
Expand Down
17 changes: 16 additions & 1 deletion src/agent/compactor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,23 @@ impl Compactor {
let channel_id = self.channel_id.clone();
let deps = self.deps.clone();
let prompt_engine = deps.runtime_config.prompts.load();
let routing = deps.runtime_config.routing.load();
let model_name = routing.resolve(ProcessType::Compactor, None).to_string();
let tool_use_enforcement = deps.runtime_config.tool_use_enforcement.load();
let compactor_prompt = match prompt_engine.render_static("compactor") {
Ok(p) => p,
Ok(prompt) => match prompt_engine.maybe_append_tool_use_enforcement(
prompt,
tool_use_enforcement.as_ref(),
&model_name,
) {
Ok(prompt) => prompt,
Err(error) => {
tracing::error!(%error, "failed to append tool-use enforcement to compactor 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.

This makes compaction bail if the enforcement fragment render fails. That feels like a non-critical failure path — I'd fall back to the base prompt and keep going.

Suggested change
tracing::error!(%error, "failed to append tool-use enforcement to compactor 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::error!(%error, "failed to append tool-use enforcement to compactor prompt");
prompt
}
},

let mut flag = is_compacting.write().await;
*flag = false;
return;
}
},
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 @@ -1496,8 +1496,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,
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_engine.render_static("cortex").unwrap_or_default()
}
},
Err(error) => {
tracing::warn!(%error, "failed to render cortex prompt, using empty preamble");
String::new()
Expand Down Expand Up @@ -3275,6 +3288,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 @@ -3287,6 +3303,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
8 changes: 6 additions & 2 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,
)?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let model = SpacebotModel::make(&deps.llm_manager, &model_name)
.with_context(&*deps.agent_id, "branch")
.with_worker_type("ingestion")
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 @@ -2149,4 +2149,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 @@ -845,6 +845,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 @@ -1429,6 +1430,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 @@ -1670,6 +1675,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 @@ -1803,6 +1809,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
7 changes: 6 additions & 1 deletion src/config/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ use arc_swap::ArcSwap;
use super::{
BrowserConfig, ChannelConfig, CoalesceConfig, CompactionConfig, Config, CortexConfig,
DefaultsConfig, IngestionConfig, McpServerConfig, MemoryPersistenceConfig, OpenCodeConfig,
ResolvedAgentConfig, WarmupConfig, WarmupStatus, WorkReadiness, evaluate_work_readiness,
ResolvedAgentConfig, ToolUseEnforcement, WarmupConfig, WarmupStatus, WorkReadiness,
evaluate_work_readiness,
};
use crate::llm::routing::RoutingConfig;
use crate::tools::browser::SharedBrowserHandle;
Expand Down Expand Up @@ -34,6 +35,7 @@ pub struct RuntimeConfig {
pub max_turns: ArcSwap<usize>,
pub branch_max_turns: ArcSwap<usize>,
pub context_window: ArcSwap<usize>,
pub tool_use_enforcement: ArcSwap<ToolUseEnforcement>,
pub max_concurrent_branches: ArcSwap<usize>,
pub max_concurrent_workers: ArcSwap<usize>,
pub browser_config: ArcSwap<BrowserConfig>,
Expand Down Expand Up @@ -128,6 +130,7 @@ impl RuntimeConfig {
max_turns: ArcSwap::from_pointee(agent_config.max_turns),
branch_max_turns: ArcSwap::from_pointee(agent_config.branch_max_turns),
context_window: ArcSwap::from_pointee(agent_config.context_window),
tool_use_enforcement: ArcSwap::from_pointee(agent_config.tool_use_enforcement.clone()),
max_concurrent_branches: ArcSwap::from_pointee(agent_config.max_concurrent_branches),
max_concurrent_workers: ArcSwap::from_pointee(agent_config.max_concurrent_workers),
browser_config: ArcSwap::from_pointee(agent_config.browser.clone()),
Expand Down Expand Up @@ -295,6 +298,8 @@ impl RuntimeConfig {
self.branch_max_turns
.store(Arc::new(resolved.branch_max_turns));
self.context_window.store(Arc::new(resolved.context_window));
self.tool_use_enforcement
.store(Arc::new(resolved.tool_use_enforcement.clone()));
self.max_concurrent_branches
.store(Arc::new(resolved.max_concurrent_branches));
self.max_concurrent_workers
Expand Down
4 changes: 4 additions & 0 deletions src/config/toml_schema.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// -- TOML deserialization types --

use super::types::ToolUseEnforcement;

use serde::{Deserialize, Deserializer};
use std::collections::HashMap;

Expand Down Expand Up @@ -278,6 +280,7 @@ pub(super) struct TomlDefaultsConfig {
pub(super) max_turns: Option<usize>,
pub(super) branch_max_turns: Option<usize>,
pub(super) context_window: Option<usize>,
pub(super) tool_use_enforcement: Option<ToolUseEnforcement>,
pub(super) compaction: Option<TomlCompactionConfig>,
pub(super) memory_persistence: Option<TomlMemoryPersistenceConfig>,
pub(super) coalesce: Option<TomlCoalesceConfig>,
Expand Down Expand Up @@ -456,6 +459,7 @@ pub(super) struct TomlAgentConfig {
pub(super) max_turns: Option<usize>,
pub(super) branch_max_turns: Option<usize>,
pub(super) context_window: Option<usize>,
pub(super) tool_use_enforcement: Option<ToolUseEnforcement>,
pub(super) compaction: Option<TomlCompactionConfig>,
pub(super) memory_persistence: Option<TomlMemoryPersistenceConfig>,
pub(super) coalesce: Option<TomlCoalesceConfig>,
Expand Down
Loading
Loading