diff --git a/Cargo.lock b/Cargo.lock index b5a2868..5fa9048 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,8 @@ dependencies = [ [[package]] name = "a3s-common" version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40cd6201d7bcccd75f7719d5f3054c275dc31d0f406eab40d591d2c907f4fc60" dependencies = [ "async-trait", "bytes", diff --git a/core/Cargo.toml b/core/Cargo.toml index 990d803..95c6a74 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -13,7 +13,7 @@ path = "src/lib.rs" [dependencies] # Internal crates -a3s-common = { version = "0.1", path = "../../common" } +a3s-common = { version = "0.1.1" } a3s-memory = { version = "0.1.1", path = "../../memory" } a3s-lane = { version = "0.4", path = "../../lane" } a3s-search = { version = "1.2.3", path = "../../search", default-features = false, features = ["lightpanda"] } diff --git a/core/src/config/loader.rs b/core/src/config/loader.rs index d84d6ea..2af22fe 100644 --- a/core/src/config/loader.rs +++ b/core/src/config/loader.rs @@ -52,6 +52,10 @@ fn acl_usize_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option { } } +fn acl_u32_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option { + acl_usize_attr(block, keys).map(|value| value.min(u32::MAX as usize) as u32) +} + fn acl_path_list_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option> { let value = acl_attr(block, keys)?; match value { @@ -276,10 +280,55 @@ impl CodeConfig { model.release_date = Some(release_date); } } + "maxTokens" | "max_tokens" | "outputTokens" + | "output_tokens" => { + if let Some(output) = acl_u32_attr( + model_block, + &[ + "maxTokens", + "max_tokens", + "outputTokens", + "output_tokens", + ], + ) { + model.limit.output = output; + } + } + "contextTokens" | "context_tokens" | "maxContextTokens" + | "max_context_tokens" => { + if let Some(context) = acl_u32_attr( + model_block, + &[ + "contextTokens", + "context_tokens", + "maxContextTokens", + "max_context_tokens", + ], + ) { + model.limit.context = context; + } + } _ => {} } } + for limit_block in &model_block.blocks { + if limit_block.name == "limit" { + if let Some(output) = acl_u32_attr( + limit_block, + &["output", "max_tokens", "output_tokens"], + ) { + model.limit.output = output; + } + if let Some(context) = acl_u32_attr( + limit_block, + &["context", "context_tokens", "max_context_tokens"], + ) { + model.limit.context = context; + } + } + } + provider.models.push(model); } } diff --git a/core/src/config/tests.rs b/core/src/config/tests.rs index e33ba64..efb494f 100644 --- a/core/src/config/tests.rs +++ b/core/src/config/tests.rs @@ -111,6 +111,40 @@ fn test_config_supports_acl_style_provider_labels() { assert!(config.providers[0].models[0].tool_call); } +#[test] +fn test_config_parses_acl_model_token_limits() { + let config = CodeConfig::from_acl( + r#" + default_model = "openai/glm-5.1" + + providers "openai" { + api_key = "sk-test" + base_url = "http://127.0.0.1:18051/v1" + + models "glm-5.1" { + name = "GLM 5.1" + max_tokens = 4096 + context_tokens = 32768 + } + + models "camel-aliases" { + outputTokens = 8192 + maxContextTokens = 65536 + } + } + "#, + ) + .unwrap(); + + let flat = &config.providers[0].models[0].limit; + assert_eq!(flat.output, 4096); + assert_eq!(flat.context, 32768); + + let aliases = &config.providers[0].models[1].limit; + assert_eq!(aliases.output, 8192); + assert_eq!(aliases.context, 65536); +} + #[test] fn test_config_builder() { let config = CodeConfig::new() diff --git a/core/src/skills/mod.rs b/core/src/skills/mod.rs index df2fb4f..eb27c60 100644 --- a/core/src/skills/mod.rs +++ b/core/src/skills/mod.rs @@ -33,7 +33,7 @@ pub use validator::{ DefaultSkillValidator, SkillValidationError, SkillValidator, ValidationErrorKind, }; -use serde::{Deserialize, Serialize}; +use serde::{de, Deserialize, Deserializer, Serialize}; use std::collections::HashSet; use std::path::Path; @@ -101,7 +101,11 @@ pub struct Skill { pub description: String, /// Allowed tools (Claude Code format: "Bash(pattern:*), read(*)") - #[serde(default, rename = "allowed-tools")] + #[serde( + default, + rename = "allowed-tools", + deserialize_with = "deserialize_allowed_tools" + )] pub allowed_tools: Option, /// Whether to disable model invocation @@ -183,11 +187,25 @@ impl Skill { return permissions; }; - // Parse comma-separated tool permissions - for part in allowed.split(',') { + // Parse Claude-style comma-separated permissions, plus legacy + // whitespace-only tool lists such as "Read Write Edit Bash". + let parts: Vec<&str> = if allowed.contains(',') { + allowed.split(',').collect() + } else { + allowed.split_whitespace().collect() + }; + for part in parts { let part = part.trim(); + if part.is_empty() { + continue; + } if let Some(perm) = ToolPermission::parse(part) { permissions.insert(perm); + } else { + permissions.insert(ToolPermission { + tool: part.to_string(), + pattern: "*".to_string(), + }); } } @@ -218,6 +236,34 @@ impl Skill { } } +fn deserialize_allowed_tools<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + match value { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::String(s)) => Ok(Some(s)), + Some(serde_yaml::Value::Sequence(items)) => { + let mut tools = Vec::new(); + for item in items { + match item { + serde_yaml::Value::String(s) => tools.push(s), + other => { + return Err(de::Error::custom(format!( + "allowed-tools list entries must be strings, got {other:?}" + ))); + } + } + } + Ok(Some(tools.join(", "))) + } + Some(other) => Err(de::Error::custom(format!( + "allowed-tools must be a string or a list of strings, got {other:?}" + ))), + } +} + #[cfg(test)] mod tests { use super::*; @@ -270,6 +316,51 @@ You are a test assistant. assert_eq!(permissions.len(), 3); } + #[test] + fn test_parse_legacy_whitespace_allowed_tools() { + let skill = Skill { + name: "test".to_string(), + description: "test".to_string(), + allowed_tools: Some("Read Write Edit Bash".to_string()), + disable_model_invocation: false, + kind: SkillKind::Instruction, + content: String::new(), + tags: Vec::new(), + version: None, + }; + + let permissions = skill.parse_allowed_tools(); + assert_eq!(permissions.len(), 4); + assert!(permissions + .iter() + .any(|perm| perm.tool == "Bash" && perm.pattern == "*")); + } + + #[test] + fn test_parse_allowed_tools_yaml_list() { + let content = r#"--- +name: test-skill +description: A test skill +allowed-tools: + - Read + - Write + - Bash(uv run skills analyze-ci:*) +--- +# Instructions +"#; + + let skill = Skill::parse(content).unwrap(); + assert_eq!( + skill.allowed_tools.as_deref(), + Some("Read, Write, Bash(uv run skills analyze-ci:*)") + ); + let permissions = skill.parse_allowed_tools(); + assert_eq!(permissions.len(), 3); + assert!(permissions + .iter() + .any(|perm| perm.tool == "Read" && perm.pattern == "*")); + } + #[test] fn test_is_tool_allowed() { let skill = Skill { diff --git a/core/src/tools/skill.rs b/core/src/tools/skill.rs index af2507f..fc3ef2f 100644 --- a/core/src/tools/skill.rs +++ b/core/src/tools/skill.rs @@ -226,6 +226,16 @@ impl SkillTool { fn create_skill_permission_policy(skill: &Skill) -> PermissionPolicy { let permissions = skill.parse_allowed_tools(); + if permissions.is_empty() { + return PermissionPolicy { + deny: Vec::new(), + allow: Vec::new(), + ask: Vec::new(), + default_decision: PermissionDecision::Allow, + enabled: true, + }; + } + // Convert skill permissions to PermissionRules let mut allow_rules = Vec::new(); for perm in permissions { @@ -453,6 +463,56 @@ mod tests { ); } + #[test] + fn test_skill_permission_policy_allows_when_unspecified() { + let skill = Skill { + name: "test-skill".to_string(), + description: "Test".to_string(), + allowed_tools: None, + disable_model_invocation: false, + kind: SkillKind::Instruction, + content: String::new(), + tags: Vec::new(), + version: None, + }; + + let policy = SkillTool::create_skill_permission_policy(&skill); + + assert_eq!( + policy.check("bash", &serde_json::json!({"command": "python --version"})), + PermissionDecision::Allow + ); + assert_eq!( + policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})), + PermissionDecision::Allow + ); + } + + #[test] + fn test_skill_permission_policy_accepts_legacy_allowed_tools() { + let skill = Skill { + name: "test-skill".to_string(), + description: "Test".to_string(), + allowed_tools: Some("Read Write Edit Bash".to_string()), + disable_model_invocation: false, + kind: SkillKind::Instruction, + content: String::new(), + tags: Vec::new(), + version: None, + }; + + let policy = SkillTool::create_skill_permission_policy(&skill); + + assert_eq!( + policy.check("bash", &serde_json::json!({"command": "python --version"})), + PermissionDecision::Allow + ); + assert_eq!( + policy.check("grep", &serde_json::json!({"pattern": "x"})), + PermissionDecision::Deny + ); + } + #[test] fn test_skill_args_accepts_documented_shape() { let args =