From 69a203c2a8a4c97a9dc3d6391dd99a696f5ae8be Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 5 Mar 2026 21:44:50 -0500 Subject: [PATCH] Add $$ escape mechanism for variable expansion Allows literal $ signs in prompts and DOT files by writing $$. Also refactors VariableExpansionTransform to use expand_vars instead of string replace, fixing a substring-matching bug with $goal. Co-Authored-By: Claude Opus 4.6 --- crates/arc-workflows/src/cli/run_config.rs | 26 ++++++++++++++- crates/arc-workflows/src/transform.rs | 38 +++++++++++++++++++--- docs/workflows/variables.mdx | 10 +++++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index 41658359f..e6b495d95 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -189,7 +189,10 @@ pub fn expand_vars(source: &str, vars: &HashMap) -> anyhow::Resu while i < len { if bytes[i] == b'$' { let start = i + 1; - if start < len && (bytes[start].is_ascii_alphabetic() || bytes[start] == b'_') { + if start < len && bytes[start] == b'$' { + result.push('$'); + i = start + 1; + } else if start < len && (bytes[start].is_ascii_alphabetic() || bytes[start] == b'_') { let mut end = start + 1; while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') { end += 1; @@ -315,6 +318,27 @@ language = "python" assert_eq!(expand_vars("costs $5", &vars).unwrap(), "costs $5"); } + #[test] + fn expand_escaped_dollar() { + let vars = HashMap::from([("name".to_string(), "world".to_string())]); + assert_eq!( + expand_vars("literal $$name here", &vars).unwrap(), + "literal $name here" + ); + } + + #[test] + fn expand_escaped_dollar_at_end() { + let vars = HashMap::new(); + assert_eq!(expand_vars("trailing $$", &vars).unwrap(), "trailing $"); + } + + #[test] + fn expand_escaped_dollar_before_non_ident() { + let vars = HashMap::new(); + assert_eq!(expand_vars("price is $$5", &vars).unwrap(), "price is $5"); + } + #[test] fn parse_toml_with_sandbox() { let toml = r#" diff --git a/crates/arc-workflows/src/transform.rs b/crates/arc-workflows/src/transform.rs index 4faccf173..29fd0104d 100644 --- a/crates/arc-workflows/src/transform.rs +++ b/crates/arc-workflows/src/transform.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::graph::{AttrValue, Edge, Graph, Node}; use crate::stylesheet::{apply_stylesheet, parse_stylesheet}; @@ -12,12 +14,14 @@ pub struct VariableExpansionTransform; impl Transform for VariableExpansionTransform { fn apply(&self, graph: &mut Graph) { let goal = graph.goal().to_string(); + let vars = HashMap::from([("goal".to_string(), goal)]); for node in graph.nodes.values_mut() { if let Some(AttrValue::String(prompt)) = node.attrs.get("prompt") { - if prompt.contains("$goal") { - let expanded = prompt.replace("$goal", &goal); - node.attrs - .insert("prompt".to_string(), AttrValue::String(expanded)); + if let Ok(expanded) = crate::cli::run_config::expand_vars(prompt, &vars) { + if expanded != *prompt { + node.attrs + .insert("prompt".to_string(), AttrValue::String(expanded)); + } } } } @@ -192,6 +196,32 @@ mod tests { assert!(!graph.nodes["plan"].attrs.contains_key("prompt")); } + #[test] + fn variable_expansion_escaped_dollar_goal() { + let mut graph = Graph::new("test"); + graph.attrs.insert( + "goal".to_string(), + AttrValue::String("Fix bugs".to_string()), + ); + + let mut node = Node::new("plan"); + node.attrs.insert( + "prompt".to_string(), + AttrValue::String("literal $$goal here".to_string()), + ); + graph.nodes.insert("plan".to_string(), node); + + let transform = VariableExpansionTransform; + transform.apply(&mut graph); + + let prompt = graph.nodes["plan"] + .attrs + .get("prompt") + .and_then(AttrValue::as_str) + .unwrap(); + assert_eq!(prompt, "literal $goal here"); + } + #[test] fn stylesheet_transform_empty_stylesheet() { let mut graph = Graph::new("test"); diff --git a/docs/workflows/variables.mdx b/docs/workflows/variables.mdx index cf5a2a020..a31a52441 100644 --- a/docs/workflows/variables.mdx +++ b/docs/workflows/variables.mdx @@ -42,7 +42,15 @@ When launched with `arc run start run.toml`, Arc replaces `$repo_name`, `$repo_u If a `$variable` in the DOT file has no matching entry in `[vars]`, Arc raises an error. This catches typos early — a misspelled `$langauge` fails immediately rather than passing a literal `$langauge` to the LLM. -A bare `$` not followed by an identifier character (e.g. `costs $5`) is left as-is. +### Escaping `$` + +To include a literal `$` in the output, write `$$`: + +```dot +test [prompt="The env var is $$HOME"] +``` + +This produces `The env var is $HOME` without treating `$HOME` as a variable reference. A bare `$` not followed by an identifier character (e.g. `costs $5`) does not need escaping. ## The `$goal` variable