diff --git a/src/openhuman/agent/agents/archivist/agent.toml b/src/openhuman/agent/agents/archivist/agent.toml index 9a8ec7504..8d33b1766 100644 --- a/src/openhuman/agent/agents/archivist/agent.toml +++ b/src/openhuman/agent/agents/archivist/agent.toml @@ -1,5 +1,6 @@ id = "archivist" display_name = "Archivist" +delegate_name = "archive_session" when_to_use = "Background librarian — extracts lessons from a completed session, updates MEMORY.md, and indexes to FTS5. Runs cheap and slow." temperature = 0.4 max_iterations = 3 diff --git a/src/openhuman/agent/agents/code_executor/agent.toml b/src/openhuman/agent/agents/code_executor/agent.toml index fb8fe1316..75d173d83 100644 --- a/src/openhuman/agent/agents/code_executor/agent.toml +++ b/src/openhuman/agent/agents/code_executor/agent.toml @@ -1,5 +1,6 @@ id = "code_executor" display_name = "Code Executor" +delegate_name = "run_code" when_to_use = "Sandboxed developer — writes, runs, and debugs code until tests pass. Use for any task that requires producing or modifying source files and exercising them with shell or test commands." temperature = 0.4 max_iterations = 10 diff --git a/src/openhuman/agent/agents/critic/agent.toml b/src/openhuman/agent/agents/critic/agent.toml index 55b459c1d..fbe6cfce4 100644 --- a/src/openhuman/agent/agents/critic/agent.toml +++ b/src/openhuman/agent/agents/critic/agent.toml @@ -1,5 +1,6 @@ id = "critic" display_name = "Critic" +delegate_name = "review_code" when_to_use = "Adversarial reviewer — reviews diffs and code against project rules, flags vulnerabilities, regressions, and missing tests. Read-only." temperature = 0.4 max_iterations = 5 diff --git a/src/openhuman/agent/agents/orchestrator/agent.toml b/src/openhuman/agent/agents/orchestrator/agent.toml index d51cbea84..75b8b0e8a 100644 --- a/src/openhuman/agent/agents/orchestrator/agent.toml +++ b/src/openhuman/agent/agents/orchestrator/agent.toml @@ -9,13 +9,46 @@ omit_memory_context = true omit_safety_preamble = true omit_skills_catalog = true +# Delegation surface. The `collect_orchestrator_tools` helper reads this +# at agent-build time and synthesises one `delegate_*` tool per entry: +# +# * Bare agent ids → one `ArchetypeDelegationTool` each, using the +# target agent's `delegate_name` override (if any) or `delegate_{id}` +# as the tool name, and the target's `when_to_use` as the +# LLM-visible tool description. +# +# * `{ skills = "*" }` → one `SkillDelegationTool` per connected +# Composio toolkit, all routing to the generic `skills_agent` with +# the toolkit slug pre-filled as `skill_filter`. +# +# The orchestrator LLM sees these as first-class entries in its +# function-calling schema, so routing decisions happen at the tool- +# selection layer rather than hidden inside a mega-tool's enum param. +# +# NOTE: `subagents = [...]` MUST appear before any `[table]` section +# header — once a TOML table opens, subsequent top-level keys are +# consumed by that table, and `subagents` would get parsed as +# `tools.subagents` (which fails because `ToolScope` is an enum). +subagents = [ + "researcher", + "planner", + "code_executor", + "critic", + "archivist", + { skills = "*" }, +] + [model] hint = "reasoning" [tools] +# Direct tools — things the orchestrator calls itself rather than +# delegating. `spawn_subagent` is retained as an advanced fallback so +# power users can still spawn arbitrary agent ids that are not listed +# in `subagents` above (e.g. workspace-override custom agents). named = [ - "spawn_subagent", "query_memory", "read_workspace_state", "ask_user_clarification", + "spawn_subagent", ] diff --git a/src/openhuman/agent/agents/planner/agent.toml b/src/openhuman/agent/agents/planner/agent.toml index 737ee9973..24e6e9374 100644 --- a/src/openhuman/agent/agents/planner/agent.toml +++ b/src/openhuman/agent/agents/planner/agent.toml @@ -1,5 +1,6 @@ id = "planner" display_name = "Planner" +delegate_name = "plan" when_to_use = "Architect — break a complex task into a small DAG of subtasks with explicit acceptance criteria. Reads memory and searches the web to ground plans in real context. Read-only; produces JSON, not code." temperature = 0.4 max_iterations = 8 diff --git a/src/openhuman/agent/agents/researcher/agent.toml b/src/openhuman/agent/agents/researcher/agent.toml index 505d1c72f..46be95e70 100644 --- a/src/openhuman/agent/agents/researcher/agent.toml +++ b/src/openhuman/agent/agents/researcher/agent.toml @@ -1,5 +1,6 @@ id = "researcher" display_name = "Researcher" +delegate_name = "research" when_to_use = "Web & docs crawler — reads real documentation, compresses to dense markdown. Use for any task that requires looking up external knowledge." temperature = 0.4 max_iterations = 8 diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index eda2708f6..33023d961 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -453,17 +453,59 @@ impl Agent { } } - // Generate the orchestrator's tool set: one tool per skill + - // one tool per archetype (research, run_code, etc.) + spawn_subagent - // as a fallback. These are the only tools the LLM sees in its - // function-calling schema. Sub-agents still access the full `tools` - // registry via ParentExecutionContext. - let orchestrator_tools = tools::orchestrator_tools::collect_orchestrator_tools(); + // Generate the orchestrator's delegation tool set from its + // declarative `subagents = [...]` field: one `ArchetypeDelegationTool` + // per named sub-agent, plus one `SkillDelegationTool` per connected + // Composio toolkit when the orchestrator includes a + // `{ skills = "*" }` wildcard. These are the only delegation tools + // the main-agent LLM sees; sub-agents themselves still access the + // full `tools` registry via `ParentExecutionContext` and apply + // their own per-definition whitelist in the subagent runner. + // + // This builder is synchronous and sits on the CLI / REPL code + // path. It does not have access to the async Composio fetcher, + // so we pass an empty slice of connected integrations here — the + // skill-wildcard expansion therefore produces zero delegation + // tools in this path, which is correct behaviour for CLI (the + // CLI user can still reach any toolkit via the generic + // `composio_execute` tool or `spawn_subagent`). + // + // The channel-dispatch path (`channels::runtime::dispatch`) has + // its own tool registry assembly and will populate the + // connected integrations list from the live Composio fetch in a + // later commit (#525/#526 dispatch routing). + let orchestrator_tools = + match crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global() + { + Some(reg) => match reg.get("orchestrator") { + Some(orch_def) => tools::orchestrator_tools::collect_orchestrator_tools( + orch_def, + reg, + &[], + ), + None => { + log::debug!( + "[agent::builder] orchestrator definition not in registry — \ + skipping delegation tool synthesis" + ); + Vec::new() + } + }, + None => { + log::debug!( + "[agent::builder] AgentDefinitionRegistry not initialised — \ + skipping delegation tool synthesis" + ); + Vec::new() + } + }; let visible: std::collections::HashSet = orchestrator_tools .iter() .map(|t| t.name().to_string()) .collect(); - // De-duplicate: spawn_subagent is already in the base registry. + // De-duplicate: some synthesised tool names may collide with + // already-registered tools (unlikely for `delegate_*` names but + // cheap to guard against). let existing_names: std::collections::HashSet = tools.iter().map(|t| t.name().to_string()).collect(); tools.extend( diff --git a/src/openhuman/tools/impl/agent/mod.rs b/src/openhuman/tools/impl/agent/mod.rs index 20e63ef69..1f0354d8f 100644 --- a/src/openhuman/tools/impl/agent/mod.rs +++ b/src/openhuman/tools/impl/agent/mod.rs @@ -2,6 +2,7 @@ mod archetype_delegation; mod ask_clarification; mod complete_onboarding; mod delegate; +mod skill_delegation; mod spawn_subagent; use crate::core::event_bus::{publish_global, DomainEvent}; @@ -10,29 +11,6 @@ use crate::openhuman::agent::harness::fork_context::current_parent; use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions}; use crate::openhuman::tools::traits::ToolResult; -pub(crate) const ARCHETYPE_TOOLS: &[(&str, &str, &str)] = &[ - ( - "research", - "researcher", - "Search the web, read docs, and gather information. Returns a dense markdown summary with sources.", - ), - ( - "run_code", - "code_executor", - "Write, run, debug, and test code in a sandboxed environment. Has shell, file access, and git.", - ), - ( - "review_code", - "critic", - "Review code changes for quality, security, and correctness. Read-only — returns findings, never edits.", - ), - ( - "plan", - "planner", - "Break a complex goal into a structured step-by-step plan with dependencies. Use for tasks with 3+ steps.", - ), -]; - pub(crate) async fn dispatch_subagent( agent_id: &str, tool_name: &str, @@ -122,4 +100,5 @@ pub use archetype_delegation::ArchetypeDelegationTool; pub use ask_clarification::AskClarificationTool; pub use complete_onboarding::CompleteOnboardingTool; pub use delegate::DelegateTool; +pub use skill_delegation::SkillDelegationTool; pub use spawn_subagent::SpawnSubagentTool; diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 67b1765ba..e2ff87601 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -8,7 +8,6 @@ pub mod traits; #[path = "impl/mod.rs"] mod implementations; -pub(crate) use implementations::agent::ARCHETYPE_TOOLS; pub use implementations::*; pub use ops::*; #[allow(unused_imports)] diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 864d86f01..fb255e9ed 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -266,23 +266,6 @@ pub fn all_tools_with_runtime( tools } -/// Legacy allowlist — no longer the primary source of truth for -/// orchestrator tool visibility. The `from_config` builder now uses -/// `orchestrator_tools::collect_orchestrator_tools()` to generate -/// the visible tool set dynamically. Kept for backward compatibility -/// with callers that reference this constant. -pub const MAIN_AGENT_TOOL_ALLOWLIST: &[&str] = &["spawn_subagent"]; - -/// Filter a full tool registry down to only the tools the main agent -/// should see. Sub-agents receive the unfiltered registry via -/// `ParentExecutionContext::all_tools` and apply their own per-definition -/// whitelist in the subagent runner. -pub fn main_agent_tools(all: Vec>) -> Vec> { - all.into_iter() - .filter(|t| MAIN_AGENT_TOOL_ALLOWLIST.contains(&t.name())) - .collect() -} - /// Hardware peripheral tools — always empty (boards removed); config kept for compatibility. pub async fn create_peripheral_tools( _config: &crate::openhuman::config::PeripheralsConfig, diff --git a/src/openhuman/tools/orchestrator_tools.rs b/src/openhuman/tools/orchestrator_tools.rs index 6137e9071..898e9beed 100644 --- a/src/openhuman/tools/orchestrator_tools.rs +++ b/src/openhuman/tools/orchestrator_tools.rs @@ -1,46 +1,334 @@ //! Dynamic orchestrator tool generation. //! -//! Instead of a single `spawn_subagent` mega-tool, this module generates -//! one tool per subagent archetype. The orchestrator's function-calling -//! schema becomes a flat list of well-named tools: +//! The orchestrator agent doesn't directly execute work — it routes it to +//! specialised sub-agents. Rather than exposing a single generic +//! `spawn_subagent(agent_id, prompt)` mega-tool, we synthesise one named +//! tool per entry in the orchestrator's `subagents = [...]` TOML field, +//! so the LLM's function-calling schema contains discoverable, well-named +//! tools like `research`, `plan`, `run_code`, `delegate_gmail`, +//! `delegate_github`, etc. //! -//! `research`, `run_code`, `review_code`, `plan` +//! Each synthesised tool's description is pulled live from the target +//! agent's [`AgentDefinition::when_to_use`] (for +//! [`SubagentEntry::AgentId`]) or from the connected Composio toolkit +//! metadata (for [`SubagentEntry::Skills`] wildcard expansions) — so +//! descriptions automatically stay in sync with the definitions and +//! never drift from a hardcoded table. //! -//! Each tool's `execute()` internally calls `run_subagent` with the -//! correct definition. The LLM just picks the right tool by name. +//! Called from [`crate::openhuman::agent::harness::session::builder`] at +//! agent-build time, with the orchestrator's own definition, the global +//! registry (for delegation target lookups), and the current list of +//! connected Composio integrations. +//! +//! [`AgentDefinition::when_to_use`]: crate::openhuman::agent::harness::definition::AgentDefinition::when_to_use +//! [`SubagentEntry::AgentId`]: crate::openhuman::agent::harness::definition::SubagentEntry::AgentId +//! [`SubagentEntry::Skills`]: crate::openhuman::agent::harness::definition::SubagentEntry::Skills -use super::{ArchetypeDelegationTool, SpawnSubagentTool, Tool, ARCHETYPE_TOOLS}; +use crate::openhuman::agent::harness::definition::{ + AgentDefinition, AgentDefinitionRegistry, SubagentEntry, +}; +use crate::openhuman::context::prompt::ConnectedIntegration; -/// Build the orchestrator's tool list: one tool per installed skill + -/// one tool per archetype. Also includes `spawn_subagent` as a fallback -/// for advanced use cases (fork mode, custom agent_ids). +use super::{ArchetypeDelegationTool, SkillDelegationTool, Tool}; + +/// Synthesise the delegation tool list for an agent based on its +/// declarative `subagents` field. /// -/// Call this at agent build time when the visible-tool filter is active -/// (i.e. the main agent is an orchestrator). -pub fn collect_orchestrator_tools() -> Vec> { +/// Each [`SubagentEntry::AgentId`] is resolved against `registry` and +/// rendered as an [`ArchetypeDelegationTool`] whose `name()` defaults to +/// `delegate_{target.id}` (overridable via the target agent's +/// `delegate_name` field) and whose `description()` is the target's +/// `when_to_use` — so editing an agent's TOML description immediately +/// updates the tool schema the orchestrator LLM sees, with zero drift. +/// +/// Each [`SubagentEntry::Skills`] wildcard expands to one +/// [`SkillDelegationTool`] per connected Composio integration in +/// `connected_integrations`. The synthesised tool routes to the generic +/// `skills_agent` with `skill_filter = Some("{toolkit_slug}")` pre-set. +/// +/// Entries that reference unknown agent ids (not in the registry) are +/// logged at `warn` and skipped — the orchestrator still builds, just +/// without the broken delegation. Entries that reference Skills wildcards +/// with an empty `connected_integrations` slice produce zero tools, which +/// is the correct behaviour when the user has not yet connected any +/// integrations (the LLM should not see phantom `delegate_gmail` tools +/// for unconnected toolkits). +/// +/// Returns an empty Vec when `definition.subagents` is empty — callers +/// (notably the builder) handle this by not extending the visible-tool +/// set, so non-delegating agents behave identically to how they did +/// before this module existed. +pub fn collect_orchestrator_tools( + definition: &AgentDefinition, + registry: &AgentDefinitionRegistry, + connected_integrations: &[ConnectedIntegration], +) -> Vec> { let mut tools: Vec> = Vec::new(); - // ── Archetype-based tools (static) ──────────────────────────────── - for (tool_name, agent_id, description) in ARCHETYPE_TOOLS { - log::info!( - "[orchestrator_tools] registering archetype delegation tool: {} -> {}", - tool_name, - agent_id - ); - tools.push(Box::new(ArchetypeDelegationTool { - tool_name: tool_name.to_string(), - agent_id: agent_id.to_string(), - tool_description: description.to_string(), - })); + for entry in &definition.subagents { + match entry { + SubagentEntry::AgentId(agent_id) => { + let Some(target) = registry.get(agent_id) else { + log::warn!( + "[orchestrator_tools] subagent '{}' referenced by '{}' is not in the registry — skipping", + agent_id, + definition.id + ); + continue; + }; + let tool_name = target + .delegate_name + .clone() + .unwrap_or_else(|| format!("delegate_{}", target.id)); + log::debug!( + "[orchestrator_tools] registering archetype delegation tool: {} -> {}", + tool_name, + target.id + ); + tools.push(Box::new(ArchetypeDelegationTool { + tool_name, + agent_id: target.id.clone(), + tool_description: target.when_to_use.clone(), + })); + } + SubagentEntry::Skills(wildcard) => { + if !wildcard.matches_all() { + log::warn!( + "[orchestrator_tools] subagent skills wildcard '{}' referenced by '{}' is not supported (only \"*\") — skipping", + wildcard.skills, + definition.id + ); + continue; + } + for integration in connected_integrations { + // Slug the toolkit name into a tool-name-safe form. + // Composio toolkit slugs are already lowercase / dash- + // separated (e.g. "gmail", "google_calendar"), but + // we guard against surprises so a quirky slug can + // never produce an invalid function-calling schema. + let slug = sanitise_slug(&integration.toolkit); + let tool_name = format!("delegate_{}", slug); + // Prefer the toolkit's own one-line description when + // available; fall back to a generic template so the + // LLM still gets a meaningful tool description even + // on brand-new or poorly-populated toolkits. + let description = if integration.description.trim().is_empty() { + format!( + "Delegate to the skills agent with the `{}` integration pre-selected.", + integration.toolkit + ) + } else { + format!( + "Delegate to the skills agent using `{}`. {}", + integration.toolkit, integration.description + ) + }; + log::debug!( + "[orchestrator_tools] registering skill delegation tool: {} -> skills_agent (skill_filter={})", + tool_name, + slug + ); + tools.push(Box::new(SkillDelegationTool { + tool_name, + skill_id: slug, + tool_description: description, + })); + } + } + } } - // ── spawn_subagent as fallback for advanced use ──────────────────── - tools.push(Box::new(SpawnSubagentTool::new())); - log::info!( - "[orchestrator_tools] total orchestrator tools: {}", - tools.len() + "[orchestrator_tools] assembled {} delegation tool(s) for agent '{}' ({} integrations connected)", + tools.len(), + definition.id, + connected_integrations.len() ); tools } + +/// Produce a tool-name-safe slug from a free-form integration id. +/// Allows ASCII alphanumerics and underscores; everything else becomes +/// an underscore. OpenAI-style function names only accept +/// `[a-zA-Z0-9_-]{1,64}`, so this is the conservative subset. +fn sanitise_slug(raw: &str) -> String { + raw.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::agent::harness::definition::{ + DefinitionSource, ModelSpec, PromptSource, SandboxMode, SkillsWildcard, ToolScope, + }; + + fn def(id: &str, when_to_use: &str, delegate_name: Option<&str>) -> AgentDefinition { + AgentDefinition { + id: id.into(), + when_to_use: when_to_use.into(), + display_name: None, + system_prompt: PromptSource::Inline(String::new()), + omit_identity: true, + omit_memory_context: true, + omit_safety_preamble: true, + omit_skills_catalog: true, + model: ModelSpec::Inherit, + temperature: 0.4, + tools: ToolScope::Wildcard, + disallowed_tools: vec![], + skill_filter: None, + category_filter: None, + max_iterations: 8, + timeout_secs: None, + sandbox_mode: SandboxMode::None, + background: false, + uses_fork_context: false, + subagents: vec![], + delegate_name: delegate_name.map(String::from), + source: DefinitionSource::Builtin, + } + } + + /// A real orchestrator definition that delegates to two named agents + /// (one with an explicit `delegate_name`, one without) plus a skills + /// wildcard. Exercises every branch of `collect_orchestrator_tools`. + fn sample_orchestrator() -> AgentDefinition { + let mut orch = def( + "orchestrator", + "Routes work to the right specialist", + None, + ); + orch.subagents = vec![ + SubagentEntry::AgentId("researcher".into()), + SubagentEntry::AgentId("archivist".into()), + SubagentEntry::Skills(SkillsWildcard { skills: "*".into() }), + ]; + orch + } + + fn registry_with_targets() -> AgentDefinitionRegistry { + let mut reg = AgentDefinitionRegistry::default(); + reg.insert(def( + "researcher", + "Web & docs crawler — reads real documentation", + Some("research"), + )); + // `archivist` has no `delegate_name` override — tool name should + // fall back to `delegate_archivist`. + reg.insert(def( + "archivist", + "Background librarian — extracts lessons from a completed session", + None, + )); + reg + } + + fn integration(toolkit: &str, description: &str) -> ConnectedIntegration { + ConnectedIntegration { + toolkit: toolkit.into(), + description: description.into(), + tools: vec![], + } + } + + /// Baseline: an orchestrator with 2 AgentId entries + a Skills + /// wildcard, against a registry that knows both targets and a + /// connected_integrations list with three toolkits, should produce + /// 2 + 3 = 5 delegation tools, each with the expected name and + /// description source. + #[test] + fn collects_agentid_entries_and_expands_skills_wildcard() { + let orch = sample_orchestrator(); + let reg = registry_with_targets(); + let integrations = vec![ + integration("gmail", "Send and read email via Gmail."), + integration("github", "Manage repos, issues, and pull requests."), + integration("notion", "Read and write pages and databases."), + ]; + + let tools = collect_orchestrator_tools(&orch, ®, &integrations); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + + assert_eq!( + names, + vec![ + "research", // researcher's delegate_name override + "delegate_archivist", // archivist has no delegate_name → default + "delegate_gmail", + "delegate_github", + "delegate_notion", + ], + "tool names should come from delegate_name overrides, id fallbacks, and sanitised toolkit slugs" + ); + + // Descriptions should come from when_to_use for archetype tools, + // and from a templated string mentioning the toolkit display name + // for skill tools. + let research_tool = tools.iter().find(|t| t.name() == "research").unwrap(); + assert!(research_tool.description().contains("crawler")); + + let gmail_tool = tools.iter().find(|t| t.name() == "delegate_gmail").unwrap(); + assert!(gmail_tool.description().contains("gmail")); + assert!(gmail_tool.description().contains("email")); + } + + /// An orchestrator with a Skills wildcard but no connected + /// integrations should produce zero skill delegation tools — the LLM + /// must not be shown phantom `delegate_*` tools for toolkits that + /// aren't authorised. + #[test] + fn skills_wildcard_with_no_integrations_produces_no_tools() { + let orch = sample_orchestrator(); + let reg = registry_with_targets(); + let tools = collect_orchestrator_tools(&orch, ®, &[]); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert_eq!(names, vec!["research", "delegate_archivist"]); + } + + /// An AgentId entry that points at an id not present in the registry + /// should be logged and silently skipped, rather than panicking or + /// aborting tool assembly. The orchestrator still builds. + #[test] + fn unknown_subagent_id_is_skipped_not_fatal() { + let mut orch = def("orchestrator", "test", None); + orch.subagents = vec![ + SubagentEntry::AgentId("researcher".into()), + SubagentEntry::AgentId("ghost_agent_nope".into()), + ]; + let reg = registry_with_targets(); + let tools = collect_orchestrator_tools(&orch, ®, &[]); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert_eq!(names, vec!["research"]); + } + + /// An empty `subagents` list should produce zero tools — regular + /// non-delegating agents (welcome, code_executor, etc.) reach this + /// path without any subagents and must not pick up stray tools. + #[test] + fn empty_subagents_produces_no_tools() { + let orch = def("welcome", "First agent", None); + let reg = registry_with_targets(); + let tools = collect_orchestrator_tools(&orch, ®, &[]); + assert!(tools.is_empty()); + } + + /// Toolkit slugs with dashes, spaces, or mixed case should be + /// normalised to `[a-z0-9_]` before being used as part of a function + /// name — the OpenAI tool-calling schema has strict character rules. + #[test] + fn sanitise_slug_lowercases_and_replaces_invalid_chars() { + assert_eq!(sanitise_slug("Gmail"), "gmail"); + assert_eq!(sanitise_slug("google-calendar"), "google_calendar"); + assert_eq!(sanitise_slug("slack.bot"), "slack_bot"); + assert_eq!(sanitise_slug("weird name!"), "weird_name_"); + } +}