mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-10 22:43:37 +00:00
Merge pull request #643 from fabro-sh/feat/claude-5-profile
feat(agent): add Claude 5 profile
This commit is contained in:
commit
fa85bc42a2
33 changed files with 2865 additions and 312 deletions
|
|
@ -6781,7 +6781,7 @@ async fn test_model_explicit_provider_alias_returns_canonical_model_id_when_unav
|
|||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
assert_eq!(body["model_id"], "claude-sonnet-4-6");
|
||||
assert_eq!(body["model_id"], "claude-sonnet-5");
|
||||
assert_eq!(body["provider"], "anthropic");
|
||||
assert_eq!(body["status"], "skip");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ impl NativeToolOptions {
|
|||
// Matched exhaustively so a new profile kind has to state its answer
|
||||
// rather than silently inheriting the default timeout.
|
||||
let default_command_timeout_ms = match profile_kind {
|
||||
AgentProfileKind::Anthropic => 120_000,
|
||||
AgentProfileKind::Anthropic | AgentProfileKind::Claude5 => 120_000,
|
||||
// Matches the 60s foreground default Kimi Code's Bash tool
|
||||
// documents, which is what these models are used to budgeting
|
||||
// against.
|
||||
|
|
@ -333,12 +333,15 @@ mod tests {
|
|||
fn native_tool_options_have_expected_profile_defaults() {
|
||||
let openai = NativeToolOptions::for_profile(AgentProfileKind::OpenAi);
|
||||
let anthropic = NativeToolOptions::for_profile(AgentProfileKind::Anthropic);
|
||||
let claude5 = NativeToolOptions::for_profile(AgentProfileKind::Claude5);
|
||||
let kimi = NativeToolOptions::for_profile(AgentProfileKind::Kimi);
|
||||
|
||||
assert_eq!(openai.default_command_timeout_ms, 10_000);
|
||||
assert_eq!(openai.max_command_timeout_ms, 600_000);
|
||||
assert_eq!(anthropic.default_command_timeout_ms, 120_000);
|
||||
assert_eq!(anthropic.max_command_timeout_ms, 600_000);
|
||||
assert_eq!(claude5.default_command_timeout_ms, 120_000);
|
||||
assert_eq!(claude5.max_command_timeout_ms, 600_000);
|
||||
assert_eq!(kimi.default_command_timeout_ms, 60_000);
|
||||
assert_eq!(kimi.max_command_timeout_ms, 600_000);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ pub use loop_detection::detect_loop;
|
|||
pub use memory::{MemoryDocument, discover_memory};
|
||||
pub use native_tool::{NativeTool, ToolVocabulary};
|
||||
pub use profiles::{
|
||||
AgentProfileBuilder, AnthropicProfile, EnvContext, GeminiProfile, KimiProfile, OpenAiProfile,
|
||||
AgentProfileBuilder, AnthropicProfile, Claude5Profile, EnvContext, GeminiProfile, KimiProfile,
|
||||
OpenAiProfile,
|
||||
};
|
||||
pub use question_tools::{
|
||||
ANTHROPIC_ASK_USER_QUESTION_TOOL, AgentQuestion, AgentQuestionAnswer,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ pub async fn discover_memory(
|
|||
let directories = build_directory_walk(git_root, working_dir);
|
||||
|
||||
let candidate_filenames: Vec<&str> = match profile_kind {
|
||||
AgentProfileKind::Anthropic => vec!["AGENTS.md", "CLAUDE.md"],
|
||||
AgentProfileKind::Anthropic | AgentProfileKind::Claude5 => {
|
||||
vec!["AGENTS.md", "CLAUDE.md"]
|
||||
}
|
||||
AgentProfileKind::OpenAi | AgentProfileKind::Gpt56 => {
|
||||
vec!["AGENTS.md", ".codex/instructions.md"]
|
||||
}
|
||||
|
|
@ -207,6 +209,23 @@ mod tests {
|
|||
assert_eq!(anthropic_docs[0].content, "agents");
|
||||
assert_eq!(anthropic_docs[1].content, "claude");
|
||||
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
files: files.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
let claude5_docs = discover_memory(
|
||||
env.as_ref(),
|
||||
"/repo",
|
||||
"/repo",
|
||||
AgentProfileKind::Claude5,
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(claude5_docs.len(), 2);
|
||||
assert_eq!(claude5_docs[0].content, "agents");
|
||||
assert_eq!(claude5_docs[1].content, "claude");
|
||||
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
files: files.clone(),
|
||||
..Default::default()
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
//!
|
||||
//! A [`NativeTool`] is an identity, not a name. The same tool is expressed
|
||||
//! under different names depending on the [`ToolVocabulary`] a profile speaks:
|
||||
//! fabro's own names by default, Kimi Code's names for the Kimi profile, and
|
||||
//! Codex's names for the GPT-5.6 profile.
|
||||
//! fabro's own names by default, Anthropic's names for Claude 5, Kimi Code's
|
||||
//! names for the Kimi profile, and Codex's names for the GPT-5.6 profile.
|
||||
//! Permissions, categories, and telemetry resolve any name back to the
|
||||
//! identity, so behavior never depends on which vocabulary is in play.
|
||||
//!
|
||||
|
|
@ -26,6 +26,8 @@ pub enum ToolVocabulary {
|
|||
/// Fabro's own names, and the canonical identity used internally.
|
||||
#[default]
|
||||
Fabro,
|
||||
/// The names Anthropic's Claude 5 coding harness exposes.
|
||||
Claude5,
|
||||
/// The names Kimi Code exposes, for models trained against that harness.
|
||||
KimiCode,
|
||||
/// The names Codex exposes, for the GPT-5.6 models trained against it.
|
||||
|
|
@ -57,7 +59,11 @@ pub enum NativeTool {
|
|||
Shell,
|
||||
#[strum(to_string = "web_search", serialize = "WebSearch")]
|
||||
WebSearch,
|
||||
#[strum(to_string = "web_fetch", serialize = "FetchURL")]
|
||||
#[strum(
|
||||
to_string = "web_fetch",
|
||||
serialize = "FetchURL",
|
||||
serialize = "WebFetch"
|
||||
)]
|
||||
WebFetch,
|
||||
#[strum(to_string = "spawn_agent")]
|
||||
SpawnAgent,
|
||||
|
|
@ -67,6 +73,21 @@ pub enum NativeTool {
|
|||
Wait,
|
||||
#[strum(to_string = "close_agent")]
|
||||
CloseAgent,
|
||||
// Claude 5 drives one background agent through four tools, where fabro's
|
||||
// own vocabulary uses `spawn_agent`/`wait`/`close_agent`/`send_input`.
|
||||
// They are separate identities rather than aliases of those because the
|
||||
// capabilities differ: `Agent` runs in the background or inline depending
|
||||
// on `run_in_background`, and `TaskOutput` both polls and waits. Mapping
|
||||
// them onto the fabro four would promise semantics those tools do not
|
||||
// have -- the same reason Kimi Code's `Agent` is deliberately unmapped.
|
||||
#[strum(to_string = "background_agent", serialize = "Agent")]
|
||||
BackgroundAgent,
|
||||
#[strum(to_string = "agent_output", serialize = "TaskOutput")]
|
||||
AgentOutput,
|
||||
#[strum(to_string = "stop_agent", serialize = "TaskStop")]
|
||||
StopAgent,
|
||||
#[strum(to_string = "message_agent", serialize = "SendMessage")]
|
||||
MessageAgent,
|
||||
#[strum(to_string = "use_skill", serialize = "Skill")]
|
||||
UseSkill,
|
||||
#[strum(to_string = "update_plan")]
|
||||
|
|
@ -116,6 +137,25 @@ impl NativeTool {
|
|||
pub fn name(self, vocabulary: ToolVocabulary) -> &'static str {
|
||||
match vocabulary {
|
||||
ToolVocabulary::Fabro => self.canonical_name(),
|
||||
ToolVocabulary::Claude5 => match self {
|
||||
Self::ReadFile => "Read",
|
||||
Self::WriteFile => "Write",
|
||||
Self::EditFile => "Edit",
|
||||
Self::Shell => "Bash",
|
||||
// Named for completeness: this arm describes the vocabulary,
|
||||
// not the profile's registry, and the Claude 5 profile
|
||||
// deliberately registers neither.
|
||||
Self::Grep => "Grep",
|
||||
Self::Glob => "Glob",
|
||||
Self::WebSearch => "WebSearch",
|
||||
Self::WebFetch => "WebFetch",
|
||||
Self::UseSkill => "Skill",
|
||||
Self::BackgroundAgent => "Agent",
|
||||
Self::AgentOutput => "TaskOutput",
|
||||
Self::StopAgent => "TaskStop",
|
||||
Self::MessageAgent => "SendMessage",
|
||||
other => other.canonical_name(),
|
||||
},
|
||||
ToolVocabulary::KimiCode => match self {
|
||||
Self::ReadFile => "Read",
|
||||
Self::WriteFile => "Write",
|
||||
|
|
@ -177,9 +217,14 @@ impl NativeTool {
|
|||
}
|
||||
Self::WriteFile | Self::EditFile | Self::ApplyPatch => Some(AgentToolCategory::Write),
|
||||
Self::Shell => Some(AgentToolCategory::Shell),
|
||||
Self::SpawnAgent | Self::SendInput | Self::Wait | Self::CloseAgent => {
|
||||
Some(AgentToolCategory::Subagent)
|
||||
}
|
||||
Self::SpawnAgent
|
||||
| Self::SendInput
|
||||
| Self::Wait
|
||||
| Self::CloseAgent
|
||||
| Self::BackgroundAgent
|
||||
| Self::AgentOutput
|
||||
| Self::StopAgent
|
||||
| Self::MessageAgent => Some(AgentToolCategory::Subagent),
|
||||
// Uncategorized today. Giving these a category would change the CLI
|
||||
// permission gate, which is a behavior change rather than a
|
||||
// classification cleanup, so they keep their existing answer.
|
||||
|
|
@ -261,6 +306,50 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude5_vocabulary_uses_anthropic_harness_names() {
|
||||
assert_eq!(NativeTool::ReadFile.name(ToolVocabulary::Claude5), "Read");
|
||||
assert_eq!(NativeTool::Shell.name(ToolVocabulary::Claude5), "Bash");
|
||||
assert_eq!(
|
||||
NativeTool::WebFetch.name(ToolVocabulary::Claude5),
|
||||
"WebFetch"
|
||||
);
|
||||
assert_eq!(
|
||||
NativeTool::BackgroundAgent.name(ToolVocabulary::Claude5),
|
||||
"Agent"
|
||||
);
|
||||
assert_eq!(
|
||||
NativeTool::AgentOutput.name(ToolVocabulary::Claude5),
|
||||
"TaskOutput"
|
||||
);
|
||||
assert_eq!(
|
||||
NativeTool::StopAgent.name(ToolVocabulary::Claude5),
|
||||
"TaskStop"
|
||||
);
|
||||
assert_eq!(
|
||||
NativeTool::MessageAgent.name(ToolVocabulary::Claude5),
|
||||
"SendMessage"
|
||||
);
|
||||
}
|
||||
|
||||
/// The harness name is how a tool is expressed, not what it is: the
|
||||
/// identity keeps a fabro name, and the harness name resolves back to it.
|
||||
#[test]
|
||||
fn claude5_subagent_tools_keep_fabro_canonical_names() {
|
||||
for (tool, canonical, claude5) in [
|
||||
(NativeTool::BackgroundAgent, "background_agent", "Agent"),
|
||||
(NativeTool::AgentOutput, "agent_output", "TaskOutput"),
|
||||
(NativeTool::StopAgent, "stop_agent", "TaskStop"),
|
||||
(NativeTool::MessageAgent, "message_agent", "SendMessage"),
|
||||
] {
|
||||
assert_eq!(tool.canonical_name(), canonical);
|
||||
assert_eq!(tool.name(ToolVocabulary::Fabro), canonical);
|
||||
assert_eq!(tool.name(ToolVocabulary::Claude5), claude5);
|
||||
assert_eq!(NativeTool::from_any_name(canonical), Some(tool));
|
||||
assert_eq!(NativeTool::from_any_name(claude5), Some(tool));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_vocabulary_renames_only_the_shell() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -5,17 +5,16 @@ use fabro_model::{AgentProfileKind, Catalog, ProviderId};
|
|||
use super::EnvContext;
|
||||
use crate::agent_profile::AgentProfile;
|
||||
use crate::config::NativeToolOptions;
|
||||
use crate::profiles::{self, BaseProfile, EmbeddedPrompt};
|
||||
use crate::profiles::{
|
||||
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors,
|
||||
};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::todo_runtime::TodoRuntime;
|
||||
use crate::todo_tools::{
|
||||
make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool,
|
||||
};
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{
|
||||
WEB_SEARCH_TOOL_NAME, WebFetchSummarizer, make_edit_file_tool, register_core_tools,
|
||||
};
|
||||
use crate::tools::{WEB_SEARCH_TOOL_NAME, make_edit_file_tool, register_core_tools};
|
||||
|
||||
pub struct AnthropicProfile {
|
||||
base: BaseProfile,
|
||||
|
|
@ -26,21 +25,20 @@ const CORE_PROMPT: &str = include_str!("prompts/anthropic.md.j2");
|
|||
impl AnthropicProfile {
|
||||
#[must_use]
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
let options = NativeToolOptions::for_profile(AgentProfileKind::Anthropic);
|
||||
Self::with_native_tools(model, &options, None)
|
||||
let deps =
|
||||
ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Anthropic));
|
||||
Self::with_native_tools(model, &deps)
|
||||
}
|
||||
|
||||
pub(crate) fn with_native_tools(
|
||||
model: impl Into<String>,
|
||||
options: &NativeToolOptions,
|
||||
summarizer: Option<WebFetchSummarizer>,
|
||||
) -> Self {
|
||||
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
|
||||
let mut registry = ToolRegistry::new();
|
||||
|
||||
register_core_tools(&mut registry, options, summarizer);
|
||||
register_core_tools(&mut registry, &deps.options, deps.summarizer.clone());
|
||||
registry.register(make_edit_file_tool());
|
||||
// Anthropic task tools share one runtime per profile instance.
|
||||
let todo_runtime = Arc::new(TodoRuntime::new());
|
||||
// Task tools scope their list by `root_session_id`, so a root session
|
||||
// and its children address one logical list. They must therefore
|
||||
// resolve it through the one runtime the builder shares between them.
|
||||
let todo_runtime = Arc::clone(&deps.todo_runtime);
|
||||
registry.register(make_task_create_tool(todo_runtime.clone()));
|
||||
registry.register(make_task_update_tool(todo_runtime.clone()));
|
||||
registry.register(make_task_get_tool(todo_runtime.clone()));
|
||||
|
|
@ -72,29 +70,7 @@ impl AnthropicProfile {
|
|||
}
|
||||
|
||||
impl AgentProfile for AnthropicProfile {
|
||||
fn profile_kind(&self) -> AgentProfileKind {
|
||||
self.base.profile_kind
|
||||
}
|
||||
|
||||
fn provider_id(&self) -> ProviderId {
|
||||
self.base.provider_id.clone()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.base.model
|
||||
}
|
||||
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.base.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn tool_registry(&self) -> &ToolRegistry {
|
||||
&self.base.registry
|
||||
}
|
||||
|
||||
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
|
||||
&mut self.base.registry
|
||||
}
|
||||
impl_base_profile_accessors!();
|
||||
|
||||
fn build_system_prompt(
|
||||
&self,
|
||||
|
|
|
|||
225
lib/components/fabro-agent/src/profiles/claude5.rs
Normal file
225
lib/components/fabro-agent/src/profiles/claude5.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
//! Profile for Claude Fable 5, Opus 5, and Sonnet 5.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_model::{AgentProfileKind, Catalog, ProviderId};
|
||||
|
||||
use super::EnvContext;
|
||||
use crate::agent_profile::AgentProfile;
|
||||
use crate::config::NativeToolOptions;
|
||||
use crate::native_tool::{NativeTool, ToolVocabulary};
|
||||
use crate::profiles::{
|
||||
self, BaseProfile, EmbeddedPrompt, ProfileDeps, claude5_tools, impl_base_profile_accessors,
|
||||
};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::subagent::{SessionFactory, SubAgentSupervisor};
|
||||
use crate::todo_tools::{
|
||||
make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool,
|
||||
};
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
|
||||
const CORE_PROMPT: &str = include_str!("prompts/claude5.md.j2");
|
||||
|
||||
pub struct Claude5Profile {
|
||||
base: BaseProfile,
|
||||
}
|
||||
|
||||
impl Claude5Profile {
|
||||
#[must_use]
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
let deps =
|
||||
ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Claude5));
|
||||
Self::with_native_tools(model, &deps)
|
||||
}
|
||||
|
||||
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
|
||||
let options = &deps.options;
|
||||
let summarizer = deps.summarizer.clone();
|
||||
let todo_runtime = Arc::clone(&deps.todo_runtime);
|
||||
let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::Claude5);
|
||||
registry.register(claude5_tools::make_read_tool());
|
||||
registry.register(claude5_tools::make_write_tool());
|
||||
registry.register(claude5_tools::make_edit_tool());
|
||||
registry.register(claude5_tools::make_bash_tool(options));
|
||||
registry.register(claude5_tools::make_web_fetch_tool(summarizer));
|
||||
if let Some(api_key) = &options.secrets.brave_search_api_key {
|
||||
registry.register(claude5_tools::make_web_search_tool(api_key.clone()));
|
||||
}
|
||||
|
||||
registry.register(claude5_tools::strict_object_tool(make_task_create_tool(
|
||||
todo_runtime.clone(),
|
||||
)));
|
||||
registry.register(claude5_tools::strict_object_tool(make_task_update_tool(
|
||||
todo_runtime.clone(),
|
||||
)));
|
||||
registry.register(claude5_tools::strict_object_tool(make_task_get_tool(
|
||||
todo_runtime.clone(),
|
||||
)));
|
||||
registry.register(claude5_tools::strict_object_tool(make_task_list_tool(
|
||||
todo_runtime,
|
||||
)));
|
||||
|
||||
Self {
|
||||
base: BaseProfile {
|
||||
profile_kind: AgentProfileKind::Claude5,
|
||||
provider_id: ProviderId::anthropic(),
|
||||
model: model.into(),
|
||||
catalog: None,
|
||||
registry,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the transport provider while retaining Claude 5 harness
|
||||
/// behavior.
|
||||
#[must_use]
|
||||
pub fn with_provider_id(mut self, provider_id: ProviderId) -> Self {
|
||||
self.base.provider_id = provider_id;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
|
||||
self.base.catalog = Some(catalog);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentProfile for Claude5Profile {
|
||||
impl_base_profile_accessors!();
|
||||
|
||||
fn build_system_prompt(
|
||||
&self,
|
||||
env: &dyn Sandbox,
|
||||
env_context: &EnvContext,
|
||||
memory: &[String],
|
||||
user_instructions: Option<&str>,
|
||||
skills: &[Skill],
|
||||
) -> String {
|
||||
let template = EmbeddedPrompt::new("claude5.md.j2", CORE_PROMPT)
|
||||
.with_vocabulary(self.base.registry.vocabulary())
|
||||
.with_bool(
|
||||
"has_agent",
|
||||
self.base
|
||||
.registry
|
||||
.get_native(NativeTool::BackgroundAgent)
|
||||
.is_some(),
|
||||
)
|
||||
.with_bool(
|
||||
"has_ask_user_question",
|
||||
self.base
|
||||
.registry
|
||||
.get_native(NativeTool::AskUserQuestion)
|
||||
.is_some(),
|
||||
)
|
||||
.with_bool(
|
||||
"has_web_search",
|
||||
self.base
|
||||
.registry
|
||||
.get_native(NativeTool::WebSearch)
|
||||
.is_some(),
|
||||
);
|
||||
|
||||
profiles::assemble_system_prompt(
|
||||
template,
|
||||
env,
|
||||
env_context,
|
||||
memory,
|
||||
user_instructions,
|
||||
skills,
|
||||
)
|
||||
}
|
||||
|
||||
fn register_subagent_tools(
|
||||
&mut self,
|
||||
supervisor: SubAgentSupervisor,
|
||||
session_factory: SessionFactory,
|
||||
current_depth: usize,
|
||||
) {
|
||||
self.base.registry.register(claude5_tools::make_agent_tool(
|
||||
supervisor.clone(),
|
||||
session_factory,
|
||||
current_depth,
|
||||
));
|
||||
self.base
|
||||
.registry
|
||||
.register(claude5_tools::make_task_output_tool(supervisor.clone()));
|
||||
self.base
|
||||
.registry
|
||||
.register(claude5_tools::make_task_stop_tool(supervisor.clone()));
|
||||
self.base
|
||||
.registry
|
||||
.register(claude5_tools::make_send_message_tool(supervisor));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::subagent::SessionFactory;
|
||||
use crate::test_support::MockSandbox;
|
||||
|
||||
#[test]
|
||||
fn profile_identity() {
|
||||
let profile = Claude5Profile::new("claude-fable-5");
|
||||
assert_eq!(profile.profile_kind(), AgentProfileKind::Claude5);
|
||||
assert_eq!(profile.provider_id(), ProviderId::anthropic());
|
||||
assert_eq!(profile.model(), "claude-fable-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_tools_match_the_accepted_claude5_surface() {
|
||||
let profile = Claude5Profile::new("claude-sonnet-5");
|
||||
let mut names = profile.tool_registry().names();
|
||||
names.sort();
|
||||
assert_eq!(names, vec![
|
||||
"Bash",
|
||||
"Edit",
|
||||
"Read",
|
||||
"TaskCreate",
|
||||
"TaskGet",
|
||||
"TaskList",
|
||||
"TaskUpdate",
|
||||
"WebFetch",
|
||||
"Write",
|
||||
]);
|
||||
assert!(!names.iter().any(|name| name == "Grep" || name == "Glob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_agent_tools_use_claude_names() {
|
||||
let mut profile = Claude5Profile::new("claude-opus-5");
|
||||
let factory: SessionFactory = Arc::new(|| panic!("unused"));
|
||||
profile.register_subagent_tools(SubAgentSupervisor::new(3), factory, 0);
|
||||
|
||||
for expected in ["Agent", "TaskOutput", "TaskStop", "SendMessage"] {
|
||||
assert!(
|
||||
profile.tool_registry().get(expected).is_some(),
|
||||
"missing {expected}"
|
||||
);
|
||||
}
|
||||
for absent in ["spawn_agent", "wait", "close_agent", "send_input"] {
|
||||
assert!(
|
||||
profile.tool_registry().get(absent).is_none(),
|
||||
"found {absent}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_conditionals_follow_registered_tools() {
|
||||
let env = MockSandbox::linux();
|
||||
let profile = Claude5Profile::new("claude-fable-5");
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(!prompt.contains("# Background agents"));
|
||||
assert!(!prompt.contains("# Asking the user"));
|
||||
assert!(!prompt.contains("Use `WebSearch`"));
|
||||
|
||||
let mut profile = profile;
|
||||
let factory: SessionFactory = Arc::new(|| panic!("unused"));
|
||||
profile.register_subagent_tools(SubAgentSupervisor::new(3), factory, 0);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("# Background agents"));
|
||||
}
|
||||
}
|
||||
721
lib/components/fabro-agent/src/profiles/claude5_tools.rs
Normal file
721
lib/components/fabro-agent/src/profiles/claude5_tools.rs
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
//! Claude 5 harness adapters.
|
||||
//!
|
||||
//! Execution stays shared with Fabro wherever the behavior agrees. This module
|
||||
//! narrows the model-facing schemas and supplies the few lifecycle semantics
|
||||
//! that differ from Fabro's native tools.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_llm::types::ToolDefinition;
|
||||
use fabro_util::error as util_error;
|
||||
use serde_json::Value;
|
||||
use tokio::time;
|
||||
|
||||
use crate::config::NativeToolOptions;
|
||||
use crate::error::{Error, InterruptReason};
|
||||
use crate::native_tool::NativeTool;
|
||||
use crate::session::Session;
|
||||
use crate::subagent::{SessionFactory, SubAgentResult, SubAgentStatus, SubAgentSupervisor};
|
||||
use crate::tool_registry::{RegisteredTool, ToolContext, ToolSource};
|
||||
use crate::tools::{self, WebFetchSummarizer};
|
||||
|
||||
fn definition(
|
||||
tool: NativeTool,
|
||||
description: impl Into<String>,
|
||||
parameters: Value,
|
||||
) -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: tool.canonical_name().to_string(),
|
||||
description: description.into(),
|
||||
parameters,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject unknown top-level fields while retaining a shared executor.
|
||||
#[must_use]
|
||||
pub(crate) fn strict_object_tool(mut tool: RegisteredTool) -> RegisteredTool {
|
||||
let object = tool
|
||||
.definition
|
||||
.parameters
|
||||
.as_object_mut()
|
||||
.expect("native JSON-schema tools should use an object schema");
|
||||
object.insert("additionalProperties".to_string(), Value::Bool(false));
|
||||
tool
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_read_tool() -> RegisteredTool {
|
||||
strict_object_tool(tools::make_read_file_tool())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_write_tool() -> RegisteredTool {
|
||||
strict_object_tool(tools::make_write_file_tool())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_edit_tool() -> RegisteredTool {
|
||||
strict_object_tool(tools::make_edit_file_tool())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_bash_tool(options: &NativeToolOptions) -> RegisteredTool {
|
||||
let default_timeout_ms = options.default_command_timeout_ms;
|
||||
let max_timeout_ms = options.max_command_timeout_ms;
|
||||
RegisteredTool {
|
||||
definition: definition(
|
||||
NativeTool::Shell,
|
||||
format!(
|
||||
"Execute a Bash command in a fresh foreground non-login shell. Use this for \
|
||||
searches, git inspection, builds, tests, package managers, and terminal \
|
||||
operations. Prefer `rg` for content search and `rg --files` for file discovery. \
|
||||
Working-directory and environment changes do not persist between calls. \
|
||||
`timeout` is in milliseconds, defaults to {default_timeout_ms}, and is capped at \
|
||||
{max_timeout_ms}."
|
||||
),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Bash source to evaluate."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": max_timeout_ms,
|
||||
"description": format!(
|
||||
"Maximum runtime in milliseconds (default {default_timeout_ms})."
|
||||
)
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Short description of what the command does."
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
),
|
||||
executor: Arc::new(move |args, ctx| {
|
||||
Box::pin(async move {
|
||||
let command = tools::required_str(&args, "command")?;
|
||||
let timeout_ms = args
|
||||
.get("timeout")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(default_timeout_ms)
|
||||
.min(max_timeout_ms);
|
||||
tools::run_shell_command(&ctx, command, timeout_ms, None).await
|
||||
})
|
||||
}),
|
||||
source: ToolSource::Native,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_web_search_tool(api_key: String) -> RegisteredTool {
|
||||
let mut tool = tools::make_web_search_tool_with_api_key(api_key);
|
||||
tool.definition = definition(
|
||||
NativeTool::WebSearch,
|
||||
"Search the web when current external information is needed. Returns result titles, URLs, \
|
||||
and descriptions; use WebFetch to inspect a specific URL.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The web search query."
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
);
|
||||
tool
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_web_fetch_tool(summarizer: Option<WebFetchSummarizer>) -> RegisteredTool {
|
||||
let mut tool = tools::make_web_fetch_tool(summarizer);
|
||||
tool.definition = definition(
|
||||
NativeTool::WebFetch,
|
||||
"Fetch an HTTP or HTTPS URL and answer the supplied prompt from its contents.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The HTTP or HTTPS URL to fetch."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The question or extraction instruction to apply to the page."
|
||||
}
|
||||
},
|
||||
"required": ["url", "prompt"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
);
|
||||
tool
|
||||
}
|
||||
|
||||
fn child_session(session_factory: &SessionFactory, ctx: &ToolContext) -> Session {
|
||||
let mut session = session_factory();
|
||||
if let Some(root) = ctx.root_session_id.as_ref().or(ctx.session_id.as_ref()) {
|
||||
session.set_root_session_id(root.clone());
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
fn format_agent_result(result: &SubAgentResult) -> String {
|
||||
format!(
|
||||
"Agent completed (success: {}, turns: {})\n\n{}",
|
||||
result.success, result.turns_used, result.output
|
||||
)
|
||||
}
|
||||
|
||||
fn format_error(error: &Error) -> String {
|
||||
util_error::collect_chain(error).join(": ")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_agent_tool(
|
||||
supervisor: SubAgentSupervisor,
|
||||
session_factory: SessionFactory,
|
||||
current_depth: usize,
|
||||
) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: definition(
|
||||
NativeTool::BackgroundAgent,
|
||||
"Launch a child agent for an independent task. Agents run in the background by \
|
||||
default and notify the parent when they finish. Set run_in_background to false to \
|
||||
wait for the result synchronously.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A short 3-5 word description of the task."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The task for the agent to perform."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to return immediately (default true)."
|
||||
}
|
||||
},
|
||||
"required": ["description", "prompt"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
),
|
||||
executor: Arc::new(move |args, ctx| {
|
||||
let supervisor = supervisor.clone();
|
||||
let session_factory = session_factory.clone();
|
||||
Box::pin(async move {
|
||||
let description = tools::required_str(&args, "description")?;
|
||||
let prompt = tools::required_str(&args, "prompt")?;
|
||||
let run_in_background = args
|
||||
.get("run_in_background")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let session = child_session(&session_factory, &ctx);
|
||||
|
||||
if run_in_background {
|
||||
let task_id = supervisor
|
||||
.spawn_with_parent_notification(
|
||||
session,
|
||||
prompt.to_string(),
|
||||
description.to_string(),
|
||||
current_depth,
|
||||
)
|
||||
.map_err(|error| format_error(&error))?;
|
||||
Ok(format!(
|
||||
"Agent started in the background.\n\nTask ID: {task_id}"
|
||||
))
|
||||
} else {
|
||||
let task_id = supervisor
|
||||
.spawn(session, prompt.to_string(), current_depth)
|
||||
.map_err(|error| format_error(&error))?;
|
||||
match supervisor.wait_with_cancel(&task_id, &ctx.cancel).await {
|
||||
Ok(result) => Ok(format_agent_result(&result)),
|
||||
Err(Error::Interrupted(InterruptReason::Cancelled)) => {
|
||||
Err("Cancelled".to_string())
|
||||
}
|
||||
Err(error) => Err(format_error(&error)),
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
source: ToolSource::Native,
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema keeps `block` and `timeout` required to match the Claude 5
|
||||
/// contract, so these defaults only cover a model that omits them anyway.
|
||||
const TASK_OUTPUT_DEFAULT_BLOCK: bool = true;
|
||||
const TASK_OUTPUT_DEFAULT_TIMEOUT_MS: u64 = 30_000;
|
||||
const TASK_OUTPUT_MAX_TIMEOUT_MS: u64 = 600_000;
|
||||
|
||||
fn optional_bool(args: &Value, key: &str, default: bool) -> Result<bool, String> {
|
||||
match args.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.ok_or_else(|| format!("{key} must be a boolean")),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_u64(args: &Value, key: &str, default: u64) -> Result<u64, String> {
|
||||
match args.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.ok_or_else(|| format!("{key} must be a non-negative integer")),
|
||||
}
|
||||
}
|
||||
|
||||
fn finished_output(
|
||||
supervisor: &SubAgentSupervisor,
|
||||
task_id: &str,
|
||||
result: Result<SubAgentResult, Error>,
|
||||
) -> Result<String, String> {
|
||||
supervisor.suppress_parent_notification(task_id);
|
||||
match result {
|
||||
Ok(result) => Ok(format_agent_result(&result)),
|
||||
Err(error) => Err(format_error(&error)),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_task_output_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: definition(
|
||||
NativeTool::AgentOutput,
|
||||
"Get a background agent's current status or wait for its final output. Automatic \
|
||||
completion notifications make ordinary polling unnecessary.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "The background agent task ID."
|
||||
},
|
||||
"block": {
|
||||
"type": "boolean",
|
||||
"default": TASK_OUTPUT_DEFAULT_BLOCK,
|
||||
"description": "Whether to wait for completion."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": TASK_OUTPUT_MAX_TIMEOUT_MS,
|
||||
"default": TASK_OUTPUT_DEFAULT_TIMEOUT_MS,
|
||||
"description": "Maximum wait time in milliseconds."
|
||||
}
|
||||
},
|
||||
"required": ["task_id", "block", "timeout"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
),
|
||||
executor: Arc::new(move |args, ctx| {
|
||||
let supervisor = supervisor.clone();
|
||||
Box::pin(async move {
|
||||
let task_id = tools::required_str(&args, "task_id")?;
|
||||
let block = optional_bool(&args, "block", TASK_OUTPUT_DEFAULT_BLOCK)?;
|
||||
let timeout_ms = optional_u64(&args, "timeout", TASK_OUTPUT_DEFAULT_TIMEOUT_MS)?;
|
||||
if timeout_ms > TASK_OUTPUT_MAX_TIMEOUT_MS {
|
||||
return Err(format!(
|
||||
"timeout must be between 0 and {TASK_OUTPUT_MAX_TIMEOUT_MS} milliseconds"
|
||||
));
|
||||
}
|
||||
|
||||
match supervisor.status(task_id) {
|
||||
Some(SubAgentStatus::Finished(result)) => {
|
||||
return finished_output(&supervisor, task_id, result);
|
||||
}
|
||||
Some(SubAgentStatus::Running) if !block => {
|
||||
return Ok(format!("Agent {task_id} is still running."));
|
||||
}
|
||||
Some(SubAgentStatus::Closing | SubAgentStatus::Closed) => {
|
||||
return Ok(format!("Agent {task_id} has been stopped."));
|
||||
}
|
||||
None => {
|
||||
return Err(format!(
|
||||
"No agent found with id: {task_id} (it was never spawned)"
|
||||
));
|
||||
}
|
||||
Some(SubAgentStatus::Running) => {}
|
||||
}
|
||||
|
||||
match time::timeout(
|
||||
Duration::from_millis(timeout_ms),
|
||||
supervisor.wait_with_cancel(task_id, &ctx.cancel),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(result)) => {
|
||||
supervisor.suppress_parent_notification(task_id);
|
||||
Ok(format_agent_result(&result))
|
||||
}
|
||||
Ok(Err(Error::Interrupted(InterruptReason::Cancelled))) => {
|
||||
supervisor.suppress_parent_notification(task_id);
|
||||
Err("Cancelled".to_string())
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
supervisor.suppress_parent_notification(task_id);
|
||||
Err(format_error(&error))
|
||||
}
|
||||
Err(_) => Ok(format!(
|
||||
"Agent {task_id} is still running after waiting {timeout_ms} ms."
|
||||
)),
|
||||
}
|
||||
})
|
||||
}),
|
||||
source: ToolSource::Native,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_task_stop_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: definition(
|
||||
NativeTool::StopAgent,
|
||||
"Stop a running background agent by task ID.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "The background agent task ID to stop."
|
||||
}
|
||||
},
|
||||
"required": ["task_id"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
),
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
let supervisor = supervisor.clone();
|
||||
Box::pin(async move {
|
||||
let task_id = tools::required_str(&args, "task_id")?;
|
||||
supervisor
|
||||
.close_agent(task_id)
|
||||
.await
|
||||
.map_err(|error| format_error(&error))?;
|
||||
Ok(format!("Agent {task_id} stopped."))
|
||||
})
|
||||
}),
|
||||
source: ToolSource::Native,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn make_send_message_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: definition(
|
||||
NativeTool::MessageAgent,
|
||||
"Send additional instructions to a running background agent by its task ID.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "The background agent task ID."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The follow-up message."
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"maxLength": 200,
|
||||
"description": "Optional short preview of the message."
|
||||
}
|
||||
},
|
||||
"required": ["to", "message"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
),
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
let supervisor = supervisor.clone();
|
||||
Box::pin(async move {
|
||||
let recipient = tools::required_str(&args, "to")?;
|
||||
let message = tools::required_str(&args, "message")?;
|
||||
supervisor
|
||||
.send_input(recipient, message)
|
||||
.map_err(|error| format_error(&error))?;
|
||||
Ok(format!("Message sent to agent {recipient}."))
|
||||
})
|
||||
}),
|
||||
source: ToolSource::Native,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::json;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::*;
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::test_support::{MockSandbox, make_session, text_response};
|
||||
use crate::todo_runtime::TodoRuntime;
|
||||
use crate::todo_tools::{
|
||||
make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool,
|
||||
};
|
||||
|
||||
fn property_names(tool: &RegisteredTool) -> BTreeSet<&str> {
|
||||
tool.definition.parameters["properties"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn required_names(tool: &RegisteredTool) -> BTreeSet<&str> {
|
||||
tool.definition.parameters["required"]
|
||||
.as_array()
|
||||
.map(|required| {
|
||||
required
|
||||
.iter()
|
||||
.map(|value| value.as_str().unwrap())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn assert_schema(tool: &RegisteredTool, properties: &[&str], required: &[&str]) {
|
||||
assert_eq!(tool.definition.parameters["type"], "object");
|
||||
assert_eq!(
|
||||
tool.definition.parameters["additionalProperties"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
assert_eq!(property_names(tool), properties.iter().copied().collect());
|
||||
assert_eq!(required_names(tool), required.iter().copied().collect());
|
||||
}
|
||||
|
||||
fn context() -> ToolContext {
|
||||
ToolContext {
|
||||
env: Arc::new(MockSandbox::default()) as Arc<dyn Sandbox>,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env_provider: None,
|
||||
session_id: Some("root".to_string()),
|
||||
root_session_id: Some("root".to_string()),
|
||||
tool_call_id: Some("call".to_string()),
|
||||
agent_event_emitter: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_adapter_schemas_match_the_claude5_contract() {
|
||||
let options = NativeToolOptions::for_profile(fabro_model::AgentProfileKind::Claude5);
|
||||
assert_schema(&make_read_tool(), &["file_path", "limit", "offset"], &[
|
||||
"file_path",
|
||||
]);
|
||||
assert_schema(&make_write_tool(), &["content", "file_path"], &[
|
||||
"content",
|
||||
"file_path",
|
||||
]);
|
||||
assert_schema(
|
||||
&make_edit_tool(),
|
||||
&["file_path", "new_string", "old_string", "replace_all"],
|
||||
&["file_path", "new_string", "old_string"],
|
||||
);
|
||||
let bash = make_bash_tool(&options);
|
||||
assert_schema(&bash, &["command", "description", "timeout"], &["command"]);
|
||||
assert_eq!(
|
||||
bash.definition.parameters["properties"]["timeout"]["maximum"],
|
||||
600_000
|
||||
);
|
||||
assert_schema(&make_web_fetch_tool(None), &["prompt", "url"], &[
|
||||
"prompt", "url",
|
||||
]);
|
||||
assert_schema(&make_web_search_tool("key".to_string()), &["query"], &[
|
||||
"query",
|
||||
]);
|
||||
|
||||
let todo_runtime = Arc::new(TodoRuntime::new());
|
||||
assert_schema(
|
||||
&strict_object_tool(make_task_create_tool(todo_runtime.clone())),
|
||||
&["activeForm", "description", "metadata", "subject"],
|
||||
&["description", "subject"],
|
||||
);
|
||||
assert_schema(
|
||||
&strict_object_tool(make_task_update_tool(todo_runtime.clone())),
|
||||
&[
|
||||
"activeForm",
|
||||
"addBlockedBy",
|
||||
"addBlocks",
|
||||
"description",
|
||||
"metadata",
|
||||
"owner",
|
||||
"status",
|
||||
"subject",
|
||||
"taskId",
|
||||
],
|
||||
&["taskId"],
|
||||
);
|
||||
assert_schema(
|
||||
&strict_object_tool(make_task_get_tool(todo_runtime.clone())),
|
||||
&["taskId"],
|
||||
&["taskId"],
|
||||
);
|
||||
assert_schema(
|
||||
&strict_object_tool(make_task_list_tool(todo_runtime)),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_adapter_schemas_match_the_claude5_contract() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let factory: SessionFactory = Arc::new(|| panic!("unused"));
|
||||
assert_schema(
|
||||
&make_agent_tool(supervisor.clone(), factory, 0),
|
||||
&["description", "prompt", "run_in_background"],
|
||||
&["description", "prompt"],
|
||||
);
|
||||
assert_schema(
|
||||
&make_task_output_tool(supervisor.clone()),
|
||||
&["block", "task_id", "timeout"],
|
||||
&["block", "task_id", "timeout"],
|
||||
);
|
||||
assert_schema(&make_task_stop_tool(supervisor.clone()), &["task_id"], &[
|
||||
"task_id",
|
||||
]);
|
||||
assert_schema(
|
||||
&make_send_message_tool(supervisor),
|
||||
&["message", "summary", "to"],
|
||||
&["message", "to"],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_defaults_to_background_and_produces_parent_notification() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let session = make_session(vec![text_response("child report")]).await;
|
||||
let session_slot = Arc::new(Mutex::new(Some(session)));
|
||||
let factory_slot = Arc::clone(&session_slot);
|
||||
let factory: SessionFactory = Arc::new(move || {
|
||||
factory_slot
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.expect("factory should be called once")
|
||||
});
|
||||
let tool = make_agent_tool(supervisor.clone(), factory, 0);
|
||||
|
||||
let output = (tool.executor)(
|
||||
json!({
|
||||
"description": "Inspect child",
|
||||
"prompt": "Inspect the child task"
|
||||
}),
|
||||
context(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let task_id = output
|
||||
.strip_prefix("Agent started in the background.\n\nTask ID: ")
|
||||
.expect("Agent should return a background task ID");
|
||||
let notifications = supervisor
|
||||
.next_parent_notification_batch(&CancellationToken::new())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(notifications.len(), 1);
|
||||
assert_eq!(notifications[0].agent_id, task_id);
|
||||
assert_eq!(notifications[0].description, "Inspect child");
|
||||
assert_eq!(
|
||||
notifications[0].result.as_ref().unwrap().output,
|
||||
"child report"
|
||||
);
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_output_suppresses_a_racing_automatic_notification() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let session = make_session(vec![text_response("explicit report")]).await;
|
||||
let task_id = supervisor
|
||||
.spawn_with_parent_notification(
|
||||
session,
|
||||
"Inspect".to_string(),
|
||||
"Inspect explicitly".to_string(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
supervisor
|
||||
.wait_with_cancel(&task_id, &CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = make_task_output_tool(supervisor.clone());
|
||||
let output = (tool.executor)(
|
||||
json!({
|
||||
"task_id": task_id,
|
||||
"block": false,
|
||||
"timeout": 0
|
||||
}),
|
||||
context(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.contains("explicit report"));
|
||||
assert!(
|
||||
supervisor
|
||||
.next_parent_notification_batch(&CancellationToken::new())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_output_applies_the_schema_defaults_when_the_model_omits_them() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let session = make_session(vec![text_response("defaulted report")]).await;
|
||||
let task_id = supervisor.spawn(session, "Inspect".to_string(), 0).unwrap();
|
||||
supervisor
|
||||
.wait_with_cancel(&task_id, &CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = make_task_output_tool(supervisor.clone());
|
||||
let output = (tool.executor)(json!({ "task_id": task_id }), context())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.contains("defaulted report"));
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_output_rejects_a_wrongly_typed_optional_parameter() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let tool = make_task_output_tool(supervisor);
|
||||
let error = (tool.executor)(
|
||||
json!({
|
||||
"task_id": "agent-1",
|
||||
"block": "yes"
|
||||
}),
|
||||
context(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, "block must be a boolean");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,13 +5,15 @@ use fabro_model::{AgentProfileKind, Catalog, ProviderId};
|
|||
use super::EnvContext;
|
||||
use crate::agent_profile::AgentProfile;
|
||||
use crate::config::NativeToolOptions;
|
||||
use crate::profiles::{self, BaseProfile, EmbeddedPrompt};
|
||||
use crate::profiles::{
|
||||
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors,
|
||||
};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{
|
||||
WEB_SEARCH_TOOL_NAME, WebFetchSummarizer, make_edit_file_tool, make_list_dir_tool,
|
||||
make_read_many_files_tool, register_core_tools,
|
||||
WEB_SEARCH_TOOL_NAME, make_edit_file_tool, make_list_dir_tool, make_read_many_files_tool,
|
||||
register_core_tools,
|
||||
};
|
||||
|
||||
const CORE_PROMPT: &str = include_str!("prompts/gemini.md.j2");
|
||||
|
|
@ -23,18 +25,15 @@ pub struct GeminiProfile {
|
|||
impl GeminiProfile {
|
||||
#[must_use]
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
let options = NativeToolOptions::for_profile(AgentProfileKind::Gemini);
|
||||
Self::with_native_tools(model, &options, None)
|
||||
let deps =
|
||||
ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Gemini));
|
||||
Self::with_native_tools(model, &deps)
|
||||
}
|
||||
|
||||
pub(crate) fn with_native_tools(
|
||||
model: impl Into<String>,
|
||||
options: &NativeToolOptions,
|
||||
summarizer: Option<WebFetchSummarizer>,
|
||||
) -> Self {
|
||||
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
|
||||
let mut registry = ToolRegistry::new();
|
||||
|
||||
register_core_tools(&mut registry, options, summarizer);
|
||||
register_core_tools(&mut registry, &deps.options, deps.summarizer.clone());
|
||||
registry.register(make_edit_file_tool());
|
||||
registry.register(make_read_many_files_tool());
|
||||
registry.register(make_list_dir_tool());
|
||||
|
|
@ -65,29 +64,7 @@ impl GeminiProfile {
|
|||
}
|
||||
|
||||
impl AgentProfile for GeminiProfile {
|
||||
fn profile_kind(&self) -> AgentProfileKind {
|
||||
self.base.profile_kind
|
||||
}
|
||||
|
||||
fn provider_id(&self) -> ProviderId {
|
||||
self.base.provider_id.clone()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.base.model
|
||||
}
|
||||
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.base.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn tool_registry(&self) -> &ToolRegistry {
|
||||
&self.base.registry
|
||||
}
|
||||
|
||||
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
|
||||
&mut self.base.registry
|
||||
}
|
||||
impl_base_profile_accessors!();
|
||||
|
||||
fn build_system_prompt(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ use super::EnvContext;
|
|||
use crate::agent_profile::AgentProfile;
|
||||
use crate::config::NativeToolOptions;
|
||||
use crate::native_tool::{NativeTool, ToolVocabulary};
|
||||
use crate::profiles::{self, BaseProfile, EmbeddedPrompt, FileEditToolKind};
|
||||
use crate::profiles::{
|
||||
self, BaseProfile, EmbeddedPrompt, FileEditToolKind, ProfileDeps, impl_base_profile_accessors,
|
||||
};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::todo_runtime::TodoRuntime;
|
||||
|
|
@ -45,11 +47,13 @@ pub struct Gpt56Profile {
|
|||
impl Gpt56Profile {
|
||||
#[must_use]
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
let options = NativeToolOptions::for_profile(AgentProfileKind::Gpt56);
|
||||
Self::with_native_tools(model, &options)
|
||||
let deps = ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Gpt56));
|
||||
Self::with_native_tools(model, &deps)
|
||||
}
|
||||
|
||||
pub(crate) fn with_native_tools(model: impl Into<String>, options: &NativeToolOptions) -> Self {
|
||||
/// `deps.summarizer` is ignored: this profile exposes no `web_fetch`.
|
||||
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
|
||||
let options = &deps.options;
|
||||
// The registry carries the vocabulary, so tools registered later --
|
||||
// subagent tools, skills -- are named consistently too.
|
||||
let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::Codex);
|
||||
|
|
@ -177,29 +181,7 @@ fn make_shell_command_tool(options: &NativeToolOptions) -> RegisteredTool {
|
|||
}
|
||||
|
||||
impl AgentProfile for Gpt56Profile {
|
||||
fn profile_kind(&self) -> AgentProfileKind {
|
||||
self.base.profile_kind
|
||||
}
|
||||
|
||||
fn provider_id(&self) -> ProviderId {
|
||||
self.base.provider_id.clone()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.base.model
|
||||
}
|
||||
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.base.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn tool_registry(&self) -> &ToolRegistry {
|
||||
&self.base.registry
|
||||
}
|
||||
|
||||
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
|
||||
&mut self.base.registry
|
||||
}
|
||||
impl_base_profile_accessors!();
|
||||
|
||||
fn build_system_prompt(
|
||||
&self,
|
||||
|
|
@ -386,7 +368,8 @@ mod tests {
|
|||
|
||||
let mut options = NativeToolOptions::for_profile(AgentProfileKind::Gpt56);
|
||||
options.secrets.brave_search_api_key = Some("configured-key".to_string());
|
||||
let searching = Gpt56Profile::with_native_tools("gpt-5.6-sol", &options);
|
||||
let deps = ProfileDeps::standalone(options);
|
||||
let searching = Gpt56Profile::with_native_tools("gpt-5.6-sol", &deps);
|
||||
assert!(searching.tool_registry().get("web_search").is_some());
|
||||
assert!(prompt(&searching).contains("web_search"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@ use super::EnvContext;
|
|||
use crate::agent_profile::AgentProfile;
|
||||
use crate::config::NativeToolOptions;
|
||||
use crate::native_tool::{NativeTool, ToolVocabulary};
|
||||
use crate::profiles::{self, BaseProfile, EmbeddedPrompt, kimi_tools};
|
||||
use crate::profiles::{
|
||||
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors, kimi_tools,
|
||||
};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::todo_runtime::TodoRuntime;
|
||||
use crate::todo_tools::make_todo_list_tool;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{WebFetchSummarizer, register_discovery_and_web_tools};
|
||||
use crate::tools::register_discovery_and_web_tools;
|
||||
|
||||
const CORE_PROMPT: &str = include_str!("prompts/kimi.md.j2");
|
||||
|
||||
|
|
@ -64,15 +66,12 @@ pub struct KimiProfile {
|
|||
impl KimiProfile {
|
||||
#[must_use]
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
let options = NativeToolOptions::for_profile(AgentProfileKind::Kimi);
|
||||
Self::with_native_tools(model, &options, None)
|
||||
let deps = ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Kimi));
|
||||
Self::with_native_tools(model, &deps)
|
||||
}
|
||||
|
||||
pub(crate) fn with_native_tools(
|
||||
model: impl Into<String>,
|
||||
options: &NativeToolOptions,
|
||||
summarizer: Option<WebFetchSummarizer>,
|
||||
) -> Self {
|
||||
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
|
||||
let options = &deps.options;
|
||||
// The registry carries the vocabulary, so tools registered later
|
||||
// (subagent tools, skills) are renamed too.
|
||||
let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::KimiCode);
|
||||
|
|
@ -80,7 +79,7 @@ impl KimiProfile {
|
|||
// Glob and the web tools have the same contract in both vocabularies.
|
||||
// The remaining Kimi tools use adapters for their different schemas,
|
||||
// while reusing shared execution helpers where their behavior agrees.
|
||||
register_discovery_and_web_tools(&mut registry, options, summarizer);
|
||||
register_discovery_and_web_tools(&mut registry, options, deps.summarizer.clone());
|
||||
registry.register(kimi_tools::make_kimi_read_tool());
|
||||
registry.register(kimi_tools::make_kimi_write_tool());
|
||||
registry.register(kimi_tools::make_kimi_edit_tool(EDIT_FILE_DESCRIPTION));
|
||||
|
|
@ -127,29 +126,7 @@ impl KimiProfile {
|
|||
}
|
||||
|
||||
impl AgentProfile for KimiProfile {
|
||||
fn profile_kind(&self) -> AgentProfileKind {
|
||||
self.base.profile_kind
|
||||
}
|
||||
|
||||
fn provider_id(&self) -> ProviderId {
|
||||
self.base.provider_id.clone()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.base.model
|
||||
}
|
||||
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.base.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn tool_registry(&self) -> &ToolRegistry {
|
||||
&self.base.registry
|
||||
}
|
||||
|
||||
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
|
||||
&mut self.base.registry
|
||||
}
|
||||
impl_base_profile_accessors!();
|
||||
|
||||
fn build_system_prompt(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ use std::sync::Arc;
|
|||
use fabro_model::{AgentProfileKind, Catalog, CodecKind, ProviderId};
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod claude5;
|
||||
pub(crate) mod claude5_tools;
|
||||
pub mod gemini;
|
||||
pub mod gpt56;
|
||||
pub mod kimi;
|
||||
|
|
@ -11,6 +13,7 @@ pub mod kimi_tools;
|
|||
pub mod openai;
|
||||
|
||||
pub use anthropic::AnthropicProfile;
|
||||
pub use claude5::Claude5Profile;
|
||||
pub use gemini::GeminiProfile;
|
||||
pub use gpt56::Gpt56Profile;
|
||||
pub use kimi::KimiProfile;
|
||||
|
|
@ -22,6 +25,7 @@ use crate::config::{NativeToolOptions, ToolSecrets};
|
|||
use crate::native_tool::{NativeTool, ToolVocabulary};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::{Skill, format_skills_prompt_section};
|
||||
use crate::todo_runtime::TodoRuntime;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{self, WebFetchSummarizer};
|
||||
|
||||
|
|
@ -39,6 +43,33 @@ pub struct AgentProfileBuilder {
|
|||
catalog: Arc<Catalog>,
|
||||
native_tool_options: NativeToolOptions,
|
||||
summarizer: Option<WebFetchSummarizer>,
|
||||
todo_runtime: Arc<TodoRuntime>,
|
||||
}
|
||||
|
||||
/// Everything a profile constructor needs from the builder.
|
||||
///
|
||||
/// Bundled rather than passed positionally so that adding a dependency does
|
||||
/// not mean editing every profile's signature -- and, more importantly, so a
|
||||
/// dependency cannot reach some profiles and silently miss others. The shared
|
||||
/// `todo_runtime` is exactly that case: task tools scope their list by
|
||||
/// `root_session_id`, so a root and its children address one logical list and
|
||||
/// must resolve it through one runtime.
|
||||
pub(crate) struct ProfileDeps {
|
||||
pub options: NativeToolOptions,
|
||||
pub summarizer: Option<WebFetchSummarizer>,
|
||||
pub todo_runtime: Arc<TodoRuntime>,
|
||||
}
|
||||
|
||||
impl ProfileDeps {
|
||||
/// Standalone defaults, for `Profile::new` and tests. A profile built this
|
||||
/// way owns its runtime because it has no children to share one with.
|
||||
pub(crate) fn standalone(options: NativeToolOptions) -> Self {
|
||||
Self {
|
||||
options,
|
||||
summarizer: None,
|
||||
todo_runtime: Arc::new(TodoRuntime::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentProfileBuilder {
|
||||
|
|
@ -56,6 +87,7 @@ impl AgentProfileBuilder {
|
|||
catalog,
|
||||
native_tool_options: NativeToolOptions::for_profile(profile_kind),
|
||||
summarizer: None,
|
||||
todo_runtime: Arc::new(TodoRuntime::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,34 +110,42 @@ impl AgentProfileBuilder {
|
|||
#[must_use]
|
||||
pub fn build(&self) -> Box<dyn AgentProfile> {
|
||||
let model = self.model.as_str();
|
||||
let options = &self.native_tool_options;
|
||||
let summarizer = if self.profile_kind == AgentProfileKind::Gpt56 {
|
||||
None
|
||||
} else {
|
||||
self.summarizer.clone()
|
||||
let deps = ProfileDeps {
|
||||
options: self.native_tool_options.clone(),
|
||||
summarizer: if self.profile_kind == AgentProfileKind::Gpt56 {
|
||||
None
|
||||
} else {
|
||||
self.summarizer.clone()
|
||||
},
|
||||
todo_runtime: Arc::clone(&self.todo_runtime),
|
||||
};
|
||||
match self.profile_kind {
|
||||
AgentProfileKind::OpenAi => Box::new(
|
||||
OpenAiProfile::with_native_tools(model, options, summarizer)
|
||||
OpenAiProfile::with_native_tools(model, &deps)
|
||||
.with_route(self.provider_id.clone(), Arc::clone(&self.catalog)),
|
||||
),
|
||||
AgentProfileKind::Gemini => Box::new(
|
||||
GeminiProfile::with_native_tools(model, options, summarizer)
|
||||
GeminiProfile::with_native_tools(model, &deps)
|
||||
.with_provider_id(self.provider_id.clone())
|
||||
.with_catalog(Arc::clone(&self.catalog)),
|
||||
),
|
||||
AgentProfileKind::Anthropic => Box::new(
|
||||
AnthropicProfile::with_native_tools(model, options, summarizer)
|
||||
AnthropicProfile::with_native_tools(model, &deps)
|
||||
.with_provider_id(self.provider_id.clone())
|
||||
.with_catalog(Arc::clone(&self.catalog)),
|
||||
),
|
||||
AgentProfileKind::Claude5 => Box::new(
|
||||
Claude5Profile::with_native_tools(model, &deps)
|
||||
.with_provider_id(self.provider_id.clone())
|
||||
.with_catalog(Arc::clone(&self.catalog)),
|
||||
),
|
||||
AgentProfileKind::Kimi => Box::new(
|
||||
KimiProfile::with_native_tools(model, options, summarizer)
|
||||
KimiProfile::with_native_tools(model, &deps)
|
||||
.with_provider_id(self.provider_id.clone())
|
||||
.with_catalog(Arc::clone(&self.catalog)),
|
||||
),
|
||||
AgentProfileKind::Gpt56 => Box::new(
|
||||
Gpt56Profile::with_native_tools(model, options)
|
||||
Gpt56Profile::with_native_tools(model, &deps)
|
||||
.with_route(self.provider_id.clone(), Arc::clone(&self.catalog)),
|
||||
),
|
||||
}
|
||||
|
|
@ -159,6 +199,45 @@ impl FileEditToolKind {
|
|||
}
|
||||
}
|
||||
|
||||
/// Implement the [`AgentProfile`](crate::agent_profile::AgentProfile)
|
||||
/// accessors that just delegate to an embedded [`BaseProfile`] named `base`.
|
||||
///
|
||||
/// Every profile that owns a `BaseProfile` writes the same six methods; what
|
||||
/// actually distinguishes them is `build_system_prompt` and, for some,
|
||||
/// `register_subagent_tools`. Types that implement the trait without a
|
||||
/// `BaseProfile` -- test doubles, and the server's ask-fabro profile -- write
|
||||
/// the accessors themselves, which is why this is a macro rather than a set of
|
||||
/// trait defaults: there is no sensible default for a profile that has no base.
|
||||
macro_rules! impl_base_profile_accessors {
|
||||
() => {
|
||||
fn profile_kind(&self) -> ::fabro_model::AgentProfileKind {
|
||||
self.base.profile_kind
|
||||
}
|
||||
|
||||
fn provider_id(&self) -> ::fabro_model::ProviderId {
|
||||
self.base.provider_id.clone()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.base.model
|
||||
}
|
||||
|
||||
fn catalog(&self) -> Option<&::fabro_model::Catalog> {
|
||||
self.base.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn tool_registry(&self) -> &$crate::tool_registry::ToolRegistry {
|
||||
&self.base.registry
|
||||
}
|
||||
|
||||
fn tool_registry_mut(&mut self) -> &mut $crate::tool_registry::ToolRegistry {
|
||||
&mut self.base.registry
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use impl_base_profile_accessors;
|
||||
|
||||
/// Common fields shared by all provider profiles.
|
||||
///
|
||||
/// Each concrete profile embeds this struct and delegates `profile_kind()`,
|
||||
|
|
@ -374,11 +453,13 @@ pub fn build_env_context_block_with(env: &dyn Sandbox, ctx: &EnvContext) -> Stri
|
|||
mod tests {
|
||||
use fabro_llm::types::ToolDefinition;
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::*;
|
||||
use crate::question_tools;
|
||||
use crate::subagent::{SessionFactory, SubAgentSupervisor};
|
||||
use crate::test_support::MockSandbox;
|
||||
use crate::tools::WEB_SEARCH_TOOL_NAME;
|
||||
use crate::tool_registry::ToolContext;
|
||||
|
||||
fn native_tool_options(
|
||||
profile_kind: AgentProfileKind,
|
||||
|
|
@ -404,35 +485,60 @@ mod tests {
|
|||
|
||||
fn anthropic_profile(has_web_search: bool, has_subagents: bool) -> AnthropicProfile {
|
||||
let options = native_tool_options(AgentProfileKind::Anthropic, has_web_search);
|
||||
let mut profile = AnthropicProfile::with_native_tools("claude-haiku-4-5", &options, None);
|
||||
let deps = ProfileDeps::standalone(options);
|
||||
let mut profile = AnthropicProfile::with_native_tools("claude-haiku-4-5", &deps);
|
||||
if has_subagents {
|
||||
register_test_subagent_tools(&mut profile);
|
||||
}
|
||||
profile
|
||||
}
|
||||
|
||||
fn claude5_profile(
|
||||
has_web_search: bool,
|
||||
has_subagents: bool,
|
||||
has_question: bool,
|
||||
) -> Claude5Profile {
|
||||
let options = native_tool_options(AgentProfileKind::Claude5, has_web_search);
|
||||
let deps = ProfileDeps::standalone(options);
|
||||
let mut profile = Claude5Profile::with_native_tools("claude-sonnet-5", &deps);
|
||||
if has_subagents {
|
||||
register_test_subagent_tools(&mut profile);
|
||||
}
|
||||
if has_question {
|
||||
question_tools::register_question_tools(
|
||||
AgentProfileKind::Claude5,
|
||||
profile.tool_registry_mut(),
|
||||
);
|
||||
}
|
||||
profile
|
||||
}
|
||||
|
||||
fn gemini_profile(has_web_search: bool) -> GeminiProfile {
|
||||
let options = native_tool_options(AgentProfileKind::Gemini, has_web_search);
|
||||
GeminiProfile::with_native_tools("gemini-3-flash-preview", &options, None)
|
||||
let deps = ProfileDeps::standalone(options);
|
||||
GeminiProfile::with_native_tools("gemini-3-flash-preview", &deps)
|
||||
}
|
||||
|
||||
fn openai_apply_patch_profile(has_web_search: bool) -> OpenAiProfile {
|
||||
let options = native_tool_options(AgentProfileKind::OpenAi, has_web_search);
|
||||
OpenAiProfile::with_native_tools("gpt-5.4-mini", &options, None)
|
||||
let deps = ProfileDeps::standalone(options);
|
||||
OpenAiProfile::with_native_tools("gpt-5.4-mini", &deps)
|
||||
}
|
||||
|
||||
fn gpt56_profile(has_web_search: bool) -> Gpt56Profile {
|
||||
let options = native_tool_options(AgentProfileKind::Gpt56, has_web_search);
|
||||
Gpt56Profile::with_native_tools("gpt-5.6-sol", &options)
|
||||
let deps = ProfileDeps::standalone(options);
|
||||
Gpt56Profile::with_native_tools("gpt-5.6-sol", &deps)
|
||||
}
|
||||
|
||||
/// GPT-5.6 through an OpenAI-compatible gateway, where `apply_patch`
|
||||
/// cannot be carried and `edit_file` takes its place.
|
||||
fn gpt56_edit_file_profile(has_web_search: bool) -> Gpt56Profile {
|
||||
let options = native_tool_options(AgentProfileKind::Gpt56, has_web_search);
|
||||
let deps = ProfileDeps::standalone(options);
|
||||
let overrides: LlmCatalogSettings =
|
||||
toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap();
|
||||
Gpt56Profile::with_native_tools("gpt-5.6-sol", &options).with_route(
|
||||
Gpt56Profile::with_native_tools("gpt-5.6-sol", &deps).with_route(
|
||||
ProviderId::new("openrouter"),
|
||||
Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap()),
|
||||
)
|
||||
|
|
@ -440,7 +546,8 @@ mod tests {
|
|||
|
||||
fn openai_edit_file_profile(has_web_search: bool) -> OpenAiProfile {
|
||||
let options = native_tool_options(AgentProfileKind::OpenAi, has_web_search);
|
||||
OpenAiProfile::with_native_tools("kimi-k2.5", &options, None).with_route(
|
||||
let deps = ProfileDeps::standalone(options);
|
||||
OpenAiProfile::with_native_tools("kimi-k2.5", &deps).with_route(
|
||||
ProviderId::new("kimi"),
|
||||
Arc::new(Catalog::from_builtin().unwrap()),
|
||||
)
|
||||
|
|
@ -605,6 +712,11 @@ mod tests {
|
|||
ProviderId::gemini(),
|
||||
"gemini-3-flash-preview",
|
||||
),
|
||||
(
|
||||
AgentProfileKind::Claude5,
|
||||
ProviderId::anthropic(),
|
||||
"claude-sonnet-5",
|
||||
),
|
||||
(AgentProfileKind::Gpt56, ProviderId::openai(), "gpt-5.6-sol"),
|
||||
];
|
||||
|
||||
|
|
@ -616,12 +728,13 @@ mod tests {
|
|||
Arc::clone(&catalog),
|
||||
)
|
||||
.build();
|
||||
let web_search_name = NativeTool::WebSearch.name(profile.tool_registry().vocabulary());
|
||||
assert_eq!(profile.profile_kind(), profile_kind);
|
||||
assert_eq!(profile.provider_id(), provider_id);
|
||||
assert!(profile.tool_registry().get(WEB_SEARCH_TOOL_NAME).is_none());
|
||||
assert!(profile.tool_registry().get(web_search_name).is_none());
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(
|
||||
!prompt.contains("web_search"),
|
||||
!prompt.contains(web_search_name),
|
||||
"{profile_kind:?} prompt advertised an unavailable tool"
|
||||
);
|
||||
|
||||
|
|
@ -637,22 +750,97 @@ mod tests {
|
|||
// Built twice: one configured builder must outfit both a root
|
||||
// session and the child sessions it spawns.
|
||||
for configured in [configured_builder.build(), configured_builder.build()] {
|
||||
assert!(
|
||||
configured
|
||||
.tool_registry()
|
||||
.get(WEB_SEARCH_TOOL_NAME)
|
||||
.is_some()
|
||||
);
|
||||
assert!(configured.tool_registry().get(web_search_name).is_some());
|
||||
let prompt =
|
||||
configured.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(
|
||||
prompt.contains("web_search"),
|
||||
prompt.contains(web_search_name),
|
||||
"{profile_kind:?} prompt omitted guidance for an available tool"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Task tools scope their list by `root_session_id`, so a root session and
|
||||
/// every child it spawns address one logical list. `build()` runs once per
|
||||
/// session, so the runtime behind that list has to come from the builder --
|
||||
/// a per-profile runtime gives each session its own projection and its own
|
||||
/// ID counter, and the two sessions then collide on `#1` in the merged
|
||||
/// projection while neither can see the other's tasks.
|
||||
async fn assert_builder_shares_tasks_across_root_and_child(
|
||||
profile_kind: AgentProfileKind,
|
||||
model: &str,
|
||||
) {
|
||||
let builder = AgentProfileBuilder::new(
|
||||
profile_kind,
|
||||
ProviderId::anthropic(),
|
||||
model,
|
||||
Arc::new(Catalog::from_builtin().unwrap()),
|
||||
);
|
||||
let root = builder.build();
|
||||
let child = builder.build();
|
||||
let executor = |profile: &dyn AgentProfile, name: &str| {
|
||||
Arc::clone(
|
||||
&profile
|
||||
.tool_registry()
|
||||
.get(name)
|
||||
.unwrap_or_else(|| panic!("{profile_kind} should expose {name}"))
|
||||
.executor,
|
||||
)
|
||||
};
|
||||
let root_create = executor(root.as_ref(), "TaskCreate");
|
||||
let child_create = executor(child.as_ref(), "TaskCreate");
|
||||
let child_list = executor(child.as_ref(), "TaskList");
|
||||
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
|
||||
let context = |session_id: &str| ToolContext {
|
||||
env: Arc::clone(&env),
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env_provider: None,
|
||||
session_id: Some(session_id.to_string()),
|
||||
root_session_id: Some("root-session".to_string()),
|
||||
tool_call_id: None,
|
||||
agent_event_emitter: None,
|
||||
};
|
||||
|
||||
root_create(
|
||||
serde_json::json!({"subject": "Parent task", "description": "Root work"}),
|
||||
context("root-session"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
child_create(
|
||||
serde_json::json!({"subject": "Child task", "description": "Child work"}),
|
||||
context("child-session"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let tasks = child_list(serde_json::json!({}), context("child-session"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(tasks.contains("#1 [pending] Parent task"), "{tasks}");
|
||||
assert!(tasks.contains("#2 [pending] Child task"), "{tasks}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claude5_builder_shares_tasks_across_root_and_child_profiles() {
|
||||
assert_builder_shares_tasks_across_root_and_child(
|
||||
AgentProfileKind::Claude5,
|
||||
"claude-sonnet-5",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_builder_shares_tasks_across_root_and_child_profiles() {
|
||||
assert_builder_shares_tasks_across_root_and_child(
|
||||
AgentProfileKind::Anthropic,
|
||||
"claude-haiku-4-5",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_builder_selects_a_codec_compatible_gpt56_editor() {
|
||||
let overrides: LlmCatalogSettings =
|
||||
|
|
@ -690,6 +878,47 @@ mod tests {
|
|||
insta::assert_snapshot!(system_prompt(&anthropic_profile(true, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude5_default_prompt_snapshot() {
|
||||
insta::assert_snapshot!(system_prompt(&claude5_profile(false, false, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude5_all_conditionals_prompt_snapshot() {
|
||||
insta::assert_snapshot!(system_prompt(&claude5_profile(true, true, true)));
|
||||
}
|
||||
|
||||
/// The two snapshots above pin the wording of every conditional section.
|
||||
/// This covers the six intermediate combinations, which only need to show
|
||||
/// that each section appears exactly when its tool is registered -- as
|
||||
/// snapshots they were six near-identical copies of the same prose, and any
|
||||
/// edit to the template invalidated all eight at once.
|
||||
#[test]
|
||||
fn claude5_prompt_sections_track_registered_tools() {
|
||||
for web_search in [false, true] {
|
||||
for subagents in [false, true] {
|
||||
for question in [false, true] {
|
||||
let prompt = system_prompt(&claude5_profile(web_search, subagents, question));
|
||||
assert_eq!(
|
||||
prompt.contains("Use `WebSearch`"),
|
||||
web_search,
|
||||
"web_search={web_search} subagents={subagents} question={question}"
|
||||
);
|
||||
assert_eq!(
|
||||
prompt.contains("# Background agents"),
|
||||
subagents,
|
||||
"web_search={web_search} subagents={subagents} question={question}"
|
||||
);
|
||||
assert_eq!(
|
||||
prompt.contains("# Asking the user"),
|
||||
question,
|
||||
"web_search={web_search} subagents={subagents} question={question}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_default_prompt_snapshot() {
|
||||
insta::assert_snapshot!(system_prompt(&gemini_profile(false)));
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@ use super::EnvContext;
|
|||
use crate::agent_profile::AgentProfile;
|
||||
use crate::apply_patch;
|
||||
use crate::config::NativeToolOptions;
|
||||
use crate::profiles::{self, BaseProfile, EmbeddedPrompt};
|
||||
use crate::profiles::{
|
||||
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors,
|
||||
};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::todo_runtime::TodoRuntime;
|
||||
use crate::todo_tools::make_update_plan_tool;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{self, WebFetchSummarizer, register_core_tools};
|
||||
use crate::tools::{self, register_core_tools};
|
||||
|
||||
const CORE_PROMPT: &str = include_str!("prompts/openai.md.j2");
|
||||
|
||||
|
|
@ -23,18 +25,15 @@ pub struct OpenAiProfile {
|
|||
impl OpenAiProfile {
|
||||
#[must_use]
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
let options = NativeToolOptions::for_profile(AgentProfileKind::OpenAi);
|
||||
Self::with_native_tools(model, &options, None)
|
||||
let deps =
|
||||
ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::OpenAi));
|
||||
Self::with_native_tools(model, &deps)
|
||||
}
|
||||
|
||||
pub(crate) fn with_native_tools(
|
||||
model: impl Into<String>,
|
||||
options: &NativeToolOptions,
|
||||
summarizer: Option<WebFetchSummarizer>,
|
||||
) -> Self {
|
||||
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
|
||||
let mut registry = ToolRegistry::new();
|
||||
|
||||
register_core_tools(&mut registry, options, summarizer);
|
||||
register_core_tools(&mut registry, &deps.options, deps.summarizer.clone());
|
||||
registry.register(apply_patch::make_apply_patch_tool());
|
||||
// Codex-compatible `update_plan` is OpenAI-only.
|
||||
let todo_runtime = Arc::new(TodoRuntime::new());
|
||||
|
|
@ -62,29 +61,7 @@ impl OpenAiProfile {
|
|||
}
|
||||
|
||||
impl AgentProfile for OpenAiProfile {
|
||||
fn profile_kind(&self) -> AgentProfileKind {
|
||||
self.base.profile_kind
|
||||
}
|
||||
|
||||
fn provider_id(&self) -> ProviderId {
|
||||
self.base.provider_id.clone()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.base.model
|
||||
}
|
||||
|
||||
fn catalog(&self) -> Option<&Catalog> {
|
||||
self.base.catalog.as_deref()
|
||||
}
|
||||
|
||||
fn tool_registry(&self) -> &ToolRegistry {
|
||||
&self.base.registry
|
||||
}
|
||||
|
||||
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
|
||||
&mut self.base.registry
|
||||
}
|
||||
impl_base_profile_accessors!();
|
||||
|
||||
fn build_system_prompt(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
You are Claude, a software engineering agent running in Fabro. Use the tools available in this session to complete software engineering work and to answer questions about the codebase.
|
||||
|
||||
When the request is to explain, diagnose, review, or report status, inspect the relevant evidence and return an assessment. Do not modify files or external state unless the request asks for a change. When the request asks you to build or change something, implement the complete change and verify it.
|
||||
|
||||
{{ inputs.env_block }}
|
||||
|
||||
# Harness
|
||||
|
||||
- Text outside tool calls is shown to the user as GitHub-flavored Markdown.
|
||||
- The user may not see your reasoning or raw tool output. Make the final response self-contained.
|
||||
- Independent tool calls can run in parallel in one response. Run dependent operations sequentially.
|
||||
- Follow all project and user instructions included in this prompt.
|
||||
- Reference code with `file_path:line_number` when a precise location helps.
|
||||
|
||||
# Delivering work
|
||||
|
||||
Work on the request actually given. Do not quietly narrow, widen, or transform its scope. Make routine judgment calls yourself. If different interpretations would materially change the result, finish everything independent of that decision and use `AskUserQuestion` when it is available.
|
||||
|
||||
Keep going until the requested outcome is complete. Do not stop at a plan, a list of next steps, or a promise to do work that can be performed with the available tools. If one part is blocked, complete the remaining independent work and report the blocker precisely.
|
||||
|
||||
Use evidence rather than guesses. When an attempt fails, inspect the error and assumptions before making a focused adjustment. Report outcomes faithfully: state which verification ran, what passed or failed, and what was not checked.
|
||||
|
||||
# Working in the codebase
|
||||
|
||||
- Read relevant code before proposing or making changes.
|
||||
- This workspace tracks reads before writes. Before every `Edit` or `Write` to an existing file, call `Read` on its current contents. Re-read after the file changes before making another edit to it.
|
||||
- Prefer `Edit` for targeted changes. `Write` creates a file or deliberately replaces its entire contents.
|
||||
- Make the smallest complete change that satisfies the request. Avoid unrelated refactors, speculative abstractions, and compatibility machinery without a real requirement.
|
||||
- Match the surrounding code's structure, naming, formatting, and comment density. Add comments only for constraints or reasoning the code cannot make evident.
|
||||
- Validate at real boundaries such as external input and APIs; do not add defensive branches for states excluded by established invariants.
|
||||
- Run the most targeted useful verification first, then broaden it when the risk warrants it. Exercise user-facing behavior when the environment supports doing so.
|
||||
|
||||
# Tool use
|
||||
|
||||
Use `Read`, `Edit`, and `Write` instead of shell commands for file inspection and mutation. `Read` handles UTF-8 text and returns numbered lines; it does not render images, PDFs, or notebooks.
|
||||
|
||||
Use `Bash` for searches, git inspection, builds, tests, package managers, and other terminal operations. Prefer `rg` for content search and `rg --files` with `-g` filters for file discovery. Narrow commands so their output remains useful.
|
||||
|
||||
Each `Bash` call is a fresh foreground, non-login shell. Working-directory and environment changes do not persist between calls; keep dependent `cd` or environment setup in the same command. `timeout` is measured in milliseconds.
|
||||
|
||||
Use `TaskCreate`, `TaskUpdate`, `TaskGet`, and `TaskList` when meaningful multi-step work benefits from visible tracking. Keep statuses current as work progresses. Skip task tracking when it adds no value.
|
||||
|
||||
Use `WebFetch` with both a URL and a prompt describing the information to extract.
|
||||
{% if inputs.has_web_search %}
|
||||
Use `WebSearch` when current external information is needed. Its input is a search query; use `WebFetch` to inspect a specific result.
|
||||
{% endif %}
|
||||
|
||||
Other registered tools may be supplied by MCP servers or the surrounding Fabro workflow. Follow their definitions.
|
||||
|
||||
{% if inputs.has_agent %}
|
||||
# Background agents
|
||||
|
||||
Use `Agent` for independent, substantial work or to keep broad exploration out of the parent context. Do not delegate a task and duplicate the same work yourself.
|
||||
|
||||
Agents run in the background by default. Launch independent agents together in one response so they run concurrently. Set `run_in_background` to false when their result is an immediate prerequisite and there is no useful parent work to do meanwhile.
|
||||
|
||||
Background completion and failure notifications arrive automatically. Do not poll `TaskOutput` for ordinary progress. Use it only when you deliberately need to block or inspect status. Use `SendMessage` to add instructions to a running agent and `TaskStop` when a running agent is no longer needed.
|
||||
|
||||
An agent's report is evidence, not automatic proof. Inspect relevant changes or run appropriate verification before reporting delegated implementation as complete. Synthesize the useful result for the user; raw agent reports are not a substitute for the final response.
|
||||
{% endif %}
|
||||
|
||||
{% if inputs.has_ask_user_question %}
|
||||
# Asking the user
|
||||
|
||||
Use `AskUserQuestion` only when blocked on a decision that genuinely belongs to the user and cannot be resolved from the request, code, project instructions, or a sensible default. Do not use it for routine permission to continue.
|
||||
|
||||
When recommending an option, put it first and mark it as recommended. The interface supplies a free-form alternative automatically.
|
||||
{% endif %}
|
||||
|
||||
# Communicating with the user
|
||||
|
||||
Before the first tool call, state what you are about to do in one concise sentence. While working, give brief updates only at meaningful milestones, such as finding the cause, changing direction, or completing a major phase.
|
||||
|
||||
Do not expose internal deliberation. Write for a teammate catching up: use complete sentences, explain only details that affect conclusions or next actions, and avoid private shorthand.
|
||||
|
||||
Lead the final response with the outcome. A simple question deserves a direct answer rather than unnecessary sections. Be concise by omitting low-value detail, not by compressing useful explanation into fragments.
|
||||
|
||||
# Context management
|
||||
|
||||
Long sessions may be summarized and continued in a new context window. Treat the supplied summary as the continuation of the same work. Do not wrap up early merely because the session is long.
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
source: lib/components/fabro-agent/src/profiles/mod.rs
|
||||
expression: "system_prompt(&claude5_profile(true, true, true))"
|
||||
---
|
||||
You are Claude, a software engineering agent running in Fabro. Use the tools available in this session to complete software engineering work and to answer questions about the codebase.
|
||||
|
||||
When the request is to explain, diagnose, review, or report status, inspect the relevant evidence and return an assessment. Do not modify files or external state unless the request asks for a change. When the request asks you to build or change something, implement the complete change and verify it.
|
||||
|
||||
<environment>
|
||||
Working directory: /home/test
|
||||
Is git repository: false
|
||||
Platform: linux
|
||||
OS version: Linux 6.1.0
|
||||
</environment>
|
||||
|
||||
# Harness
|
||||
|
||||
- Text outside tool calls is shown to the user as GitHub-flavored Markdown.
|
||||
- The user may not see your reasoning or raw tool output. Make the final response self-contained.
|
||||
- Independent tool calls can run in parallel in one response. Run dependent operations sequentially.
|
||||
- Follow all project and user instructions included in this prompt.
|
||||
- Reference code with `file_path:line_number` when a precise location helps.
|
||||
|
||||
# Delivering work
|
||||
|
||||
Work on the request actually given. Do not quietly narrow, widen, or transform its scope. Make routine judgment calls yourself. If different interpretations would materially change the result, finish everything independent of that decision and use `AskUserQuestion` when it is available.
|
||||
|
||||
Keep going until the requested outcome is complete. Do not stop at a plan, a list of next steps, or a promise to do work that can be performed with the available tools. If one part is blocked, complete the remaining independent work and report the blocker precisely.
|
||||
|
||||
Use evidence rather than guesses. When an attempt fails, inspect the error and assumptions before making a focused adjustment. Report outcomes faithfully: state which verification ran, what passed or failed, and what was not checked.
|
||||
|
||||
# Working in the codebase
|
||||
|
||||
- Read relevant code before proposing or making changes.
|
||||
- This workspace tracks reads before writes. Before every `Edit` or `Write` to an existing file, call `Read` on its current contents. Re-read after the file changes before making another edit to it.
|
||||
- Prefer `Edit` for targeted changes. `Write` creates a file or deliberately replaces its entire contents.
|
||||
- Make the smallest complete change that satisfies the request. Avoid unrelated refactors, speculative abstractions, and compatibility machinery without a real requirement.
|
||||
- Match the surrounding code's structure, naming, formatting, and comment density. Add comments only for constraints or reasoning the code cannot make evident.
|
||||
- Validate at real boundaries such as external input and APIs; do not add defensive branches for states excluded by established invariants.
|
||||
- Run the most targeted useful verification first, then broaden it when the risk warrants it. Exercise user-facing behavior when the environment supports doing so.
|
||||
|
||||
# Tool use
|
||||
|
||||
Use `Read`, `Edit`, and `Write` instead of shell commands for file inspection and mutation. `Read` handles UTF-8 text and returns numbered lines; it does not render images, PDFs, or notebooks.
|
||||
|
||||
Use `Bash` for searches, git inspection, builds, tests, package managers, and other terminal operations. Prefer `rg` for content search and `rg --files` with `-g` filters for file discovery. Narrow commands so their output remains useful.
|
||||
|
||||
Each `Bash` call is a fresh foreground, non-login shell. Working-directory and environment changes do not persist between calls; keep dependent `cd` or environment setup in the same command. `timeout` is measured in milliseconds.
|
||||
|
||||
Use `TaskCreate`, `TaskUpdate`, `TaskGet`, and `TaskList` when meaningful multi-step work benefits from visible tracking. Keep statuses current as work progresses. Skip task tracking when it adds no value.
|
||||
|
||||
Use `WebFetch` with both a URL and a prompt describing the information to extract.
|
||||
|
||||
Use `WebSearch` when current external information is needed. Its input is a search query; use `WebFetch` to inspect a specific result.
|
||||
|
||||
|
||||
Other registered tools may be supplied by MCP servers or the surrounding Fabro workflow. Follow their definitions.
|
||||
|
||||
|
||||
# Background agents
|
||||
|
||||
Use `Agent` for independent, substantial work or to keep broad exploration out of the parent context. Do not delegate a task and duplicate the same work yourself.
|
||||
|
||||
Agents run in the background by default. Launch independent agents together in one response so they run concurrently. Set `run_in_background` to false when their result is an immediate prerequisite and there is no useful parent work to do meanwhile.
|
||||
|
||||
Background completion and failure notifications arrive automatically. Do not poll `TaskOutput` for ordinary progress. Use it only when you deliberately need to block or inspect status. Use `SendMessage` to add instructions to a running agent and `TaskStop` when a running agent is no longer needed.
|
||||
|
||||
An agent's report is evidence, not automatic proof. Inspect relevant changes or run appropriate verification before reporting delegated implementation as complete. Synthesize the useful result for the user; raw agent reports are not a substitute for the final response.
|
||||
|
||||
|
||||
|
||||
# Asking the user
|
||||
|
||||
Use `AskUserQuestion` only when blocked on a decision that genuinely belongs to the user and cannot be resolved from the request, code, project instructions, or a sensible default. Do not use it for routine permission to continue.
|
||||
|
||||
When recommending an option, put it first and mark it as recommended. The interface supplies a free-form alternative automatically.
|
||||
|
||||
|
||||
# Communicating with the user
|
||||
|
||||
Before the first tool call, state what you are about to do in one concise sentence. While working, give brief updates only at meaningful milestones, such as finding the cause, changing direction, or completing a major phase.
|
||||
|
||||
Do not expose internal deliberation. Write for a teammate catching up: use complete sentences, explain only details that affect conclusions or next actions, and avoid private shorthand.
|
||||
|
||||
Lead the final response with the outcome. A simple question deserves a direct answer rather than unnecessary sections. Be concise by omitting low-value detail, not by compressing useful explanation into fragments.
|
||||
|
||||
# Context management
|
||||
|
||||
Long sessions may be summarized and continued in a new context window. Treat the supplied summary as the continuation of the same work. Do not wrap up early merely because the session is long.
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
source: lib/components/fabro-agent/src/profiles/mod.rs
|
||||
expression: "system_prompt(&claude5_profile(false, false, false))"
|
||||
---
|
||||
You are Claude, a software engineering agent running in Fabro. Use the tools available in this session to complete software engineering work and to answer questions about the codebase.
|
||||
|
||||
When the request is to explain, diagnose, review, or report status, inspect the relevant evidence and return an assessment. Do not modify files or external state unless the request asks for a change. When the request asks you to build or change something, implement the complete change and verify it.
|
||||
|
||||
<environment>
|
||||
Working directory: /home/test
|
||||
Is git repository: false
|
||||
Platform: linux
|
||||
OS version: Linux 6.1.0
|
||||
</environment>
|
||||
|
||||
# Harness
|
||||
|
||||
- Text outside tool calls is shown to the user as GitHub-flavored Markdown.
|
||||
- The user may not see your reasoning or raw tool output. Make the final response self-contained.
|
||||
- Independent tool calls can run in parallel in one response. Run dependent operations sequentially.
|
||||
- Follow all project and user instructions included in this prompt.
|
||||
- Reference code with `file_path:line_number` when a precise location helps.
|
||||
|
||||
# Delivering work
|
||||
|
||||
Work on the request actually given. Do not quietly narrow, widen, or transform its scope. Make routine judgment calls yourself. If different interpretations would materially change the result, finish everything independent of that decision and use `AskUserQuestion` when it is available.
|
||||
|
||||
Keep going until the requested outcome is complete. Do not stop at a plan, a list of next steps, or a promise to do work that can be performed with the available tools. If one part is blocked, complete the remaining independent work and report the blocker precisely.
|
||||
|
||||
Use evidence rather than guesses. When an attempt fails, inspect the error and assumptions before making a focused adjustment. Report outcomes faithfully: state which verification ran, what passed or failed, and what was not checked.
|
||||
|
||||
# Working in the codebase
|
||||
|
||||
- Read relevant code before proposing or making changes.
|
||||
- This workspace tracks reads before writes. Before every `Edit` or `Write` to an existing file, call `Read` on its current contents. Re-read after the file changes before making another edit to it.
|
||||
- Prefer `Edit` for targeted changes. `Write` creates a file or deliberately replaces its entire contents.
|
||||
- Make the smallest complete change that satisfies the request. Avoid unrelated refactors, speculative abstractions, and compatibility machinery without a real requirement.
|
||||
- Match the surrounding code's structure, naming, formatting, and comment density. Add comments only for constraints or reasoning the code cannot make evident.
|
||||
- Validate at real boundaries such as external input and APIs; do not add defensive branches for states excluded by established invariants.
|
||||
- Run the most targeted useful verification first, then broaden it when the risk warrants it. Exercise user-facing behavior when the environment supports doing so.
|
||||
|
||||
# Tool use
|
||||
|
||||
Use `Read`, `Edit`, and `Write` instead of shell commands for file inspection and mutation. `Read` handles UTF-8 text and returns numbered lines; it does not render images, PDFs, or notebooks.
|
||||
|
||||
Use `Bash` for searches, git inspection, builds, tests, package managers, and other terminal operations. Prefer `rg` for content search and `rg --files` with `-g` filters for file discovery. Narrow commands so their output remains useful.
|
||||
|
||||
Each `Bash` call is a fresh foreground, non-login shell. Working-directory and environment changes do not persist between calls; keep dependent `cd` or environment setup in the same command. `timeout` is measured in milliseconds.
|
||||
|
||||
Use `TaskCreate`, `TaskUpdate`, `TaskGet`, and `TaskList` when meaningful multi-step work benefits from visible tracking. Keep statuses current as work progresses. Skip task tracking when it adds no value.
|
||||
|
||||
Use `WebFetch` with both a URL and a prompt describing the information to extract.
|
||||
|
||||
|
||||
Other registered tools may be supplied by MCP servers or the surrounding Fabro workflow. Follow their definitions.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Communicating with the user
|
||||
|
||||
Before the first tool call, state what you are about to do in one concise sentence. While working, give brief updates only at meaningful milestones, such as finding the cause, changing direction, or completing a major phase.
|
||||
|
||||
Do not expose internal deliberation. Write for a teammate catching up: use complete sentences, explain only details that affect conclusions or next actions, and avoid private shorthand.
|
||||
|
||||
Lead the final response with the outcome. A simple question deserves a direct answer rather than unnecessary sections. Be concise by omitting low-value detail, not by compressing useful explanation into fragments.
|
||||
|
||||
# Context management
|
||||
|
||||
Long sessions may be summarized and continued in a new context window. Treat the supplied summary as the continuation of the same work. Do not wrap up early merely because the session is long.
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use std::collections::BTreeMap;
|
||||
use std::future::Future;
|
||||
use std::ops::RangeInclusive;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -149,6 +150,42 @@ struct AnthropicOption {
|
|||
preview: Option<String>,
|
||||
}
|
||||
|
||||
/// Contract rules the JSON Schema cannot express, and which differ between
|
||||
/// the two harnesses sharing one normalizer.
|
||||
struct QuestionLimits {
|
||||
questions: RangeInclusive<usize>,
|
||||
questions_error: &'static str,
|
||||
/// `None` leaves the option count unbounded.
|
||||
options: Option<RangeInclusive<usize>>,
|
||||
options_error: &'static str,
|
||||
max_header_chars: Option<usize>,
|
||||
/// Claude 5's schema marks `header` and every option `description`
|
||||
/// required, so both are validated rather than passed through as given.
|
||||
require_header_and_descriptions: bool,
|
||||
/// Claude 5 renders multi-select without a preview pane.
|
||||
allow_preview_with_multi_select: bool,
|
||||
}
|
||||
|
||||
const ANTHROPIC_QUESTION_LIMITS: QuestionLimits = QuestionLimits {
|
||||
questions: 1..=usize::MAX,
|
||||
questions_error: "questions must contain at least one question",
|
||||
options: None,
|
||||
options_error: "",
|
||||
max_header_chars: None,
|
||||
require_header_and_descriptions: false,
|
||||
allow_preview_with_multi_select: true,
|
||||
};
|
||||
|
||||
const CLAUDE5_QUESTION_LIMITS: QuestionLimits = QuestionLimits {
|
||||
questions: 1..=4,
|
||||
questions_error: "questions must contain between one and four questions",
|
||||
options: Some(2..=4),
|
||||
options_error: "each question must contain between two and four options",
|
||||
max_header_chars: Some(12),
|
||||
require_header_and_descriptions: true,
|
||||
allow_preview_with_multi_select: false,
|
||||
};
|
||||
|
||||
#[must_use]
|
||||
pub fn is_question_tool(name: &str) -> bool {
|
||||
matches!(
|
||||
|
|
@ -168,6 +205,9 @@ pub fn register_question_tools(profile_kind: AgentProfileKind, registry: &mut To
|
|||
AgentProfileKind::Anthropic | AgentProfileKind::Kimi => {
|
||||
registry.register(make_anthropic_question_tool());
|
||||
}
|
||||
AgentProfileKind::Claude5 => {
|
||||
registry.register(make_claude5_question_tool());
|
||||
}
|
||||
AgentProfileKind::Gemini => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -260,7 +300,85 @@ fn make_anthropic_question_tool() -> RegisteredTool {
|
|||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?;
|
||||
let questions = normalize_anthropic_questions(parsed)?;
|
||||
let questions =
|
||||
normalize_anthropic_questions(parsed, &ANTHROPIC_QUESTION_LIMITS)?;
|
||||
let answers = execute_question_tool(ctx, questions).await?;
|
||||
format_anthropic_answers(&answers)
|
||||
})
|
||||
}),
|
||||
source: ToolSource::Native,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_claude5_question_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(),
|
||||
description: "Ask the human up to four questions when a decision is genuinely theirs to make. The UI automatically provides an Other option for custom text.".to_string(),
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questions": {
|
||||
"description": "Questions to ask the user (1-4 questions)",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"description": "The complete, clear, and specific question to ask.",
|
||||
"type": "string"
|
||||
},
|
||||
"header": {
|
||||
"description": "Very short label displayed as a chip/tag (max 12 chars).",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"description": "Two to four choices. Do not include Other; the UI adds it automatically.",
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 4,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"description": "Concise display text for the option.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "What the option means and its relevant trade-offs.",
|
||||
"type": "string"
|
||||
},
|
||||
"preview": {
|
||||
"description": "Optional Markdown preview for single-select visual comparisons.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["label", "description"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"multiSelect": {
|
||||
"description": "Whether the user may select multiple options.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["question", "header", "options", "multiSelect"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["questions"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?;
|
||||
let questions =
|
||||
normalize_anthropic_questions(parsed, &CLAUDE5_QUESTION_LIMITS)?;
|
||||
let answers = execute_question_tool(ctx, questions).await?;
|
||||
format_anthropic_answers(&answers)
|
||||
})
|
||||
|
|
@ -325,25 +443,71 @@ fn normalize_openai_questions(args: OpenAiQuestionToolArgs) -> Result<Vec<AgentQ
|
|||
|
||||
fn normalize_anthropic_questions(
|
||||
args: AnthropicQuestionToolArgs,
|
||||
limits: &QuestionLimits,
|
||||
) -> Result<Vec<AgentQuestion>, String> {
|
||||
if args.questions.is_empty() {
|
||||
return Err("questions must contain at least one question".to_string());
|
||||
if !limits.questions.contains(&args.questions.len()) {
|
||||
return Err(limits.questions_error.to_string());
|
||||
}
|
||||
|
||||
args.questions
|
||||
.into_iter()
|
||||
.map(|question| {
|
||||
let original_question = non_empty(&question.question, "question")?;
|
||||
let header = if limits.require_header_and_descriptions {
|
||||
let header = non_empty(
|
||||
question.header.as_deref().unwrap_or_default(),
|
||||
"question header",
|
||||
)?;
|
||||
if limits
|
||||
.max_header_chars
|
||||
.is_some_and(|max| header.chars().count() > max)
|
||||
{
|
||||
return Err(format!(
|
||||
"question header must contain at most {} characters",
|
||||
limits.max_header_chars.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
Some(header)
|
||||
} else {
|
||||
question.header
|
||||
};
|
||||
|
||||
if let Some(bounds) = &limits.options {
|
||||
if !bounds.contains(&question.options.len()) {
|
||||
return Err(limits.options_error.to_string());
|
||||
}
|
||||
}
|
||||
if !limits.allow_preview_with_multi_select
|
||||
&& question.multi_select
|
||||
&& question
|
||||
.options
|
||||
.iter()
|
||||
.any(|option| option.preview.is_some())
|
||||
{
|
||||
return Err(
|
||||
"option previews are not supported for multi-select questions".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// The lenient contract renders the question and header exactly as
|
||||
// supplied; the strict one has already trimmed them.
|
||||
let text = if limits.require_header_and_descriptions {
|
||||
display_text(header.as_deref(), &original_question)
|
||||
} else {
|
||||
display_text(header.as_deref(), &question.question)
|
||||
};
|
||||
|
||||
Ok(AgentQuestion {
|
||||
original_id: None,
|
||||
text: display_text(question.header.as_deref(), &question.question),
|
||||
header: question.header,
|
||||
text,
|
||||
header,
|
||||
original_question,
|
||||
question_type: if question.multi_select {
|
||||
QuestionType::MultiSelect
|
||||
} else {
|
||||
QuestionType::MultipleChoice
|
||||
},
|
||||
options: options_from_anthropic(question.options),
|
||||
options: options_from_anthropic(question.options, limits)?,
|
||||
allow_freeform: true,
|
||||
})
|
||||
})
|
||||
|
|
@ -365,19 +529,34 @@ fn options_from_openai(options: Vec<OpenAiOption>) -> Vec<InterviewOption> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn options_from_anthropic(options: Vec<AnthropicOption>) -> Vec<InterviewOption> {
|
||||
fn options_from_anthropic(
|
||||
options: Vec<AnthropicOption>,
|
||||
limits: &QuestionLimits,
|
||||
) -> Result<Vec<InterviewOption>, String> {
|
||||
options
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, option)| InterviewOption {
|
||||
key: option_key(idx),
|
||||
label: option.label,
|
||||
description: option
|
||||
.description
|
||||
.map(|value| bounded_display_field(&value, OPTION_DESCRIPTION_MAX_CHARS)),
|
||||
preview: option
|
||||
.preview
|
||||
.map(|value| bounded_display_field(&value, OPTION_PREVIEW_MAX_CHARS)),
|
||||
.map(|(idx, option)| {
|
||||
let (label, description) = if limits.require_header_and_descriptions {
|
||||
(
|
||||
non_empty(&option.label, "option label")?,
|
||||
Some(non_empty(
|
||||
option.description.as_deref().unwrap_or_default(),
|
||||
"option description",
|
||||
)?),
|
||||
)
|
||||
} else {
|
||||
(option.label, option.description)
|
||||
};
|
||||
Ok(InterviewOption {
|
||||
key: option_key(idx),
|
||||
label,
|
||||
description: description
|
||||
.map(|value| bounded_display_field(&value, OPTION_DESCRIPTION_MAX_CHARS)),
|
||||
preview: option
|
||||
.preview
|
||||
.map(|value| bounded_display_field(&value, OPTION_PREVIEW_MAX_CHARS)),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -472,6 +651,7 @@ fn format_anthropic_answers(answers: &[AgentQuestionAnswer]) -> Result<String, S
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::native_tool::ToolVocabulary;
|
||||
use crate::test_support::MockSandbox;
|
||||
|
||||
fn answered(
|
||||
original_id: Option<&str>,
|
||||
|
|
@ -529,7 +709,7 @@ mod tests {
|
|||
}))
|
||||
.unwrap();
|
||||
|
||||
let questions = normalize_anthropic_questions(args).unwrap();
|
||||
let questions = normalize_anthropic_questions(args, &ANTHROPIC_QUESTION_LIMITS).unwrap();
|
||||
|
||||
assert_eq!(questions[0].question_type, QuestionType::MultiSelect);
|
||||
assert_eq!(
|
||||
|
|
@ -601,8 +781,198 @@ mod tests {
|
|||
assert!(kimi.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_some());
|
||||
assert!(kimi.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none());
|
||||
|
||||
let mut claude5 = ToolRegistry::with_vocabulary(ToolVocabulary::Claude5);
|
||||
register_question_tools(AgentProfileKind::Claude5, &mut claude5);
|
||||
let tool = claude5.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).unwrap();
|
||||
assert_eq!(tool.definition.parameters["additionalProperties"], false);
|
||||
assert_eq!(
|
||||
tool.definition.parameters["properties"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["questions"]
|
||||
);
|
||||
assert_eq!(
|
||||
tool.definition.parameters["properties"]["questions"]["maxItems"],
|
||||
4
|
||||
);
|
||||
assert!(claude5.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none());
|
||||
|
||||
let mut gemini = ToolRegistry::new();
|
||||
register_question_tools(AgentProfileKind::Gemini, &mut gemini);
|
||||
assert!(gemini.names().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude5_question_contract_is_strict_and_preserves_preview() {
|
||||
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
|
||||
"questions": [{
|
||||
"header": "Approach",
|
||||
"question": "Which approach should we use?",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "Simple",
|
||||
"description": "Use the smallest implementation.",
|
||||
"preview": "fn simple() {}"
|
||||
},
|
||||
{
|
||||
"label": "Flexible",
|
||||
"description": "Allow future extension."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let questions = normalize_anthropic_questions(args, &CLAUDE5_QUESTION_LIMITS).unwrap();
|
||||
|
||||
assert_eq!(questions[0].header.as_deref(), Some("Approach"));
|
||||
assert_eq!(
|
||||
questions[0].options[0].preview.as_deref(),
|
||||
Some("fn simple() {}")
|
||||
);
|
||||
assert!(questions[0].allow_freeform);
|
||||
}
|
||||
|
||||
/// The Claude 5 payload is deserialized through the lenient struct now, so
|
||||
/// the rules its own struct used to enforce are the normalizer's job.
|
||||
#[test]
|
||||
fn claude5_limits_reject_what_the_lenient_contract_allows() {
|
||||
let question = |patch: serde_json::Value| {
|
||||
let mut base = json!({
|
||||
"question": "Which approach?",
|
||||
"header": "Approach",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "First", "description": "One"},
|
||||
{"label": "Second", "description": "Two"}
|
||||
]
|
||||
});
|
||||
let object = base.as_object_mut().unwrap();
|
||||
for (key, value) in patch.as_object().unwrap() {
|
||||
if value.is_null() {
|
||||
object.remove(key);
|
||||
} else {
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
base
|
||||
};
|
||||
let normalize = |questions: serde_json::Value| {
|
||||
let args: AnthropicQuestionToolArgs =
|
||||
serde_json::from_value(json!({"questions": questions})).unwrap();
|
||||
normalize_anthropic_questions(args, &CLAUDE5_QUESTION_LIMITS)
|
||||
};
|
||||
|
||||
// A missing header and a missing option description used to be caught
|
||||
// by serde; the normalizer has to reject them now.
|
||||
assert!(normalize(json!([question(json!({"header": null}))])).is_err());
|
||||
assert!(
|
||||
normalize(json!([question(json!({
|
||||
"options": [{"label": "First"}, {"label": "Second"}]
|
||||
}))]))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
normalize(json!([question(json!({"header": "ThirteenChars"}))])).is_err(),
|
||||
"header longer than 12 characters"
|
||||
);
|
||||
assert!(
|
||||
normalize(json!([question(json!({
|
||||
"options": [{"label": "Only", "description": "One"}]
|
||||
}))]))
|
||||
.is_err(),
|
||||
"fewer than two options"
|
||||
);
|
||||
assert!(
|
||||
normalize(json!(vec![question(json!({})); 5])).is_err(),
|
||||
"more than four questions"
|
||||
);
|
||||
|
||||
assert!(normalize(json!([question(json!({}))])).is_ok());
|
||||
}
|
||||
|
||||
/// The same payloads stay acceptable under the lenient contract, so the
|
||||
/// shared normalizer has not tightened the Anthropic tool.
|
||||
#[test]
|
||||
fn anthropic_limits_still_accept_optional_headers_and_descriptions() {
|
||||
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
|
||||
"questions": [{
|
||||
"question": "Which approach?",
|
||||
"options": [{"label": "First"}]
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let questions = normalize_anthropic_questions(args, &ANTHROPIC_QUESTION_LIMITS).unwrap();
|
||||
assert_eq!(questions.len(), 1);
|
||||
assert_eq!(questions[0].header, None);
|
||||
assert_eq!(questions[0].options[0].description, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude5_rejects_previews_for_multi_select_questions() {
|
||||
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
|
||||
"questions": [{
|
||||
"header": "Features",
|
||||
"question": "Which features should we enable?",
|
||||
"multiSelect": true,
|
||||
"options": [
|
||||
{
|
||||
"label": "Auth",
|
||||
"description": "Enable authentication.",
|
||||
"preview": "auth = true"
|
||||
},
|
||||
{
|
||||
"label": "Metrics",
|
||||
"description": "Enable metrics."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(normalize_anthropic_questions(args, &CLAUDE5_QUESTION_LIMITS).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claude5_question_tool_rejects_subagent_sessions() {
|
||||
let tool = make_claude5_question_tool();
|
||||
let error = (tool.executor)(
|
||||
json!({
|
||||
"questions": [{
|
||||
"header": "Approach",
|
||||
"question": "Which approach?",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "Simple",
|
||||
"description": "Use the simple approach."
|
||||
},
|
||||
{
|
||||
"label": "Flexible",
|
||||
"description": "Use the flexible approach."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}),
|
||||
ToolContext {
|
||||
env: Arc::new(MockSandbox::default()),
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env_provider: None,
|
||||
session_id: Some("child".to_string()),
|
||||
root_session_id: Some("root".to_string()),
|
||||
tool_call_id: Some("call".to_string()),
|
||||
agent_event_emitter: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("only available to the root agent"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -368,6 +368,18 @@ struct BuiltRequest {
|
|||
context_window: StageContextWindowProjection,
|
||||
}
|
||||
|
||||
/// Whether an input's `/name` tokens should be treated as skill references.
|
||||
///
|
||||
/// Only text the user actually typed can invoke a skill. Harness-synthesized
|
||||
/// input carries whatever a child agent wrote, where `/tmp` is a path rather
|
||||
/// than an invocation: expanding it would either fail the parent turn on an
|
||||
/// unknown name or splice a skill template in place of the envelope.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum SkillExpansion {
|
||||
Apply,
|
||||
Skip,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
id: String,
|
||||
/// Root agent session ID for this session's agent tree. A root session
|
||||
|
|
@ -1318,10 +1330,14 @@ impl Session {
|
|||
})
|
||||
});
|
||||
|
||||
// Process the initial input, then drain any followups
|
||||
// Process the initial input, then drain followups. Claude-compatible
|
||||
// background-agent results join this same boundary queue: they never
|
||||
// interrupt inference or a tool call, and all results already ready at
|
||||
// a boundary are delivered in one additional parent turn.
|
||||
let mut result = self
|
||||
.run_single_input(
|
||||
input,
|
||||
SkillExpansion::Apply,
|
||||
&agent_tool_runtime,
|
||||
&mut timing,
|
||||
&mut usage,
|
||||
|
|
@ -1336,10 +1352,34 @@ impl Session {
|
|||
.lock()
|
||||
.expect("followup queue lock poisoned")
|
||||
.pop_front();
|
||||
let Some(followup) = followup else { break };
|
||||
let next_input = if let Some(followup) = followup {
|
||||
Some((followup, SkillExpansion::Apply))
|
||||
} else if let Some(supervisor) = self.subagent_supervisor.clone() {
|
||||
match supervisor
|
||||
.next_parent_notification_turn(&self.cancel_token)
|
||||
.await
|
||||
{
|
||||
Ok(Some(turn)) => Some((turn, SkillExpansion::Skip)),
|
||||
Ok(None) => None,
|
||||
Err(Error::Interrupted(InterruptReason::Cancelled)) => {
|
||||
result = Err(self.interrupted_error());
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
result = Err(error);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some((next_input, skill_expansion)) = next_input else {
|
||||
break;
|
||||
};
|
||||
result = self
|
||||
.run_single_input(
|
||||
&followup,
|
||||
&next_input,
|
||||
skill_expansion,
|
||||
&agent_tool_runtime,
|
||||
&mut timing,
|
||||
&mut usage,
|
||||
|
|
@ -1377,6 +1417,7 @@ impl Session {
|
|||
async fn run_single_input(
|
||||
&mut self,
|
||||
input: &str,
|
||||
skill_expansion: SkillExpansion,
|
||||
agent_tool_runtime: &AgentToolRuntime,
|
||||
timing: &mut SessionInputTiming,
|
||||
usage_accumulator: &mut TokenCounts,
|
||||
|
|
@ -1391,7 +1432,7 @@ impl Session {
|
|||
self.transition(SessionState::Thinking);
|
||||
|
||||
// Expand skill references in input
|
||||
let expanded = if self.skills.is_empty() {
|
||||
let expanded = if self.skills.is_empty() || skill_expansion == SkillExpansion::Skip {
|
||||
ExpandedInput {
|
||||
text: input.to_string(),
|
||||
skill_name: None,
|
||||
|
|
@ -3040,6 +3081,121 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_notifications_are_batched_into_one_parent_turn() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let first = make_session(vec![text_response("first result")]).await;
|
||||
let second = make_session(vec![text_response("second result")]).await;
|
||||
let first_id = supervisor
|
||||
.spawn_with_parent_notification(
|
||||
first,
|
||||
"first task".to_string(),
|
||||
"Inspect first".to_string(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let second_id = supervisor
|
||||
.spawn_with_parent_notification(
|
||||
second,
|
||||
"second task".to_string(),
|
||||
"Inspect second".to_string(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Make both results ready before the parent reaches its safe turn
|
||||
// boundary so batching is deterministic.
|
||||
supervisor
|
||||
.wait_with_cancel(&first_id, &CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
supervisor
|
||||
.wait_with_cancel(&second_id, &CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider = Arc::new(ScriptedStreamProvider::new(vec![
|
||||
ScriptedStreamCall::Response(Box::new(text_response("Parent is waiting"))),
|
||||
ScriptedStreamCall::Response(Box::new(text_response("Synthesized both results"))),
|
||||
]));
|
||||
let mut parent =
|
||||
make_session_with_provider_and_manager(provider, Some(supervisor.clone())).await;
|
||||
|
||||
let output = parent
|
||||
.process_input_with_output("Delegate both tasks")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.as_deref(), Some("Synthesized both results"));
|
||||
let turns = parent.history().turns();
|
||||
assert_eq!(turns.len(), 4);
|
||||
let Message::User {
|
||||
content: notification,
|
||||
..
|
||||
} = &turns[2]
|
||||
else {
|
||||
panic!("third turn should deliver the background results");
|
||||
};
|
||||
assert_eq!(notification.matches("<task-notification>").count(), 2);
|
||||
assert!(notification.contains(&first_id));
|
||||
assert!(notification.contains(&second_id));
|
||||
assert!(notification.contains("first result"));
|
||||
assert!(notification.contains("second result"));
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_output_is_not_parsed_for_skill_references() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let child = make_session(vec![text_response("Cleaned up /tmp and exited")]).await;
|
||||
let child_id = supervisor
|
||||
.spawn_with_parent_notification(
|
||||
child,
|
||||
"clean up".to_string(),
|
||||
"Clean scratch files".to_string(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
supervisor
|
||||
.wait_with_cancel(&child_id, &CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider = Arc::new(ScriptedStreamProvider::new(vec![
|
||||
ScriptedStreamCall::Response(Box::new(text_response("Delegated"))),
|
||||
ScriptedStreamCall::Response(Box::new(text_response("Acknowledged"))),
|
||||
]));
|
||||
let mut parent =
|
||||
make_session_with_provider_and_manager(provider, Some(supervisor.clone())).await;
|
||||
parent.skills = vec![Skill {
|
||||
name: "commit".to_string(),
|
||||
description: "Make a commit".to_string(),
|
||||
template: "Review changes and commit.".to_string(),
|
||||
}];
|
||||
|
||||
// A child that mentions a bare path must not fail the parent turn on
|
||||
// `Unknown skill: /tmp`, nor have its report replaced by a skill body.
|
||||
let output = parent
|
||||
.process_input_with_output("Delegate the cleanup")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.as_deref(), Some("Acknowledged"));
|
||||
let turns = parent.history().turns();
|
||||
let Message::User {
|
||||
content: notification,
|
||||
..
|
||||
} = &turns[2]
|
||||
else {
|
||||
panic!("third turn should deliver the background result");
|
||||
};
|
||||
assert!(notification.contains("Cleaned up /tmp and exited"));
|
||||
assert!(!notification.contains("Review changes and commit."));
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn events_emitted() {
|
||||
let mut session = make_session(vec![text_response("Hello")]).await;
|
||||
|
|
|
|||
|
|
@ -189,6 +189,24 @@ pub fn make_use_skill_tool_for_vocabulary(
|
|||
"required": ["skill_name"]
|
||||
}),
|
||||
),
|
||||
ToolVocabulary::Claude5 => (
|
||||
"skill",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"description": "Exact name of the skill to invoke"
|
||||
},
|
||||
"args": {
|
||||
"type": "string",
|
||||
"description": "Optional argument string to pass to the skill"
|
||||
}
|
||||
},
|
||||
"required": ["skill"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
),
|
||||
ToolVocabulary::KimiCode => (
|
||||
"skill",
|
||||
serde_json::json!({
|
||||
|
|
@ -730,4 +748,46 @@ name: trimmed
|
|||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claude5_skill_schema_uses_skill_and_optional_args() {
|
||||
let skills = Arc::new(test_skills());
|
||||
let tool = make_use_skill_tool_for_vocabulary(skills, ToolVocabulary::Claude5);
|
||||
let result = (tool.executor)(
|
||||
serde_json::json!({"skill": "commit", "args": "only staged files"}),
|
||||
ToolContext {
|
||||
env: Arc::new(MockSandbox::default()),
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env_provider: None,
|
||||
session_id: None,
|
||||
root_session_id: None,
|
||||
tool_call_id: None,
|
||||
agent_event_emitter: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.contains("only staged files"), "{result}");
|
||||
assert_eq!(
|
||||
tool.definition.parameters["required"],
|
||||
serde_json::json!(["skill"])
|
||||
);
|
||||
assert_eq!(tool.definition.parameters["additionalProperties"], false);
|
||||
assert!(
|
||||
tool.definition.parameters["properties"]
|
||||
.get("skill")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
tool.definition.parameters["properties"]
|
||||
.get("args")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
tool.definition.parameters["properties"]
|
||||
.get("skill_name")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex, RwLock};
|
|||
use std::time::Duration;
|
||||
|
||||
use fabro_llm::types::ToolDefinition;
|
||||
use fabro_util::error as util_error;
|
||||
use futures::future;
|
||||
use tokio::sync::{oneshot, watch};
|
||||
use tokio::task::{AbortHandle, JoinHandle};
|
||||
|
|
@ -32,6 +33,51 @@ pub struct SubAgentResult {
|
|||
pub turns_used: usize,
|
||||
}
|
||||
|
||||
/// A terminal background-agent result waiting to be delivered to its parent at
|
||||
/// a safe turn boundary.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SubAgentParentNotification {
|
||||
pub agent_id: String,
|
||||
pub description: String,
|
||||
pub result: Result<SubAgentResult, Error>,
|
||||
}
|
||||
|
||||
fn format_parent_notification_batch(notifications: &[SubAgentParentNotification]) -> String {
|
||||
notifications
|
||||
.iter()
|
||||
.map(|notification| {
|
||||
let (status, result) = match ¬ification.result {
|
||||
Ok(result) if result.success => ("completed", result.output.clone()),
|
||||
Ok(result) => ("failed", result.output.clone()),
|
||||
Err(error) => ("failed", util_error::collect_chain(error).join(": ")),
|
||||
};
|
||||
format!(
|
||||
"<task-notification>\n <task-id>{}</task-id>\n <status>{status}</status>\n \
|
||||
<description>{}</description>\n <result>{}</result>\n</task-notification>",
|
||||
escape_notification_xml(¬ification.agent_id),
|
||||
escape_notification_xml(¬ification.description),
|
||||
escape_notification_xml(&result),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
fn escape_notification_xml(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len());
|
||||
for character in value.chars() {
|
||||
match character {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(character),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubAgentStatus {
|
||||
Running,
|
||||
|
|
@ -43,16 +89,27 @@ pub enum SubAgentStatus {
|
|||
const SUBAGENT_SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
|
||||
|
||||
struct SubAgent {
|
||||
status: watch::Sender<SubAgentStatus>,
|
||||
cleanup_done: watch::Sender<bool>,
|
||||
cleanup_started: bool,
|
||||
monitor_task: Option<JoinHandle<()>>,
|
||||
event_forwarder: Option<JoinHandle<()>>,
|
||||
cleanup_task: Option<JoinHandle<()>>,
|
||||
child_abort_handle: AbortHandle,
|
||||
followup_queue: Arc<Mutex<VecDeque<String>>>,
|
||||
cancel_token: CancellationToken,
|
||||
depth: usize,
|
||||
status: watch::Sender<SubAgentStatus>,
|
||||
cleanup_done: watch::Sender<bool>,
|
||||
cleanup_started: bool,
|
||||
monitor_task: Option<JoinHandle<()>>,
|
||||
event_forwarder: Option<JoinHandle<()>>,
|
||||
cleanup_task: Option<JoinHandle<()>>,
|
||||
child_abort_handle: AbortHandle,
|
||||
followup_queue: Arc<Mutex<VecDeque<String>>>,
|
||||
cancel_token: CancellationToken,
|
||||
depth: usize,
|
||||
/// Task description, set when the parent should receive this child's
|
||||
/// terminal result automatically. Cleared once the result is delivered,
|
||||
/// the parent retrieves it explicitly, or the agent is shut down.
|
||||
///
|
||||
/// Keeping this beside the status it is delivered with means a
|
||||
/// notification cannot be registered before -- or suppressed after -- the
|
||||
/// state it describes: there is only one lock and one ordering.
|
||||
parent_notification: Option<String>,
|
||||
/// Spawn order, so a batch is delivered oldest-first rather than in
|
||||
/// whatever order the map happens to iterate.
|
||||
spawn_seq: u64,
|
||||
}
|
||||
|
||||
impl Drop for SubAgent {
|
||||
|
|
@ -73,7 +130,8 @@ impl Drop for SubAgent {
|
|||
|
||||
#[derive(Default)]
|
||||
struct SupervisorState {
|
||||
agents: HashMap<String, SubAgent>,
|
||||
agents: HashMap<String, SubAgent>,
|
||||
next_spawn_seq: u64,
|
||||
}
|
||||
|
||||
struct ShutdownWork {
|
||||
|
|
@ -115,10 +173,20 @@ impl Drop for CleanupDoneGuard {
|
|||
}
|
||||
}
|
||||
|
||||
/// Wake anything parked in
|
||||
/// [`SubAgentSupervisor::next_parent_notification_batch`] so it can re-evaluate
|
||||
/// which children are deliverable.
|
||||
fn signal_notifications(changed: &watch::Sender<u64>) {
|
||||
changed.send_modify(|generation| {
|
||||
*generation = generation.wrapping_add(1);
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_result_monitor(
|
||||
child_task: JoinHandle<Result<SubAgentResult, Error>>,
|
||||
status: watch::Sender<SubAgentStatus>,
|
||||
event_callback: Arc<RwLock<Option<SubAgentEventCallback>>>,
|
||||
notifications_changed: Arc<watch::Sender<u64>>,
|
||||
agent_id: String,
|
||||
depth: usize,
|
||||
) -> JoinHandle<()> {
|
||||
|
|
@ -140,18 +208,20 @@ fn spawn_result_monitor(
|
|||
if !committed {
|
||||
return;
|
||||
}
|
||||
// The status this agent will be delivered with is now committed.
|
||||
signal_notifications(¬ifications_changed);
|
||||
|
||||
let event = match task_result {
|
||||
let event = match &task_result {
|
||||
Ok(result) => AgentEvent::SubAgentCompleted {
|
||||
agent_id,
|
||||
agent_id: agent_id.clone(),
|
||||
depth,
|
||||
success: result.success,
|
||||
turns_used: result.turns_used,
|
||||
},
|
||||
Err(error) => AgentEvent::SubAgentFailed {
|
||||
agent_id,
|
||||
agent_id: agent_id.clone(),
|
||||
depth,
|
||||
error,
|
||||
error: error.clone(),
|
||||
},
|
||||
};
|
||||
let callback = event_callback
|
||||
|
|
@ -171,9 +241,10 @@ fn spawn_result_monitor(
|
|||
/// happen after the guard has been released.
|
||||
#[derive(Clone)]
|
||||
pub struct SubAgentSupervisor {
|
||||
state: Arc<Mutex<SupervisorState>>,
|
||||
max_depth: usize,
|
||||
event_callback: Arc<RwLock<Option<SubAgentEventCallback>>>,
|
||||
state: Arc<Mutex<SupervisorState>>,
|
||||
max_depth: usize,
|
||||
event_callback: Arc<RwLock<Option<SubAgentEventCallback>>>,
|
||||
notifications_changed: Arc<watch::Sender<u64>>,
|
||||
}
|
||||
|
||||
impl SubAgentSupervisor {
|
||||
|
|
@ -183,6 +254,7 @@ impl SubAgentSupervisor {
|
|||
state: Arc::new(Mutex::new(SupervisorState::default())),
|
||||
max_depth,
|
||||
event_callback: Arc::new(RwLock::new(None)),
|
||||
notifications_changed: Arc::new(watch::channel(0).0),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,10 +277,32 @@ impl SubAgentSupervisor {
|
|||
}
|
||||
|
||||
pub fn spawn(
|
||||
&self,
|
||||
session: Session,
|
||||
task_prompt: String,
|
||||
depth: usize,
|
||||
) -> Result<String, Error> {
|
||||
self.spawn_inner(session, task_prompt, depth, None)
|
||||
}
|
||||
|
||||
/// Spawn a child whose terminal result should automatically be delivered
|
||||
/// to the parent session.
|
||||
pub(crate) fn spawn_with_parent_notification(
|
||||
&self,
|
||||
session: Session,
|
||||
task_prompt: String,
|
||||
description: String,
|
||||
depth: usize,
|
||||
) -> Result<String, Error> {
|
||||
self.spawn_inner(session, task_prompt, depth, Some(description))
|
||||
}
|
||||
|
||||
fn spawn_inner(
|
||||
&self,
|
||||
mut session: Session,
|
||||
task_prompt: String,
|
||||
depth: usize,
|
||||
parent_notification_description: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
if depth >= self.max_depth {
|
||||
return Err(Error::InvalidState(format!(
|
||||
|
|
@ -295,12 +389,15 @@ impl SubAgentSupervisor {
|
|||
child_task,
|
||||
status.clone(),
|
||||
Arc::clone(&self.event_callback),
|
||||
Arc::clone(&self.notifications_changed),
|
||||
agent_id.clone(),
|
||||
child_depth,
|
||||
);
|
||||
|
||||
{
|
||||
let mut state = self.state.lock().expect("subagent state lock poisoned");
|
||||
let spawn_seq = state.next_spawn_seq;
|
||||
state.next_spawn_seq = state.next_spawn_seq.saturating_add(1);
|
||||
state.agents.insert(agent_id.clone(), SubAgent {
|
||||
status,
|
||||
cleanup_done,
|
||||
|
|
@ -312,8 +409,11 @@ impl SubAgentSupervisor {
|
|||
followup_queue,
|
||||
cancel_token,
|
||||
depth: child_depth,
|
||||
parent_notification: parent_notification_description,
|
||||
spawn_seq,
|
||||
});
|
||||
}
|
||||
signal_notifications(&self.notifications_changed);
|
||||
|
||||
self.emit_event(AgentEvent::SubAgentSpawned {
|
||||
agent_id: agent_id.clone(),
|
||||
|
|
@ -397,6 +497,108 @@ impl SubAgentSupervisor {
|
|||
}
|
||||
}
|
||||
|
||||
/// Stop automatic delivery for an agent whose result the parent retrieved
|
||||
/// explicitly.
|
||||
pub(crate) fn suppress_parent_notification(&self, agent_id: &str) {
|
||||
let cleared = {
|
||||
let mut state = self.state.lock().expect("subagent state lock poisoned");
|
||||
state
|
||||
.agents
|
||||
.get_mut(agent_id)
|
||||
.and_then(|agent| agent.parent_notification.take())
|
||||
.is_some()
|
||||
};
|
||||
if cleared {
|
||||
signal_notifications(&self.notifications_changed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until all currently-ready background results can be delivered in
|
||||
/// one parent turn, rendered as the text of that turn. Returns `None` once
|
||||
/// no notifiable agents remain.
|
||||
///
|
||||
/// The envelope format is the supervisor's concern, so callers receive a
|
||||
/// finished turn rather than the notifications behind it.
|
||||
pub(crate) async fn next_parent_notification_turn(
|
||||
&self,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, Error> {
|
||||
Ok(self
|
||||
.next_parent_notification_batch(cancel)
|
||||
.await?
|
||||
.map(|notifications| format_parent_notification_batch(¬ifications)))
|
||||
}
|
||||
|
||||
/// The notifications behind [`Self::next_parent_notification_turn`], for
|
||||
/// tests that assert on delivery semantics rather than on the rendering.
|
||||
pub(crate) async fn next_parent_notification_batch(
|
||||
&self,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<Vec<SubAgentParentNotification>>, Error> {
|
||||
let mut changed = self.notifications_changed.subscribe();
|
||||
loop {
|
||||
{
|
||||
let mut state = self.state.lock().expect("subagent state lock poisoned");
|
||||
let mut ready = Vec::new();
|
||||
let mut awaiting_result = false;
|
||||
for (agent_id, agent) in &state.agents {
|
||||
let Some(description) = agent.parent_notification.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let finished = match &*agent.status.borrow() {
|
||||
SubAgentStatus::Finished(result) => Some(result.clone()),
|
||||
SubAgentStatus::Running => {
|
||||
awaiting_result = true;
|
||||
None
|
||||
}
|
||||
// Being torn down, so no result is coming. Ignoring
|
||||
// these is what keeps a shutdown that races delivery
|
||||
// from parking the parent forever.
|
||||
SubAgentStatus::Closing | SubAgentStatus::Closed => None,
|
||||
};
|
||||
if let Some(result) = finished {
|
||||
ready.push((agent.spawn_seq, SubAgentParentNotification {
|
||||
agent_id: agent_id.clone(),
|
||||
description: description.clone(),
|
||||
result,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if !ready.is_empty() {
|
||||
ready.sort_by_key(|(spawn_seq, _)| *spawn_seq);
|
||||
let batch: Vec<_> = ready
|
||||
.into_iter()
|
||||
.map(|(_, notification)| notification)
|
||||
.collect();
|
||||
for notification in &batch {
|
||||
if let Some(agent) = state.agents.get_mut(¬ification.agent_id) {
|
||||
agent.parent_notification = None;
|
||||
}
|
||||
}
|
||||
return Ok(Some(batch));
|
||||
}
|
||||
if !awaiting_result {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => {
|
||||
return Err(Error::Interrupted(InterruptReason::Cancelled));
|
||||
}
|
||||
observed = changed.changed() => {
|
||||
observed.map_err(|_| {
|
||||
Error::InvalidState(
|
||||
"Background-agent notification observer closed unexpectedly".to_string(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn wait(&self, agent_id: &str) -> Result<SubAgentResult, Error> {
|
||||
self.wait_with_cancel(agent_id, &CancellationToken::new())
|
||||
|
|
@ -444,6 +646,11 @@ impl SubAgentSupervisor {
|
|||
}
|
||||
};
|
||||
|
||||
// Shutdown is committed, so this child's result will never reach the
|
||||
// parent. The early returns above leave the notification intact, so a
|
||||
// rejected shutdown cannot discard a result the parent is owed.
|
||||
agent.parent_notification = None;
|
||||
|
||||
if agent.cleanup_started {
|
||||
return Ok(ShutdownDisposition::Follow(agent.cleanup_done.subscribe()));
|
||||
}
|
||||
|
|
@ -549,7 +756,9 @@ impl SubAgentSupervisor {
|
|||
}
|
||||
|
||||
async fn ensure_closed(&self, agent_id: &str) -> Result<(), Error> {
|
||||
let cleanup_done = match self.begin_shutdown(agent_id, false)? {
|
||||
let disposition = self.begin_shutdown(agent_id, false)?;
|
||||
signal_notifications(&self.notifications_changed);
|
||||
let cleanup_done = match disposition {
|
||||
ShutdownDisposition::Lead(work) => self.spawn_shutdown(work),
|
||||
ShutdownDisposition::Follow(cleanup_done) => cleanup_done,
|
||||
ShutdownDisposition::Done => return Ok(()),
|
||||
|
|
@ -560,7 +769,9 @@ impl SubAgentSupervisor {
|
|||
|
||||
/// Strict user-facing close: only a currently running child may be closed.
|
||||
pub async fn close_agent(&self, agent_id: &str) -> Result<(), Error> {
|
||||
let cleanup_done = match self.begin_shutdown(agent_id, true)? {
|
||||
let disposition = self.begin_shutdown(agent_id, true)?;
|
||||
signal_notifications(&self.notifications_changed);
|
||||
let cleanup_done = match disposition {
|
||||
ShutdownDisposition::Lead(work) => self.spawn_shutdown(work),
|
||||
ShutdownDisposition::Follow(_) | ShutdownDisposition::Done => {
|
||||
return Err(Error::InvalidState(format!(
|
||||
|
|
@ -628,6 +839,7 @@ impl SubAgentSupervisor {
|
|||
child_task,
|
||||
status.clone(),
|
||||
Arc::clone(&self.event_callback),
|
||||
Arc::clone(&self.notifications_changed),
|
||||
agent_id.clone(),
|
||||
depth,
|
||||
);
|
||||
|
|
@ -643,6 +855,8 @@ impl SubAgentSupervisor {
|
|||
event_forwarder,
|
||||
cleanup_task: None,
|
||||
child_abort_handle,
|
||||
parent_notification: None,
|
||||
spawn_seq: 0,
|
||||
followup_queue: Arc::new(Mutex::new(VecDeque::new())),
|
||||
cancel_token,
|
||||
depth,
|
||||
|
|
@ -842,6 +1056,192 @@ mod tests {
|
|||
assert!(manager.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_notification_envelope_escapes_xml() {
|
||||
let envelope = format_parent_notification_batch(&[SubAgentParentNotification {
|
||||
agent_id: "agent<&".to_string(),
|
||||
description: "Review <core> & tests".to_string(),
|
||||
result: Ok(SubAgentResult {
|
||||
output: "done <safely> & \"verified\"".to_string(),
|
||||
success: true,
|
||||
turns_used: 2,
|
||||
}),
|
||||
}]);
|
||||
|
||||
assert!(envelope.contains("<status>completed</status>"));
|
||||
assert!(envelope.contains("<task-id>agent<&</task-id>"));
|
||||
assert!(envelope.contains("<description>Review <core> & tests</description>"));
|
||||
assert!(
|
||||
envelope.contains("<result>done <safely> & "verified"</result>")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_finished_agent_is_delivered_to_the_parent_exactly_once() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let child = make_session(vec![text_response("child result")]).await;
|
||||
let agent_id = supervisor
|
||||
.spawn_with_parent_notification(
|
||||
child,
|
||||
"task".to_string(),
|
||||
"Inspect the module".to_string(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
supervisor
|
||||
.wait_with_cancel(&agent_id, &CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let batch = supervisor
|
||||
.next_parent_notification_batch(&CancellationToken::new())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("the finished child must be delivered");
|
||||
assert_eq!(batch.len(), 1);
|
||||
assert_eq!(batch[0].agent_id, agent_id);
|
||||
assert_eq!(batch[0].description, "Inspect the module");
|
||||
|
||||
// The status stays `Finished`, so re-delivery is prevented by clearing
|
||||
// the registration rather than by consuming the result.
|
||||
assert!(
|
||||
supervisor
|
||||
.next_parent_notification_batch(&CancellationToken::new())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batches_are_delivered_in_spawn_order() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let mut ids = Vec::new();
|
||||
for index in 0..3 {
|
||||
let child = make_session(vec![text_response("done")]).await;
|
||||
ids.push(
|
||||
supervisor
|
||||
.spawn_with_parent_notification(
|
||||
child,
|
||||
format!("task {index}"),
|
||||
format!("Task {index}"),
|
||||
0,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for id in &ids {
|
||||
supervisor
|
||||
.wait_with_cancel(id, &CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let batch = supervisor
|
||||
.next_parent_notification_batch(&CancellationToken::new())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("all three children must be delivered together");
|
||||
let delivered: Vec<_> = batch.iter().map(|n| n.agent_id.clone()).collect();
|
||||
assert_eq!(delivered, ids);
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suppressing_before_completion_stops_delivery() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let child = make_session(vec![text_response("child result")]).await;
|
||||
let agent_id = supervisor
|
||||
.spawn_with_parent_notification(
|
||||
child,
|
||||
"task".to_string(),
|
||||
"Inspect the module".to_string(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
supervisor.suppress_parent_notification(&agent_id);
|
||||
supervisor
|
||||
.wait_with_cancel(&agent_id, &CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
supervisor
|
||||
.next_parent_notification_batch(&CancellationToken::new())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_a_running_agent_stops_delivery_without_parking_the_parent() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let child = make_session(vec![text_response("child result")]).await;
|
||||
let agent_id = supervisor
|
||||
.spawn_with_parent_notification(
|
||||
child,
|
||||
"task".to_string(),
|
||||
"Inspect the module".to_string(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
supervisor.close_agent(&agent_id).await.unwrap();
|
||||
|
||||
// Must resolve rather than wait for a result that will never arrive.
|
||||
assert!(
|
||||
supervisor
|
||||
.next_parent_notification_batch(&CancellationToken::new())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_stop_of_a_finished_agent_keeps_its_notification() {
|
||||
let supervisor = SubAgentSupervisor::new(3);
|
||||
let child = make_session(vec![text_response("child result")]).await;
|
||||
let agent_id = supervisor
|
||||
.spawn_with_parent_notification(
|
||||
child,
|
||||
"task".to_string(),
|
||||
"Inspect the module".to_string(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Finish the child so its result is queued for automatic delivery.
|
||||
supervisor
|
||||
.wait_with_cancel(&agent_id, &CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Stopping a finished agent is rejected...
|
||||
let error = supervisor.close_agent(&agent_id).await.unwrap_err();
|
||||
assert!(matches!(error, Error::InvalidState(_)), "{error:?}");
|
||||
|
||||
// ...so it must not have discarded the result the parent is owed.
|
||||
let batch = supervisor
|
||||
.next_parent_notification_batch(&CancellationToken::new())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("a rejected stop must leave the pending result deliverable");
|
||||
assert_eq!(batch.len(), 1);
|
||||
assert_eq!(batch[0].agent_id, agent_id);
|
||||
|
||||
supervisor.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_creates_agent_and_returns_id() {
|
||||
let manager = SubAgentSupervisor::new(3);
|
||||
|
|
|
|||
|
|
@ -17,27 +17,46 @@ use fabro_types::{
|
|||
use crate::tool_registry::ToolContext;
|
||||
use crate::types::AgentEvent;
|
||||
|
||||
/// Projections and their ID counters, behind one lock so a list and its
|
||||
/// counter can never be observed out of step.
|
||||
#[derive(Debug, Default)]
|
||||
struct TodoRuntimeState {
|
||||
lists: BTreeMap<String, TodoListProjection>,
|
||||
task_counters: BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
/// Shared, thread-safe todo projection. Wrap it in `Arc` and clone the
|
||||
/// `Arc` into each tool closure that needs it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TodoRuntime {
|
||||
lists: Mutex<BTreeMap<String, TodoListProjection>>,
|
||||
state: Mutex<TodoRuntimeState>,
|
||||
}
|
||||
|
||||
impl TodoRuntime {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
lists: Mutex::new(BTreeMap::new()),
|
||||
state: Mutex::new(TodoRuntimeState::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate the next monotonically increasing Claude task ID for a list.
|
||||
///
|
||||
/// Keeping the counter beside the projection lets root and child profiles
|
||||
/// safely create tasks in the same shared list.
|
||||
pub(crate) fn next_task_id(&self, list_id: &str) -> u64 {
|
||||
let mut guard = self.state.lock().expect("todo runtime lock poisoned");
|
||||
let counter = guard.task_counters.entry(list_id.to_string()).or_default();
|
||||
*counter = counter.saturating_add(1);
|
||||
*counter
|
||||
}
|
||||
|
||||
/// Snapshot the projection for `list_id`. Used by tests and by the
|
||||
/// list-style tools that need a stable view.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self, list_id: &str) -> Option<TodoListProjection> {
|
||||
let guard = self.lists.lock().expect("todo runtime lock poisoned");
|
||||
guard.get(list_id).cloned()
|
||||
let guard = self.state.lock().expect("todo runtime lock poisoned");
|
||||
guard.lists.get(list_id).cloned()
|
||||
}
|
||||
|
||||
/// Insert (or replace) a todo and emit `todo.created`.
|
||||
|
|
@ -63,8 +82,9 @@ impl TodoRuntime {
|
|||
metadata: todo.metadata.clone(),
|
||||
};
|
||||
{
|
||||
let mut guard = self.lists.lock().expect("todo runtime lock poisoned");
|
||||
let mut guard = self.state.lock().expect("todo runtime lock poisoned");
|
||||
guard
|
||||
.lists
|
||||
.entry(list_id)
|
||||
.or_insert_with(|| TodoListProjection::new(kind, props.list_id.clone()))
|
||||
.upsert(todo);
|
||||
|
|
@ -81,8 +101,8 @@ impl TodoRuntime {
|
|||
}
|
||||
|
||||
let applied = {
|
||||
let mut guard = self.lists.lock().expect("todo runtime lock poisoned");
|
||||
let Some(list) = guard.get_mut(&props.list_id) else {
|
||||
let mut guard = self.state.lock().expect("todo runtime lock poisoned");
|
||||
let Some(list) = guard.lists.get_mut(&props.list_id) else {
|
||||
return false;
|
||||
};
|
||||
list.apply_patch(&props.todo_id, &TodoPatch::from_props(&props))
|
||||
|
|
@ -103,8 +123,8 @@ impl TodoRuntime {
|
|||
todo_id: String,
|
||||
) -> bool {
|
||||
let removed = {
|
||||
let mut guard = self.lists.lock().expect("todo runtime lock poisoned");
|
||||
let Some(list) = guard.get_mut(&list_id) else {
|
||||
let mut guard = self.state.lock().expect("todo runtime lock poisoned");
|
||||
let Some(list) = guard.lists.get_mut(&list_id) else {
|
||||
return false;
|
||||
};
|
||||
list.remove(&todo_id)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fmt::Write;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_llm::types::ToolDefinition;
|
||||
use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps};
|
||||
|
|
@ -395,28 +394,6 @@ pub fn make_todo_list_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Per-list monotonically-increasing task counter for Anthropic
|
||||
/// `TaskCreate`. Shared state lives inside the tool closure so two parallel
|
||||
/// `TaskCreate` calls inside one session can never receive the same ID.
|
||||
#[derive(Debug, Default)]
|
||||
struct AnthropicTaskCounters {
|
||||
counters: Mutex<BTreeMap<String, Arc<AtomicU64>>>,
|
||||
}
|
||||
|
||||
impl AnthropicTaskCounters {
|
||||
fn next(&self, list_id: &str) -> u64 {
|
||||
let counter = {
|
||||
let mut guard = self.counters.lock().expect("task counter lock poisoned");
|
||||
Arc::clone(
|
||||
guard
|
||||
.entry(list_id.to_string())
|
||||
.or_insert_with(|| Arc::new(AtomicU64::new(0))),
|
||||
)
|
||||
};
|
||||
counter.fetch_add(1, Ordering::Relaxed) + 1
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_string(args: &Value, key: &str) -> Option<String> {
|
||||
args.get(key)
|
||||
.and_then(Value::as_str)
|
||||
|
|
@ -471,7 +448,6 @@ fn format_task_details(todo: &TodoProjection) -> String {
|
|||
|
||||
#[must_use]
|
||||
pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
|
||||
let counters = Arc::new(AnthropicTaskCounters::default());
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "TaskCreate".into(),
|
||||
|
|
@ -489,7 +465,6 @@ pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
|
|||
},
|
||||
executor: Arc::new(move |args, ctx| {
|
||||
let runtime = runtime.clone();
|
||||
let counters = counters.clone();
|
||||
Box::pin(async move {
|
||||
let list_id = anthropic_task_scope(&ctx)?;
|
||||
let subject = args
|
||||
|
|
@ -502,7 +477,7 @@ pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
|
|||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "Missing required parameter: description".to_string())?
|
||||
.to_string();
|
||||
let task_id = counters.next(&list_id);
|
||||
let task_id = runtime.next_task_id(&list_id);
|
||||
let id_string = task_id.to_string();
|
||||
let order = u32::try_from(task_id.saturating_sub(1)).unwrap_or(u32::MAX);
|
||||
|
||||
|
|
|
|||
|
|
@ -647,7 +647,7 @@ fn format_brave_results(body: &serde_json::Value) -> String {
|
|||
output
|
||||
}
|
||||
|
||||
fn make_web_search_tool_with_api_key(api_key: String) -> RegisteredTool {
|
||||
pub(crate) fn make_web_search_tool_with_api_key(api_key: String) -> RegisteredTool {
|
||||
use std::sync::OnceLock;
|
||||
static CLIENT: OnceLock<fabro_http::HttpClient> = OnceLock::new();
|
||||
|
||||
|
|
|
|||
|
|
@ -294,14 +294,15 @@ mod tests {
|
|||
#[rustfmt::skip]
|
||||
let expected: &[RouteRow] = &[
|
||||
// model id deployment_id transport codec billing profile
|
||||
("claude-fable-5", "claude-fable-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
|
||||
("claude-fable-5", "claude-fable-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
|
||||
("claude-haiku-4-5", "claude-haiku-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
|
||||
("claude-opus-4-6", "claude-opus-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
|
||||
("claude-opus-4-7", "claude-opus-4-7", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
|
||||
("claude-opus-4-8", "claude-opus-4-8", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
|
||||
("claude-opus-5", "claude-opus-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
|
||||
("claude-opus-5", "claude-opus-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
|
||||
("claude-sonnet-4-5", "claude-sonnet-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
|
||||
("claude-sonnet-4-6", "claude-sonnet-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
|
||||
("claude-sonnet-5", "claude-sonnet-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
|
||||
("gemini-3-flash-preview", "gemini-3-flash-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
|
||||
("gemini-3.1-flash-lite", "gemini-3.1-flash-lite", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
|
||||
("gemini-3.1-pro-preview", "gemini-3.1-pro-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
|
||||
|
|
@ -360,7 +361,7 @@ mod tests {
|
|||
|
||||
let by_alias = resolve_route(catalog, select_from_all(catalog, "sonnet"))
|
||||
.expect("alias should resolve");
|
||||
let by_id = resolve_route(catalog, select_from_all(catalog, "claude-sonnet-4-6"))
|
||||
let by_id = resolve_route(catalog, select_from_all(catalog, "claude-sonnet-5"))
|
||||
.expect("id should resolve");
|
||||
|
||||
assert_eq!(by_alias, by_id);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, Tool
|
|||
use fabro_agent::{
|
||||
AgentEvent, AgentProfile, AgentProfileBuilder, CompletionCoordinator, Message as AgentMessage,
|
||||
Sandbox, Session, SessionOptions, SessionShutdownReason, StaticEnvProvider, ToolEnvProvider,
|
||||
ToolSecrets, canonical_tool_name, register_question_tools,
|
||||
ToolSecrets, WebFetchSummarizer, canonical_tool_name, register_question_tools,
|
||||
};
|
||||
use fabro_auth::{CredentialSource, EnvCredentialSource};
|
||||
use fabro_graphviz::graph::{AttrValue, Node};
|
||||
|
|
@ -19,10 +19,10 @@ use fabro_llm::types::{
|
|||
};
|
||||
use fabro_mcp::config::McpServerSettings;
|
||||
#[cfg(test)]
|
||||
use fabro_model::AgentProfileKind;
|
||||
#[cfg(test)]
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{Catalog, FallbackTarget, ModelRef, ProviderId, UsdMicros};
|
||||
use fabro_model::{
|
||||
AgentProfileKind, Catalog, FallbackTarget, ModelHandle, ModelRef, ProviderId, UsdMicros,
|
||||
};
|
||||
use fabro_types::settings::run::RunModelControls;
|
||||
use fabro_types::{PermissionLevel, RunId, SessionCapability, StageId, StageTiming};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
|
@ -823,6 +823,17 @@ impl AgentApiBackend {
|
|||
Arc::clone(&catalog),
|
||||
)
|
||||
.with_tool_secrets(tool_secrets);
|
||||
let profile_builder = if provider.profile_kind == AgentProfileKind::Claude5 {
|
||||
profile_builder.with_web_fetch_summarizer(Some(WebFetchSummarizer {
|
||||
client: client.clone(),
|
||||
model_id: ModelHandle::ByName {
|
||||
provider: provider.provider_id.clone(),
|
||||
model: model.to_string(),
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
profile_builder
|
||||
};
|
||||
let mut profile = profile_builder.build();
|
||||
|
||||
let config = SessionOptions {
|
||||
|
|
@ -2843,6 +2854,25 @@ reasoning = false
|
|||
assert_eq!(provider.profile_kind, AgentProfileKind::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_backend_selects_claude5_profile_for_sonnet5() {
|
||||
let backend = AgentApiBackend::new_with_catalog(
|
||||
"claude-sonnet-5".to_string(),
|
||||
ProviderId::anthropic(),
|
||||
Vec::new(),
|
||||
Arc::new(EnvCredentialSource::new()),
|
||||
SteeringHub::for_tests(),
|
||||
Arc::new(Catalog::from_builtin().unwrap()),
|
||||
);
|
||||
|
||||
let provider = backend
|
||||
.resolve_provider_context("claude-sonnet-5", None)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(provider.provider_id, ProviderId::anthropic());
|
||||
assert_eq!(provider.profile_kind, AgentProfileKind::Claude5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_backend_preserves_default_provider_for_legacy_model_identifier() {
|
||||
let settings: LlmCatalogSettings = toml::from_str(
|
||||
|
|
|
|||
|
|
@ -1058,7 +1058,7 @@ reasoning = false
|
|||
|
||||
assert_eq!(
|
||||
validated.graph().nodes["work"].attrs.get("model"),
|
||||
Some(&AttrValue::String("claude-sonnet-4-6".into()))
|
||||
Some(&AttrValue::String("claude-sonnet-5".into()))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1460,7 +1460,7 @@ reasoning = false
|
|||
.model
|
||||
.name
|
||||
.as_deref(),
|
||||
Some("claude-sonnet-4-6")
|
||||
Some("claude-sonnet-5")
|
||||
);
|
||||
assert_eq!(
|
||||
created
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ mod tests {
|
|||
let transformed = transform(parsed, &transform_options()).unwrap();
|
||||
assert_eq!(
|
||||
transformed.graph.nodes["work"].attrs.get("model"),
|
||||
Some(&AttrValue::String("claude-sonnet-4-6".into()))
|
||||
Some(&AttrValue::String("claude-sonnet-5".into()))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
lint.attrs.get("model"),
|
||||
Some(&AttrValue::String("claude-sonnet-4-6".into()))
|
||||
Some(&AttrValue::String("claude-sonnet-5".into()))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ fn materialize_run_applies_graph_and_catalog_defaults() {
|
|||
.unwrap();
|
||||
let resolved = &materialized.run;
|
||||
|
||||
assert_eq!(resolved.model.name.as_deref(), Some("claude-sonnet-4-6"));
|
||||
assert_eq!(resolved.model.name.as_deref(), Some("claude-sonnet-5"));
|
||||
assert_eq!(resolved.model.provider.as_deref(), Some("anthropic"));
|
||||
assert_eq!(
|
||||
materialized.run.goal.as_ref(),
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ impl AsRef<str> for AdapterKind {
|
|||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum AgentProfileKind {
|
||||
Anthropic,
|
||||
/// Claude 5 models trained against Anthropic's current coding-agent
|
||||
/// harness. This remains model-scoped so older Claude models keep the
|
||||
/// established Anthropic profile.
|
||||
#[serde(rename = "claude-5")]
|
||||
#[strum(to_string = "claude-5")]
|
||||
Claude5,
|
||||
#[serde(rename = "openai")]
|
||||
#[strum(to_string = "openai")]
|
||||
OpenAi,
|
||||
|
|
|
|||
|
|
@ -2873,7 +2873,7 @@ enabled = true
|
|||
catalog
|
||||
.default_for_provider(&bedrock)
|
||||
.map(|model| model.id.as_str()),
|
||||
Some("claude-sonnet-4-6")
|
||||
Some("claude-sonnet-5")
|
||||
);
|
||||
// Fable 5 ships with sampling params pinned off (the Converse
|
||||
// encoder drops temperature/top_p for it).
|
||||
|
|
@ -2881,12 +2881,11 @@ enabled = true
|
|||
.get_on_provider(&bedrock, "claude-fable-5")
|
||||
.expect("fable row should be present");
|
||||
assert!(!fable.features.sampling_params);
|
||||
assert!(
|
||||
catalog
|
||||
.settings_for(fable)
|
||||
.expect("fable settings should be present")
|
||||
.reasoning_by_default
|
||||
);
|
||||
let fable_settings = catalog
|
||||
.settings_for(fable)
|
||||
.expect("fable settings should be present");
|
||||
assert!(fable_settings.reasoning_by_default);
|
||||
assert_eq!(fable_settings.agent_profile, AgentProfileKind::Claude5);
|
||||
assert_eq!(
|
||||
catalog
|
||||
.model_settings_on_provider(&bedrock, "claude-fable-5")
|
||||
|
|
@ -2894,6 +2893,16 @@ enabled = true
|
|||
.billing_policy,
|
||||
BillingPolicy::Anthropic
|
||||
);
|
||||
let sonnet = catalog
|
||||
.get_on_provider(&bedrock, "claude-sonnet-5")
|
||||
.expect("Sonnet 5 row should be present");
|
||||
assert_eq!(sonnet.limits.context_window, 1_000_000);
|
||||
assert_eq!(sonnet.limits.max_output, Some(128_000));
|
||||
assert!(!sonnet.features.sampling_params);
|
||||
assert_eq!(
|
||||
catalog.settings_for(sonnet).unwrap().agent_profile,
|
||||
AgentProfileKind::Claude5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -3040,7 +3049,7 @@ enabled = true
|
|||
// open-weights rows inherit it.
|
||||
assert_eq!(
|
||||
catalog
|
||||
.model_settings_on_provider(&openrouter, "claude-sonnet-4-6")
|
||||
.model_settings_on_provider(&openrouter, "claude-sonnet-5")
|
||||
.unwrap()
|
||||
.billing_policy,
|
||||
BillingPolicy::Anthropic
|
||||
|
|
@ -3056,7 +3065,7 @@ enabled = true
|
|||
catalog
|
||||
.default_for_provider(&openrouter)
|
||||
.map(|model| model.id.as_str()),
|
||||
Some("claude-sonnet-4-6")
|
||||
Some("claude-sonnet-5")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3149,6 +3158,19 @@ enabled = true
|
|||
true,
|
||||
BillingPolicy::Anthropic,
|
||||
),
|
||||
(
|
||||
"claude-sonnet-5",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"claude-5",
|
||||
1_000_000,
|
||||
2.0,
|
||||
10.0,
|
||||
0.2,
|
||||
ReasoningEffortFeature::Levels,
|
||||
false,
|
||||
true,
|
||||
BillingPolicy::Anthropic,
|
||||
),
|
||||
];
|
||||
|
||||
for (
|
||||
|
|
@ -3200,13 +3222,21 @@ enabled = true
|
|||
ReasoningEffort::VARIANTS,
|
||||
"{id}"
|
||||
);
|
||||
if family == "claude-5" {
|
||||
assert_eq!(settings.agent_profile, AgentProfileKind::Claude5, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
for alias in ["opus", "claude-opus"] {
|
||||
for (alias, expected) in [
|
||||
("opus", "claude-opus-5"),
|
||||
("claude-opus", "claude-opus-5"),
|
||||
("sonnet", "claude-sonnet-5"),
|
||||
("claude-sonnet", "claude-sonnet-5"),
|
||||
] {
|
||||
let model = catalog
|
||||
.resolve_on_provider(&ProviderId::new("openrouter"), alias)
|
||||
.unwrap_or_else(|error| panic!("{alias} should resolve on OpenRouter: {error}"));
|
||||
assert_eq!(model.id, "claude-opus-5", "{alias}");
|
||||
assert_eq!(model.id, expected, "{alias}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4063,7 +4093,7 @@ enabled = true
|
|||
let m = Catalog::builtin()
|
||||
.default_for_provider(&ProviderId::anthropic())
|
||||
.unwrap();
|
||||
assert_eq!(m.id, "claude-sonnet-4-6");
|
||||
assert_eq!(m.id, "claude-sonnet-5");
|
||||
assert!(m.default);
|
||||
|
||||
let m = Catalog::builtin()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ header = { custom = "x-api-key" }
|
|||
display_name = "Claude Fable 5"
|
||||
family = "claude-5"
|
||||
aliases = ["fable", "claude-fable"]
|
||||
agent_profile = "claude-5"
|
||||
|
||||
[providers.anthropic.models."claude-fable-5".limits]
|
||||
context_window = 1000000
|
||||
|
|
@ -37,6 +38,7 @@ family = "claude-5"
|
|||
training = "2026-05-01"
|
||||
knowledge_cutoff = "May 2026"
|
||||
aliases = ["opus", "claude-opus"]
|
||||
agent_profile = "claude-5"
|
||||
|
||||
[providers.anthropic.models."claude-opus-5".limits]
|
||||
context_window = 1000000
|
||||
|
|
@ -63,6 +65,33 @@ input_cost_per_mtok = 10.0
|
|||
output_cost_per_mtok = 50.0
|
||||
cache_input_cost_per_mtok = 1.0
|
||||
|
||||
[providers.anthropic.models."claude-sonnet-5"]
|
||||
display_name = "Claude Sonnet 5"
|
||||
family = "claude-5"
|
||||
training = "2026-01-01"
|
||||
knowledge_cutoff = "Jan 2026"
|
||||
default = true
|
||||
aliases = ["sonnet", "claude-sonnet"]
|
||||
agent_profile = "claude-5"
|
||||
|
||||
[providers.anthropic.models."claude-sonnet-5".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[providers.anthropic.models."claude-sonnet-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
# Introductory pricing through August 31, 2026.
|
||||
[providers.anthropic.models."claude-sonnet-5".costs]
|
||||
input_cost_per_mtok = 2.0
|
||||
output_cost_per_mtok = 10.0
|
||||
cache_input_cost_per_mtok = 0.2
|
||||
|
||||
[providers.anthropic.models."claude-opus-4-8"]
|
||||
display_name = "Claude Opus 4.8"
|
||||
family = "claude-4"
|
||||
|
|
@ -188,9 +217,7 @@ display_name = "Claude Sonnet 4.6"
|
|||
family = "claude-4"
|
||||
training = "2025-08-01"
|
||||
knowledge_cutoff = "May 2025"
|
||||
default = true
|
||||
estimated_output_tps = 50
|
||||
aliases = ["sonnet", "claude-sonnet"]
|
||||
|
||||
[providers.anthropic.models."claude-sonnet-4-6".limits]
|
||||
context_window = 200000
|
||||
|
|
|
|||
|
|
@ -41,16 +41,15 @@ credentials = [
|
|||
# ---------- Anthropic Claude ----------
|
||||
#
|
||||
# Claude bills Anthropic-style cache reads/writes, so these rows override
|
||||
# the provider's billing default. Claude Fable 5 appears at the end of this
|
||||
# file because its Bedrock deployment pins sampling parameters and requires an
|
||||
# extra data-sharing opt-in.
|
||||
# the provider's billing default. Claude 5 models appear at the end of this
|
||||
# file because their Bedrock deployments pin sampling parameters and require
|
||||
# extra endpoint-specific handling.
|
||||
|
||||
[providers.bedrock.models."claude-sonnet-4-6"]
|
||||
api_id = "us.anthropic.claude-sonnet-4-6"
|
||||
display_name = "Claude Sonnet 4.6 (Bedrock)"
|
||||
family = "claude-4"
|
||||
billing_policy = "anthropic"
|
||||
default = true
|
||||
|
||||
[providers.bedrock.models."claude-sonnet-4-6".limits]
|
||||
context_window = 1000000
|
||||
|
|
@ -360,6 +359,7 @@ api_id = "us.anthropic.claude-fable-5"
|
|||
display_name = "Claude Fable 5 (Bedrock)"
|
||||
family = "claude-5"
|
||||
billing_policy = "anthropic"
|
||||
agent_profile = "claude-5"
|
||||
|
||||
[providers.bedrock.models."claude-fable-5".limits]
|
||||
context_window = 1000000
|
||||
|
|
@ -377,3 +377,33 @@ sampling_params = false
|
|||
input_cost_per_mtok = 10.0
|
||||
output_cost_per_mtok = 50.0
|
||||
cache_input_cost_per_mtok = 1.0
|
||||
|
||||
# Claude Sonnet 5 uses adaptive thinking by default and rejects non-default
|
||||
# sampling parameters. Effort-level mapping through
|
||||
# additionalModelRequestFields is a named follow-up, as for Fable 5.
|
||||
|
||||
[providers.bedrock.models."claude-sonnet-5"]
|
||||
api_id = "us.anthropic.claude-sonnet-5"
|
||||
display_name = "Claude Sonnet 5 (Bedrock)"
|
||||
family = "claude-5"
|
||||
billing_policy = "anthropic"
|
||||
default = true
|
||||
agent_profile = "claude-5"
|
||||
|
||||
[providers.bedrock.models."claude-sonnet-5".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[providers.bedrock.models."claude-sonnet-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_by_default = true
|
||||
prompt_cache = true
|
||||
sampling_params = false
|
||||
|
||||
# Introductory pricing through August 31, 2026.
|
||||
[providers.bedrock.models."claude-sonnet-5".costs]
|
||||
input_cost_per_mtok = 2.0
|
||||
output_cost_per_mtok = 10.0
|
||||
cache_input_cost_per_mtok = 0.2
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ display_name = "Claude Fable 5 (via OpenRouter)"
|
|||
family = "claude-5"
|
||||
billing_policy = "anthropic"
|
||||
aliases = ["fable", "claude-fable"]
|
||||
agent_profile = "claude-5"
|
||||
|
||||
[providers.openrouter.models."claude-fable-5".limits]
|
||||
context_window = 1000000
|
||||
|
|
@ -66,6 +67,7 @@ billing_policy = "anthropic"
|
|||
training = "2026-05-01"
|
||||
knowledge_cutoff = "May 2026"
|
||||
aliases = ["opus", "claude-opus"]
|
||||
agent_profile = "claude-5"
|
||||
|
||||
[providers.openrouter.models."claude-opus-5".limits]
|
||||
context_window = 1000000
|
||||
|
|
@ -85,6 +87,37 @@ input_cost_per_mtok = 5.0
|
|||
output_cost_per_mtok = 25.0
|
||||
cache_input_cost_per_mtok = 0.5
|
||||
|
||||
[providers.openrouter.models."claude-sonnet-5"]
|
||||
api_id = "anthropic/claude-sonnet-5"
|
||||
display_name = "Claude Sonnet 5 (via OpenRouter)"
|
||||
family = "claude-5"
|
||||
billing_policy = "anthropic"
|
||||
training = "2026-01-01"
|
||||
knowledge_cutoff = "Jan 2026"
|
||||
default = true
|
||||
aliases = ["sonnet", "claude-sonnet"]
|
||||
agent_profile = "claude-5"
|
||||
|
||||
[providers.openrouter.models."claude-sonnet-5".limits]
|
||||
context_window = 1000000
|
||||
max_output = 128000
|
||||
|
||||
[providers.openrouter.models."claude-sonnet-5".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
prompt_cache = true
|
||||
cache_control_breakpoints = true
|
||||
sampling_params = false
|
||||
|
||||
# Current introductory rate. OpenRouter's authoritative in-band usage.cost
|
||||
# supersedes this estimate on completed responses.
|
||||
[providers.openrouter.models."claude-sonnet-5".costs]
|
||||
input_cost_per_mtok = 2.0
|
||||
output_cost_per_mtok = 10.0
|
||||
cache_input_cost_per_mtok = 0.2
|
||||
|
||||
[providers.openrouter.models."claude-opus-4-8"]
|
||||
api_id = "anthropic/claude-opus-4.8"
|
||||
display_name = "Claude Opus 4.8 (via OpenRouter)"
|
||||
|
|
@ -138,8 +171,6 @@ api_id = "anthropic/claude-sonnet-4.6"
|
|||
display_name = "Claude Sonnet 4.6 (via OpenRouter)"
|
||||
family = "claude-4"
|
||||
billing_policy = "anthropic"
|
||||
default = true
|
||||
aliases = ["sonnet", "claude-sonnet"]
|
||||
|
||||
[providers.openrouter.models."claude-sonnet-4-6".limits]
|
||||
context_window = 1000000
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue