From b4159243ac14f3fbbf22f731093a871000c00bd5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 6 Mar 2026 11:55:31 -0500 Subject: [PATCH] Add context key constants and typed accessors to eliminate string literal typo risk Introduces context/keys.rs with 24 static constants, 4 prefix constants, and 4 helper functions for dynamic keys. Adds 6 typed accessor methods on Context (run_id, fidelity, preamble, thread_id, node_visit_count, current_node_id). Replaces all bare string literals across 13 files with constants/accessors. Fixes bug in manager_loop.rs where "internal.node_visit" was read instead of "internal.node_visit_count". Co-Authored-By: Claude Opus 4.6 --- crates/arc-workflows/src/cli/backend.rs | 2 +- crates/arc-workflows/src/condition.rs | 15 ++- crates/arc-workflows/src/context/keys.rs | 100 ++++++++++++++ .../src/{context.rs => context/mod.rs} | 122 ++++++++++++++++++ crates/arc-workflows/src/engine.rs | 48 ++++--- crates/arc-workflows/src/handler/agent.rs | 25 ++-- crates/arc-workflows/src/handler/command.rs | 23 ++-- crates/arc-workflows/src/handler/fan_in.rs | 33 ++--- crates/arc-workflows/src/handler/human.rs | 15 ++- .../arc-workflows/src/handler/manager_loop.rs | 10 +- crates/arc-workflows/src/handler/parallel.rs | 11 +- crates/arc-workflows/src/handler/prompt.rs | 11 +- crates/arc-workflows/src/preamble.rs | 121 ++++++++--------- 13 files changed, 378 insertions(+), 158 deletions(-) create mode 100644 crates/arc-workflows/src/context/keys.rs rename crates/arc-workflows/src/{context.rs => context/mod.rs} (67%) diff --git a/crates/arc-workflows/src/cli/backend.rs b/crates/arc-workflows/src/cli/backend.rs index 9e1a06d5e..53fc09511 100644 --- a/crates/arc-workflows/src/cli/backend.rs +++ b/crates/arc-workflows/src/cli/backend.rs @@ -342,7 +342,7 @@ impl CodergenBackend for AgentApiBackend { stage_dir: &std::path::Path, sandbox: &Arc, ) -> Result { - let fidelity = context.get_string("internal.fidelity", ""); + let fidelity = context.fidelity(); let reuse_key = if fidelity == "full" { thread_id.map(String::from) } else { diff --git a/crates/arc-workflows/src/condition.rs b/crates/arc-workflows/src/condition.rs index d65e56cee..ed5c9a84b 100644 --- a/crates/arc-workflows/src/condition.rs +++ b/crates/arc-workflows/src/condition.rs @@ -10,6 +10,7 @@ /// Op ::= '=' | '!=' | '>' | '<' | '>=' | '<=' /// | 'contains' | 'matches' /// ``` +use crate::context::keys; use crate::context::Context; use crate::error::ArcError; use crate::outcome::Outcome; @@ -312,10 +313,10 @@ pub fn parse_condition(expr: &str) -> Result<(), ArcError> { // --------------------------------------------------------------------------- fn resolve_key(key: &str, outcome: &Outcome, context: &Context) -> String { - if key == "outcome" { + if key == keys::OUTCOME { return outcome.status.to_string(); } - if key == "preferred_label" { + if key == keys::PREFERRED_LABEL { return outcome.preferred_label.as_deref().unwrap_or("").to_string(); } if let Some(path) = key.strip_prefix("context.") { @@ -337,10 +338,10 @@ fn resolve_key_value( outcome: &Outcome, context: &Context, ) -> serde_json::Value { - if key == "outcome" { + if key == keys::OUTCOME { return serde_json::Value::String(outcome.status.to_string()); } - if key == "preferred_label" { + if key == keys::PREFERRED_LABEL { return outcome .preferred_label .as_deref() @@ -635,7 +636,7 @@ mod tests { fn context_failure_class_matches_when_set() { let outcome = make_outcome(StageStatus::Fail); let context = Context::new(); - context.set("failure_class", serde_json::json!("budget_exhausted")); + context.set(keys::FAILURE_CLASS, serde_json::json!("budget_exhausted")); assert!(evaluate_condition( "context.failure_class=budget_exhausted", &outcome, @@ -647,7 +648,7 @@ mod tests { fn context_failure_class_not_equals_on_success() { let outcome = make_outcome(StageStatus::Success); let context = Context::new(); - context.set("failure_class", serde_json::json!("")); + context.set(keys::FAILURE_CLASS, serde_json::json!("")); assert!(evaluate_condition( "context.failure_class!=transient_infra", &outcome, @@ -659,7 +660,7 @@ mod tests { fn context_failure_class_combined_with_outcome() { let outcome = make_outcome(StageStatus::Fail); let context = Context::new(); - context.set("failure_class", serde_json::json!("transient_infra")); + context.set(keys::FAILURE_CLASS, serde_json::json!("transient_infra")); assert!(evaluate_condition( "outcome=fail && context.failure_class=transient_infra", &outcome, diff --git a/crates/arc-workflows/src/context/keys.rs b/crates/arc-workflows/src/context/keys.rs new file mode 100644 index 000000000..11e04f3dc --- /dev/null +++ b/crates/arc-workflows/src/context/keys.rs @@ -0,0 +1,100 @@ +/// Static context key constants and helper functions for dynamic keys. +/// +/// All context keys used across the engine, handlers, and preamble are +/// defined here to prevent typos and improve discoverability. + +// --- Top-level keys --- +pub const CURRENT_NODE: &str = "current_node"; +pub const OUTCOME: &str = "outcome"; +pub const FAILURE_CLASS: &str = "failure_class"; +pub const FAILURE_SIGNATURE: &str = "failure_signature"; +pub const PREFERRED_LABEL: &str = "preferred_label"; +pub const LAST_STAGE: &str = "last_stage"; +pub const LAST_RESPONSE: &str = "last_response"; + +// --- graph.* keys --- +pub const GRAPH_GOAL: &str = "graph.goal"; + +// --- internal.* keys --- +pub const INTERNAL_RUN_ID: &str = "internal.run_id"; +pub const INTERNAL_WORK_DIR: &str = "internal.work_dir"; +pub const INTERNAL_FIDELITY: &str = "internal.fidelity"; +pub const INTERNAL_THREAD_ID: &str = "internal.thread_id"; +pub const INTERNAL_NODE_VISIT_COUNT: &str = "internal.node_visit_count"; + +// --- current.* keys --- +pub const CURRENT_PREAMBLE: &str = "current.preamble"; + +// --- command.* keys --- +pub const COMMAND_OUTPUT: &str = "command.output"; +pub const COMMAND_STDERR: &str = "command.stderr"; + +// --- human.gate.* keys --- +pub const HUMAN_GATE_SELECTED: &str = "human.gate.selected"; +pub const HUMAN_GATE_LABEL: &str = "human.gate.label"; +pub const HUMAN_GATE_TEXT: &str = "human.gate.text"; + +// --- parallel.* keys --- +pub const PARALLEL_RESULTS: &str = "parallel.results"; +pub const PARALLEL_BRANCH_COUNT: &str = "parallel.branch_count"; +pub const PARALLEL_FAN_IN_BEST_ID: &str = "parallel.fan_in.best_id"; +pub const PARALLEL_FAN_IN_BEST_OUTCOME: &str = "parallel.fan_in.best_outcome"; +pub const PARALLEL_FAN_IN_BEST_HEAD_SHA: &str = "parallel.fan_in.best_head_sha"; + +// --- Prefix constants (for filtering and dynamic keys) --- +pub const GRAPH_PREFIX: &str = "graph."; +pub const INTERNAL_PREFIX: &str = "internal."; +pub const CURRENT_PREFIX: &str = "current"; +pub const THREAD_PREFIX: &str = "thread."; +pub const RESPONSE_PREFIX: &str = "response."; +pub const INTERNAL_RETRY_COUNT_PREFIX: &str = "internal.retry_count."; + +// --- Helper functions for dynamic keys --- + +#[must_use] +pub fn response_key(node_id: &str) -> String { + format!("{RESPONSE_PREFIX}{node_id}") +} + +#[must_use] +pub fn thread_current_node_key(thread_id: &str) -> String { + format!("{THREAD_PREFIX}{thread_id}.current_node") +} + +#[must_use] +pub fn graph_attr_key(attr: &str) -> String { + format!("{GRAPH_PREFIX}{attr}") +} + +#[must_use] +pub fn retry_count_key(node_id: &str) -> String { + format!("{INTERNAL_RETRY_COUNT_PREFIX}{node_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_key_formats_correctly() { + assert_eq!(response_key("plan"), "response.plan"); + } + + #[test] + fn thread_current_node_key_formats_correctly() { + assert_eq!( + thread_current_node_key("main"), + "thread.main.current_node" + ); + } + + #[test] + fn graph_attr_key_formats_correctly() { + assert_eq!(graph_attr_key("goal"), "graph.goal"); + } + + #[test] + fn retry_count_key_formats_correctly() { + assert_eq!(retry_count_key("plan"), "internal.retry_count.plan"); + } +} diff --git a/crates/arc-workflows/src/context.rs b/crates/arc-workflows/src/context/mod.rs similarity index 67% rename from crates/arc-workflows/src/context.rs rename to crates/arc-workflows/src/context/mod.rs index ca502b19b..15c6bbdf7 100644 --- a/crates/arc-workflows/src/context.rs +++ b/crates/arc-workflows/src/context/mod.rs @@ -1,3 +1,5 @@ +pub mod keys; + use std::collections::HashMap; use std::sync::{Arc, RwLock}; @@ -113,6 +115,41 @@ impl Context { values.insert(key.clone(), value.clone()); } } + + // --- Typed accessors --- + + #[must_use] + pub fn run_id(&self) -> String { + self.get_string(keys::INTERNAL_RUN_ID, "unknown") + } + + #[must_use] + pub fn fidelity(&self) -> String { + self.get_string(keys::INTERNAL_FIDELITY, "") + } + + #[must_use] + pub fn preamble(&self) -> String { + self.get_string(keys::CURRENT_PREAMBLE, "") + } + + #[must_use] + pub fn thread_id(&self) -> Option { + self.get(keys::INTERNAL_THREAD_ID) + .and_then(|v| v.as_str().map(String::from)) + } + + #[must_use] + pub fn node_visit_count(&self) -> usize { + self.get(keys::INTERNAL_NODE_VISIT_COUNT) + .and_then(|v| v.as_u64()) + .unwrap_or(1) as usize + } + + #[must_use] + pub fn current_node_id(&self) -> String { + self.get_string(keys::CURRENT_NODE, "") + } } #[cfg(test)] @@ -219,4 +256,89 @@ mod tests { let ctx = Context::default(); assert!(ctx.snapshot().is_empty()); } + + #[test] + fn run_id_default() { + let ctx = Context::new(); + assert_eq!(ctx.run_id(), "unknown"); + } + + #[test] + fn run_id_set() { + let ctx = Context::new(); + ctx.set(keys::INTERNAL_RUN_ID, serde_json::json!("abc-123")); + assert_eq!(ctx.run_id(), "abc-123"); + } + + #[test] + fn fidelity_default() { + let ctx = Context::new(); + assert_eq!(ctx.fidelity(), ""); + } + + #[test] + fn fidelity_set() { + let ctx = Context::new(); + ctx.set(keys::INTERNAL_FIDELITY, serde_json::json!("compact")); + assert_eq!(ctx.fidelity(), "compact"); + } + + #[test] + fn preamble_default() { + let ctx = Context::new(); + assert_eq!(ctx.preamble(), ""); + } + + #[test] + fn preamble_set() { + let ctx = Context::new(); + ctx.set(keys::CURRENT_PREAMBLE, serde_json::json!("hello")); + assert_eq!(ctx.preamble(), "hello"); + } + + #[test] + fn thread_id_default() { + let ctx = Context::new(); + assert_eq!(ctx.thread_id(), None); + } + + #[test] + fn thread_id_null() { + let ctx = Context::new(); + ctx.set(keys::INTERNAL_THREAD_ID, serde_json::Value::Null); + assert_eq!(ctx.thread_id(), None); + } + + #[test] + fn thread_id_set() { + let ctx = Context::new(); + ctx.set(keys::INTERNAL_THREAD_ID, serde_json::json!("main")); + assert_eq!(ctx.thread_id(), Some("main".to_string())); + } + + #[test] + fn node_visit_count_default() { + let ctx = Context::new(); + assert_eq!(ctx.node_visit_count(), 1); + } + + #[test] + fn node_visit_count_set() { + let ctx = Context::new(); + ctx.set(keys::INTERNAL_NODE_VISIT_COUNT, serde_json::json!(3)); + assert_eq!(ctx.node_visit_count(), 3); + } + + #[test] + fn current_node_id_default() { + let ctx = Context::new(); + assert_eq!(ctx.current_node_id(), ""); + } + + #[test] + fn current_node_id_set() { + let ctx = Context::new(); + ctx.set(keys::CURRENT_NODE, serde_json::json!("plan")); + assert_eq!(ctx.current_node_id(), "plan"); + } } diff --git a/crates/arc-workflows/src/engine.rs b/crates/arc-workflows/src/engine.rs index e40d1b6ad..265523db3 100644 --- a/crates/arc-workflows/src/engine.rs +++ b/crates/arc-workflows/src/engine.rs @@ -17,6 +17,7 @@ use crate::artifact::{offload_large_values, sync_artifacts_to_env, ArtifactStore use crate::asset_snapshot; use crate::checkpoint::Checkpoint; use crate::condition::evaluate_condition; +use crate::context; use crate::context::Context; use crate::error::{ArcError, FailureClass, FailureSignature, Result}; use crate::event::{EventEmitter, WorkflowRunEvent}; @@ -311,10 +312,7 @@ pub fn node_dir(logs_root: &Path, node_id: &str, visit: usize) -> PathBuf { /// Read the visit count from context, defaulting to 1 if not set. pub fn visit_from_context(context: &Context) -> usize { - context - .get("internal.node_visit_count") - .and_then(|v| v.as_u64()) - .unwrap_or(1) as usize + context.node_visit_count() } /// Write status.json for a completed node into {`logs_root}/nodes/{node_id}/status.json`. @@ -820,11 +818,11 @@ impl WorkflowRunEngine { /// Mirror graph-level attributes into the context. fn mirror_graph_attributes(graph: &Graph, context: &Context) { if !graph.goal().is_empty() { - context.set("graph.goal", serde_json::json!(graph.goal())); + context.set(context::keys::GRAPH_GOAL, serde_json::json!(graph.goal())); } for (key, val) in &graph.attrs { context.set( - format!("graph.{key}"), + context::keys::graph_attr_key(key), serde_json::json!(val.to_string_value()), ); } @@ -1210,7 +1208,7 @@ impl WorkflowRunEngine { } } // Gap #6: Check if the checkpointed node used full fidelity - if cp.context_values.get("internal.fidelity") == Some(&serde_json::json!("full")) { + if cp.context_values.get(context::keys::INTERNAL_FIDELITY) == Some(&serde_json::json!("full")) { degrade_fidelity_on_resume = true; } } else if let Some(start) = start_at { @@ -1232,10 +1230,10 @@ impl WorkflowRunEngine { } // Store run_id and work_dir in context for handlers - context.set("internal.run_id", serde_json::json!(run_id)); + context.set(context::keys::INTERNAL_RUN_ID, serde_json::json!(run_id)); if let Some(GitCheckpointMode::Host(ref wd)) = config.git_checkpoint { context.set( - "internal.work_dir", + context::keys::INTERNAL_WORK_DIR, serde_json::json!(wd.to_string_lossy().as_ref()), ); } @@ -1384,16 +1382,16 @@ impl WorkflowRunEngine { fidelity = "summary:high".to_string(); } degrade_fidelity_on_resume = false; - context.set("internal.fidelity", serde_json::json!(&fidelity)); + context.set(context::keys::INTERNAL_FIDELITY, serde_json::json!(&fidelity)); // Preamble injection at execution time (spec 5.4 / 8.3): synthesize a // fidelity-appropriate preamble from runtime data for handlers to read if fidelity == "full" { - context.set("current.preamble", serde_json::json!("")); + context.set(context::keys::CURRENT_PREAMBLE, serde_json::json!("")); } else { let preamble = build_preamble(&fidelity, &context, graph, &completed_nodes, &node_outcomes); - context.set("current.preamble", serde_json::json!(preamble)); + context.set(context::keys::CURRENT_PREAMBLE, serde_json::json!(preamble)); } // Thread context sharing: resolve thread ID and store in context for handlers @@ -1401,18 +1399,18 @@ impl WorkflowRunEngine { resolve_thread_id(incoming_edge, node, graph, previous_node_id.as_deref()); if let Some(ref tid) = resolved_thread_id { context.set( - format!("thread.{tid}.current_node"), + context::keys::thread_current_node_key(tid), serde_json::json!(&node.id), ); - context.set("internal.thread_id", serde_json::json!(tid)); + context.set(context::keys::INTERNAL_THREAD_ID, serde_json::json!(tid)); } else { - context.set("internal.thread_id", serde_json::Value::Null); + context.set(context::keys::INTERNAL_THREAD_ID, serde_json::Value::Null); } // Step 2: Execute node handler with retry policy let visit = *loop_state.node_visits.get(¤t_node_id).unwrap_or(&1); - context.set("internal.node_visit_count", serde_json::json!(visit)); - context.set("current_node", serde_json::json!(&node.id)); + context.set(context::keys::INTERNAL_NODE_VISIT_COUNT, serde_json::json!(visit)); + context.set(context::keys::CURRENT_NODE, serde_json::json!(&node.id)); let retry_policy = build_retry_policy(node, graph); self.services.emitter.emit(&WorkflowRunEvent::StageStarted { @@ -1505,7 +1503,7 @@ impl WorkflowRunEngine { // Gap #5: Track retry count per node node_retries.insert(node.id.clone(), attempts_used); context.set( - format!("internal.retry_count.{}", node.id), + context::keys::retry_count_key(&node.id), serde_json::json!(attempts_used), ); @@ -1637,19 +1635,19 @@ impl WorkflowRunEngine { // Step 4: Apply context updates from outcome context.apply_updates(&outcome.context_updates); - context.set("outcome", serde_json::json!(outcome.status.to_string())); + context.set(context::keys::OUTCOME, serde_json::json!(outcome.status.to_string())); context.set( - "failure_class", + context::keys::FAILURE_CLASS, serde_json::json!(outcome_failure_class.map_or(String::new(), |fc| fc.to_string())), ); context.set( - "failure_signature", + context::keys::FAILURE_SIGNATURE, serde_json::json!(failure_sig .as_ref() .map_or(String::new(), |s| s.to_string())), ); if let Some(ref pref) = outcome.preferred_label { - context.set("preferred_label", serde_json::json!(pref)); + context.set(context::keys::PREFERRED_LABEL, serde_json::json!(pref)); } // Step 5: Select next edge (done before checkpoint so we can store next_node_id) @@ -2762,7 +2760,7 @@ mod tests { // Verify checkpoint has graph.goal mirrored let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap(); assert_eq!( - cp.context_values.get("graph.goal"), + cp.context_values.get(context::keys::GRAPH_GOAL), Some(&serde_json::json!("Run tests")) ); } @@ -3035,7 +3033,7 @@ mod tests { // The checkpoint context should contain internal.fidelity let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap(); assert_eq!( - cp.context_values.get("internal.fidelity"), + cp.context_values.get(context::keys::INTERNAL_FIDELITY), Some(&serde_json::json!("compact")) ); } @@ -4543,7 +4541,7 @@ mod tests { // Check the checkpoint for the failure_signature context value let checkpoint_path = dir.path().join("checkpoint.json"); let cp = Checkpoint::load(&checkpoint_path).unwrap(); - let sig_value = cp.context_values.get("failure_signature").unwrap(); + let sig_value = cp.context_values.get(context::keys::FAILURE_SIGNATURE).unwrap(); let sig_str = sig_value.as_str().unwrap(); assert!( sig_str.contains("work|deterministic|"), diff --git a/crates/arc-workflows/src/handler/agent.rs b/crates/arc-workflows/src/handler/agent.rs index 06b3a39e5..056ea5dde 100644 --- a/crates/arc-workflows/src/handler/agent.rs +++ b/crates/arc-workflows/src/handler/agent.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use arc_agent::Sandbox; use async_trait::async_trait; +use crate::context::keys; use crate::context::Context; use crate::error::ArcError; use crate::event::EventEmitter; @@ -188,7 +189,7 @@ impl Handler for AgentHandler { .filter(|p| !p.is_empty()) .unwrap_or_else(|| node.label()); let expanded = expand_variables(raw_prompt, graph)?; - let preamble = context.get_string("current.preamble", ""); + let preamble = context.preamble(); let prompt = if preamble.is_empty() { expanded } else { @@ -202,9 +203,7 @@ impl Handler for AgentHandler { tokio::fs::write(stage_dir.join("prompt.md"), &prompt).await?; // 3. Call LLM backend (agent loop) - let thread_id = context - .get("internal.thread_id") - .and_then(|v| v.as_str().map(String::from)); + let thread_id = context.thread_id(); let (response_text, stage_usage, backend_files_touched) = if let Some(backend) = &self.backend { let result = backend @@ -253,13 +252,13 @@ impl Handler for AgentHandler { outcome.notes = Some(format!("Stage completed: {}", node.id)); outcome .context_updates - .insert("last_stage".to_string(), serde_json::json!(node.id)); + .insert(keys::LAST_STAGE.to_string(), serde_json::json!(node.id)); outcome.context_updates.insert( - "last_response".to_string(), + keys::LAST_RESPONSE.to_string(), serde_json::json!(truncate(&response_text, 200)), ); outcome.context_updates.insert( - format!("response.{}", node.id), + keys::response_key(&node.id), serde_json::json!(&response_text), ); @@ -395,12 +394,12 @@ mod tests { .unwrap(); assert_eq!( - outcome.context_updates.get("last_stage"), + outcome.context_updates.get(keys::LAST_STAGE), Some(&serde_json::json!("step")) ); - assert!(outcome.context_updates.contains_key("last_response")); + assert!(outcome.context_updates.contains_key(keys::LAST_RESPONSE)); assert_eq!( - outcome.context_updates.get("response.step"), + outcome.context_updates.get(&keys::response_key("step")), Some(&serde_json::json!("[Simulated] Response for stage: step")) ); } @@ -489,7 +488,7 @@ mod tests { let node = Node::new("work"); let context = Context::new(); // Simulate what the engine stores in internal.thread_id - context.set("internal.thread_id", serde_json::json!("main")); + context.set(keys::INTERNAL_THREAD_ID, serde_json::json!("main")); let graph = Graph::new("test"); let tmp = TempDir::new().unwrap(); @@ -744,7 +743,7 @@ Some text in between. ); let context = Context::new(); context.set( - "current.preamble", + keys::CURRENT_PREAMBLE, serde_json::json!("## Test Output\n10 passed, 0 failed"), ); let graph = Graph::new("test"); @@ -834,7 +833,7 @@ Some text in between. ); let context = Context::new(); context.set( - "current.preamble", + keys::CURRENT_PREAMBLE, serde_json::json!("## Script Output\nAll tests passed"), ); let graph = Graph::new("test"); diff --git a/crates/arc-workflows/src/handler/command.rs b/crates/arc-workflows/src/handler/command.rs index 5ad5bfbeb..fbd533569 100644 --- a/crates/arc-workflows/src/handler/command.rs +++ b/crates/arc-workflows/src/handler/command.rs @@ -2,6 +2,7 @@ use std::path::Path; use async_trait::async_trait; +use crate::context::keys; use crate::context::Context; use crate::error::ArcError; use crate::graph::{Graph, Node}; @@ -129,10 +130,10 @@ impl Handler for CommandHandler { let mut outcome = Outcome::success(); outcome .context_updates - .insert("command.output".to_string(), serde_json::json!(stdout)); + .insert(keys::COMMAND_OUTPUT.to_string(), serde_json::json!(stdout)); outcome .context_updates - .insert("command.stderr".to_string(), serde_json::json!(stderr)); + .insert(keys::COMMAND_STDERR.to_string(), serde_json::json!(stderr)); outcome.notes = Some(format!("Script completed: {script}")); Ok(outcome) } else { @@ -151,10 +152,10 @@ impl Handler for CommandHandler { let mut outcome = Outcome::fail_classify(reason); outcome .context_updates - .insert("command.output".to_string(), serde_json::json!(stdout)); + .insert(keys::COMMAND_OUTPUT.to_string(), serde_json::json!(stdout)); outcome .context_updates - .insert("command.stderr".to_string(), serde_json::json!(stderr)); + .insert(keys::COMMAND_STDERR.to_string(), serde_json::json!(stderr)); Ok(outcome) } } @@ -219,9 +220,9 @@ mod tests { .unwrap(); assert_eq!(outcome.status, StageStatus::Success); assert!(outcome.notes.as_deref().unwrap().contains("echo hello")); - let command_output = outcome.context_updates.get("command.output").unwrap(); + let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap(); assert!(command_output.as_str().unwrap().contains("hello")); - let command_stderr = outcome.context_updates.get("command.stderr").unwrap(); + let command_stderr = outcome.context_updates.get(keys::COMMAND_STDERR).unwrap(); assert_eq!(command_stderr.as_str().unwrap(), ""); } @@ -486,7 +487,7 @@ mod tests { .await .unwrap(); assert_eq!(outcome.status, StageStatus::Success); - let command_output = outcome.context_updates.get("command.output").unwrap(); + let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap(); assert!(command_output .as_str() .unwrap() @@ -560,7 +561,7 @@ mod tests { .await .unwrap(); assert_eq!(outcome.status, StageStatus::Success); - let command_output = outcome.context_updates.get("command.output").unwrap(); + let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap(); assert!(command_output.as_str().unwrap().contains("legacy")); } @@ -581,7 +582,7 @@ mod tests { .await .unwrap(); assert_eq!(outcome.status, StageStatus::Success); - let command_stderr = outcome.context_updates.get("command.stderr").unwrap(); + let command_stderr = outcome.context_updates.get(keys::COMMAND_STDERR).unwrap(); assert!( command_stderr.as_str().unwrap().contains("err"), "command.stderr should contain 'err', got: {:?}", @@ -606,7 +607,7 @@ mod tests { .await .unwrap(); assert_eq!(outcome.status, StageStatus::Success); - assert!(outcome.context_updates.contains_key("command.output")); + assert!(outcome.context_updates.contains_key(keys::COMMAND_OUTPUT)); assert!( !outcome.context_updates.contains_key("tool.output"), "tool.output should not be emitted" @@ -679,7 +680,7 @@ mod tests { assert_eq!(outcome.status, StageStatus::Fail); let command_output = outcome .context_updates - .get("command.output") + .get(keys::COMMAND_OUTPUT) .expect("command.output should be set on failure"); assert!( command_output.as_str().unwrap().contains("build output"), diff --git a/crates/arc-workflows/src/handler/fan_in.rs b/crates/arc-workflows/src/handler/fan_in.rs index f9f3a9323..34f5238e4 100644 --- a/crates/arc-workflows/src/handler/fan_in.rs +++ b/crates/arc-workflows/src/handler/fan_in.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use arc_agent::Sandbox; use async_trait::async_trait; +use crate::context::keys; use crate::context::Context; use crate::error::ArcError; use crate::event::EventEmitter; @@ -35,7 +36,7 @@ impl Handler for FanInHandler { logs_root: &Path, services: &EngineServices, ) -> Result { - let results = context.get("parallel.results"); + let results = context.get(keys::PARALLEL_RESULTS); let Some(results) = results else { return Ok(Outcome::fail_deterministic( "No parallel results to evaluate", @@ -99,16 +100,16 @@ impl Handler for FanInHandler { let mut outcome = Outcome::success(); outcome.context_updates.insert( - "parallel.fan_in.best_id".to_string(), + keys::PARALLEL_FAN_IN_BEST_ID.to_string(), serde_json::json!(best.id), ); outcome.context_updates.insert( - "parallel.fan_in.best_outcome".to_string(), + keys::PARALLEL_FAN_IN_BEST_OUTCOME.to_string(), serde_json::json!(best.status), ); if let Some(ref sha) = best_head_sha { outcome.context_updates.insert( - "parallel.fan_in.best_head_sha".to_string(), + keys::PARALLEL_FAN_IN_BEST_HEAD_SHA.to_string(), serde_json::json!(sha), ); } @@ -234,7 +235,7 @@ async fn llm_evaluate( // If the backend returned a full Outcome, extract best_id from context_updates let best_id = outcome .context_updates - .get("parallel.fan_in.best_id") + .get(keys::PARALLEL_FAN_IN_BEST_ID) .and_then(|v| v.as_str()) .map(String::from) .or_else(|| outcome.notes.clone()) @@ -330,7 +331,7 @@ mod tests { let node = Node::new("fan_in"); let context = Context::new(); context.set( - "parallel.results", + keys::PARALLEL_RESULTS, serde_json::json!([ {"id": "branch_a", "status": "fail"}, {"id": "branch_b", "status": "success"}, @@ -345,7 +346,7 @@ mod tests { .unwrap(); assert_eq!(outcome.status, StageStatus::Success); assert_eq!( - outcome.context_updates.get("parallel.fan_in.best_id"), + outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), Some(&serde_json::json!("branch_b")) ); } @@ -356,7 +357,7 @@ mod tests { let node = Node::new("fan_in"); let context = Context::new(); context.set( - "parallel.results", + keys::PARALLEL_RESULTS, serde_json::json!([ {"id": "c", "status": "success"}, {"id": "a", "status": "success"}, @@ -371,7 +372,7 @@ mod tests { .await .unwrap(); assert_eq!( - outcome.context_updates.get("parallel.fan_in.best_id"), + outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), Some(&serde_json::json!("a")) ); } @@ -394,7 +395,7 @@ mod tests { ); let context = Context::new(); context.set( - "parallel.results", + keys::PARALLEL_RESULTS, serde_json::json!([ {"id": "branch_a", "status": "success"}, {"id": "branch_b", "status": "fail"}, @@ -410,7 +411,7 @@ mod tests { assert_eq!(outcome.status, StageStatus::Success); // Should still pick branch_a via heuristic (success beats fail) assert_eq!( - outcome.context_updates.get("parallel.fan_in.best_id"), + outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), Some(&serde_json::json!("branch_a")) ); } @@ -451,7 +452,7 @@ mod tests { ); let context = Context::new(); context.set( - "parallel.results", + keys::PARALLEL_RESULTS, serde_json::json!([ {"id": "branch_a", "status": "success"}, {"id": "branch_b", "status": "success"}, @@ -467,7 +468,7 @@ mod tests { assert_eq!(outcome.status, StageStatus::Success); // LLM chose branch_b assert_eq!( - outcome.context_updates.get("parallel.fan_in.best_id"), + outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), Some(&serde_json::json!("branch_b")) ); @@ -489,7 +490,7 @@ mod tests { let node = Node::new("fan_in"); let context = Context::new(); context.set( - "parallel.results", + keys::PARALLEL_RESULTS, serde_json::json!([ {"id": "branch_a", "status": "fail"}, {"id": "branch_b", "status": "fail"}, @@ -516,7 +517,7 @@ mod tests { let node = Node::new("fan_in"); let context = Context::new(); context.set( - "parallel.results", + keys::PARALLEL_RESULTS, serde_json::json!([ {"id": "branch_a", "status": "success", "score": 0.5}, {"id": "branch_b", "status": "success", "score": 0.9}, @@ -533,7 +534,7 @@ mod tests { assert_eq!(outcome.status, StageStatus::Success); // branch_b has highest score assert_eq!( - outcome.context_updates.get("parallel.fan_in.best_id"), + outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), Some(&serde_json::json!("branch_b")) ); } diff --git a/crates/arc-workflows/src/handler/human.rs b/crates/arc-workflows/src/handler/human.rs index e313d92af..e0831052b 100644 --- a/crates/arc-workflows/src/handler/human.rs +++ b/crates/arc-workflows/src/handler/human.rs @@ -4,6 +4,7 @@ use std::time::Instant; use async_trait::async_trait; +use crate::context::keys; use crate::context::Context; use crate::error::ArcError; use crate::event::{EventEmitter, WorkflowRunEvent}; @@ -204,15 +205,15 @@ impl Handler for HumanHandler { let mut outcome = Outcome::success(); outcome.suggested_next_ids = vec![freeform_to.clone()]; outcome.context_updates.insert( - "human.gate.selected".to_string(), + keys::HUMAN_GATE_SELECTED.to_string(), serde_json::json!("freeform"), ); outcome .context_updates - .insert("human.gate.label".to_string(), serde_json::json!(text)); + .insert(keys::HUMAN_GATE_LABEL.to_string(), serde_json::json!(text)); outcome .context_updates - .insert("human.gate.text".to_string(), serde_json::json!(text)); + .insert(keys::HUMAN_GATE_TEXT.to_string(), serde_json::json!(text)); return Ok(outcome); } @@ -231,10 +232,10 @@ fn make_choice_outcome(key: &str, label: &str, to: &str) -> Outcome { outcome.suggested_next_ids = vec![to.to_string()]; outcome .context_updates - .insert("human.gate.selected".to_string(), serde_json::json!(key)); + .insert(keys::HUMAN_GATE_SELECTED.to_string(), serde_json::json!(key)); outcome .context_updates - .insert("human.gate.label".to_string(), serde_json::json!(label)); + .insert(keys::HUMAN_GATE_LABEL.to_string(), serde_json::json!(label)); outcome } @@ -362,7 +363,7 @@ mod tests { assert_eq!(outcome.status, crate::outcome::StageStatus::Success); // Auto-approve picks first option key "A" assert_eq!( - outcome.context_updates.get("human.gate.selected"), + outcome.context_updates.get(keys::HUMAN_GATE_SELECTED), Some(&serde_json::json!("A")) ); assert_eq!(outcome.suggested_next_ids, vec!["approve"]); @@ -418,7 +419,7 @@ mod tests { assert_eq!(outcome.status, crate::outcome::StageStatus::Success); assert_eq!(outcome.suggested_next_ids, vec!["freeform_target"]); assert_eq!( - outcome.context_updates.get("human.gate.text"), + outcome.context_updates.get(keys::HUMAN_GATE_TEXT), Some(&serde_json::json!("custom input")) ); } diff --git a/crates/arc-workflows/src/handler/manager_loop.rs b/crates/arc-workflows/src/handler/manager_loop.rs index 5a5809405..df3f799d7 100644 --- a/crates/arc-workflows/src/handler/manager_loop.rs +++ b/crates/arc-workflows/src/handler/manager_loop.rs @@ -128,17 +128,11 @@ impl Handler for SubWorkflowHandler { }; // Build child RunConfig - let visit = context - .get("internal.node_visit") - .and_then(|v| v.as_u64()) - .unwrap_or(1); + let visit = context.node_visit_count() as u64; let child_logs = logs_root.join(format!("nodes/{}_{visit}/child", node.id)); let _ = std::fs::create_dir_all(&child_logs); - let parent_run_id = context - .get("internal.run_id") - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(); + let parent_run_id = context.run_id(); let cancel_token = Arc::new(AtomicBool::new(false)); let child_cancel = Arc::clone(&cancel_token); diff --git a/crates/arc-workflows/src/handler/parallel.rs b/crates/arc-workflows/src/handler/parallel.rs index e4ea3b21c..3d5081f5f 100644 --- a/crates/arc-workflows/src/handler/parallel.rs +++ b/crates/arc-workflows/src/handler/parallel.rs @@ -6,6 +6,7 @@ use arc_agent::Sandbox; use async_trait::async_trait; use tokio::sync::Semaphore; +use crate::context::keys; use crate::context::Context; use crate::engine::GitCheckpointMode; use crate::error::ArcError; @@ -317,7 +318,7 @@ impl Handler for ParallelHandler { ArcError::handler(format!("worktree setup join error: {e}")) })??; branch_context.set( - "internal.work_dir", + keys::INTERNAL_WORK_DIR, serde_json::json!(wt_path.to_string_lossy().as_ref()), ); let env: Arc = @@ -366,7 +367,7 @@ impl Handler for ParallelHandler { "failed to reset remote worktree {wt_path_str}" ))); } - branch_context.set("internal.work_dir", serde_json::json!(&wt_path_str)); + branch_context.set(keys::INTERNAL_WORK_DIR, serde_json::json!(&wt_path_str)); let env: Arc = Arc::new(WorktreeSandbox { inner: Arc::clone(&services.sandbox), worktree_dir: wt_path_str.clone(), @@ -637,8 +638,8 @@ impl Handler for ParallelHandler { entry }) .collect(); - context.set("parallel.results", serde_json::json!(results_json)); - context.set("parallel.branch_count", serde_json::json!(total)); + context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json)); + context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total)); let visit = crate::engine::visit_from_context(context); let node_dir = crate::engine::node_dir(logs_root, &node.id, visit); @@ -815,7 +816,7 @@ mod tests { assert!(outcome.notes.as_deref().unwrap().contains("2 branches")); // Check context was set - let results = context.get("parallel.results"); + let results = context.get(keys::PARALLEL_RESULTS); assert!(results.is_some()); // Check parallel_results.json was written diff --git a/crates/arc-workflows/src/handler/prompt.rs b/crates/arc-workflows/src/handler/prompt.rs index d6e318fcb..b9dd5326d 100644 --- a/crates/arc-workflows/src/handler/prompt.rs +++ b/crates/arc-workflows/src/handler/prompt.rs @@ -2,6 +2,7 @@ use std::path::Path; use async_trait::async_trait; +use crate::context::keys; use crate::context::Context; use crate::error::ArcError; use crate::graph::{Graph, Node}; @@ -40,7 +41,7 @@ impl Handler for PromptHandler { .filter(|p| !p.is_empty()) .unwrap_or_else(|| node.label()); let expanded = expand_variables(raw_prompt, graph)?; - let preamble = context.get_string("current.preamble", ""); + let preamble = context.preamble(); let prompt = if preamble.is_empty() { expanded } else { @@ -92,13 +93,13 @@ impl Handler for PromptHandler { outcome.notes = Some(format!("Stage completed: {}", node.id)); outcome .context_updates - .insert("last_stage".to_string(), serde_json::json!(node.id)); + .insert(keys::LAST_STAGE.to_string(), serde_json::json!(node.id)); outcome.context_updates.insert( - "last_response".to_string(), + keys::LAST_RESPONSE.to_string(), serde_json::json!(truncate(&response_text, 200)), ); outcome.context_updates.insert( - format!("response.{}", node.id), + keys::response_key(&node.id), serde_json::json!(&response_text), ); @@ -277,7 +278,7 @@ mod tests { AttrValue::String("Classify this".to_string()), ); let context = Context::new(); - context.set("current.preamble", serde_json::json!("Prior output here")); + context.set(keys::CURRENT_PREAMBLE, serde_json::json!("Prior output here")); let graph = Graph::new("test"); let tmp = TempDir::new().unwrap(); diff --git a/crates/arc-workflows/src/preamble.rs b/crates/arc-workflows/src/preamble.rs index c8053c7a1..7748ca469 100644 --- a/crates/arc-workflows/src/preamble.rs +++ b/crates/arc-workflows/src/preamble.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; use crate::artifact::{artifact_path, format_artifact_reference}; +use crate::context::keys; use crate::context::Context; use crate::graph::{is_llm_handler_type, Graph, Node}; use crate::outcome::Outcome; @@ -23,7 +24,7 @@ pub fn build_preamble( node_outcomes: &HashMap, ) -> String { let goal = graph.goal(); - let run_id = context.get_string("internal.run_id", "unknown"); + let run_id = context.run_id(); match fidelity { "truncate" => { @@ -69,15 +70,15 @@ pub fn build_preamble( // --------------------------------------------------------------------------- fn is_context_key_excluded(key: &str) -> bool { - key.starts_with("internal.") - || key.starts_with("current") - || key.starts_with("graph.") - || key.starts_with("thread.") - || key.starts_with("response.") - || key == "outcome" - || key == "last_stage" - || key == "last_response" - || key == "preferred_label" + key.starts_with(keys::INTERNAL_PREFIX) + || key.starts_with(keys::CURRENT_PREFIX) + || key.starts_with(keys::GRAPH_PREFIX) + || key.starts_with(keys::THREAD_PREFIX) + || key.starts_with(keys::RESPONSE_PREFIX) + || key == keys::OUTCOME + || key == keys::LAST_STAGE + || key == keys::LAST_RESPONSE + || key == keys::PREFERRED_LABEL } fn format_value(val: &serde_json::Value) -> String { @@ -101,11 +102,11 @@ fn format_token_count(tokens: i64) -> String { /// handler-specific details, so they can be skipped in the trailing context section. fn stage_rendered_keys(node_id: &str, outcome: &Outcome) -> HashSet { let candidates = [ - "command.output".to_string(), - "command.stderr".to_string(), - "last_stage".to_string(), - "last_response".to_string(), - format!("response.{node_id}"), + keys::COMMAND_OUTPUT.to_string(), + keys::COMMAND_STDERR.to_string(), + keys::LAST_STAGE.to_string(), + keys::LAST_RESPONSE.to_string(), + keys::response_key(node_id), ]; candidates .into_iter() @@ -133,7 +134,7 @@ fn render_compact_stage_details( lines.push(format!(" - Script: `{cmd}`")); } } - if let Some(stdout_val) = outcome.context_updates.get("command.output") { + if let Some(stdout_val) = outcome.context_updates.get(keys::COMMAND_OUTPUT) { let stdout = format_value(stdout_val); if stdout.trim().is_empty() { lines.push(" - Stdout: (empty)".to_string()); @@ -144,7 +145,7 @@ fn render_compact_stage_details( lines.push(" ```".to_string()); } } - if let Some(stderr_val) = outcome.context_updates.get("command.stderr") { + if let Some(stderr_val) = outcome.context_updates.get(keys::COMMAND_STDERR) { let stderr = format_value(stderr_val); if stderr.trim().is_empty() { lines.push(" - Stderr: (empty)".to_string()); @@ -203,7 +204,7 @@ fn render_summary_high_stage_section( lines.push(format!("- Script: `{cmd}`")); } } - if let Some(stdout_val) = outcome.context_updates.get("command.output") { + if let Some(stdout_val) = outcome.context_updates.get(keys::COMMAND_OUTPUT) { if let Some(path) = artifact_path(stdout_val) { lines.push(format!("- Stdout: {}", format_artifact_reference(path))); } else { @@ -218,7 +219,7 @@ fn render_summary_high_stage_section( } } } - if let Some(stderr_val) = outcome.context_updates.get("command.stderr") { + if let Some(stderr_val) = outcome.context_updates.get(keys::COMMAND_STDERR) { if let Some(path) = artifact_path(stderr_val) { lines.push(format!("- Stderr: {}", format_artifact_reference(path))); } else { @@ -250,7 +251,7 @@ fn render_summary_high_stage_section( )); } // Include full response from context_updates (or artifact pointer) - if let Some(resp_val) = outcome.context_updates.get(&format!("response.{node_id}")) { + if let Some(resp_val) = outcome.context_updates.get(&keys::response_key(node_id)) { if let Some(path) = artifact_path(resp_val) { lines.push(format!("- Response: {}", format_artifact_reference(path))); } else { @@ -532,7 +533,7 @@ mod tests { AttrValue::String("Fix the login bug".to_string()), ); let context = Context::new(); - context.set("internal.run_id", serde_json::json!("abc-123")); + context.set(keys::INTERNAL_RUN_ID, serde_json::json!("abc-123")); let completed_nodes: Vec = Vec::new(); let node_outcomes: HashMap = HashMap::new(); @@ -596,7 +597,7 @@ mod tests { AttrValue::String("Deploy app".to_string()), ); let context = Context::new(); - context.set("internal.run_id", serde_json::json!("run-456")); + context.set(keys::INTERNAL_RUN_ID, serde_json::json!("run-456")); let completed_nodes = vec!["plan".to_string(), "code".to_string()]; let mut node_outcomes: HashMap = HashMap::new(); node_outcomes.insert("plan".to_string(), Outcome::success()); @@ -637,7 +638,7 @@ mod tests { fn build_preamble_compact_includes_context_values() { let graph = Graph::new("test"); let context = Context::new(); - context.set("graph.goal", serde_json::json!("Build it")); + context.set(keys::GRAPH_GOAL, serde_json::json!("Build it")); context.set("user.name", serde_json::json!("alice")); let completed_nodes: Vec = Vec::new(); let node_outcomes: HashMap = HashMap::new(); @@ -665,15 +666,15 @@ mod tests { fn build_preamble_compact_excludes_internal_keys() { let graph = Graph::new("test"); let context = Context::new(); - context.set("internal.fidelity", serde_json::json!("compact")); - context.set("internal.retry_count.plan", serde_json::json!(1)); - context.set("current_node", serde_json::json!("work")); - context.set("graph.default_fidelity", serde_json::json!("compact")); + context.set(keys::INTERNAL_FIDELITY, serde_json::json!("compact")); + context.set(&keys::retry_count_key("plan"), serde_json::json!(1)); + context.set(keys::CURRENT_NODE, serde_json::json!("work")); + context.set(&keys::graph_attr_key("default_fidelity"), serde_json::json!("compact")); context.set("thread.main.current_node", serde_json::json!("work")); - context.set("response.plan", serde_json::json!("some response")); - context.set("last_stage", serde_json::json!("plan")); - context.set("last_response", serde_json::json!("resp")); - context.set("preferred_label", serde_json::json!("success")); + context.set(&keys::response_key("plan"), serde_json::json!("some response")); + context.set(keys::LAST_STAGE, serde_json::json!("plan")); + context.set(keys::LAST_RESPONSE, serde_json::json!("resp")); + context.set(keys::PREFERRED_LABEL, serde_json::json!("success")); context.set("user.name", serde_json::json!("bob")); let completed_nodes: Vec = Vec::new(); let node_outcomes: HashMap = HashMap::new(); @@ -777,12 +778,12 @@ mod tests { let mut node_outcomes: HashMap = HashMap::new(); let mut outcome = Outcome::success(); outcome.context_updates.insert( - "command.output".to_string(), + keys::COMMAND_OUTPUT.to_string(), serde_json::json!("10 passed\n"), ); outcome .context_updates - .insert("command.stderr".to_string(), serde_json::json!("")); + .insert(keys::COMMAND_STDERR.to_string(), serde_json::json!("")); node_outcomes.insert("run_tests".to_string(), outcome); let preamble = build_preamble( @@ -856,10 +857,10 @@ mod tests { fn compact_context_excludes_engine_keys() { let graph = Graph::new("test"); let context = Context::new(); - context.set("graph.default_fidelity", serde_json::json!("compact")); + context.set(&keys::graph_attr_key("default_fidelity"), serde_json::json!("compact")); context.set("thread.main.current_node", serde_json::json!("work")); - context.set("response.plan", serde_json::json!("some LLM response")); - context.set("last_stage", serde_json::json!("plan")); + context.set(&keys::response_key("plan"), serde_json::json!("some LLM response")); + context.set(keys::LAST_STAGE, serde_json::json!("plan")); context.set("user.preference", serde_json::json!("dark")); let completed_nodes: Vec = Vec::new(); let node_outcomes: HashMap = HashMap::new(); @@ -910,17 +911,17 @@ mod tests { let context = Context::new(); // command.output is set in context (the engine copies context_updates to context) - context.set("command.output", serde_json::json!("hi\n")); - context.set("command.stderr", serde_json::json!("")); + context.set(keys::COMMAND_OUTPUT, serde_json::json!("hi\n")); + context.set(keys::COMMAND_STDERR, serde_json::json!("")); let completed_nodes = vec!["step".to_string()]; let mut node_outcomes: HashMap = HashMap::new(); let mut outcome = Outcome::success(); outcome .context_updates - .insert("command.output".to_string(), serde_json::json!("hi\n")); + .insert(keys::COMMAND_OUTPUT.to_string(), serde_json::json!("hi\n")); outcome .context_updates - .insert("command.stderr".to_string(), serde_json::json!("")); + .insert(keys::COMMAND_STDERR.to_string(), serde_json::json!("")); node_outcomes.insert("step".to_string(), outcome); let preamble = build_preamble( @@ -1037,7 +1038,7 @@ mod tests { let mut node_outcomes: HashMap = HashMap::new(); let mut outcome = Outcome::fail_classify("exit code 1"); outcome.context_updates.insert( - "command.output".to_string(), + keys::COMMAND_OUTPUT.to_string(), serde_json::json!("test failed"), ); node_outcomes.insert("run_tests".to_string(), outcome); @@ -1214,12 +1215,12 @@ mod tests { let mut node_outcomes: HashMap = HashMap::new(); let mut outcome = Outcome::success(); outcome.context_updates.insert( - "command.output".to_string(), + keys::COMMAND_OUTPUT.to_string(), serde_json::json!("All tests passed\n"), ); outcome .context_updates - .insert("command.stderr".to_string(), serde_json::json!("")); + .insert(keys::COMMAND_STDERR.to_string(), serde_json::json!("")); node_outcomes.insert("run_tests".to_string(), outcome); let preamble = build_preamble( @@ -1355,7 +1356,7 @@ mod tests { fn build_preamble_summary_high_includes_context_values() { let graph = Graph::new("test"); let context = Context::new(); - context.set("graph.goal", serde_json::json!("Build")); + context.set(keys::GRAPH_GOAL, serde_json::json!("Build")); context.set("user.name", serde_json::json!("alice")); let completed_nodes: Vec = Vec::new(); let node_outcomes: HashMap = HashMap::new(); @@ -1427,11 +1428,11 @@ mod tests { let mut node_outcomes: HashMap = HashMap::new(); let mut outcome = Outcome::success(); outcome.context_updates.insert( - "command.output".to_string(), + keys::COMMAND_OUTPUT.to_string(), serde_json::json!("All tests passed\n"), ); outcome.context_updates.insert( - "command.stderr".to_string(), + keys::COMMAND_STDERR.to_string(), serde_json::json!("warning: unused var\n"), ); node_outcomes.insert("run_tests".to_string(), outcome); @@ -1487,7 +1488,7 @@ mod tests { }); outcome.files_touched = vec!["src/lib.rs".to_string()]; outcome.context_updates.insert( - "response.report".to_string(), + keys::response_key("report"), serde_json::json!("The tests all pass successfully."), ); node_outcomes.insert("report".to_string(), outcome); @@ -1605,21 +1606,21 @@ mod tests { #[test] fn is_context_key_excluded_checks() { - assert!(is_context_key_excluded("internal.fidelity")); - assert!(is_context_key_excluded("internal.retry_count.plan")); - assert!(is_context_key_excluded("current_node")); - assert!(is_context_key_excluded("current.preamble")); - assert!(is_context_key_excluded("graph.default_fidelity")); - assert!(is_context_key_excluded("graph.goal")); - assert!(is_context_key_excluded("thread.main.current_node")); - assert!(is_context_key_excluded("response.plan")); - assert!(is_context_key_excluded("outcome")); - assert!(is_context_key_excluded("last_stage")); - assert!(is_context_key_excluded("last_response")); - assert!(is_context_key_excluded("preferred_label")); + assert!(is_context_key_excluded(keys::INTERNAL_FIDELITY)); + assert!(is_context_key_excluded(&keys::retry_count_key("plan"))); + assert!(is_context_key_excluded(keys::CURRENT_NODE)); + assert!(is_context_key_excluded(keys::CURRENT_PREAMBLE)); + assert!(is_context_key_excluded(&keys::graph_attr_key("default_fidelity"))); + assert!(is_context_key_excluded(keys::GRAPH_GOAL)); + assert!(is_context_key_excluded(&keys::thread_current_node_key("main"))); + assert!(is_context_key_excluded(&keys::response_key("plan"))); + assert!(is_context_key_excluded(keys::OUTCOME)); + assert!(is_context_key_excluded(keys::LAST_STAGE)); + assert!(is_context_key_excluded(keys::LAST_RESPONSE)); + assert!(is_context_key_excluded(keys::PREFERRED_LABEL)); assert!(!is_context_key_excluded("user.name")); assert!(!is_context_key_excluded("custom.key")); - assert!(!is_context_key_excluded("command.output")); + assert!(!is_context_key_excluded(keys::COMMAND_OUTPUT)); } // --- unknown fidelity mode ---