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
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ impl Agent for CustomMode {
}

fn default_tools(&self) -> Vec<String> {
self.data.tools.clone()
let mut tools = self.data.tools.clone();
bitfun_agent_runtime::thread_goal_tools::ensure_thread_goal_tools(&mut tools);
tools
}

fn user_context_policy(&self) -> UserContextPolicy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ impl ClawMode {
"Glob".to_string(),
"WebSearch".to_string(),
"WebFetch".to_string(),
"get_goal".to_string(),
"create_goal".to_string(),
"update_goal".to_string(),
"Skill".to_string(),
"Git".to_string(),
"SessionControl".to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ impl DeepResearchMode {
"AgentWait".to_string(),
"WebSearch".to_string(),
"WebFetch".to_string(),
"get_goal".to_string(),
"create_goal".to_string(),
"update_goal".to_string(),
"Read".to_string(),
"view_image".to_string(),
"analyze_image".to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ impl TeamMode {
"Glob".to_string(),
"WebSearch".to_string(),
"WebFetch".to_string(),
"get_goal".to_string(),
"create_goal".to_string(),
"update_goal".to_string(),
"TodoWrite".to_string(),
"AskUserQuestion".to_string(),
"Git".to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,10 @@ fn external_agent_info(
projection: ExternalAgentProjection,
) -> AgentInfo {
let agent = entry.registration.agent.as_ref();
let default_tools = agent.default_tools();
let mut default_tools = agent.default_tools();
if matches!(projection, ExternalAgentProjection::Primary) {
bitfun_agent_runtime::thread_goal_tools::ensure_thread_goal_tools(&mut default_tools);
}
AgentInfo {
key: format!(
"external::{}::{}",
Expand Down
53 changes: 42 additions & 11 deletions src/crates/assembly/core/src/agentic/agents/registry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ use crate::agentic::agents::registry::types::{
use crate::agentic::agents::registry::visibility::{
BuiltinSubagentExposure, SubagentVisibilityPolicy,
};
use crate::agentic::agents::{resolve_mode_config_profile_id, Agent, UserContextPolicy};
use crate::agentic::agents::{
builtin_agent_specs, resolve_mode_config_profile_id, Agent, UserContextPolicy,
};
use crate::agentic::workspace::session_execution_workspace_root;
use crate::service::config::types::AgentSubagentOverrideState;
use async_trait::async_trait;
Expand All @@ -19,6 +21,7 @@ use bitfun_agent_runtime::custom_agent::{
};
use bitfun_agent_runtime::sdk::{RuntimeAgentRegistry, RuntimeAgentRegistryQuery};
use bitfun_agent_runtime::session::SessionConfig;
use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES;
use bitfun_product_domains::external_sources::EcosystemId;
use bitfun_product_domains::external_subagents::ExternalSubagentMode;
use std::collections::{BTreeMap, HashMap};
Expand Down Expand Up @@ -318,6 +321,25 @@ async fn computer_use_is_builtin_subagent_not_mode() {
);
}

#[test]
fn every_builtin_primary_mode_defaults_to_the_thread_goal_lifecycle() {
for spec in builtin_agent_specs()
.iter()
.filter(|spec| spec.category == AgentCategory::Mode)
{
let mode = (spec.factory)();
let default_tools = mode.default_tools();
for tool_name in THREAD_GOAL_TOOL_NAMES {
assert!(
default_tools.iter().any(|tool| tool == tool_name),
"builtin primary mode {} is missing {}",
mode.id(),
tool_name
);
}
}
}

#[test]
fn non_deep_review_builtin_subagents_default_to_primary() {
for agent_type in [
Expand Down Expand Up @@ -803,10 +825,11 @@ async fn explicit_custom_mode_load_exposes_user_mode_metadata_in_modes_info() {
assert_eq!(mode.source, AgentSource::User);
assert_eq!(mode.path, Some(mode_path.to_string_lossy().to_string()));
assert_eq!(mode.model, Some("primary".to_string()));
assert_eq!(
mode.default_tools,
vec!["Read".to_string(), "Grep".to_string()]
);
assert!(mode.default_tools.contains(&"Read".to_string()));
assert!(mode.default_tools.contains(&"Grep".to_string()));
for tool_name in THREAD_GOAL_TOOL_NAMES {
assert!(mode.default_tools.iter().any(|tool| tool == tool_name));
}
assert!(mode.is_readonly);
}

Expand Down Expand Up @@ -1443,11 +1466,15 @@ async fn external_agent_role_controls_main_and_task_projection() {
)],
route("external::primary"),
);
assert!(registry
let primary = registry
.get_modes_info_for_workspace(Some(&workspace), true)
.await
.iter()
.any(|agent| agent.id == logical_id));
.into_iter()
.find(|agent| agent.id == logical_id)
.expect("external primary projection should be visible");
for tool_name in THREAD_GOAL_TOOL_NAMES {
assert!(primary.default_tools.iter().any(|tool| tool == tool_name));
}
assert!(!registry
.get_subagents_for_query(&SubagentQueryContext {
parent_agent_type: Some("agentic"),
Expand All @@ -1473,7 +1500,7 @@ async fn external_agent_role_controls_main_and_task_projection() {
.await
.iter()
.any(|agent| agent.id == logical_id));
assert!(registry
let subagent = registry
.get_subagents_for_query(&SubagentQueryContext {
parent_agent_type: Some("agentic"),
workspace_root: Some(&workspace),
Expand All @@ -1482,8 +1509,12 @@ async fn external_agent_role_controls_main_and_task_projection() {
external_sources_supported: true,
})
.await
.iter()
.any(|agent| agent.id == logical_id));
.into_iter()
.find(|agent| agent.id == logical_id)
.expect("external subagent projection should be visible");
for tool_name in THREAD_GOAL_TOOL_NAMES {
assert!(!subagent.default_tools.iter().any(|tool| tool == tool_name));
}
}

#[test]
Expand Down
38 changes: 34 additions & 4 deletions src/crates/assembly/core/src/agentic/execution/execution_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ use crate::util::types::ToolDefinition;
use crate::util::{elapsed_ms_u64, truncate_at_char_boundary};
use bitfun_agent_runtime::output_surface::TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY;
use bitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY;
use bitfun_agent_runtime::thread_goal_tools::ensure_thread_goal_tools;
use bitfun_ai_adapters::ModelExchangeTraceConfig;
use bitfun_core_types::SessionModelBindingPolicy;
use log::{debug, error, info, trace, warn};
Expand All @@ -69,6 +70,12 @@ use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tool_runtime::context::PrimaryModelFacts;

fn ensure_primary_session_goal_tools(allowed_tools: &mut Vec<String>, is_subagent: bool) {
if !is_subagent {
ensure_thread_goal_tools(allowed_tools);
}
}

/// Execution engine configuration
#[derive(Debug, Clone)]
pub struct ExecutionEngineConfig {
Expand Down Expand Up @@ -2257,7 +2264,11 @@ impl ExecutionEngine {
.map(|workspace| workspace.root_path()),
)
.await;
let allowed_tools = tool_policy.allowed_tools.clone();
let mut allowed_tools = tool_policy.allowed_tools.clone();
ensure_primary_session_goal_tools(
&mut allowed_tools,
context.subagent_parent_info.is_some(),
);
let enable_tools = context
.context
.get("enable_tools")
Expand Down Expand Up @@ -3182,7 +3193,11 @@ impl ExecutionEngine {
.map(|workspace| workspace.root_path()),
)
.await;
let allowed_tools = tool_policy.allowed_tools.clone();
let mut allowed_tools = tool_policy.allowed_tools.clone();
ensure_primary_session_goal_tools(
&mut allowed_tools,
context.subagent_parent_info.is_some(),
);
let enable_tools = context
.context
.get("enable_tools")
Expand Down Expand Up @@ -4621,8 +4636,9 @@ impl ExecutionEngine {
#[cfg(test)]
mod tests {
use super::{
activate_conditional_instructions_after_round, manual_compaction_terminal_error,
ContextHealthSnapshot, ExecutionEngine, RoundResult, TurnPromptScaffold,
activate_conditional_instructions_after_round, ensure_primary_session_goal_tools,
manual_compaction_terminal_error, ContextHealthSnapshot, ExecutionEngine, RoundResult,
TurnPromptScaffold,
};
use crate::agentic::agents::{
PrependedPromptReminders, PromptBuilderContext, UserContextPolicy,
Expand All @@ -4642,6 +4658,7 @@ mod tests {
use crate::service::config::types::AIModelConfig;
use crate::service::remote_ssh::workspace_state::workspace_session_identity;
use crate::util::types::ToolDefinition;
use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES;
use bitfun_runtime_ports::{WorkspaceDirEntry, WorkspaceFileSystem, WorkspacePathKind};
use serde_json::json;
use sha2::{Digest, Sha256};
Expand All @@ -4651,6 +4668,19 @@ mod tests {
use std::sync::Arc;
use std::time::Duration;

#[test]
fn primary_session_tool_policy_restores_goal_tools_but_subagents_stay_scoped() {
let mut primary_tools = vec!["Read".to_string()];
ensure_primary_session_goal_tools(&mut primary_tools, false);
for tool_name in THREAD_GOAL_TOOL_NAMES {
assert!(primary_tools.iter().any(|tool| tool == tool_name));
}

let mut subagent_tools = vec!["Read".to_string()];
ensure_primary_session_goal_tools(&mut subagent_tools, true);
assert_eq!(subagent_tools, vec!["Read".to_string()]);
}

#[test]
fn manual_compaction_preserves_cancellation_as_a_terminal_cancellation() {
let error = manual_compaction_terminal_error(crate::BitFunError::Cancelled(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::service::config::types::{
};
use crate::util::errors::*;
use bitfun_agent_runtime::skills::normalize_user_mode_skill_overrides;
use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES;
use bitfun_runtime_ports::PermissionRule;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
Expand Down Expand Up @@ -88,13 +89,13 @@ pub fn resolve_effective_tools(
mode_config: Option<&AgentProfileConfig>,
valid_tools: &HashSet<String>,
) -> Vec<String> {
let Some(config) = mode_config else {
return normalize_tools(default_tools.to_vec(), valid_tools);
};

let default_tools = normalize_tools(default_tools.to_vec(), valid_tools);
let removed: HashSet<String> = config.removed_tools.iter().cloned().collect();
let added = normalize_tools(config.added_tools.clone(), valid_tools);
let removed: HashSet<String> = mode_config
.map(|config| config.removed_tools.iter().cloned().collect())
.unwrap_or_default();
let added = mode_config
.map(|config| normalize_tools(config.added_tools.clone(), valid_tools))
.unwrap_or_default();

let mut effective = Vec::new();
let mut seen = HashSet::new();
Expand All @@ -114,6 +115,16 @@ pub fn resolve_effective_tools(
}
}

// Thread goals are a main-session lifecycle capability, not an optional
// mode specialization. The UI and backend can activate a goal without a
// model tool call, so allowing a profile override to remove update_goal
// would strand the active goal in the automatic continuation loop.
for tool_name in THREAD_GOAL_TOOL_NAMES {
if valid_tools.contains(tool_name) && seen.insert(tool_name.to_string()) {
effective.push(tool_name.to_string());
}
}

effective
}

Expand Down Expand Up @@ -195,6 +206,7 @@ fn stored_agent_profile_from_overrides(

added_tools.retain(|tool| !default_set.contains(tool));
removed_tools.retain(|tool| default_set.contains(tool));
removed_tools.retain(|tool| !THREAD_GOAL_TOOL_NAMES.contains(&tool.as_str()));

let removed_set: HashSet<String> = removed_tools.iter().cloned().collect();
added_tools.retain(|tool| !removed_set.contains(tool));
Expand Down Expand Up @@ -578,14 +590,55 @@ pub fn agent_profile_member_mode_ids_for(agent_id: &str) -> Vec<String> {
mod tests {
use super::{
agent_profile_member_mode_ids_for, canonicalize_agent_profile,
normalize_skill_override_lists, stored_agent_profile_from_overrides,
StoredAgentProfileOverrides,
normalize_skill_override_lists, resolve_effective_tools,
stored_agent_profile_from_overrides, StoredAgentProfileOverrides,
};
use crate::service::config::types::AgentSubagentOverrideState;
use crate::service::config::types::{AgentProfileConfig, AgentSubagentOverrideState};
use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES;
use bitfun_runtime_ports::{PermissionEffect, PermissionRule};
use serde_json::Value;
use std::collections::HashSet;

#[test]
fn mode_profiles_cannot_remove_required_thread_goal_tools() {
let default_tools = vec![
"Read".to_string(),
"get_goal".to_string(),
"create_goal".to_string(),
"update_goal".to_string(),
];
let valid_tools = default_tools.iter().cloned().collect();
let stored = stored_agent_profile_from_overrides(StoredAgentProfileOverrides {
agent_id: "Claw",
added_tools: Vec::new(),
removed_tools: default_tools.clone(),
disabled_user_skills: Vec::new(),
enabled_user_skills: Vec::new(),
subagent_overrides: Default::default(),
tool_permission_rules: Vec::new(),
default_tools: &default_tools,
valid_tools: &valid_tools,
})
.expect("the ordinary Read removal should keep the profile");

assert_eq!(stored.removed_tools, vec!["Read".to_string()]);
for tool_name in THREAD_GOAL_TOOL_NAMES {
assert!(!stored.removed_tools.iter().any(|tool| tool == tool_name));
}

let legacy_config = AgentProfileConfig {
profile_id: "Claw".to_string(),
removed_tools: default_tools.clone(),
..AgentProfileConfig::default()
};
let effective_tools =
resolve_effective_tools(&default_tools, Some(&legacy_config), &valid_tools);
assert!(!effective_tools.contains(&"Read".to_string()));
for tool_name in THREAD_GOAL_TOOL_NAMES {
assert!(effective_tools.iter().any(|tool| tool == tool_name));
}
}

#[test]
fn normalize_skill_override_lists_removes_duplicates_and_conflicts() {
let (disabled, enabled) = normalize_skill_override_lists(
Expand Down
13 changes: 13 additions & 0 deletions src/crates/execution/agent-runtime/src/custom_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub const DEFAULT_CUSTOM_MODE_TOOLS: &[&str] = &[
"Skill",
"WebSearch",
"WebFetch",
"get_goal",
"create_goal",
"update_goal",
];
pub const DEFAULT_CUSTOM_SUBAGENT_TOOLS: &[&str] = &["LS", "Read", "Glob", "Grep"];
pub const DEFAULT_CUSTOM_MODE_READONLY: bool = false;
Expand Down Expand Up @@ -783,8 +786,18 @@ fn custom_agent_markdown_metadata(definition: &CustomAgentDefinition) -> Value {
#[cfg(test)]
mod tests {
use super::*;
use crate::thread_goal_tools::THREAD_GOAL_TOOL_NAMES;
use std::time::{SystemTime, UNIX_EPOCH};

#[test]
fn custom_mode_defaults_include_the_thread_goal_lifecycle() {
let tools = default_custom_agent_tools(CustomAgentKind::Mode);

for tool_name in THREAD_GOAL_TOOL_NAMES {
assert!(tools.iter().any(|tool| tool == tool_name));
}
}

#[test]
fn custom_agent_user_context_policy_round_trips_memory_summary() {
let definition = CustomAgentDefinition {
Expand Down
Loading