Skip to content
Open
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
15 changes: 14 additions & 1 deletion crates/tui/src/tools/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,20 @@ fn project_readonly_evidence_schema(name: &str, schema: &mut Value) {
}
return;
}
let Some(actions) = schema["properties"]["action"]["enum"].as_array_mut() else {
// Probe with `get_mut`, never `schema["properties"]["action"]["enum"]`:
// serde_json's IndexMut auto-vivifies missing keys by inserting Null, so
// the old probe left `properties.action = {"enum": null}` inside schemas
// that have no action property (e.g. lowercase `bash`). Strict
// OpenAI-compatible validators then reject the whole request with
// `Invalid schema for function 'bash': null is not of type "array"`
// (observed on Fleet read-only workers; see registry tests).
let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
return;
};
let Some(action) = properties.get_mut("action") else {
return;
};
let Some(actions) = action.get_mut("enum").and_then(Value::as_array_mut) else {
return;
};
match name {
Expand Down
67 changes: 67 additions & 0 deletions crates/tui/src/tools/registry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1690,3 +1690,70 @@ fn rlm_family_removes_legacy_aliases() {
);
}
}

/// Regression probe for the fleet-52663788 class of provider 400
/// (`Invalid schema for function 'bash': null is not of type "array"`):
/// a read-only Fleet worker (reviewer) projects its tool schemas before the
/// wire; no projected schema may carry a JSON null, because strict
/// OpenAI-compatible validators reject null where arrays/objects are typed.
#[test]
fn fleet_readonly_reviewer_wire_catalog_carries_no_null_schema_fields() {
use crate::tools::spec::{
ToolMutationAuthority, ToolShellAuthority, ToolVerificationAuthority,
};

fn collect_null_paths(value: &Value, path: String, out: &mut Vec<String>) {
match value {
Value::Null => out.push(path),
Value::Object(map) => {
for (key, child) in map {
collect_null_paths(child, format!("{path}.{key}"), out);
}
}
Value::Array(items) => {
for (index, child) in items.iter().enumerate() {
collect_null_paths(child, format!("{path}[{index}]"), out);
}
}
_ => {}
}
}

let tmp = tempdir().expect("tempdir");
let reviewer_authority = ToolAuthorityEnvelope {
schema_version: 1,
owner: "reviewer".to_string(),
authority: ToolMutationAuthority::ReadOnly,
network_access: Some(false),
shell: ToolShellAuthority::ReadOnly,
verification: ToolVerificationAuthority::None,
writable_roots: Vec::new(),
writable_files: Vec::new(),
coordination_contracts: Vec::new(),
};
let context = ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(reviewer_authority)
.expect("reviewer authority");

let registry = ToolRegistryBuilder::new()
.with_file_tools()
.with_foreground_shell_tools()
.with_search_tools()
.build(context);
let tools = registry.to_api_tools();
assert!(
tools.iter().any(|tool| tool.name == "bash"),
"reviewer keeps classifier-bounded bash"
);

for tool in &tools {
let mut nulls = Vec::new();
collect_null_paths(&tool.input_schema, "$".to_string(), &mut nulls);
assert!(
nulls.is_empty(),
"tool {} schema carries null at {nulls:?}: {}",
tool.name,
tool.input_schema
);
}
}
Loading