From 764f76782cc9726af033d6a97f9e12a033e0d373 Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Tue, 27 Oct 2026 15:08:27 +0100 Subject: [PATCH] feat: agents graph maps how agents, tools, tests and documents connect Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 3 +- README.md | 56 +- cli/elevenlabs/workflow/agents.rs | 40 + cli/elevenlabs/workflow/diagram.rs | 330 +---- .../fixtures/graph/agent_configs/billing.json | 12 + .../graph/agent_configs/support.beta.json | 17 + .../fixtures/graph/agent_configs/support.json | 23 + .../workflow/fixtures/graph/agents.json | 13 + .../workflow/fixtures/graph/expected.mmd | 30 + .../fixtures/graph/test_configs/greets.json | 1 + .../workflow/fixtures/graph/tests.json | 5 + .../graph/tool_configs/issue_refund.json | 1 + .../graph/tool_configs/lookup_order.json | 1 + .../workflow/fixtures/graph/tools.json | 6 + cli/elevenlabs/workflow/graph.rs | 1288 +++++++++++++++++ cli/elevenlabs/workflow/mermaid.rs | 359 +++++ cli/elevenlabs/workflow/mod.rs | 3 + cli/elevenlabs/workflow/project.rs | 8 + cli/elevenlabs/workflow/tools.rs | 12 +- tests/agents_graph_test.rs | 182 +++ 20 files changed, 2072 insertions(+), 318 deletions(-) create mode 100644 cli/elevenlabs/workflow/fixtures/graph/agent_configs/billing.json create mode 100644 cli/elevenlabs/workflow/fixtures/graph/agent_configs/support.beta.json create mode 100644 cli/elevenlabs/workflow/fixtures/graph/agent_configs/support.json create mode 100644 cli/elevenlabs/workflow/fixtures/graph/agents.json create mode 100644 cli/elevenlabs/workflow/fixtures/graph/expected.mmd create mode 100644 cli/elevenlabs/workflow/fixtures/graph/test_configs/greets.json create mode 100644 cli/elevenlabs/workflow/fixtures/graph/tests.json create mode 100644 cli/elevenlabs/workflow/fixtures/graph/tool_configs/issue_refund.json create mode 100644 cli/elevenlabs/workflow/fixtures/graph/tool_configs/lookup_order.json create mode 100644 cli/elevenlabs/workflow/fixtures/graph/tools.json create mode 100644 cli/elevenlabs/workflow/graph.rs create mode 100644 cli/elevenlabs/workflow/mermaid.rs create mode 100644 tests/agents_graph_test.rs diff --git a/Cargo.toml b/Cargo.toml index 1054168..425f48d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,8 @@ hmac = "0.12" # Draws `agents workflow diagram` in the terminal: Mermaid in, box-drawing text # out, in pure Rust. The default `cli` feature is mmdflux's own binary. mmdflux = { version = "2.6.1", default-features = false } -# The terminal's width, so diagrams can shorten long IDs to fit it. +# The terminal's width, so diagrams and `agents graph` can shorten long IDs +# to fit it. terminal_size = "0.4" dotenvy = "0.15" futures-util = "0.3" diff --git a/README.md b/README.md index b9992fe..432a067 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,10 @@ elevenlabs agents test # Draw an agent's workflow in the terminal, or print it as Mermaid (local files only, no API key) elevenlabs agents workflow diagram [--format ascii|mermaid|mermaid-md] + +# Show how agents connect to tools, tests, documents and each other (local files only) +elevenlabs agents graph [config-path|agent_id|name] [--format ascii|mermaid|mermaid-md] +elevenlabs agents graph --uses ``` ### Two surfaces in one namespace @@ -220,7 +224,7 @@ elevenlabs agents workflow diagram [--format ascii|m | | Workflow commands | API commands | |---|---|---| -| **What** | `init`, `add`, `status`, `push`, `pull`, `test`, `templates`, `widget embed`, `workflow diagram` | `create`, `get`, `list`, `update`, `delete`, `duplicate`, `run_tests`, and subgroups like `branches`, `tools`, `tests`, `conversations` | +| **What** | `init`, `add`, `status`, `push`, `pull`, `test`, `templates`, `widget embed`, `workflow diagram`, `graph` | `create`, `get`, `list`, `update`, `delete`, `duplicate`, `run_tests`, and subgroups like `branches`, `tools`, `tests`, `conversations` | | **Operates on** | Your local project files, syncing them with ElevenLabs | The API directly — one command, one request | | **Arguments** | Positional, e.g. `agents test ` | Flags, e.g. `agents get --agent-id ` | | **Output** | Progress text | The API response (`--format json\|table\|yaml\|csv`) | @@ -310,6 +314,56 @@ flowchart TD n_start_node -->|"The caller has given an order number"| n_lookup ``` +### Mapping a project + +Agent configs refer to tools, tests, knowledge-base documents and other agents by ID. `agents graph` follows those references across every agent in `agents.json` (branches included) and resolves them against `tools.json`, `tests.json` and `agents.json`, so you can see what connects to what, and what a change affects. + +```bash +# Every agent, one drawing each, in the terminal +elevenlabs agents graph + +# One agent: everything it uses, and every agent that hands off to it +elevenlabs agents graph Billing + +# The whole project as one Mermaid graph, for docs or a PR comment +elevenlabs agents graph --format mermaid-md + +# Which agents use this tool (or test, agent or document)? An ID, or a tool's or test's name +elevenlabs agents graph --uses tool_refund +``` + +It follows: +- tools in `prompt.tool_ids`, workflow tool nodes and `additional_tool_ids` +- agent handoffs in the `transfer_to_agent` tool and workflow `standalone_agent` nodes (thick arrows); a handoff to a registered branch points at that branch +- tests in `platform_settings.testing.attached_tests` +- knowledge-base documents in `prompt.knowledge_base` and `additional_knowledge_base`, shown by the name the config gives them, since there's no local registry for documents + +References inside a workflow node's prompt override count too. An ID that isn't in the project, such as a tool made in the dashboard or a handoff to an agent outside the repo, is shown as external rather than dropped. + +In the terminal, each agent is drawn on its own, as a card with a count of each kind of connection, and what surrounds it grouped into one box per kind: the agents that hand off to it on the left, and on the right the agents it hands off to, its tools, its tests and its documents. Handoffs are the thick arrows, and the boxes are ordered so no two lines cross. If the drawing is wider than the terminal (or `COLUMNS`, when set), long IDs are shortened to their start and end, as in `tool_01jx…sfb67`. `--format mermaid` draws the whole project as one graph instead, with a node for every resource. + +``` + ┌──────────────┐ + ║ Tools (1) ║ + ║ issue_refund ║ + ┌──────────────────┐────────►└──────────────┘ +┌─────────────────────────┐ │ Agent │ +│ Handed off from (2) │ │ Billing │ +│ Support │━━━━━►│ 2 hand off to it │ +│ Support (branch 'beta') │ │ 1 tool │ +└─────────────────────────┘ │ 1 document │ ┌────►┌───────────────┐ + └──────────────────┘────┘ ( Documents (1) ) + ( Pricing FAQ ) + └───────────────┘ +``` + +`--uses` prints one line per reference, tab-separated: the agent's ID, its name, and where the reference is. That makes it easy to script, for example to check which agents a changed tool config affects: + +``` +agent_billing Billing prompt.tool_ids +agent_support Support (branch 'beta') workflow node 'refunds' +``` + ### Tools Manage the webhook and client tools your agents reference. Tools are tracked in `tools.json` with configs under `tool_configs/`. diff --git a/cli/elevenlabs/workflow/agents.rs b/cli/elevenlabs/workflow/agents.rs index 8f76bb9..bccb9e0 100644 --- a/cli/elevenlabs/workflow/agents.rs +++ b/cli/elevenlabs/workflow/agents.rs @@ -111,6 +111,46 @@ pub(super) fn find_agent<'a>( }) } +/// One config file registered in `agents.json`: an agent's main config, or +/// one of its registered branches. +pub(super) struct AgentConfigEntry { + /// The agent's display name, plus the branch for a branch config. + pub(super) title: String, + pub(super) config_path: String, + pub(super) agent_id: Option, + /// The branch ID: the registered branch's, or for the main config the + /// `branch_id` agents.json recorded for it, if any. + pub(super) branch_id: Option, + /// Set for a registered branch: its name in agents.json. + pub(super) branch_name: Option, +} + +/// Every config in `agents.json`: each agent in file order, followed by its +/// registered branches by name. Both orders are stable. +pub(super) fn config_entries(registry: &project::AgentsConfig) -> Vec { + let mut entries = Vec::new(); + for agent in ®istry.agents { + let name = agent_display_name(&agent.config); + entries.push(AgentConfigEntry { + title: name.clone(), + config_path: agent.config.clone(), + agent_id: agent.id.clone(), + branch_id: agent.branch_id.clone(), + branch_name: None, + }); + for (branch, def) in agent.branches.iter().flatten() { + entries.push(AgentConfigEntry { + title: format!("{name} (branch '{branch}')"), + config_path: def.config.clone(), + agent_id: agent.id.clone(), + branch_id: Some(def.branch_id.clone()), + branch_name: Some(branch.clone()), + }); + } + } + entries +} + // ── init ──────────────────────────────────────────────────────────── #[derive(clap::Args)] diff --git a/cli/elevenlabs/workflow/diagram.rs b/cli/elevenlabs/workflow/diagram.rs index cd76d1f..18c6c08 100644 --- a/cli/elevenlabs/workflow/diagram.rs +++ b/cli/elevenlabs/workflow/diagram.rs @@ -17,7 +17,6 @@ //! diagram only changes when the workflow does. use std::collections::{BTreeMap, BTreeSet}; -use std::io::IsTerminal; use std::path::Path; use fern_cli_sdk::app::CliApp; @@ -25,7 +24,12 @@ use fern_cli_sdk::error::CliError; use fern_cli_sdk::openapi::AppContext; use serde_json::Value; -use super::agents::{agent_display_name, find_agent, require_agents}; +use super::agents::{config_entries, find_agent, require_agents, AgentConfigEntry}; +use super::mermaid::{ + abbreviate, comment_safe, draw_text, fence, node_line, push_classes, quote, str_field, tangles, + terminal_width, text_width, truncate, use_color, Format, MermaidIds, NodeLabel, Style, Target, + EDGE_LABEL_MAX, NODE_LABEL_MAX, SHORT_ID_MIN, +}; use super::{api, project}; /// Register the `agents workflow` command group. @@ -53,16 +57,6 @@ struct DiagramArgs { format: Format, } -#[derive(Clone, Copy, PartialEq, clap::ValueEnum)] -enum Format { - /// Boxes and arrows drawn with box-drawing characters. - Ascii, - /// Mermaid source, which GitHub and most docs tools render. - Mermaid, - /// Mermaid source in a ```mermaid fence. - MermaidMd, -} - fn handle_diagram(args: DiagramArgs, _ctx: &AppContext) -> Result<(), CliError> { let _scope = api::command_scope("agents.workflow.diagram"); let Some(target) = &args.target else { @@ -71,7 +65,7 @@ fn handle_diagram(args: DiagramArgs, _ctx: &AppContext) -> Result<(), CliError> let workflow = load_one(target)?; match args.format { Format::Ascii => { - let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + let color = use_color(); print!( "{}", render_terminal(&workflow.workflow, color, terminal_width())? @@ -84,7 +78,7 @@ fn handle_diagram(args: DiagramArgs, _ctx: &AppContext) -> Result<(), CliError> } fn diagram_all(format: Format) -> Result<(), CliError> { - let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + let color = use_color(); let width = terminal_width(); for (i, workflow) in load_all()?.iter().enumerate() { if i > 0 { @@ -161,18 +155,11 @@ fn load_one(target: &str) -> Result { /// skipping, with a note on stderr, configs that are unreadable or have none. fn load_all() -> Result, CliError> { let registry = require_agents()?; - // agents.json order, then each agent's branches by name: both stable. - let mut entries: Vec<(String, String)> = Vec::new(); - for agent in ®istry.agents { - let name = agent_display_name(&agent.config); - entries.push((name.clone(), agent.config.clone())); - for (branch, def) in agent.branches.iter().flatten() { - entries.push((format!("{name} (branch '{branch}')"), def.config.clone())); - } - } - let mut loaded = Vec::new(); - for (title, config_path) in entries { + for AgentConfigEntry { + title, config_path, .. + } in config_entries(®istry) + { let config = match project::read_value_in_project(&config_path) { Ok(v) => v, Err(e) => { @@ -206,33 +193,8 @@ fn workflow_of(config: &Value) -> Option<&Value> { has_nodes.then_some(workflow) } -fn fence(diagram: &str) -> String { - format!("```mermaid\n{diagram}```\n") -} - // ── Rendering ─────────────────────────────────────────────────────── -/// Longest node label, in characters, before it is cut with an ellipsis. -const NODE_LABEL_MAX: usize = 60; -/// Longest edge label. LLM conditions are full sentences; the diagram only -/// needs enough to tell edges apart. -const EDGE_LABEL_MAX: usize = 40; -/// Line width node labels are wrapped to in the terminal. Side-by-side nodes -/// are what make a diagram wide, so they are kept narrow. -const TERMINAL_WRAP: usize = 24; - -/// Who the Mermaid source is for. -#[derive(Clone, Copy, PartialEq)] -enum Target { - /// mermaid.js (GitHub, docs): one-line labels, entity-escaped, with edge - /// conditions inline. - Export, - /// mmdflux, drawing in a terminal: plain-text labels wrapped onto lines, - /// and edge conditions replaced by numbered keys explained in a legend. - /// Inline conditions are what make a terminal diagram too wide to read. - Terminal, -} - /// Mermaid source, plus the legend lines for [`Target::Terminal`]. struct Built { source: String, @@ -258,14 +220,6 @@ struct Layout { min_width: BTreeMap, } -/// How each node type is drawn: Mermaid shape delimiters and its class. -/// Class names are prefixed because `end` is a Mermaid keyword. -struct Style { - open: &'static str, - close: &'static str, - class: &'static str, -} - fn style_for(node_type: &str, target: Target) -> Style { let (open, close, class) = match node_type { "start" => ("([", "])", "t_start"), @@ -283,24 +237,6 @@ fn style_for(node_type: &str, target: Target) -> Style { Style { open, close, class } } -/// `classDef` lines in emission order. Light fills with dark text read on -/// both GitHub's light and dark themes. -const CLASS_DEFS: &[(&str, &str)] = &[ - ("t_start", "fill:#d1fae5,stroke:#059669,color:#064e3b"), - ("t_end", "fill:#fee2e2,stroke:#dc2626,color:#7f1d1d"), - ("t_say", "fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e"), - ("t_tool", "fill:#fef3c7,stroke:#d97706,color:#78350f"), - ("t_state", "fill:#ede9fe,stroke:#7c3aed,color:#4c1d95"), - ("t_subagent", "fill:#f1f5f9,stroke:#475569,color:#0f172a"), - ("t_transfer", "fill:#fce7f3,stroke:#db2777,color:#831843"), - ( - "t_unknown", - "fill:#f3f4f6,stroke:#6b7280,stroke-dasharray:4 2,color:#111827", - ), - // Terminal only: the numbered condition keys. - ("t_key", "fill:#f3f4f6,stroke:#6b7280,color:#111827"), -]; - /// Render a workflow (`{"nodes": {...}, "edges": {...}}`) as a Mermaid /// flowchart. Always ends with a newline. pub(super) fn render_mermaid(workflow: &Value) -> String { @@ -331,9 +267,6 @@ fn render_terminal( if color { out = draw(&built.source, true)?; } - if !out.ends_with('\n') { - out.push('\n'); - } if !built.legend.is_empty() { out.push('\n'); for line in &built.legend { @@ -435,51 +368,13 @@ fn permutations(n: usize) -> Vec> { all } -/// Characters where lines cross (`┼`), or meet and share a stretch (`├`, -/// `┬`, ...). Boxes never use them, so each one is a tangle a reader has to -/// unpick to see what connects to what. -const TANGLES: &[char] = &[ - '┼', '╋', '╂', '┿', '╪', '╫', '╬', '├', '┤', '┬', '┴', '┣', '┫', '┳', '┻', '┠', '┨', '┯', '┷', - '┝', '┥', '┰', '┸', -]; - -fn tangles(text: &str) -> usize { - text.chars().filter(|c| TANGLES.contains(c)).count() -} - /// Mermaid source drawn as box-drawing text by mmdflux. fn draw(source: &str, color: bool) -> Result { - let mut config = mmdflux::RenderConfig::default(); - if color { - config.text_color_mode = mmdflux::TextColorMode::Ansi; - } - mmdflux::render_diagram(source, mmdflux::OutputFormat::Text, &config).map_err(|e| { - CliError::Other(anyhow::anyhow!( - "Could not draw this workflow in the terminal ({e}). \ - 'elevenlabs agents workflow diagram --format mermaid' prints it as Mermaid instead." - )) - }) -} - -fn text_width(text: &str) -> usize { - text.lines().map(|l| l.chars().count()).max().unwrap_or(0) -} - -/// The width to fit drawings to: `COLUMNS` when set (so scripts and CI can -/// ask for a width), otherwise the terminal's when stdout is one. `None` -/// means draw at full size. -fn terminal_width() -> Option { - if let Some(columns) = std::env::var("COLUMNS") - .ok() - .and_then(|c| c.parse().ok()) - .filter(|c| *c > 0) - { - return Some(columns); - } - if !std::io::stdout().is_terminal() { - return None; - } - terminal_size::terminal_size_of(std::io::stdout()).map(|(w, _)| w.0 as usize) + draw_text( + source, + color, + "elevenlabs agents workflow diagram --format mermaid", + ) } fn build(workflow: &Value, target: Target, layout: &Layout) -> Built { @@ -644,14 +539,7 @@ fn build(workflow: &Value, target: Target, layout: &Layout) -> Built { classes.insert("t_key", keys); } - for (class, style) in CLASS_DEFS { - if let Some(members) = classes.get(class) { - out.push_str(&format!(" classDef {class} {style}\n")); - let mut members = members.clone(); - members.sort_unstable(); - out.push_str(&format!(" class {} {class}\n", members.join(","))); - } - } + push_classes(&mut out, &classes); Built { source: out, legend, @@ -758,10 +646,6 @@ fn one_line(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } -/// Longest word kept whole by [`shorten_ids`]. Names are shorter; IDs such -/// as `tool_01jxcrhpk0ehdvnxw3mw4sfb67` are longer. -const SHORT_ID_MIN: usize = 16; - /// `text` with every ID-like word longer than [`SHORT_ID_MIN`] cut to its /// start and end, `tool_01j…sfb67`, for a terminal too narrow for the drawing. /// Brackets and commas around a word are kept. @@ -774,9 +658,7 @@ fn shorten_ids(text: &str) -> String { if !id_like || chars.len() <= SHORT_ID_MIN { return word.to_string(); } - let head: String = chars[..8].iter().collect(); - let tail: String = chars[chars.len() - 5..].iter().collect(); - word.replacen(core, &format!("{head}…{tail}"), 1) + word.replacen(core, &abbreviate(&chars, 8, 5), 1) }) .collect::>() .join(" ") @@ -819,51 +701,6 @@ fn flow_order<'a>( ordered.into_iter().map(|(_, edge)| edge).collect() } -/// One node's line: `id`, then `label` (already quoted) inside the shape's -/// delimiters. -/// -/// mmdflux ends a round `( )` node at the first `)`, even inside the quoted -/// label, and then fails to parse the rest ("Say · Confirm (booking)"). Other -/// shapes are unaffected, and so is mermaid.js. So in the terminal, a round -/// node's brackets become same-width lookalikes. -fn node_line(id: &str, open: &str, label: &str, close: &str, target: Target) -> String { - let label = if target == Target::Terminal && open == "(" { - label.replace('(', "❨").replace(')', "❩") - } else { - label.to_string() - }; - format!(" {id}{open}{label}{close}\n") -} - -/// A node's label: the kind of node (absent for start/end, which say it -/// themselves) and the text that identifies this one. -struct NodeLabel { - kind: Option, - text: String, -} - -impl NodeLabel { - /// The quoted label for `target`. Export puts it on one line, `kind · - /// text`; the terminal stacks the kind over the text, wrapped narrow. - fn render(&self, target: Target) -> String { - match target { - Target::Export => { - let line = match &self.kind { - Some(kind) => format!("{kind} · {}", self.text), - None => self.text.clone(), - }; - quote(&truncate(&line, NODE_LABEL_MAX)) - } - Target::Terminal => { - let mut lines: Vec = self.kind.iter().cloned().collect(); - lines.extend(wrap(&truncate(&self.text, NODE_LABEL_MAX), TERMINAL_WRAP)); - let lines: Vec = lines.iter().map(|l| plain(l)).collect(); - format!("\"{}\"", lines.join("
")) - } - } - } -} - /// A node's label: its own `label` when it has one, otherwise a summary /// derived from its type. Tagged with the kind, so types are tellable apart /// without colour. @@ -951,120 +788,6 @@ fn condition_label(cond: &Value) -> Option { } } -fn str_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> { - value.get(key).and_then(Value::as_str) -} - -/// Collapse whitespace and cut to `max` characters, ending in `…` when cut. -fn truncate(text: &str, max: usize) -> String { - let collapsed = text.split_whitespace().collect::>().join(" "); - if collapsed.chars().count() <= max { - return collapsed; - } - let cut: String = collapsed.chars().take(max - 1).collect(); - format!("{}…", cut.trim_end()) -} - -/// Quote label text for Mermaid, entity-encoding everything that would end -/// the string or be read as syntax. `#` goes first: it starts every entity. -fn quote(text: &str) -> String { - let mut out = String::with_capacity(text.len() + 2); - out.push('"'); - for c in text.chars() { - match c { - '#' => out.push_str("#35;"), - '"' => out.push_str("#quot;"), - '<' => out.push_str("#lt;"), - '>' => out.push_str("#gt;"), - '|' => out.push_str("#124;"), - '`' => out.push_str("#96;"), - '\n' | '\r' => out.push(' '), - c => out.push(c), - } - } - out.push('"'); - out -} - -/// Label text for mmdflux, which prints quoted text literally but does not -/// decode Mermaid's `#…;` entities. Only a quote (ends the string) and angle -/// brackets (read as tags, like the `
` that wraps lines) need replacing, -/// and lookalikes keep the text readable. -fn plain(text: &str) -> String { - text.chars() - .map(|c| match c { - '"' => '\'', - '<' => '‹', - '>' => '›', - '\n' | '\r' => ' ', - c => c, - }) - .collect() -} - -/// Greedy word wrap to `width` characters. A word longer than the width -/// gets a line of its own rather than being split. -fn wrap(text: &str, width: usize) -> Vec { - let mut lines: Vec = Vec::new(); - let mut line = String::new(); - for word in text.split_whitespace() { - if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > width { - lines.push(std::mem::take(&mut line)); - } - if !line.is_empty() { - line.push(' '); - } - line.push_str(word); - } - if !line.is_empty() { - lines.push(line); - } - lines -} - -/// A node ID for a `%%` comment: one line, nothing else to escape. -fn comment_safe(id: &str) -> String { - id.replace(['\n', '\r'], " ") -} - -/// Mermaid-safe node IDs for workflow node IDs. -/// -/// Workflow IDs are arbitrary strings; Mermaid IDs must be plain words and -/// must not be keywords (`end`) or start with `o`/`x` right after a link -/// (`A---oB` is a circle edge). Prefixing `n_` and replacing everything else -/// with `_` covers all of it; a numeric suffix separates IDs that collapse to -/// the same word. IDs are assigned in sorted order so they are stable. -struct MermaidIds<'a> { - map: BTreeMap<&'a str, String>, -} - -impl<'a> MermaidIds<'a> { - fn new(ids: impl Iterator) -> Self { - let sorted: BTreeSet<&str> = ids.collect(); - let mut map = BTreeMap::new(); - let mut taken = BTreeSet::new(); - for id in sorted { - let base: String = format!("n_{id}") - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect(); - let mut candidate = base.clone(); - let mut n = 2; - while !taken.insert(candidate.clone()) { - candidate = format!("{base}_{n}"); - n += 1; - } - map.insert(id, candidate); - } - MermaidIds { map } - } - - fn get(&self, id: &str) -> &str { - // Every id rendered was passed to `new`. - self.map.get(id).map(String::as_str).unwrap_or("n_") - } -} - #[cfg(test)] mod tests { use super::*; @@ -1413,7 +1136,10 @@ mod tests { let workflow = workflow_of(&config).unwrap(); for width in [None, Some(104)] { let drawn = render_terminal(workflow, false, width).expect("draws"); - let tangled: Vec = drawn.chars().filter(|c| TANGLES.contains(c)).collect(); + let tangled: Vec = drawn + .chars() + .filter(|c| super::super::mermaid::TANGLES.contains(c)) + .collect(); assert!(tangled.is_empty(), "{width:?}: {tangled:?}\n{drawn}"); if let Some(width) = width { assert!(text_width(&drawn) <= width, "{drawn}"); @@ -1483,16 +1209,6 @@ mod tests { assert!(render_mermaid(&workflow).contains("Confirm (booking)")); } - #[test] - fn wrap_keeps_long_words_whole() { - assert_eq!(wrap("a bb ccc", 4), ["a bb", "ccc"]); - assert_eq!( - wrap("system__is_text_only x", 8), - ["system__is_text_only", "x"] - ); - assert!(wrap(" ", 8).is_empty()); - } - /// Every fixture must draw: an mmdflux parse error would only show up as /// a failed command. #[test] diff --git a/cli/elevenlabs/workflow/fixtures/graph/agent_configs/billing.json b/cli/elevenlabs/workflow/fixtures/graph/agent_configs/billing.json new file mode 100644 index 0000000..ea5a6b9 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/agent_configs/billing.json @@ -0,0 +1,12 @@ +{ + "name": "Billing", + "conversation_config": { + "agent": { + "prompt": { + "tool_ids": ["tool_refund"], + "knowledge_base": [{ "type": "file", "id": "doc_pricing", "name": "Pricing FAQ", "usage_mode": "auto" }] + } + } + }, + "tags": [] +} diff --git a/cli/elevenlabs/workflow/fixtures/graph/agent_configs/support.beta.json b/cli/elevenlabs/workflow/fixtures/graph/agent_configs/support.beta.json new file mode 100644 index 0000000..55c024a --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/agent_configs/support.beta.json @@ -0,0 +1,17 @@ +{ + "name": "Support", + "conversation_config": { "agent": { "prompt": { "tool_ids": ["tool_lookup"] } } }, + "platform_settings": { "testing": { "attached_tests": [{ "test_id": "test_greets" }] } }, + "workflow": { + "nodes": { + "start_node": { "type": "start" }, + "refunds": { "type": "override_agent", "label": "Refunds", "additional_tool_ids": ["tool_refund"] }, + "to_billing": { "type": "standalone_agent", "agent_id": "agent_billing" } + }, + "edges": { + "e1": { "source": "start_node", "target": "refunds", "forward_condition": { "type": "unconditional" } }, + "e2": { "source": "refunds", "target": "to_billing", "forward_condition": { "type": "llm", "condition": "Needs billing" } } + } + }, + "tags": [] +} diff --git a/cli/elevenlabs/workflow/fixtures/graph/agent_configs/support.json b/cli/elevenlabs/workflow/fixtures/graph/agent_configs/support.json new file mode 100644 index 0000000..a98173c --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/agent_configs/support.json @@ -0,0 +1,23 @@ +{ + "name": "Support", + "conversation_config": { + "agent": { + "prompt": { + "tool_ids": ["tool_lookup", "tool_from_dashboard"], + "knowledge_base": [{ "type": "file", "id": "doc_pricing", "name": "Pricing FAQ", "usage_mode": "auto" }], + "built_in_tools": { + "transfer_to_agent": { + "type": "system", + "name": "transfer_to_agent", + "params": { + "system_tool_type": "transfer_to_agent", + "transfers": [{ "agent_id": "agent_billing", "condition": "The caller asks about an invoice." }] + } + } + } + } + } + }, + "platform_settings": { "testing": { "attached_tests": [{ "test_id": "test_greets" }] } }, + "tags": [] +} diff --git a/cli/elevenlabs/workflow/fixtures/graph/agents.json b/cli/elevenlabs/workflow/fixtures/graph/agents.json new file mode 100644 index 0000000..0324da4 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/agents.json @@ -0,0 +1,13 @@ +{ + "agents": [ + { + "config": "agent_configs/support.json", + "id": "agent_support", + "branch_id": "agtbrch_main", + "branches": { + "beta": { "config": "agent_configs/support.beta.json", "branch_id": "agtbrch_beta" } + } + }, + { "config": "agent_configs/billing.json", "id": "agent_billing" } + ] +} diff --git a/cli/elevenlabs/workflow/fixtures/graph/expected.mmd b/cli/elevenlabs/workflow/fixtures/graph/expected.mmd new file mode 100644 index 0000000..35ff404 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/expected.mmd @@ -0,0 +1,30 @@ +flowchart LR + n_Agent_agent_billing["Agent · Billing"] + n_Agent_agent_support["Agent · Support"] + n_Agent_agent_support_agtbrch_beta["Agent · Support (branch 'beta')"] + n_Tool_tool_from_dashboard[["Tool (external) · tool_from_dashboard"]] + n_Tool_tool_lookup[["Tool · lookup_order"]] + n_Tool_tool_refund[["Tool · issue_refund"]] + n_Test_test_greets(["Test · Greets the caller"]) + n_Document_doc_pricing[("Document · Pricing FAQ")] + n_Agent_agent_billing --> n_Tool_tool_refund + n_Agent_agent_billing --> n_Document_doc_pricing + n_Agent_agent_support ==> n_Agent_agent_billing + n_Agent_agent_support --> n_Tool_tool_from_dashboard + n_Agent_agent_support --> n_Tool_tool_lookup + n_Agent_agent_support --> n_Test_test_greets + n_Agent_agent_support --> n_Document_doc_pricing + n_Agent_agent_support_agtbrch_beta ==>|"via 'to_billing'"| n_Agent_agent_billing + n_Agent_agent_support_agtbrch_beta --> n_Tool_tool_lookup + n_Agent_agent_support_agtbrch_beta -->|"via 'refunds'"| n_Tool_tool_refund + n_Agent_agent_support_agtbrch_beta --> n_Test_test_greets + classDef t_tool fill:#fef3c7,stroke:#d97706,color:#78350f + class n_Tool_tool_lookup,n_Tool_tool_refund t_tool + classDef t_state fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 + class n_Test_test_greets t_state + classDef t_subagent fill:#f1f5f9,stroke:#475569,color:#0f172a + class n_Agent_agent_billing,n_Agent_agent_support,n_Agent_agent_support_agtbrch_beta t_subagent + classDef t_unknown fill:#f3f4f6,stroke:#6b7280,stroke-dasharray:4 2,color:#111827 + class n_Tool_tool_from_dashboard t_unknown + classDef t_doc fill:#ecfccb,stroke:#65a30d,color:#365314 + class n_Document_doc_pricing t_doc diff --git a/cli/elevenlabs/workflow/fixtures/graph/test_configs/greets.json b/cli/elevenlabs/workflow/fixtures/graph/test_configs/greets.json new file mode 100644 index 0000000..030b1a1 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/test_configs/greets.json @@ -0,0 +1 @@ +{ "name": "Greets the caller", "chat_history": [] } diff --git a/cli/elevenlabs/workflow/fixtures/graph/tests.json b/cli/elevenlabs/workflow/fixtures/graph/tests.json new file mode 100644 index 0000000..ff2186f --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/tests.json @@ -0,0 +1,5 @@ +{ + "tests": [ + { "config": "test_configs/greets.json", "type": "llm", "id": "test_greets" } + ] +} diff --git a/cli/elevenlabs/workflow/fixtures/graph/tool_configs/issue_refund.json b/cli/elevenlabs/workflow/fixtures/graph/tool_configs/issue_refund.json new file mode 100644 index 0000000..5d6ae21 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/tool_configs/issue_refund.json @@ -0,0 +1 @@ +{ "name": "issue_refund", "type": "webhook" } diff --git a/cli/elevenlabs/workflow/fixtures/graph/tool_configs/lookup_order.json b/cli/elevenlabs/workflow/fixtures/graph/tool_configs/lookup_order.json new file mode 100644 index 0000000..373cf45 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/tool_configs/lookup_order.json @@ -0,0 +1 @@ +{ "name": "lookup_order", "type": "webhook" } diff --git a/cli/elevenlabs/workflow/fixtures/graph/tools.json b/cli/elevenlabs/workflow/fixtures/graph/tools.json new file mode 100644 index 0000000..23d2b04 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/graph/tools.json @@ -0,0 +1,6 @@ +{ + "tools": [ + { "type": "webhook", "config": "tool_configs/lookup_order.json", "id": "tool_lookup" }, + { "type": "webhook", "config": "tool_configs/issue_refund.json", "id": "tool_refund" } + ] +} diff --git a/cli/elevenlabs/workflow/graph.rs b/cli/elevenlabs/workflow/graph.rs new file mode 100644 index 0000000..5903727 --- /dev/null +++ b/cli/elevenlabs/workflow/graph.rs @@ -0,0 +1,1288 @@ +//! `agents graph`: how the agents in a project connect to their tools, tests, +//! knowledge-base documents and to each other. +//! +//! Agent configs refer to everything else by ID only, and nothing else in the +//! CLI follows those references. This reads every config in `agents.json` +//! (branches included), resolves the IDs against `tools.json` / `tests.json` / +//! `agents.json`, and draws the result in the terminal, prints it as Mermaid, +//! or answers the reverse question with `--uses`: which agents depend on this? +//! +//! Local files only, like `agents workflow diagram`. Knowledge-base documents +//! have no local registry, so they are shown by the ID and name the agent +//! config carries. An ID that isn't in the project (a tool made in the +//! dashboard, a transfer to an agent outside the repo) becomes an external +//! node rather than being dropped. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use serde_json::Value; + +use super::agents::{config_entries, find_agent, require_agents, AgentConfigEntry}; +use super::mermaid::{ + abbreviate, draw_text, fence, node_line, plain, push_classes, quote, str_field, tangles, + terminal_width, text_width, truncate, use_color, wrap, Format, MermaidIds, NodeLabel, Target, + EDGE_LABEL_MAX, NODE_LABEL_MAX, SHORT_ID_MIN, TERMINAL_WRAP, +}; +use super::{api, project}; + +/// Register `agents graph`. +pub fn register(app: CliApp) -> CliApp { + app.command_under_typed_with( + &["agents"], + clap::Command::new("graph").about( + "Show how agents connect to their tools, tests, documents and each other (local files only)", + ), + handle_graph, + ) +} + +#[derive(clap::Args)] +struct GraphArgs { + /// Show only this agent and what it connects to: a config file, or an + /// agent ID or name from agents.json. + agent: Option, + /// List the agents that use this tool, test, agent or document (an ID, or + /// a tool's or test's name), one per line, tab-separated: agent ID, agent + /// name, where the reference is. + #[arg(long, value_name = "ID_OR_NAME", conflicts_with_all = ["agent", "format"])] + uses: Option, + /// What to print: one drawing per agent for the terminal, or the whole + /// project as Mermaid source, bare or in a ```mermaid fence. + #[arg(long, value_enum, default_value_t = Format::Ascii)] + format: Format, +} + +fn handle_graph(args: GraphArgs, _ctx: &AppContext) -> Result<(), CliError> { + let _scope = api::command_scope("agents.graph"); + let registry = require_agents()?; + let entries = config_entries(®istry); + let mut agents = Vec::new(); + for entry in entries { + match project::read_value_in_project(&entry.config_path) { + Ok(config) => agents.push(LoadedAgent { entry, config }), + Err(e) => eprintln!("Skipping '{}': {e}", entry.title), + } + } + if agents.is_empty() { + eprintln!("No agents in agents.json to graph."); + return Ok(()); + } + let graph = collect(&agents, &tool_names()?, &test_names()?); + + if let Some(query) = &args.uses { + let lines = uses(&graph, query); + if lines.is_empty() { + eprintln!("No agent in agents.json uses '{query}'."); + } + for line in lines { + println!("{line}"); + } + return Ok(()); + } + + let scope = match &args.agent { + Some(target) => Some(resolve_agent(®istry, &agents, target)?), + None => None, + }; + if args.format != Format::Ascii { + let graph = match &scope { + Some(key) => graph.scoped_to(key), + None => graph, + }; + let source = render(&graph, Target::Export); + if args.format == Format::MermaidMd { + print!("{}", fence(&source)); + } else { + print!("{source}"); + } + return Ok(()); + } + + // In the terminal the whole project is one drawing per agent, in + // agents.json order: drawn as one graph, edges cross and merge until it is + // no longer clear what connects to what. Each agent on its own is a star, + // which draws cleanly. --format mermaid keeps the single graph, which mermaid.js + // lays out well. + let color = use_color(); + let width = terminal_width(); + let draw = |key: &Key| draw_fitted(&graph.star(key), key, width, color); + match &scope { + Some(key) => print!("{}", draw(key)?), + None => { + for (i, agent) in agents.iter().enumerate() { + if i > 0 { + println!(); + } + println!("── {} ({})\n", agent.entry.title, agent.entry.config_path); + print!("{}", draw(&agent_key(&agent.entry))?); + } + } + } + Ok(()) +} + +/// Draw one agent's [`Graph::star`] for the terminal (see [`render_star`]). +/// When the drawing is wider than `width`, +/// redraw with long IDs shortened; if even that does not fit, say so on +/// stderr and print it anyway. +fn draw_fitted( + star: &Graph, + focus: &Key, + width: Option, + color: bool, +) -> Result { + const FALLBACK: &str = "elevenlabs agents graph --format mermaid"; + // Measure without colour: ANSI codes are not columns. + let (mut order, mut plain) = best_order(star, focus, FALLBACK)?; + let mut graph = star.clone(); + if let Some(width) = width.filter(|w| text_width(&plain) > *w) { + graph = shorten_ids(star); + (order, plain) = best_order(&graph, focus, FALLBACK)?; + if text_width(&plain) > width { + eprintln!( + "This drawing is {} columns wide, more than the {width} available. \ + '{FALLBACK}' prints it as Mermaid instead.", + text_width(&plain) + ); + } + } + if color { + draw_text(&render_star(&graph, focus, &order), true, FALLBACK) + } else { + Ok(plain) + } +} + +/// A copy of `graph` with every node named by a long ID shown as the ID's +/// start and end, `tool_01jx…sfb67`. Real names are left alone. Shortened +/// names are kept distinct by lengthening the ones that collide. +fn shorten_ids(graph: &Graph) -> Graph { + let mut short = graph.clone(); + let long: Vec = graph + .nodes + .iter() + .filter(|(_, n)| n.name == n.id && n.id.chars().count() > SHORT_ID_MIN) + .map(|(k, _)| k.clone()) + .collect(); + for key in &long { + let id = &graph.nodes[key].id; + let chars: Vec = id.chars().collect(); + // Start with 8 + 5 characters; grow the tail until no other long ID + // in the same graph abbreviates the same way. + let mut tail = 5; + let name = loop { + let candidate = abbreviate(&chars, 8, tail); + let clash = long.iter().any(|other| { + other != key && { + let o: Vec = graph.nodes[other].id.chars().collect(); + abbreviate(&o, 8, tail) == candidate + } + }); + if !clash || 8 + tail >= chars.len() { + break if clash { id.clone() } else { candidate }; + } + tail += 1; + }; + short.nodes.get_mut(key).expect("key from graph").name = name; + } + short +} + +/// An agents.json entry and its parsed config. +struct LoadedAgent { + entry: AgentConfigEntry, + config: Value, +} + +/// The graph key of the agent `target` names: a config file path, or an +/// agent ID or name (which means its main config). +fn resolve_agent( + registry: &project::AgentsConfig, + agents: &[LoadedAgent], + target: &str, +) -> Result { + let not_found = || { + CliError::Validation(format!( + "'{target}' is not a config file in agents.json, or an agent ID or name in it" + )) + }; + let config_path = if Path::new(target).is_file() { + let wanted = Path::new(target).canonicalize().ok(); + agents + .iter() + .find(|a| Path::new(&a.entry.config_path).canonicalize().ok() == wanted) + .map(|a| a.entry.config_path.clone()) + .ok_or_else(not_found)? + } else { + find_agent(registry, target) + .ok_or_else(not_found)? + .config + .clone() + }; + agents + .iter() + .find(|a| a.entry.config_path == config_path) + .map(|a| agent_key(&a.entry)) + .ok_or_else(not_found) +} + +/// Tool ID → local name, from tools.json. A project without one has no +/// local tools, so every tool reference is external. +fn tool_names() -> Result, CliError> { + if !Path::new(project::TOOLS_FILE).exists() { + return Ok(BTreeMap::new()); + } + Ok(project::load_tools()? + .tools + .iter() + .filter_map(|t| Some((t.id.clone()?, registry_name(&t.config)))) + .collect()) +} + +/// Test ID → local name, from tests.json. Missing means none, as for tools. +fn test_names() -> Result, CliError> { + if !Path::new(project::TESTS_FILE).exists() { + return Ok(BTreeMap::new()); + } + Ok(project::load_tests()? + .tests + .iter() + .filter_map(|t| Some((t.id.clone()?, registry_name(&t.config)))) + .collect()) +} + +/// A registered config's name, falling back to its path. +fn registry_name(config_path: &str) -> String { + project::config_name(config_path).unwrap_or_else(|| config_path.to_string()) +} + +// ── Model ─────────────────────────────────────────────────────────── + +/// What a node is. The order is the order nodes are emitted in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Kind { + Agent, + Tool, + Test, + Document, +} + +impl Kind { + fn name(self) -> &'static str { + match self { + Kind::Agent => "Agent", + Kind::Tool => "Tool", + Kind::Test => "Test", + Kind::Document => "Document", + } + } +} + +/// A node's identity: its kind and an ID unique within that kind. Agents are +/// keyed by agent ID plus `#branch_id` for a registered branch; an agent not +/// pushed yet (no ID) by its config path. +type Key = (Kind, String); + +#[derive(Clone)] +struct Node { + /// The name shown: the local name, or the ID when there is none. + name: String, + /// The resource ID `--uses` prints (an agent's ID without the branch). + id: String, + /// Referenced, but not in this project. + external: bool, +} + +#[derive(Clone, Default)] +struct Graph { + nodes: BTreeMap, + /// (from, to) → where in the source config the reference is. Several + /// references between the same two nodes are one edge. + edges: BTreeMap<(Key, Key), BTreeSet>, +} + +impl Graph { + /// One agent, everything it references, and every agent that hands off + /// to it. + fn scoped_to(&self, agent: &Key) -> Graph { + let edges: BTreeMap<(Key, Key), BTreeSet> = self + .edges + .iter() + .filter(|((from, to), _)| from == agent || to == agent) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let mut keep: BTreeSet<&Key> = BTreeSet::from([agent]); + for (from, to) in edges.keys() { + keep.insert(from); + keep.insert(to); + } + let nodes = self + .nodes + .iter() + .filter(|(k, _)| keep.contains(k)) + .map(|(k, n)| (k.clone(), n.clone())) + .collect(); + Graph { nodes, edges } + } +} + +impl Graph { + /// [`Graph::scoped_to`], laid out for drawing as text: the agents that + /// hand off to `agent` become separate "caller" nodes, so every edge runs + /// left to right (callers → agent → what it uses). + /// + /// With the real nodes, an agent that `agent` also hands off to (A ⇄ B) + /// makes a loop, and mmdflux routes the way back through the column of + /// tools and tests, as if they connected to each other. Mermaid export + /// keeps the real graph: mermaid.js lays loops out well. + fn star(&self, agent: &Key) -> Graph { + let mut star = Graph::default(); + if let Some(node) = self.nodes.get(agent) { + star.nodes.insert(agent.clone(), node.clone()); + } + for ((from, to), vias) in &self.edges { + if from == agent { + star.nodes.insert(to.clone(), self.nodes[to].clone()); + star.edges.insert((from.clone(), to.clone()), vias.clone()); + } else if to == agent { + let caller = (Kind::Agent, format!("caller:{}", from.1)); + star.nodes.insert(caller.clone(), self.nodes[from].clone()); + star.edges.insert((caller, to.clone()), vias.clone()); + } + } + star + } +} + +fn agent_key(entry: &AgentConfigEntry) -> Key { + let id = match (&entry.agent_id, &entry.branch_name, &entry.branch_id) { + (Some(id), Some(_), Some(branch)) => format!("{id}#{branch}"), + (Some(id), _, _) => id.clone(), + (None, _, _) => entry.config_path.clone(), + }; + (Kind::Agent, id) +} + +/// One reference found in an agent config. +struct Ref { + kind: Kind, + id: String, + /// Transfers only: the target branch, if the transfer names one. + branch_id: Option, + /// Documents only: the name the locator carries. + name: Option, + /// Where in the config, for `--uses` and export edge labels. + via: String, +} + +/// Every reference in one agent config, in a stable order. +fn refs_in(config: &Value) -> Vec { + let mut refs = Vec::new(); + if let Some(prompt) = config.pointer("/conversation_config/agent/prompt") { + refs_in_prompt(prompt, "prompt", &mut refs); + } + for test in array(config.pointer("/platform_settings/testing/attached_tests")) { + if let Some(id) = str_field(test, "test_id") { + refs.push(simple( + Kind::Test, + id, + "platform_settings.testing.attached_tests", + )); + } + } + let nodes = config + .pointer("/workflow/nodes") + .and_then(Value::as_object) + .map(|n| n.iter().collect::>()) + .unwrap_or_default(); + for (node_id, node) in nodes { + let via = format!("workflow node '{node_id}'"); + for tool in array(node.get("tools")) { + if let Some(id) = str_field(tool, "tool_id") { + refs.push(simple(Kind::Tool, id, &via)); + } + } + for id in array(node.get("additional_tool_ids")).filter_map(Value::as_str) { + refs.push(simple(Kind::Tool, id, &via)); + } + for doc in array(node.get("additional_knowledge_base")) { + push_document(doc, &via, &mut refs); + } + if str_field(node, "type") == Some("standalone_agent") { + if let Some(id) = str_field(node, "agent_id") { + refs.push(Ref { + kind: Kind::Agent, + id: id.to_string(), + branch_id: str_field(node, "branch_id").map(String::from), + name: None, + via: via.clone(), + }); + } + } + if let Some(prompt) = node.pointer("/conversation_config/agent/prompt") { + refs_in_prompt(prompt, &via, &mut refs); + } + } + refs +} + +/// The references a `prompt` object can hold, at the root or in a workflow +/// node's override. +fn refs_in_prompt(prompt: &Value, via: &str, refs: &mut Vec) { + let at = |field: &str| { + if via == "prompt" { + format!("prompt.{field}") + } else { + via.to_string() + } + }; + for id in array(prompt.get("tool_ids")).filter_map(Value::as_str) { + refs.push(simple(Kind::Tool, id, &at("tool_ids"))); + } + for doc in array(prompt.get("knowledge_base")) { + push_document(doc, &at("knowledge_base"), refs); + } + let transfers = prompt.pointer("/built_in_tools/transfer_to_agent/params/transfers"); + for transfer in array(transfers) { + if let Some(id) = str_field(transfer, "agent_id") { + refs.push(Ref { + kind: Kind::Agent, + id: id.to_string(), + branch_id: str_field(transfer, "branch_id").map(String::from), + name: None, + via: at("built_in_tools.transfer_to_agent"), + }); + } + } +} + +fn push_document(doc: &Value, via: &str, refs: &mut Vec) { + if let Some(id) = str_field(doc, "id") { + refs.push(Ref { + kind: Kind::Document, + id: id.to_string(), + branch_id: None, + name: str_field(doc, "name") + .filter(|n| !n.trim().is_empty()) + .map(String::from), + via: via.to_string(), + }); + } +} + +fn simple(kind: Kind, id: &str, via: &str) -> Ref { + Ref { + kind, + id: id.to_string(), + branch_id: None, + name: None, + via: via.to_string(), + } +} + +fn array(value: Option<&Value>) -> impl Iterator { + value.and_then(Value::as_array).into_iter().flatten() +} + +/// Build the graph from the loaded agent configs and the tool and test +/// registries (ID → local name). Pure, so it is testable without files. +fn collect( + agents: &[LoadedAgent], + tools: &BTreeMap, + tests: &BTreeMap, +) -> Graph { + let mut graph = Graph::default(); + // (agent ID, branch ID) → key. `None` is the main config, which a + // transfer naming the main branch's ID also reaches. + let mut agent_keys: BTreeMap<(String, Option), Key> = BTreeMap::new(); + for agent in agents { + let key = agent_key(&agent.entry); + graph.nodes.insert( + key.clone(), + Node { + name: agent.entry.title.clone(), + id: agent.entry.agent_id.clone().unwrap_or_default(), + external: false, + }, + ); + if let Some(id) = &agent.entry.agent_id { + if agent.entry.branch_name.is_none() { + agent_keys.insert((id.clone(), None), key.clone()); + } + if let Some(branch) = &agent.entry.branch_id { + agent_keys + .entry((id.clone(), Some(branch.clone()))) + .or_insert(key); + } + } + } + + for agent in agents { + let from = agent_key(&agent.entry); + for r in refs_in(&agent.config) { + let to = match r.kind { + Kind::Agent => { + let local = r + .branch_id + .as_ref() + .and_then(|b| agent_keys.get(&(r.id.clone(), Some(b.clone())))) + .or_else(|| agent_keys.get(&(r.id.clone(), None))); + match local { + Some(key) => key.clone(), + None => external(&mut graph, Kind::Agent, &r.id), + } + } + Kind::Tool | Kind::Test => { + let names = if r.kind == Kind::Tool { tools } else { tests }; + match names.get(&r.id) { + Some(name) => { + let key = (r.kind, r.id.clone()); + graph.nodes.entry(key.clone()).or_insert_with(|| Node { + name: name.clone(), + id: r.id.clone(), + external: false, + }); + key + } + None => external(&mut graph, r.kind, &r.id), + } + } + // No local registry: never "external", always named by the + // locator (the first name seen wins). + Kind::Document => { + let key = (Kind::Document, r.id.clone()); + graph.nodes.entry(key.clone()).or_insert_with(|| Node { + name: r.name.clone().unwrap_or_else(|| r.id.clone()), + id: r.id.clone(), + external: false, + }); + key + } + }; + graph + .edges + .entry((from.clone(), to)) + .or_default() + .insert(r.via); + } + } + graph +} + +/// The key of an external node, adding it on first sight. +fn external(graph: &mut Graph, kind: Kind, id: &str) -> Key { + let key = (kind, id.to_string()); + graph.nodes.entry(key.clone()).or_insert_with(|| Node { + name: id.to_string(), + id: id.to_string(), + external: true, + }); + key +} + +/// `--uses`: one line per (agent, reference), tab-separated agent ID, agent +/// name and where the reference is. `query` matches a node's ID, or the +/// local name of a tool or test. +fn uses(graph: &Graph, query: &str) -> Vec { + let targets: BTreeSet<&Key> = graph + .nodes + .iter() + .filter(|((kind, _), node)| { + node.id == query || (matches!(kind, Kind::Tool | Kind::Test) && node.name == query) + }) + .map(|(key, _)| key) + .collect(); + let mut lines = Vec::new(); + for ((from, to), vias) in &graph.edges { + if !targets.contains(to) { + continue; + } + let agent = &graph.nodes[from]; + for via in vias { + lines.push(format!("{}\t{}\t{via}", agent.id, agent.name)); + } + } + lines +} + +// ── Rendering ─────────────────────────────────────────────────────── + +/// Mermaid source for the graph, left to right: a single agent's references +/// then stack downwards instead of spreading sideways. Agents hand off along +/// thick arrows; every other reference is a plain one. For export, a reference made by a +/// workflow node is labelled with the node. +fn render(graph: &Graph, target: Target) -> String { + let mermaid_key = |(kind, id): &Key| format!("{}:{id}", kind.name()); + let keys: Vec = graph.nodes.keys().map(mermaid_key).collect(); + let ids = MermaidIds::new(keys.iter().map(String::as_str)); + let mut out = String::from("flowchart LR\n"); + let mut classes: BTreeMap<&'static str, Vec<&str>> = BTreeMap::new(); + + for (key, node) in &graph.nodes { + let id = ids.get(&mermaid_key(key)); + let kind = if node.external { + format!("{} (external)", key.0.name()) + } else { + key.0.name().to_string() + }; + let label = NodeLabel { + kind: Some(kind), + text: node.name.clone(), + } + .render(target); + let (open, close, class) = match key.0 { + Kind::Agent => ("[", "]", "t_subagent"), + Kind::Tool => ("[[", "]]", "t_tool"), + // A stadium rather than a round node: they draw alike in the + // terminal, and a round node trips mmdflux on the `)` of + // "(external)" (see `node_line`). + Kind::Test => ("([", "])", "t_state"), + Kind::Document => ("[(", ")]", "t_doc"), + }; + let class = if node.external { "t_unknown" } else { class }; + out.push_str(&node_line(id, open, &label, close, target)); + classes.entry(class).or_default().push(id); + } + + for ((from, to), vias) in &graph.edges { + let link = if to.0 == Kind::Agent { "==>" } else { "-->" }; + let (from, to) = (ids.get(&mermaid_key(from)), ids.get(&mermaid_key(to))); + let nodes: Vec<&str> = vias + .iter() + .filter_map(|v| v.strip_prefix("workflow node ")) + .collect(); + match target { + Target::Export if !nodes.is_empty() => { + let label = truncate(&format!("via {}", nodes.join(", ")), EDGE_LABEL_MAX); + out.push_str(&format!(" {from} {link}|{}| {to}\n", quote(&label))); + } + _ => out.push_str(&format!(" {from} {link} {to}\n")), + } + } + push_classes(&mut out, &classes); + out +} + +/// Longest item in a grouped box, in characters. +const GROUP_ITEM_MAX: usize = 36; + +/// One box around the agent in [`render_star`]. +struct Group<'a> { + title: &'static str, + /// How the agent's box counts this group: (one, several). + count: (&'static str, &'static str), + pick: Box bool + 'a>, + shape: (&'static str, &'static str), + class: &'static str, + id: &'static str, + /// Handoffs are thick, as in the Mermaid graph. + link: &'static str, + /// On the left, pointing at the agent, rather than on the right. + left: bool, +} +/// The boxes around `focus` in [`render_star`]: callers first (always on the +/// left), then the right-hand boxes in their natural order. +fn star_groups(focus: &Key) -> [Group<'_>; 5] { + [ + Group { + title: "Handed off from", + count: ("hands off to it", "hand off to it"), + pick: Box::new(is_caller), + shape: ("[", "]"), + class: "t_subagent", + id: "g_callers", + link: "==>", + left: true, + }, + Group { + title: "Hands off to", + count: ("handoff", "handoffs"), + pick: Box::new(move |k: &Key| k.0 == Kind::Agent && k != focus && !is_caller(k)), + shape: ("[", "]"), + class: "t_subagent", + id: "g_handoffs", + link: "==>", + left: false, + }, + Group { + title: "Tools", + count: ("tool", "tools"), + pick: Box::new(|k: &Key| k.0 == Kind::Tool), + shape: ("[[", "]]"), + class: "t_tool", + id: "g_tools", + link: "-->", + left: false, + }, + Group { + title: "Tests", + count: ("test", "tests"), + pick: Box::new(|k: &Key| k.0 == Kind::Test), + shape: ("([", "])"), + class: "t_state", + id: "g_tests", + link: "-->", + left: false, + }, + Group { + title: "Documents", + count: ("document", "documents"), + pick: Box::new(|k: &Key| k.0 == Kind::Document), + shape: ("[(", ")]"), + class: "t_doc", + id: "g_documents", + link: "-->", + left: false, + }, + ] +} + +fn is_caller((kind, id): &Key) -> bool { + *kind == Kind::Agent && id.starts_with("caller:") +} + +/// Indexes into [`star_groups`] of the right-hand boxes `star` has. +fn right_groups(star: &Graph, focus: &Key) -> Vec { + let groups = star_groups(focus); + (1..groups.len()) + .filter(|i| star.nodes.keys().any(|k| (groups[*i].pick)(k))) + .collect() +} + +/// Every ordering of `items`, starting with `items` as given. +fn permutations(items: &[usize]) -> Vec> { + if items.len() <= 1 { + return vec![items.to_vec()]; + } + let mut all = Vec::new(); + for (i, first) in items.iter().enumerate() { + let mut rest = items.to_vec(); + rest.remove(i); + for mut tail in permutations(&rest) { + tail.insert(0, *first); + all.push(tail); + } + } + all +} + +/// The right-hand box order whose drawing has the fewest tangled lines, +/// with that drawing (uncoloured). Ties keep the natural order: handoffs, +/// tools, tests, documents. +/// +/// Which order is clearest depends on how many boxes there are and how tall +/// each is, which mmdflux decides, so it is found by drawing each order (at +/// most 24, and each drawing is small) rather than by a fixed rule. +fn best_order(star: &Graph, focus: &Key, fallback: &str) -> Result<(Vec, String), CliError> { + let mut best: Option<(usize, Vec, String)> = None; + for order in permutations(&right_groups(star, focus)) { + let text = draw_text(&render_star(star, focus, &order), false, fallback)?; + let count = tangles(&text); + if best.as_ref().is_none_or(|(fewest, _, _)| count < *fewest) { + let done = count == 0; + best = Some((count, order, text)); + if done { + break; + } + } + } + let (_, order, text) = best.expect("there is always at least one order"); + Ok((order, text)) +} + +/// Mermaid source for one agent's [`Graph::star`], for the terminal. +/// +/// Everything around the agent is grouped into one box per kind, listing +/// them: the agents that hand off to it on the left, and on the right the +/// agents it hands off to, its tools, its tests and its documents. So a star +/// has at most one line in and four out. +/// +/// With a box per neighbour, an agent with a dozen tools or a few handoffs +/// needs that many parallel lines, and mmdflux gives each its own lane: they +/// cross and run alongside each other until it is unclear which line ends +/// where. `--format mermaid` keeps one node per resource. +/// +/// `order` is the top-to-bottom order of the right-hand boxes, as indexes +/// into [`star_groups`] (see [`best_order`]). +fn render_star(star: &Graph, focus: &Key, order: &[usize]) -> String { + let target = Target::Terminal; + let mut out = String::from("flowchart LR\n"); + let mut classes: BTreeMap<&'static str, Vec<&str>> = BTreeMap::new(); + + // The agent's box is a summary card: its name, then a count for each box + // around it. Besides being useful, the count lines make the box tall + // enough that mmdflux gives every line leaving it a row of its own; on a + // two-line box it attaches several lines to the same row, and they cross + // or run together on the way out. + let groups = star_groups(focus); + let mut lines = vec!["Agent".to_string()]; + lines.extend(wrap( + &truncate(&star.nodes[focus].name, NODE_LABEL_MAX), + TERMINAL_WRAP, + )); + for group in &groups { + let n = star.nodes.keys().filter(|k| (group.pick)(k)).count(); + if n > 0 { + let (one, many) = group.count; + lines.push(format!("{n} {}", if n == 1 { one } else { many })); + } + } + let lines: Vec = lines.iter().map(|l| plain(l)).collect(); + let label = format!("\"{}\"", lines.join("
")); + out.push_str(&node_line("n_focus", "[", &label, "]", target)); + classes.entry("t_subagent").or_default().push("n_focus"); + + let picked = [0].iter().chain(order).map(|i| &groups[*i]); + for group in picked { + let Group { + title, + pick, + shape: (open, close), + class, + id: group_id, + link, + left, + .. + } = group; + let mut items: Vec = star + .nodes + .iter() + .filter(|(key, _)| pick(key)) + .map(|(_, n)| { + let name = truncate(&n.name, GROUP_ITEM_MAX); + if n.external { + format!("{name} (external)") + } else { + name + } + }) + .collect(); + if items.is_empty() { + continue; + } + items.sort(); + let mut lines = vec![format!("{title} ({})", items.len())]; + lines.extend(items); + let lines: Vec = lines.iter().map(|l| plain(l)).collect(); + let label = format!("\"{}\"", lines.join("
")); + out.push_str(&node_line(group_id, open, &label, close, target)); + if *left { + out.push_str(&format!(" {group_id} {link} n_focus\n")); + } else { + out.push_str(&format!(" n_focus {link} {group_id}\n")); + } + classes.entry(class).or_default().push(group_id); + } + push_classes(&mut out, &classes); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn agent(title: &str, id: &str, config: Value) -> LoadedAgent { + LoadedAgent { + entry: AgentConfigEntry { + title: title.to_string(), + config_path: format!("agent_configs/{title}.json"), + agent_id: Some(id.to_string()), + branch_id: None, + branch_name: None, + }, + config, + } + } + + fn branch(title: &str, id: &str, name: &str, branch_id: &str, config: Value) -> LoadedAgent { + LoadedAgent { + entry: AgentConfigEntry { + title: format!("{title} (branch '{name}')"), + config_path: format!("agent_configs/{title}.{name}.json"), + agent_id: Some(id.to_string()), + branch_id: Some(branch_id.to_string()), + branch_name: Some(name.to_string()), + }, + config, + } + } + + fn names(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + fn edge(graph: &Graph, from: (Kind, &str), to: (Kind, &str)) -> Option> { + graph + .edges + .get(&((from.0, from.1.to_string()), (to.0, to.1.to_string()))) + .map(|v| v.iter().cloned().collect()) + } + + #[test] + fn every_reference_path_becomes_an_edge() { + let config = json!({ + "conversation_config": { "agent": { "prompt": { + "tool_ids": ["tool_a"], + "knowledge_base": [{ "type": "file", "id": "doc_1", "name": "Pricing", "usage_mode": "auto" }], + "built_in_tools": { "transfer_to_agent": { "type": "system", "name": "transfer_to_agent", + "params": { "system_tool_type": "transfer_to_agent", "transfers": [{ "agent_id": "agent_b", "condition": "billing" }] } } } + } } }, + "platform_settings": { "testing": { "attached_tests": [{ "test_id": "test_1" }] } }, + "workflow": { "nodes": { + "lookup": { "type": "tool", "tools": [{ "tool_id": "tool_b" }] }, + "sub": { "type": "override_agent", "label": "Sub", + "additional_tool_ids": ["tool_c"], + "additional_knowledge_base": [{ "type": "url", "id": "doc_2", "name": "FAQ" }], + "conversation_config": { "agent": { "prompt": { "tool_ids": ["tool_d"] } } } }, + "handoff": { "type": "standalone_agent", "agent_id": "agent_c" } + } } + }); + let graph = collect( + &[agent("A", "agent_a", config)], + &names(&[("tool_a", "Lookup")]), + &names(&[("test_1", "Greets")]), + ); + let a = (Kind::Agent, "agent_a"); + assert_eq!( + edge(&graph, a, (Kind::Tool, "tool_a")).unwrap(), + ["prompt.tool_ids"] + ); + assert_eq!( + edge(&graph, a, (Kind::Document, "doc_1")).unwrap(), + ["prompt.knowledge_base"] + ); + assert_eq!( + edge(&graph, a, (Kind::Agent, "agent_b")).unwrap(), + ["prompt.built_in_tools.transfer_to_agent"] + ); + assert_eq!( + edge(&graph, a, (Kind::Test, "test_1")).unwrap(), + ["platform_settings.testing.attached_tests"] + ); + assert_eq!( + edge(&graph, a, (Kind::Tool, "tool_b")).unwrap(), + ["workflow node 'lookup'"] + ); + for tool in ["tool_c", "tool_d"] { + assert_eq!( + edge(&graph, a, (Kind::Tool, tool)).unwrap(), + ["workflow node 'sub'"], + "{tool}" + ); + } + assert_eq!( + edge(&graph, a, (Kind::Document, "doc_2")).unwrap(), + ["workflow node 'sub'"] + ); + assert_eq!( + edge(&graph, a, (Kind::Agent, "agent_c")).unwrap(), + ["workflow node 'handoff'"] + ); + // Names come from the registries and the locators. + assert_eq!(graph.nodes[&(Kind::Tool, "tool_a".into())].name, "Lookup"); + assert_eq!(graph.nodes[&(Kind::Test, "test_1".into())].name, "Greets"); + assert_eq!( + graph.nodes[&(Kind::Document, "doc_1".into())].name, + "Pricing" + ); + } + + #[test] + fn ids_outside_the_project_become_external_nodes() { + let config = json!({ "conversation_config": { "agent": { "prompt": { + "tool_ids": ["tool_local", "tool_dashboard"], + "built_in_tools": { "transfer_to_agent": { "params": { "transfers": [{ "agent_id": "agent_elsewhere" }] } } } + } } } }); + let graph = collect( + &[agent("A", "agent_a", config)], + &names(&[("tool_local", "Local")]), + &BTreeMap::new(), + ); + assert!(!graph.nodes[&(Kind::Tool, "tool_local".into())].external); + assert!(graph.nodes[&(Kind::Tool, "tool_dashboard".into())].external); + assert!(graph.nodes[&(Kind::Agent, "agent_elsewhere".into())].external); + let source = render(&graph, Target::Export); + assert!( + source.contains(r#"n_Tool_tool_dashboard[["Tool (external) · tool_dashboard"]]"#), + "{source}" + ); + assert!( + source.contains("class n_Agent_agent_elsewhere,n_Tool_tool_dashboard t_unknown"), + "{source}" + ); + } + + #[test] + fn a_transfer_naming_a_registered_branch_reaches_that_branch() { + let caller = json!({ "conversation_config": { "agent": { "prompt": { + "built_in_tools": { "transfer_to_agent": { "params": { "transfers": [ + { "agent_id": "agent_b", "branch_id": "agtbrch_beta" }, + { "agent_id": "agent_b", "branch_id": "agtbrch_unregistered" } + ] } } } + } } } }); + let graph = collect( + &[ + agent("A", "agent_a", caller), + agent("B", "agent_b", json!({})), + branch("B", "agent_b", "beta", "agtbrch_beta", json!({})), + ], + &BTreeMap::new(), + &BTreeMap::new(), + ); + let a = (Kind::Agent, "agent_a"); + assert!(edge(&graph, a, (Kind::Agent, "agent_b#agtbrch_beta")).is_some()); + // A branch agents.json doesn't know falls back to the main config. + assert!(edge(&graph, a, (Kind::Agent, "agent_b")).is_some()); + assert_eq!( + graph.nodes[&(Kind::Agent, "agent_b#agtbrch_beta".into())].name, + "B (branch 'beta')" + ); + } + + #[test] + fn repeated_references_merge_into_one_edge() { + let config = json!({ + "conversation_config": { "agent": { "prompt": { "tool_ids": ["tool_a", "tool_a"] } } }, + "workflow": { "nodes": { "n": { "type": "tool", "tools": [{ "tool_id": "tool_a" }] } } } + }); + let graph = collect( + &[agent("A", "agent_a", config)], + &BTreeMap::new(), + &BTreeMap::new(), + ); + assert_eq!(graph.edges.len(), 1); + assert_eq!( + edge(&graph, (Kind::Agent, "agent_a"), (Kind::Tool, "tool_a")).unwrap(), + ["prompt.tool_ids", "workflow node 'n'"] + ); + let source = render(&graph, Target::Export); + assert!(source.contains(r#"-->|"via 'n'"|"#), "{source}"); + } + + #[test] + fn uses_matches_an_id_or_a_tool_or_test_name() { + let shared = + json!({ "conversation_config": { "agent": { "prompt": { "tool_ids": ["tool_a"] } } } }); + let graph = collect( + &[ + agent("A", "agent_a", shared.clone()), + agent("B", "agent_b", shared), + ], + &names(&[("tool_a", "Lookup")]), + &BTreeMap::new(), + ); + let expected = ["agent_a\tA\tprompt.tool_ids", "agent_b\tB\tprompt.tool_ids"]; + assert_eq!(uses(&graph, "tool_a"), expected); + assert_eq!(uses(&graph, "Lookup"), expected); + assert!(uses(&graph, "nothing").is_empty()); + } + + #[test] + fn scoping_keeps_one_agent_its_references_and_who_calls_it() { + let a = json!({ "conversation_config": { "agent": { "prompt": { + "tool_ids": ["tool_a"], + "built_in_tools": { "transfer_to_agent": { "params": { "transfers": [{ "agent_id": "agent_b" }] } } } + } } } }); + let c = + json!({ "conversation_config": { "agent": { "prompt": { "tool_ids": ["tool_c"] } } } }); + let graph = collect( + &[ + agent("A", "agent_a", a), + agent("B", "agent_b", json!({})), + agent("C", "agent_c", c), + ], + &BTreeMap::new(), + &BTreeMap::new(), + ); + let scoped = graph.scoped_to(&(Kind::Agent, "agent_b".into())); + let kept: Vec<&str> = scoped.nodes.keys().map(|(_, id)| id.as_str()).collect(); + assert_eq!(kept, ["agent_a", "agent_b"]); + let scoped = graph.scoped_to(&(Kind::Agent, "agent_a".into())); + let kept: Vec<&str> = scoped.nodes.keys().map(|(_, id)| id.as_str()).collect(); + assert_eq!(kept, ["agent_a", "agent_b", "tool_a"]); + } + + fn long_ids_graph() -> Graph { + let config = json!({ "conversation_config": { "agent": { "prompt": { + "tool_ids": ["tool_01jxcrhpk0ehdvnxw3mw4sfb67", "tool_01jxcrhpk3er4ve8nxcfprz04x", "tool_local"], + "knowledge_base": [{ "id": "F7wzgVuiIUIKYAH2JH5s", "name": "A very long document name indeed" }] + } } } }); + collect( + &[agent("A", "agent_a", config)], + &names(&[("tool_local", "a_local_tool_with_a_long_name")]), + &BTreeMap::new(), + ) + } + + #[test] + fn long_ids_are_shortened_and_kept_distinct() { + let short = shorten_ids(&long_ids_graph()); + let name = |id: &str| short.nodes[&(Kind::Tool, id.to_string())].name.clone(); + assert_eq!(name("tool_01jxcrhpk0ehdvnxw3mw4sfb67"), "tool_01j…sfb67"); + assert_eq!(name("tool_01jxcrhpk3er4ve8nxcfprz04x"), "tool_01j…rz04x"); + // Real names stay, however long. + assert_eq!(name("tool_local"), "a_local_tool_with_a_long_name"); + assert_eq!( + short.nodes[&(Kind::Document, "F7wzgVuiIUIKYAH2JH5s".into())].name, + "A very long document name indeed" + ); + } + + #[test] + fn shortened_ids_that_would_collide_grow_until_they_differ() { + let config = json!({ "conversation_config": { "agent": { "prompt": { + "tool_ids": ["tool_aaaaaaaa_same_end_xyz12", "tool_aaaaaaaa_diff_end_xyz12"] + } } } }); + let graph = collect( + &[agent("A", "agent_a", config)], + &BTreeMap::new(), + &BTreeMap::new(), + ); + let short = shorten_ids(&graph); + let names: BTreeSet = short + .nodes + .iter() + .filter(|((kind, _), _)| *kind == Kind::Tool) + .map(|(_, n)| n.name.clone()) + .collect(); + assert_eq!(names.len(), 2, "{names:?}"); + } + + #[test] + fn drawings_are_shortened_only_when_too_wide() { + let a = (Kind::Agent, "agent_a".to_string()); + let star = long_ids_graph().star(&a); + let full = draw_fitted(&star, &a, None, false).unwrap(); + assert!(full.contains("tool_01jxcrhpk0ehdvnxw3mw4sfb67"), "{full}"); + let roomy = draw_fitted(&star, &a, Some(500), false).unwrap(); + assert_eq!(roomy, full); + let narrow = draw_fitted(&star, &a, Some(40), false).unwrap(); + assert!(narrow.contains("tool_01j…sfb67"), "{narrow}"); + assert!(text_width(&narrow) < text_width(&full)); + } + + #[test] + fn the_terminal_star_puts_callers_on_their_own_side() { + // A ⇄ B: A hands off to B, and B back to A. + let to = |id: &str| { + json!({ "conversation_config": { "agent": { "prompt": { + "tool_ids": ["tool_x"], + "built_in_tools": { "transfer_to_agent": { "params": { "transfers": [{ "agent_id": id }] } } } + } } } }) + }; + let graph = collect( + &[ + agent("A", "agent_a", to("agent_b")), + agent("B", "agent_b", to("agent_a")), + ], + &BTreeMap::new(), + &BTreeMap::new(), + ); + let star = graph.star(&(Kind::Agent, "agent_a".into())); + let a = (Kind::Agent, "agent_a".to_string()); + let edges: Vec<_> = star.edges.keys().cloned().collect(); + assert_eq!( + edges, + [ + (a.clone(), (Kind::Agent, "agent_b".to_string())), + (a.clone(), (Kind::Tool, "tool_x".to_string())), + ((Kind::Agent, "caller:agent_b".to_string()), a.clone()), + ] + ); + // No edge leaves anything but A, and only callers point at A: no loops. + assert!(star.edges.keys().all(|(from, to)| from == &a || to == &a)); + assert_eq!( + star.nodes[&(Kind::Agent, "caller:agent_b".into())].name, + "B" + ); + draw_fitted(&star, &a, None, false).expect("draws"); + } + + #[test] + fn the_terminal_groups_resources_into_one_box_per_kind() { + let a = (Kind::Agent, "agent_a".to_string()); + let star = long_ids_graph().star(&a); + let source = render_star(&star, &a, &right_groups(&star, &a)); + assert!( + source.contains( + r#"g_tools[["Tools (3)
a_local_tool_with_a_long_name
tool_01jxcrhpk0ehdvnxw3mw4sfb67 (external)
tool_01jxcrhpk3er4ve8nxcfprz04x (external)"]]"# + ), + "{source}" + ); + assert!(source.contains("n_focus --> g_tools"), "{source}"); + assert!(source.contains("g_documents[(\"Documents (1)"), "{source}"); + // No kind without references gets a box. + assert!(!source.contains("g_tests"), "{source}"); + } + + #[test] + fn the_terminal_groups_handoffs_on_each_side() { + // A hands off to B and to an agent outside the project; B hands back. + let a_config = json!({ "conversation_config": { "agent": { "prompt": { + "built_in_tools": { "transfer_to_agent": { "params": { "transfers": [ + { "agent_id": "agent_b" }, { "agent_id": "agent_elsewhere" } + ] } } } + } } } }); + let b_config = json!({ "conversation_config": { "agent": { "prompt": { + "built_in_tools": { "transfer_to_agent": { "params": { "transfers": [{ "agent_id": "agent_a" }] } } } + } } } }); + let graph = collect( + &[ + agent("A", "agent_a", a_config), + agent("B", "agent_b", b_config), + ], + &BTreeMap::new(), + &BTreeMap::new(), + ); + let a = (Kind::Agent, "agent_a".to_string()); + let star = graph.star(&a); + let source = render_star(&star, &a, &right_groups(&star, &a)); + assert!( + source + .contains(r#"g_handoffs["Hands off to (2)
B
agent_elsewhere (external)"]"#), + "{source}" + ); + assert!(source.contains("n_focus ==> g_handoffs"), "{source}"); + assert!( + source.contains(r#"g_callers["Handed off from (1)
B"]"#), + "{source}" + ); + assert!(source.contains("g_callers ==> n_focus"), "{source}"); + // One line in, one line out: nothing for mmdflux to cross. + assert_eq!( + source.matches("==>").count() + source.matches("-->").count(), + 2 + ); + } + + #[test] + fn output_does_not_depend_on_input_order() { + let forward: Value = serde_json::from_str( + r#"{"conversation_config":{"agent":{"prompt":{"tool_ids":["t1","t2"]}}}, + "workflow":{"nodes":{"x":{"type":"tool","tools":[{"tool_id":"t3"}]},"y":{"type":"tool","tools":[{"tool_id":"t4"}]}}}}"#, + ) + .unwrap(); + let reversed: Value = serde_json::from_str( + r#"{"workflow":{"nodes":{"y":{"type":"tool","tools":[{"tool_id":"t4"}]},"x":{"type":"tool","tools":[{"tool_id":"t3"}]}}}, + "conversation_config":{"agent":{"prompt":{"tool_ids":["t2","t1"]}}}}"#, + ) + .unwrap(); + let render_one = |config: Value| { + let graph = collect( + &[agent("A", "agent_a", config)], + &BTreeMap::new(), + &BTreeMap::new(), + ); + render(&graph, Target::Export) + }; + assert_eq!(render_one(forward), render_one(reversed)); + } +} diff --git a/cli/elevenlabs/workflow/mermaid.rs b/cli/elevenlabs/workflow/mermaid.rs new file mode 100644 index 0000000..5b7ab04 --- /dev/null +++ b/cli/elevenlabs/workflow/mermaid.rs @@ -0,0 +1,359 @@ +//! Shared Mermaid plumbing for `agents workflow diagram` / `export` and +//! `agents graph`: Mermaid-safe IDs and escaping, the node palette, and +//! drawing Mermaid source as terminal text with mmdflux, a pure-Rust Mermaid +//! renderer. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::IsTerminal; + +use fern_cli_sdk::error::CliError; +use serde_json::Value; + +pub(super) fn fence(diagram: &str) -> String { + format!("```mermaid\n{diagram}```\n") +} + +/// Longest node label, in characters, before it is cut with an ellipsis. +pub(super) const NODE_LABEL_MAX: usize = 60; +/// Longest edge label. LLM conditions are full sentences; the diagram only +/// needs enough to tell edges apart. +pub(super) const EDGE_LABEL_MAX: usize = 40; +/// Line width node labels are wrapped to in the terminal. Side-by-side nodes +/// are what make a diagram wide, so they are kept narrow. +pub(super) const TERMINAL_WRAP: usize = 24; + +/// What `--format` asks a diagram command to print. +#[derive(Clone, Copy, PartialEq, clap::ValueEnum)] +pub(super) enum Format { + /// Boxes and arrows drawn with box-drawing characters. + Ascii, + /// Mermaid source, which GitHub and most docs tools render. + Mermaid, + /// Mermaid source in a ```mermaid fence. + MermaidMd, +} + +/// Who the Mermaid source is for. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Target { + /// mermaid.js (GitHub, docs): one-line labels, entity-escaped, with edge + /// conditions inline. + Export, + /// mmdflux, drawing in a terminal: plain-text labels wrapped onto lines, + /// and edge conditions replaced by numbered keys explained in a legend. + /// Inline conditions are what make a terminal diagram too wide to read. + Terminal, +} + +/// How each node type is drawn: Mermaid shape delimiters and its class. +/// Class names are prefixed because `end` is a Mermaid keyword. +pub(super) struct Style { + pub(super) open: &'static str, + pub(super) close: &'static str, + pub(super) class: &'static str, +} + +/// `classDef` lines in emission order. Light fills with dark text read on +/// both GitHub's light and dark themes. +pub(super) const CLASS_DEFS: &[(&str, &str)] = &[ + ("t_start", "fill:#d1fae5,stroke:#059669,color:#064e3b"), + ("t_end", "fill:#fee2e2,stroke:#dc2626,color:#7f1d1d"), + ("t_say", "fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e"), + ("t_tool", "fill:#fef3c7,stroke:#d97706,color:#78350f"), + ("t_state", "fill:#ede9fe,stroke:#7c3aed,color:#4c1d95"), + ("t_subagent", "fill:#f1f5f9,stroke:#475569,color:#0f172a"), + ("t_transfer", "fill:#fce7f3,stroke:#db2777,color:#831843"), + ( + "t_unknown", + "fill:#f3f4f6,stroke:#6b7280,stroke-dasharray:4 2,color:#111827", + ), + // `agents graph`: knowledge-base documents. + ("t_doc", "fill:#ecfccb,stroke:#65a30d,color:#365314"), + // Terminal only: the numbered condition keys. + ("t_key", "fill:#f3f4f6,stroke:#6b7280,color:#111827"), +]; + +/// A node's label: the kind of node (absent for start/end, which say it +/// themselves) and the text that identifies this one. +pub(super) struct NodeLabel { + pub(super) kind: Option, + pub(super) text: String, +} + +impl NodeLabel { + /// The quoted label for `target`. Export puts it on one line, `kind · + /// text`; the terminal stacks the kind over the text, wrapped narrow. + pub(super) fn render(&self, target: Target) -> String { + match target { + Target::Export => { + let line = match &self.kind { + Some(kind) => format!("{kind} · {}", self.text), + None => self.text.clone(), + }; + quote(&truncate(&line, NODE_LABEL_MAX)) + } + Target::Terminal => { + let mut lines: Vec = self.kind.iter().cloned().collect(); + lines.extend(wrap(&truncate(&self.text, NODE_LABEL_MAX), TERMINAL_WRAP)); + let lines: Vec = lines.iter().map(|l| plain(l)).collect(); + format!("\"{}\"", lines.join("
")) + } + } + } +} + +pub(super) fn str_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +/// Collapse whitespace and cut to `max` characters, ending in `…` when cut. +pub(super) fn truncate(text: &str, max: usize) -> String { + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= max { + return collapsed; + } + let cut: String = collapsed.chars().take(max - 1).collect(); + format!("{}…", cut.trim_end()) +} + +/// Quote label text for Mermaid, entity-encoding everything that would end +/// the string or be read as syntax. `#` goes first: it starts every entity. +pub(super) fn quote(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + out.push('"'); + for c in text.chars() { + match c { + '#' => out.push_str("#35;"), + '"' => out.push_str("#quot;"), + '<' => out.push_str("#lt;"), + '>' => out.push_str("#gt;"), + '|' => out.push_str("#124;"), + '`' => out.push_str("#96;"), + '\n' | '\r' => out.push(' '), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Label text for mmdflux, which prints quoted text literally but does not +/// decode Mermaid's `#…;` entities. Only a quote (ends the string) and angle +/// brackets (read as tags, like the `
` that wraps lines) need replacing, +/// and lookalikes keep the text readable. +pub(super) fn plain(text: &str) -> String { + text.chars() + .map(|c| match c { + '"' => '\'', + '<' => '‹', + '>' => '›', + '\n' | '\r' => ' ', + c => c, + }) + .collect() +} + +/// Greedy word wrap to `width` characters. A word longer than the width +/// gets a line of its own rather than being split. +pub(super) fn wrap(text: &str, width: usize) -> Vec { + let mut lines: Vec = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > width { + lines.push(std::mem::take(&mut line)); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + if !line.is_empty() { + lines.push(line); + } + lines +} + +/// A node ID for a `%%` comment: one line, nothing else to escape. +pub(super) fn comment_safe(id: &str) -> String { + id.replace(['\n', '\r'], " ") +} + +/// Mermaid-safe node IDs. +/// +/// Workflow and resource IDs are arbitrary strings; Mermaid IDs must be plain words and +/// must not be keywords (`end`) or start with `o`/`x` right after a link +/// (`A---oB` is a circle edge). Prefixing `n_` and replacing everything else +/// with `_` covers all of it; a numeric suffix separates IDs that collapse to +/// the same word. IDs are assigned in sorted order so they are stable. +pub(super) struct MermaidIds<'a> { + map: BTreeMap<&'a str, String>, +} + +impl<'a> MermaidIds<'a> { + pub(super) fn new(ids: impl Iterator) -> Self { + let sorted: BTreeSet<&str> = ids.collect(); + let mut map = BTreeMap::new(); + let mut taken = BTreeSet::new(); + for id in sorted { + let base: String = format!("n_{id}") + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let mut candidate = base.clone(); + let mut n = 2; + while !taken.insert(candidate.clone()) { + candidate = format!("{base}_{n}"); + n += 1; + } + map.insert(id, candidate); + } + MermaidIds { map } + } + + pub(super) fn get(&self, id: &str) -> &str { + // Every id rendered was passed to `new`. + self.map.get(id).map(String::as_str).unwrap_or("n_") + } +} + +/// One node's line: `id`, then `label` (already quoted) inside the shape's +/// delimiters. +/// +/// mmdflux ends a round `( )` node at the first `)`, even inside the quoted +/// label, and then fails to parse the rest ("Say · Confirm (booking)", or +/// "Test (external)"). Other shapes are unaffected, and so is mermaid.js. So in +/// the terminal, a round node's brackets become same-width lookalikes. +pub(super) fn node_line(id: &str, open: &str, label: &str, close: &str, target: Target) -> String { + let label = if target == Target::Terminal && open == "(" { + label.replace('(', "❨").replace(')', "❩") + } else { + label.to_string() + }; + format!(" {id}{open}{label}{close}\n") +} + +/// Whether terminal drawings should be coloured: stdout is a terminal and +/// `NO_COLOR` is unset. +pub(super) fn use_color() -> bool { + std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +/// Draw Mermaid source as box-drawing text. `color` adds ANSI colour from the +/// `classDef`s. `fallback` names the command that prints the source instead, +/// for the error message. +pub(super) fn draw_text(source: &str, color: bool, fallback: &str) -> Result { + let mut config = mmdflux::RenderConfig::default(); + if color { + config.text_color_mode = mmdflux::TextColorMode::Ansi; + } + let mut out = mmdflux::render_diagram(source, mmdflux::OutputFormat::Text, &config) + .map_err(|e| { + CliError::Other(anyhow::anyhow!( + "Could not draw this in the terminal ({e}). '{fallback}' prints it as Mermaid instead." + )) + })?; + if !out.ends_with('\n') { + out.push('\n'); + } + Ok(out) +} + +/// Append `classDef` and `class` lines for the classes in use, in +/// [`CLASS_DEFS`] order with sorted members, so output is stable. +pub(super) fn push_classes>( + out: &mut String, + classes: &BTreeMap<&'static str, Vec>, +) { + for (class, style) in CLASS_DEFS { + if let Some(members) = classes.get(class) { + out.push_str(&format!(" classDef {class} {style}\n")); + let mut members: Vec<&str> = members.iter().map(AsRef::as_ref).collect(); + members.sort_unstable(); + out.push_str(&format!(" class {} {class}\n", members.join(","))); + } + } +} + +/// The width to fit drawings to: `COLUMNS` when set (so scripts and CI can +/// ask for a width), otherwise the terminal's when stdout is one. `None` +/// means draw at full size. +pub(super) fn terminal_width() -> Option { + let columns = std::env::var("COLUMNS").ok().and_then(|c| c.parse().ok()); + if let Some(columns) = columns.filter(|c| *c > 0) { + return Some(columns); + } + if !std::io::stdout().is_terminal() { + return None; + } + terminal_size::terminal_size_of(std::io::stdout()).map(|(w, _)| w.0 as usize) +} + +/// The widest line of a drawing, in characters. +pub(super) fn text_width(text: &str) -> usize { + text.lines().map(|l| l.chars().count()).max().unwrap_or(0) +} + +/// Longest word kept whole when IDs are shortened. Names are shorter; IDs +/// such as `tool_01jxcrhpk0ehdvnxw3mw4sfb67` are longer. +pub(super) const SHORT_ID_MIN: usize = 16; + +/// The first `head` and last `tail` characters, joined by `…`: `tool_01j…sfb67`. +/// Text that short already is returned whole. +pub(super) fn abbreviate(chars: &[char], head: usize, tail: usize) -> String { + if head + tail + 1 >= chars.len() { + return chars.iter().collect(); + } + let start: String = chars[..head].iter().collect(); + let end: String = chars[chars.len() - tail..].iter().collect(); + format!("{start}…{end}") +} + +/// Characters where lines cross (`┼`), or meet and share a stretch (`├`, +/// `┬`, ...). Boxes never use them, so each one is a tangle a reader has to +/// unpick to see what connects to what. +pub(super) const TANGLES: &[char] = &[ + '┼', '╋', '╂', '┿', '╪', '╫', '╬', '├', '┤', '┬', '┴', '┣', '┫', '┳', '┻', '┠', '┨', '┯', '┷', + '┝', '┥', '┰', '┸', +]; + +/// How many tangles a drawing has (see [`TANGLES`]). +pub(super) fn tangles(text: &str) -> usize { + text.chars().filter(|c| TANGLES.contains(c)).count() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn brackets_in_a_round_terminal_node_do_not_end_it() { + for label in [ + r#""Say
Confirm (booking)""#, + r#""Test (external)
t1""#, + r#""a ) b""#, + ] { + let line = node_line("n_a", "(", label, ")", Target::Terminal); + let source = format!("flowchart LR\n{line}"); + draw_text(&source, false, "export").unwrap_or_else(|e| panic!("{line}: {e:?}")); + } + // Other shapes, and export, keep the text as it is. + assert_eq!( + node_line("n_a", "[", r#""a (b)""#, "]", Target::Terminal), + " n_a[\"a (b)\"]\n" + ); + assert_eq!( + node_line("n_a", "(", r#""a (b)""#, ")", Target::Export), + " n_a(\"a (b)\")\n" + ); + } + + #[test] + fn wrap_keeps_long_words_whole() { + assert_eq!(wrap("a bb ccc", 4), ["a bb", "ccc"]); + assert_eq!( + wrap("system__is_text_only x", 8), + ["system__is_text_only", "x"] + ); + assert!(wrap(" ", 8).is_empty()); + } +} diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs index c45d11e..6734ba6 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -19,7 +19,9 @@ mod components; mod diagram; mod feedback; mod flows_wait; +mod graph; mod intent; +mod mermaid; mod loopback; mod onboard; mod platform; @@ -48,6 +50,7 @@ pub fn register(app: CliApp) -> CliApp { let app = agents::register(app); let app = templates::register(app); let app = diagram::register(app); + let app = graph::register(app); let app = tools::register(app); let app = tests::register(app); let app = residency::register(app); diff --git a/cli/elevenlabs/workflow/project.rs b/cli/elevenlabs/workflow/project.rs index f8536f3..7109887 100644 --- a/cli/elevenlabs/workflow/project.rs +++ b/cli/elevenlabs/workflow/project.rs @@ -245,6 +245,14 @@ fn symlink_refused(path: &Path) -> CliError { )) } +/// The top-level `name` of a config named by an index file, or `None` when it +/// is unreadable or has none. +pub fn config_name(path_str: &str) -> Option { + read_value_in_project(path_str) + .ok() + .and_then(|v| v.get("name").and_then(Value::as_str).map(String::from)) +} + /// Read a config file named by an index file, with containment + symlink checks. pub fn read_value_in_project(path_str: &str) -> Result { let path = resolve_in_project(path_str)?; diff --git a/cli/elevenlabs/workflow/tools.rs b/cli/elevenlabs/workflow/tools.rs index 58d1ff6..a87c4d7 100644 --- a/cli/elevenlabs/workflow/tools.rs +++ b/cli/elevenlabs/workflow/tools.rs @@ -48,12 +48,6 @@ fn require_tools() -> Result { project::load_tools() } -fn tool_name_from_config(config_path: &str) -> Option { - project::read_value_in_project(config_path) - .ok() - .and_then(|v| v.get("name").and_then(Value::as_str).map(String::from)) -} - /// Extract a tool id from a create/get/list item, tolerating `id`, /// `tool_id`, and `toolId` spellings. fn tool_id_of(value: &Value) -> Option { @@ -200,7 +194,7 @@ fn handle_delete(args: DeleteArgs, ctx: &AppContext) -> Result<(), CliError> { } println!("\nFound {} tool(s) to delete:", registry.tools.len()); for (i, tool) in registry.tools.iter().enumerate() { - let name = tool_name_from_config(&tool.config) + let name = project::config_name(&tool.config) .or_else(|| tool.id.clone()) .unwrap_or_else(|| "Unknown".to_string()); println!( @@ -220,7 +214,7 @@ fn handle_delete(args: DeleteArgs, ctx: &AppContext) -> Result<(), CliError> { } println!("\nDeleting tools...\n"); for tool in ®istry.tools { - let name = tool_name_from_config(&tool.config) + let name = project::config_name(&tool.config) .or_else(|| tool.id.clone()) .unwrap_or_else(|| "Unknown".to_string()); println!( @@ -258,7 +252,7 @@ fn handle_delete(args: DeleteArgs, ctx: &AppContext) -> Result<(), CliError> { )) })?; let removed = registry.tools.remove(index); - let name = tool_name_from_config(&removed.config).unwrap_or_else(|| tool_id.clone()); + let name = project::config_name(&removed.config).unwrap_or_else(|| tool_id.clone()); println!("Deleting tool '{name}' (ID: {tool_id})..."); println!("Deleting from ElevenLabs..."); diff --git a/tests/agents_graph_test.rs b/tests/agents_graph_test.rs new file mode 100644 index 0000000..21d0e9e --- /dev/null +++ b/tests/agents_graph_test.rs @@ -0,0 +1,182 @@ +//! Integration tests for `elevenlabs agents graph`. +//! +//! The reference-following and rendering are unit-tested in +//! `cli/elevenlabs/workflow/graph.rs`; these run the binary against the +//! fixture project in `cli/elevenlabs/workflow/fixtures/graph/` (copied into +//! a temp dir), with no API key: the command reads local files only. + +use std::path::Path; +use std::process::{Command, Output}; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/cli/elevenlabs/workflow/fixtures/graph" +); + +fn cli(project: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_elevenlabs")) + .args(args) + .current_dir(project) + .env("HOME", project) + .env("NO_COLOR", "1") + .env("FERN_CLI_CREDENTIAL_STORE", "file") + .env_remove("ELEVENLABS_API_KEY") + .env_remove("ELEVENLABS_BASE_URL") + .env_remove("ELEVENLABS_VIA") + .output() + .expect("failed to spawn the elevenlabs binary") +} + +fn stdout(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + let target = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), target).unwrap(); + } + } +} + +/// A fresh copy of the fixture project. +fn project() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + copy_dir(Path::new(FIXTURE), dir.path()); + dir +} + +#[test] +fn mermaid_output_matches_the_snapshot() { + let dir = project(); + let out = cli(dir.path(), &["agents", "graph", "--format", "mermaid"]); + assert!(out.status.success(), "{out:?}"); + let expected = std::fs::read_to_string(Path::new(FIXTURE).join("expected.mmd")).unwrap(); + assert_eq!(stdout(&out), expected); +} + +#[test] +fn markdown_wraps_the_mermaid_in_a_fence() { + let dir = project(); + let out = cli(dir.path(), &["agents", "graph", "--format", "mermaid-md"]); + assert!(out.status.success(), "{out:?}"); + let text = stdout(&out); + assert!(text.starts_with("```mermaid\nflowchart LR\n"), "{text}"); + assert!(text.ends_with("```\n"), "{text}"); +} + +#[test] +fn uses_does_not_take_a_format() { + let dir = project(); + let out = cli( + dir.path(), + &[ + "agents", + "graph", + "--uses", + "tool_refund", + "--format", + "mermaid", + ], + ); + assert!(!out.status.success(), "{out:?}"); +} + +#[test] +fn the_terminal_draws_one_section_per_agent() { + let dir = project(); + let out = cli(dir.path(), &["agents", "graph"]); + assert!(out.status.success(), "{out:?}"); + let text = stdout(&out); + assert!(!text.contains("flowchart"), "{text}"); + for heading in [ + "── Support (agent_configs/support.json)", + "── Support (branch 'beta') (agent_configs/support.beta.json)", + "── Billing (agent_configs/billing.json)", + ] { + assert!(text.contains(heading), "{heading}: {text}"); + } + // Tools are listed in one box per agent, external ones marked. + assert!(text.contains("Tools (2)"), "{text}"); + assert!(text.contains("lookup_order"), "{text}"); + assert!(text.contains("tool_from_dashboard (external)"), "{text}"); + assert!(!text.contains('\u{1b}'), "{text}"); + // No line crosses or runs into another, in any agent's drawing. + let tangled: Vec = text.chars().filter(|c| "┼╋├┤┬┴┣┫┳┻".contains(*c)).collect(); + assert!( + tangled.is_empty(), + "lines cross or merge ({tangled:?}): {text}" + ); +} + +#[test] +fn one_agent_by_name_id_or_config_path() { + let dir = project(); + let by_name = cli( + dir.path(), + &["agents", "graph", "Billing", "--format", "mermaid"], + ); + assert!(by_name.status.success(), "{by_name:?}"); + let source = stdout(&by_name); + // Billing's own references, and the two agents that hand off to it. + assert!(source.contains("Tool · issue_refund"), "{source}"); + assert!( + source.contains("Agent · Support (branch 'beta')"), + "{source}" + ); + assert!(!source.contains("lookup_order"), "{source}"); + + for target in ["agent_billing", "agent_configs/billing.json"] { + let out = cli( + dir.path(), + &["agents", "graph", target, "--format", "mermaid"], + ); + assert!(out.status.success(), "{target}: {out:?}"); + assert_eq!(stdout(&out), source, "{target}"); + } +} + +#[test] +fn uses_lists_dependents_tab_separated() { + let dir = project(); + let out = cli(dir.path(), &["agents", "graph", "--uses", "tool_refund"]); + assert!(out.status.success(), "{out:?}"); + assert_eq!( + stdout(&out), + "agent_billing\tBilling\tprompt.tool_ids\n\ + agent_support\tSupport (branch 'beta')\tworkflow node 'refunds'\n" + ); + // A tool's local name works too. + let by_name = cli(dir.path(), &["agents", "graph", "--uses", "issue_refund"]); + assert_eq!(stdout(&by_name), stdout(&out)); +} + +#[test] +fn uses_with_no_dependents_says_so_and_succeeds() { + let dir = project(); + let out = cli( + dir.path(), + &["agents", "graph", "--uses", "tool_nobody_uses"], + ); + assert!(out.status.success(), "{out:?}"); + assert!(stdout(&out).is_empty()); + assert!(String::from_utf8_lossy(&out.stderr).contains("No agent in agents.json uses")); +} + +#[test] +fn a_project_without_tools_json_shows_its_tools_as_external() { + let dir = project(); + std::fs::remove_file(dir.path().join("tools.json")).unwrap(); + let out = cli( + dir.path(), + &["agents", "graph", "Billing", "--format", "mermaid"], + ); + assert!(out.status.success(), "{out:?}"); + let source = stdout(&out); + assert!(source.contains("Tool (external) · tool_refund"), "{source}"); +}