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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,18 @@ impl TaskAction {
return None;
}

let has_agent_id = value.get("agent_id").is_some();
let has_subagent_type = value.get("subagent_type").is_some();
let has_fork_context = value.get("fork_context").is_some();
let has_agent_id = value
.get("agent_id")
.and_then(Value::as_str)
.is_some_and(|agent_id| !agent_id.trim().is_empty());
let has_subagent_type = value
.get("subagent_type")
.and_then(Value::as_str)
.is_some_and(|subagent_type| !subagent_type.trim().is_empty());
let has_fork_context = value
.get("fork_context")
.and_then(Value::as_bool)
.unwrap_or(false);

if !has_agent_id && (has_subagent_type || has_fork_context) {
return Some(Self::Spawn);
Expand Down Expand Up @@ -143,23 +152,24 @@ impl TaskTool {
TaskAction::Spawn => {
let description = Self::required_string_for_action(input, "description", action)?;
let prompt = Self::required_string_for_action(input, "prompt", action)?;
if input.get("agent_id").is_some() {
if Self::optional_trimmed_string(input, "agent_id")?.is_some() {
return Err(BitFunError::tool(
"agent_id is not allowed when action is spawn".to_string(),
));
}
let subagent_type = Self::optional_trimmed_string(input, "subagent_type")?;
let context_mode = Self::context_mode_from_input(input)?;
match context_mode {
SubagentContextMode::Fresh => {
if input.get("subagent_type").is_none() {
if subagent_type.is_none() {
return Err(BitFunError::tool(
"subagent_type is required when action is spawn and fork_context is false or omitted"
.to_string(),
));
}
}
SubagentContextMode::Fork => {
if input.get("subagent_type").is_some() {
if subagent_type.is_some() {
return Err(BitFunError::tool(
"subagent_type cannot be combined with fork_context=true when action is spawn; use either subagent_type for a fresh subagent or fork_context=true to inherit the current context."
.to_string(),
Expand All @@ -181,7 +191,7 @@ impl TaskTool {
prompt,
context_mode,
target_agent_id: None,
subagent_type: Self::optional_trimmed_string(input, "subagent_type")?,
subagent_type,
model_id,
inherit_parent_model,
timeout_seconds: None,
Expand Down Expand Up @@ -321,7 +331,7 @@ impl TaskTool {

fn optional_trimmed_string(input: &Value, field: &str) -> BitFunResult<Option<String>> {
match input.get(field) {
None => Ok(None),
None | Some(Value::Null) => Ok(None),
Some(value) => {
let value = value
.as_str()
Expand All @@ -341,7 +351,7 @@ impl TaskTool {

fn optional_bool(input: &Value, field: &str) -> BitFunResult<Option<bool>> {
match input.get(field) {
None => Ok(None),
None | Some(Value::Null) => Ok(None),
Some(value) => value
.as_bool()
.map(Some)
Expand All @@ -367,7 +377,7 @@ impl TaskTool {
action: TaskAction,
) -> BitFunResult<()> {
for field in fields {
if input.get(field).is_some() {
if Self::has_effective_value(input, field) {
return Err(BitFunError::tool(format!(
"{field} is not allowed when action is {}",
action.as_str()
Expand All @@ -376,4 +386,15 @@ impl TaskTool {
}
Ok(())
}

fn has_effective_value(input: &Value, field: &str) -> bool {
// Some models serialize unused fields from this action-union schema as
// null, an empty string, or false. Those values carry no action intent.
match input.get(field) {
None | Some(Value::Null) => false,
Some(Value::String(value)) => !value.trim().is_empty(),
Some(Value::Bool(value)) => *value,
Some(_) => true,
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,48 @@ async fn validate_input_accepts_fork_context_with_model_id() {
assert!(validation.result);
}

#[tokio::test]
async fn validate_input_accepts_fork_spawn_with_neutral_flat_schema_placeholders() {
let validation = TaskTool::new()
.validate_input(
&json!({
"action": "spawn",
"agent_id": "",
"description": "delegate",
"fork_context": true,
"model_id": "inherit",
"prompt": "Inspect the repo",
"run_in_background": true,
"subagent_type": ""
}),
None,
)
.await;

assert!(validation.result, "{:?}", validation.message);
}

#[tokio::test]
async fn validate_input_accepts_fresh_spawn_with_neutral_flat_schema_placeholders() {
let validation = TaskTool::new()
.validate_input(
&json!({
"action": "spawn",
"agent_id": "",
"description": "delegate",
"fork_context": false,
"model_id": "inherit",
"prompt": "Inspect the repo",
"run_in_background": true,
"subagent_type": "Explore"
}),
None,
)
.await;

assert!(validation.result, "{:?}", validation.message);
}

#[tokio::test]
async fn validate_input_rejects_fork_context_with_subagent_type_as_mode_conflict() {
let validation = TaskTool::new()
Expand Down Expand Up @@ -612,6 +654,25 @@ async fn validate_input_accepts_send_input_with_model_id() {
assert!(validation.result);
}

#[tokio::test]
async fn validate_input_accepts_send_input_with_neutral_spawn_placeholders() {
let validation = TaskTool::new()
.validate_input(
&json!({
"action": "send_input",
"agent_id": "a1",
"description": "continue",
"fork_context": false,
"prompt": "Continue the previous analysis",
"subagent_type": ""
}),
None,
)
.await;

assert!(validation.result, "{:?}", validation.message);
}

#[tokio::test]
async fn validate_input_infers_send_input_without_action_when_agent_id_present() {
let validation = TaskTool::new()
Expand Down Expand Up @@ -838,6 +899,26 @@ async fn validate_input_rejects_cancel_with_prompt() {
.is_some_and(|message| message.contains("prompt is not allowed")));
}

#[tokio::test]
async fn validate_input_accepts_cancel_with_neutral_optional_placeholders() {
let validation = TaskTool::new()
.validate_input(
&json!({
"action": "cancel",
"agent_id": "a1",
"fork_context": false,
"model_id": "",
"prompt": "",
"run_in_background": false,
"subagent_type": ""
}),
None,
)
.await;

assert!(validation.result, "{:?}", validation.message);
}

#[tokio::test]
async fn task_tool_stays_available_without_enabled_subagents() {
assert!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use super::*;
impl TaskTool {
pub(super) fn context_mode_from_input(input: &Value) -> BitFunResult<SubagentContextMode> {
match input.get("fork_context") {
None => Ok(SubagentContextMode::Fresh),
None | Some(Value::Null) => Ok(SubagentContextMode::Fresh),
Some(value) => {
let fork_context = value.as_bool().ok_or_else(|| {
BitFunError::tool("fork_context must be a boolean".to_string())
Expand Down