A string max_concurrency passed the load-time check whenever it merely
contained a {{...}} pair, so forms like "n={{k}}" or "{{ key }}" that
can never resolve at runtime were only caught after the map's inputs had
been computed. The check now requires the trimmed string to be exactly
one template reference, matching what interpolate_raw resolves as a pure
reference; padded forms like " {{budget}} " stay valid.
Also: the bash tool build-failure context names the source file instead
of the destination path, and the map-subgraph validator no longer
shadows its member id list with the resolved member nodes.
224 lines
7.4 KiB
Rust
224 lines
7.4 KiB
Rust
use crate::function::{self, FunctionDeclaration, JsonSchema};
|
|
use anyhow::{Context, Result, bail};
|
|
use argc::{ChoiceValue, CommandValue, FlagOptionValue};
|
|
use indexmap::IndexMap;
|
|
use std::env;
|
|
use std::fs::File;
|
|
use std::io::Read;
|
|
use std::path::Path;
|
|
|
|
pub fn generate_bash_declarations(
|
|
mut tool_file: File,
|
|
tools_file_path: &Path,
|
|
file_name: &str,
|
|
) -> Result<Vec<FunctionDeclaration>> {
|
|
let mut src = String::new();
|
|
tool_file
|
|
.read_to_string(&mut src)
|
|
.with_context(|| format!("Failed to load script at '{tool_file:?}'"))?;
|
|
|
|
debug!("Building script at '{tool_file:?}'");
|
|
let (build_script, declarations) = build_bash_tool(&src, file_name)
|
|
.with_context(|| format!("Failed to build script '{file_name}'"))?;
|
|
function::write_file_atomic(tools_file_path, &build_script, Some(0o755))
|
|
.with_context(|| format!("Failed to write built script to '{tools_file_path:?}'"))?;
|
|
|
|
Ok(declarations)
|
|
}
|
|
|
|
/// Builds the argc script and derives its declarations without touching the
|
|
/// filesystem, so callers can inspect a tool source without rewriting it.
|
|
pub fn build_bash_tool(src: &str, file_name: &str) -> Result<(String, Vec<FunctionDeclaration>)> {
|
|
let build_script = argc::build(
|
|
src,
|
|
"",
|
|
env::var("TERM_WIDTH").ok().and_then(|v| v.parse().ok()),
|
|
)?;
|
|
let build_script = allow_empty_required_values(&build_script);
|
|
|
|
let command_value = argc::export(&build_script, file_name)
|
|
.with_context(|| format!("Failed to parse script '{file_name}'"))?;
|
|
if command_value.subcommands.is_empty() {
|
|
let function_declaration =
|
|
command_to_function_declaration(&command_value).ok_or_else(|| {
|
|
anyhow::format_err!("Tool definition missing or empty description: {file_name}")
|
|
})?;
|
|
Ok((build_script, vec![function_declaration]))
|
|
} else {
|
|
let mut declarations = vec![];
|
|
for subcommand in &command_value.subcommands {
|
|
if subcommand.name.starts_with('_') && subcommand.name != "_instructions" {
|
|
continue;
|
|
}
|
|
|
|
if let Some(mut function_declaration) = command_to_function_declaration(subcommand) {
|
|
function_declaration.agent = true;
|
|
declarations.push(function_declaration);
|
|
} else {
|
|
bail!(
|
|
"Tool definition missing or empty description: {} {}",
|
|
file_name,
|
|
subcommand.name
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok((build_script, declarations))
|
|
}
|
|
}
|
|
|
|
fn command_to_function_declaration(cmd: &CommandValue) -> Option<FunctionDeclaration> {
|
|
if cmd.describe.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(FunctionDeclaration {
|
|
name: underscore(&cmd.name),
|
|
description: cmd.describe.clone(),
|
|
parameters: parse_parameters_schema(&cmd.flag_options),
|
|
agent: false,
|
|
})
|
|
}
|
|
|
|
fn underscore(s: &str) -> String {
|
|
s.replace('-', "_")
|
|
}
|
|
|
|
/// argc's generated required-param check uses `-z "${!name:-}"`, which
|
|
/// conflates "not provided" with "provided but empty", so a required option
|
|
/// passed an explicit empty string (e.g. `fs_write --content=''` to create an
|
|
/// empty file) is rejected as "required arguments were not provided". The
|
|
/// JSON schema we advertise to models treats `required` as *presence*, so
|
|
/// rewrite the check to a set-ness test to keep runtime behavior consistent
|
|
/// with the schema. Applied post-build so it survives every regeneration.
|
|
fn allow_empty_required_values(build_script: &str) -> String {
|
|
build_script.replace(
|
|
r#"if [[ -z "${!name:-}" ]]; then"#,
|
|
r#"if [[ -z "${!name+x}" ]]; then"#,
|
|
)
|
|
}
|
|
|
|
fn schema_ty(t: &str) -> JsonSchema {
|
|
JsonSchema {
|
|
type_value: Some(t.to_string()),
|
|
description: None,
|
|
properties: None,
|
|
items: None,
|
|
any_of: None,
|
|
enum_value: None,
|
|
default: None,
|
|
required: None,
|
|
}
|
|
}
|
|
|
|
fn with_description(mut schema: JsonSchema, describe: &str) -> JsonSchema {
|
|
if !describe.is_empty() {
|
|
schema.description = Some(describe.to_string());
|
|
}
|
|
schema
|
|
}
|
|
|
|
fn with_enum(mut schema: JsonSchema, choice: &Option<ChoiceValue>) -> JsonSchema {
|
|
if let Some(ChoiceValue::Values(values)) = choice
|
|
&& !values.is_empty()
|
|
{
|
|
schema.enum_value = Some(values.clone());
|
|
}
|
|
schema
|
|
}
|
|
|
|
fn parse_property(flag: &FlagOptionValue) -> JsonSchema {
|
|
let mut schema = if flag.flag {
|
|
schema_ty("boolean")
|
|
} else if flag.multiple_occurs {
|
|
let mut arr = schema_ty("array");
|
|
arr.items = Some(Box::new(schema_ty("string")));
|
|
arr
|
|
} else if flag.notations.first().map(|s| s.as_str()) == Some("INT") {
|
|
schema_ty("integer")
|
|
} else if flag.notations.first().map(|s| s.as_str()) == Some("NUM") {
|
|
schema_ty("number")
|
|
} else {
|
|
schema_ty("string")
|
|
};
|
|
|
|
schema = with_description(schema, &flag.describe);
|
|
schema = with_enum(schema, &flag.choice);
|
|
schema
|
|
}
|
|
|
|
fn parse_parameters_schema(flags: &[FlagOptionValue]) -> JsonSchema {
|
|
let filtered = flags.iter().filter(|f| f.id != "help" && f.id != "version");
|
|
let mut props: IndexMap<String, JsonSchema> = IndexMap::new();
|
|
let mut required: Vec<String> = Vec::new();
|
|
|
|
for f in filtered {
|
|
let key = underscore(&f.id);
|
|
if f.required {
|
|
required.push(key.clone());
|
|
}
|
|
props.insert(key, parse_property(f));
|
|
}
|
|
|
|
JsonSchema {
|
|
type_value: Some("object".to_string()),
|
|
description: None,
|
|
properties: Some(props),
|
|
items: None,
|
|
any_of: None,
|
|
enum_value: None,
|
|
default: None,
|
|
required: Some(required),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn rewrites_argc_required_check_to_setness_test() {
|
|
let src = "# @describe test tool\n# @option --content! The content\nmain() { :; }\n";
|
|
let built = argc::build(src, "", None).expect("argc build failed");
|
|
assert!(
|
|
built.contains(r#"if [[ -z "${!name:-}" ]]; then"#),
|
|
"argc changed its generated required-param template; update allow_empty_required_values()"
|
|
);
|
|
|
|
let fixed = allow_empty_required_values(&built);
|
|
|
|
assert!(fixed.contains(r#"if [[ -z "${!name+x}" ]]; then"#));
|
|
assert!(!fixed.contains(r#"if [[ -z "${!name:-}" ]]; then"#));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn second_build_does_not_rewrite_tool_file_test() {
|
|
use std::fs;
|
|
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
|
|
|
let src = "# @describe test tool\n# @option --content! The content\nmain() { :; }\n";
|
|
let path = crate::utils::temp_file("bash-build", ".sh");
|
|
fs::write(&path, src).expect("failed to write temp script");
|
|
|
|
let declarations = generate_bash_declarations(File::open(&path).unwrap(), &path, "test")
|
|
.expect("first build failed");
|
|
assert!(!declarations.is_empty());
|
|
let first_ino = fs::metadata(&path).unwrap().ino();
|
|
|
|
let declarations = generate_bash_declarations(File::open(&path).unwrap(), &path, "test")
|
|
.expect("second build failed");
|
|
assert!(!declarations.is_empty());
|
|
|
|
let metadata = fs::metadata(&path).unwrap();
|
|
assert_eq!(
|
|
metadata.ino(),
|
|
first_ino,
|
|
"second build rewrote an already-built tool file"
|
|
);
|
|
assert_eq!(metadata.permissions().mode() & 0o777, 0o755);
|
|
|
|
fs::remove_file(&path).unwrap();
|
|
}
|
|
}
|