Add durable inline SVG presentation

This commit is contained in:
Georg Bauer
2026-08-31 08:29:24 +02:00
parent 0fa15bb68b
commit 8adac261ae
6 changed files with 542 additions and 1 deletions

View File

@@ -28,6 +28,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
const MAX_RALPH_REPORT_BYTES: usize = 16 * 1024;
const MAX_SVG_SOURCE_BYTES: usize = 64 * 1024;
const MAX_SVG_ALT_BYTES: usize = 512;
const MAX_SVG_NODES: usize = 1_024;
const MIN_SVG_DIMENSION: f32 = 1.0;
const MAX_SVG_DIMENSION: f32 = 4_096.0;
const DEFAULT_RALPH_ROUNDS: usize = 8;
const MAX_RALPH_STEPS_PER_ROUND: usize = 64;
const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5);
@@ -322,6 +327,7 @@ const RISK_CLASSIFIER_SYSTEM_PROMPT: &str = "You are a shell-command risk classi
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ToolHandler {
PresentSvg,
GoogleSearch,
VisitPage,
Bash,
@@ -383,6 +389,25 @@ const POSITIVE: ParameterKind = ParameterKind::Integer {
max: usize::MAX as u64,
};
const TOOLS: &[ToolSpec] = &[
ToolSpec {
name: "present_svg",
description: "Present a self-contained static SVG inline in the chat when a diagram or illustration materially improves the answer. alt must concisely describe the information conveyed. This is presentation only: use A2UI instead for interactive controls or stateful UI. Do not include scripts, animation, links, HTML, raster images, external resources, file paths, or remote URLs.",
parameters: &[
ToolParameter {
name: "svg",
kind: NON_EMPTY,
required: true,
},
ToolParameter {
name: "alt",
kind: NON_EMPTY,
required: true,
},
],
rule: ToolRule::None,
handler: ToolHandler::PresentSvg,
dev_brain: false,
},
ToolSpec {
name: "google_search",
description: "Search Google in a browser and return compact Markdown links. If browser startup is denied, do not repeat the unchanged call; explain why web access is needed or continue with local evidence.",
@@ -1260,6 +1285,198 @@ pub(crate) struct ToolCall {
pub(crate) arguments: Map<String, Value>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SvgPresentation {
pub(crate) svg: String,
pub(crate) alt: String,
pub(crate) height: f32,
}
fn present_svg(call: &ToolCall) -> Result<String, String> {
let presentation =
validate_svg_presentation(required_string(call, "svg")?, required_string(call, "alt")?)?;
Ok(format!("SVG presented inline: {}", presentation.alt))
}
fn validate_svg_presentation(svg: &str, alt: &str) -> Result<SvgPresentation, String> {
if svg.trim().is_empty() {
return Err("SVG source is empty".into());
}
if svg.len() > MAX_SVG_SOURCE_BYTES {
return Err(format!(
"SVG source is {} bytes; maximum is {MAX_SVG_SOURCE_BYTES} bytes",
svg.len()
));
}
let alt = alt.trim();
if alt.is_empty() {
return Err("SVG alt text is empty".into());
}
if alt.len() > MAX_SVG_ALT_BYTES {
return Err(format!(
"SVG alt text is {} bytes; maximum is {MAX_SVG_ALT_BYTES} bytes",
alt.len()
));
}
let document = usvg::roxmltree::Document::parse(svg)
.map_err(|error| format!("SVG is malformed XML: {error}"))?;
if document
.descendants()
.any(|node| node.node_type() == usvg::roxmltree::NodeType::PI)
{
return Err("SVG processing instructions are not allowed".into());
}
let root = document.root_element();
if root.tag_name().name() != "svg"
|| !matches!(
root.tag_name().namespace(),
None | Some("http://www.w3.org/2000/svg")
)
{
return Err("SVG source must have an svg root element".into());
}
let mut source_nodes = 0;
for node in document
.descendants()
.filter(usvg::roxmltree::Node::is_element)
{
source_nodes += 1;
if source_nodes > MAX_SVG_NODES {
return Err(format!(
"SVG has more than {MAX_SVG_NODES} elements and is too complex"
));
}
if !matches!(
node.tag_name().namespace(),
None | Some("http://www.w3.org/2000/svg")
) {
return Err("SVG contains foreign or HTML content".into());
}
let element = node.tag_name().name().to_ascii_lowercase();
if matches!(
element.as_str(),
"script"
| "animate"
| "animatecolor"
| "animatemotion"
| "animatetransform"
| "set"
| "discard"
| "handler"
| "listener"
| "a"
| "foreignobject"
| "iframe"
| "object"
| "embed"
| "audio"
| "video"
| "canvas"
| "image"
| "feimage"
| "style"
) {
return Err(format!("SVG element <{element}> is not allowed"));
}
for attribute in node.attributes() {
let name = attribute.name().to_ascii_lowercase();
let value = attribute.value().trim();
if name.starts_with("on") {
return Err(format!("SVG event attribute {name} is not allowed"));
}
if name == "href" && !value.starts_with('#') {
return Err("SVG external references and links are not allowed".into());
}
if has_external_svg_reference(value) {
return Err("SVG external resources and URLs are not allowed".into());
}
}
}
let options = usvg::Options {
resources_dir: None,
image_href_resolver: usvg::ImageHrefResolver {
resolve_data: Box::new(|_, _, _| None),
resolve_string: Box::new(|_, _| None),
},
..usvg::Options::default()
};
let tree = usvg::Tree::from_str(svg, &options)
.map_err(|error| format!("SVG cannot be rendered: {error}"))?;
let size = tree.size();
let (width, height) = (size.width(), size.height());
if !width.is_finite()
|| !height.is_finite()
|| width < MIN_SVG_DIMENSION
|| height < MIN_SVG_DIMENSION
|| width > MAX_SVG_DIMENSION
|| height > MAX_SVG_DIMENSION
{
return Err(format!(
"SVG dimensions must be finite and between {MIN_SVG_DIMENSION} and {MAX_SVG_DIMENSION}"
));
}
let mut rendered_nodes = 0;
let mut visible_nodes = 0;
count_svg_nodes(tree.root(), &mut rendered_nodes, &mut visible_nodes);
if visible_nodes == 0 {
return Err("SVG has no renderable vector or text content".into());
}
if rendered_nodes > MAX_SVG_NODES {
return Err(format!(
"rendered SVG has more than {MAX_SVG_NODES} nodes and is too complex"
));
}
Ok(SvgPresentation {
svg: svg.to_owned(),
alt: alt.to_owned(),
height,
})
}
fn has_external_svg_reference(value: &str) -> bool {
let value = value.to_ascii_lowercase();
if value.contains("@import")
|| value.contains("javascript:")
|| value.contains("data:")
|| value.contains("file:")
|| value.contains("http:")
|| value.contains("https:")
|| value.contains("expression(")
{
return true;
}
let mut rest = value.as_str();
while let Some(start) = rest.find("url(") {
let body = &rest[start + 4..];
let Some(end) = body.find(')') else {
return true;
};
if !body[..end]
.trim()
.trim_matches(['\'', '"'])
.starts_with('#')
{
return true;
}
rest = &body[end + 1..];
}
false
}
fn count_svg_nodes(group: &usvg::Group, nodes: &mut usize, visible: &mut usize) {
for node in group.children() {
*nodes += 1;
match node {
usvg::Node::Group(group) => count_svg_nodes(group, nodes, visible),
usvg::Node::Path(_) | usvg::Node::Text(_) => *visible += 1,
usvg::Node::Image(_) => {}
}
}
}
pub(crate) struct ActiveTools {
pub(crate) results: Receiver<ToolRunResult>,
pub(crate) events: Receiver<ToolEvent>,
@@ -1334,6 +1551,13 @@ impl ToolCard {
}
}
pub(crate) fn svg_presentation(card: &ToolCard) -> Option<SvgPresentation> {
if card.call.name != "present_svg" || card.state != ToolLifecycle::Completed {
return None;
}
validate_svg_presentation(string(&card.call, "svg")?, string(&card.call, "alt")?).ok()
}
#[derive(Clone, Debug)]
pub(crate) struct ApprovalPrompt {
pub(crate) title: String,
@@ -1691,6 +1915,7 @@ impl Tools {
cancel: &AtomicBool,
) -> String {
let result = match tool.handler {
ToolHandler::PresentSvg => present_svg(call),
ToolHandler::Read => self.read(call),
ToolHandler::More => self.more(call),
ToolHandler::Write => self.write(call),
@@ -3863,6 +4088,176 @@ mod tests {
);
}
#[test]
fn present_svg_parsers_recover_exact_source_for_both_model_transports() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><path d="M0 0h120v80H0z" fill="#123456"/></svg>"##;
let dsml = format!(
"<DSMLtool_calls><DSMLinvoke name=\"present_svg\"><DSMLparameter name=\"svg\" string=\"true\">{svg}</DSMLparameter><DSMLparameter name=\"alt\" string=\"true\">A dark rectangle</DSMLparameter></DSMLinvoke></DSMLtool_calls>"
);
let (_, calls) = parse_tool_calls(ModelChoice::DeepSeekV4Flash0731, &dsml).unwrap();
assert_eq!(string(&calls[0], "svg"), Some(svg));
assert_eq!(string(&calls[0], "alt"), Some("A dark rectangle"));
let glm = format!(
"<tool_call>present_svg<arg_key>svg</arg_key><arg_value>{svg}</arg_value><arg_key>alt</arg_key><arg_value>A dark rectangle</arg_value></tool_call>"
);
let (_, calls) = parse_tool_calls(ModelChoice::Glm52, &glm).unwrap();
assert_eq!(string(&calls[0], "svg"), Some(svg));
assert_eq!(string(&calls[0], "alt"), Some("A dark rectangle"));
}
#[test]
fn present_svg_validation_enforces_the_static_vector_boundary() {
let valid = r##"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><defs><linearGradient id="g"><stop offset="0" stop-color="#111"/><stop offset="1" stop-color="#555"/></linearGradient></defs><path d="M0 0h120v80H0z" fill="url(#g)"/></svg>"##;
let presentation = validate_svg_presentation(valid, "A gradient rectangle").unwrap();
assert_eq!(presentation.svg, valid);
assert_eq!(presentation.alt, "A gradient rectangle");
assert_eq!(presentation.height, 80.0);
let excessive = format!(
"<svg width=\"10\" height=\"10\">{}</svg>",
"<circle cx=\"1\" cy=\"1\" r=\"1\"/>".repeat(MAX_SVG_NODES)
);
let cases = [
("", "empty"),
(&"x".repeat(MAX_SVG_SOURCE_BYTES + 1), "maximum"),
("<svg", "malformed"),
("<html></html>", "svg root"),
("<svg width=\"10\" height=\"10\"></svg>", "no renderable"),
(
"<svg width=\"4097\" height=\"10\"><path d=\"M0 0h1v1z\"/></svg>",
"dimensions",
),
(&excessive, "too complex"),
(
"<svg width=\"10\" height=\"10\"><use href=\"other.svg#shape\"/></svg>",
"external references",
),
(
"<svg width=\"10\" height=\"10\"><path marker-start=\"url(other.svg#marker)\" d=\"M0 0h1v1z\"/></svg>",
"external resources",
),
(
"<svg width=\"10\" height=\"10\"><image href=\"data:image/png;base64,AA==\"/></svg>",
"<image>",
),
(
"<svg width=\"10\" height=\"10\"><script>alert(1)</script></svg>",
"<script>",
),
(
"<svg width=\"10\" height=\"10\"><path onclick=\"alert(1)\" d=\"M0 0h1v1z\"/></svg>",
"event attribute",
),
(
"<svg width=\"10\" height=\"10\"><a href=\"#x\"><path d=\"M0 0h1v1z\"/></a></svg>",
"<a>",
),
(
"<svg width=\"10\" height=\"10\"><animate attributeName=\"x\"/></svg>",
"<animate>",
),
(
"<svg xmlns:h=\"http://www.w3.org/1999/xhtml\" width=\"10\" height=\"10\"><foreignObject><h:div>HTML</h:div></foreignObject></svg>",
"<foreignobject>",
),
(
"<svg width=\"10\" height=\"10\"><style>@import url(https://example.com/x.css)</style><path d=\"M0 0h1v1z\"/></svg>",
"<style>",
),
(
"<?xml-stylesheet href=\"https://example.com/x.css\"?><svg width=\"10\" height=\"10\"><path d=\"M0 0h1v1z\"/></svg>",
"processing instructions",
),
];
for (svg, expected) in cases {
let error = validate_svg_presentation(svg, "description").unwrap_err();
assert!(error.contains(expected), "{error}");
}
assert!(
validate_svg_presentation(valid, &"x".repeat(MAX_SVG_ALT_BYTES + 1))
.unwrap_err()
.contains("alt text")
);
}
#[test]
fn present_svg_executes_without_side_effects_and_maps_only_completed_cards() {
let directory = std::env::temp_dir().join(format!(
"ds4-present-svg-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&directory).unwrap();
let svg = r#"<svg width="24" height="12"><path d="M0 0h24v12H0z"/></svg>"#;
let present_call = call("present_svg", [("svg", svg), ("alt", "A solid rectangle")]);
let invalid_call = call(
"present_svg",
[("svg", "not svg"), ("alt", "Invalid figure")],
);
let tools = Arc::new(Mutex::new(Tools::new(&directory, 4_096).unwrap()));
let active = execute_async(
tools,
vec![
call("list", [("path", ".")]),
present_call.clone(),
invalid_call,
],
ShellApprovalMode::Heuristic,
);
let result = active.results.recv_timeout(Duration::from_secs(2)).unwrap();
assert!(
result
.content
.contains("SVG presented inline: A solid rectangle")
);
assert!(!result.content.contains(svg));
assert!(result.touched_paths.is_empty());
assert!(fs::read_dir(&directory).unwrap().next().is_none());
let assistant = format!(
"<DSMLtool_calls><DSMLinvoke name=\"list\"><DSMLparameter name=\"path\" string=\"true\">.</DSMLparameter></DSMLinvoke><DSMLinvoke name=\"present_svg\"><DSMLparameter name=\"svg\" string=\"true\">{svg}</DSMLparameter><DSMLparameter name=\"alt\" string=\"true\">A solid rectangle</DSMLparameter></DSMLinvoke><DSMLinvoke name=\"present_svg\"><DSMLparameter name=\"svg\" string=\"true\">not svg</DSMLparameter><DSMLparameter name=\"alt\" string=\"true\">Invalid figure</DSMLparameter></DSMLinvoke></DSMLtool_calls>"
);
let cards = stored_tool_cards(
ModelChoice::DeepSeekV4Flash0731,
&assistant,
Some(&result.content),
&[],
);
assert_eq!(
cards
.iter()
.map(|card| card.call.name.as_str())
.collect::<Vec<_>>(),
["list", "present_svg", "present_svg"]
);
assert_eq!(
cards.iter().map(|card| card.state).collect::<Vec<_>>(),
[
ToolLifecycle::Completed,
ToolLifecycle::Completed,
ToolLifecycle::Failed,
]
);
assert_eq!(
cards
.iter()
.map(|card| svg_presentation(card).is_some())
.collect::<Vec<_>>(),
[false, true, false]
);
let running = ToolCard {
call: present_call,
state: ToolLifecycle::Running,
result: None,
approval_reason: None,
};
assert!(svg_presentation(&running).is_none());
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn generated_schemas_match_the_executable_tool_contracts() {
let schemas = tool_schemas(true, false)
@@ -3910,6 +4305,17 @@ mod tests {
.unwrap()
.contains("non-zero exit")
);
let present_svg = schemas
.iter()
.find(|schema| schema["function"]["name"] == "present_svg")
.unwrap();
let description = present_svg["function"]["description"].as_str().unwrap();
assert!(description.contains("static SVG inline"));
assert!(description.contains("A2UI"));
assert_eq!(
present_svg["function"]["parameters"]["required"],
serde_json::json!(["svg", "alt"])
);
}
#[test]