Merge pull request #24 from Dark-Alex-17/feat/map-subgraph-branches

feat(graph): map branches as per-item subgraph chains + templated max_concurrency
This commit is contained in:
Alex Clarke authored and GitHub committed 2026-09-11 13:12:09 -06:00
commit e8237bd924
22 files changed
+6174 -453

No files matched your search

+26 -25
View File
@@ -13,7 +13,7 @@ description: |
node type (script, llm, rag, map, agent, input, approval, end) and node type (script, llm, rag, map, agent, input, approval, end) and
both static fan-out and dynamic map fan-out. both static fan-out and dynamic map fan-out.
version: "1.0" version: '1.0'
global_tools: global_tools:
- web_search_coyote.sh - web_search_coyote.sh
@@ -24,8 +24,8 @@ mcp_servers:
- ddg-search - ddg-search
conversation_starters: conversation_starters:
- "How does HTTP/3 differ from HTTP/2?" - 'How does HTTP/3 differ from HTTP/2?'
- "Summarize recent advances in solid-state battery chemistry" - 'Summarize recent advances in solid-state battery chemistry'
settings: settings:
max_loop_iterations: 40 max_loop_iterations: 40
@@ -34,15 +34,14 @@ settings:
max_concurrency: 4 max_concurrency: 4
initial_state: initial_state:
research_feedback: "" research_feedback: ''
research_attempts: 0 research_attempts: 0
local_context: "" local_context: ''
local_sources: "" local_sources: ''
start: parse_request start: parse_request
nodes: nodes:
parse_request: parse_request:
id: parse_request id: parse_request
type: script type: script
@@ -52,10 +51,10 @@ nodes:
ask_topic: ask_topic:
id: ask_topic id: ask_topic
type: input type: input
question: "What would you like me to research?" question: 'What would you like me to research?'
validation: "len(input) > 0" validation: 'len(input) > 0'
state_updates: state_updates:
topic: "{{input}}" topic: '{{input}}'
next: bootstrap_research next: bootstrap_research
bootstrap_research: bootstrap_research:
@@ -77,7 +76,7 @@ nodes:
are precise, self-contained queries (each one is sent on its own are precise, self-contained queries (each one is sent on its own
to a separate research worker, so they must be answerable to a separate research worker, so they must be answerable
without each other's context). without each other's context).
prompt: "Research topic: {{topic}}" prompt: 'Research topic: {{topic}}'
tools: [] tools: []
output_schema: output_schema:
type: object type: object
@@ -99,22 +98,23 @@ nodes:
type: rag type: rag
documents: documents:
- ./knowledge/ - ./knowledge/
query: "{{topic}}" query: '{{topic}}'
top_k: 6 top_k: 6
chunk_size: 1000 chunk_size: 1000
chunk_overlap: 100 chunk_overlap: 100
state_updates: state_updates:
local_context: "{{output.context}}" local_context: '{{output.context}}'
local_sources: "{{output.sources}}" local_sources: '{{output.sources}}'
next: research_each_question next: research_each_question
research_each_question: research_each_question:
id: research_each_question id: research_each_question
type: map type: map
over: "{{questions}}" over: '{{questions}}'
as: question as: question
branch: research_one_question branch: research_one_question
collect_into: question_findings collect_into: question_findings
output_key: finding
max_concurrency: 3 max_concurrency: 3
next: combine_findings next: combine_findings
@@ -151,7 +151,8 @@ nodes:
- mcp:ddg-search - mcp:ddg-search
max_iterations: 10 max_iterations: 10
max_attempts: 2 max_attempts: 2
temperature: 0.1 state_updates:
finding: '{{output}}'
combine_findings: combine_findings:
id: combine_findings id: combine_findings
@@ -177,7 +178,7 @@ nodes:
- classify_source - classify_source
max_iterations: 15 max_iterations: 15
state_updates: state_updates:
source_assessment: "{{output}}" source_assessment: '{{output}}'
next: critique next: critique
critique: critique:
@@ -217,7 +218,7 @@ nodes:
{{source_assessment}} {{source_assessment}}
tools: [] tools: []
state_updates: state_updates:
critique: "{{output}}" critique: '{{output}}'
next: reflexion_gate next: reflexion_gate
reflexion_gate: reflexion_gate:
@@ -242,7 +243,7 @@ nodes:
Produce the final report following your instructions. Produce the final report following your instructions.
timeout: 300 timeout: 300
state_updates: state_updates:
report: "{{output}}" report: '{{output}}'
next: verify_sources next: verify_sources
verify_sources: verify_sources:
@@ -266,14 +267,14 @@ nodes:
Accept this report? Pick "accept" or "reject", or type specific Accept this report? Pick "accept" or "reject", or type specific
feedback to send the research back for another pass. feedback to send the research back for another pass.
options: options:
- "accept" - 'accept'
- "reject" - 'reject'
routes: routes:
"accept": end_accepted 'accept': end_accepted
"reject": end_rejected 'reject': end_rejected
on_other: incorporate_feedback on_other: incorporate_feedback
state_updates: state_updates:
decision: "{{choice}}" decision: '{{choice}}'
incorporate_feedback: incorporate_feedback:
id: incorporate_feedback id: incorporate_feedback
@@ -283,7 +284,7 @@ nodes:
end_accepted: end_accepted:
id: end_accepted id: end_accepted
type: end type: end
output: "{{report}}" output: '{{report}}'
end_rejected: end_rejected:
id: end_rejected id: end_rejected
+3 -4
View File
@@ -27,9 +27,6 @@ settings:
log_state_snapshots: true log_state_snapshots: true
timeout: 900 timeout: 900
reducers:
output: overwrite
initial_state: initial_state:
findings_list: [] findings_list: []
verdicts: [] verdicts: []
@@ -95,6 +92,7 @@ nodes:
as: finding as: finding
branch: verify_one branch: verify_one
collect_into: verdicts collect_into: verdicts
output_key: verdict
max_concurrency: 4 max_concurrency: 4
next: done next: done
@@ -149,7 +147,8 @@ nodes:
- fs_grep - fs_grep
max_iterations: 8 max_iterations: 8
max_attempts: 2 max_attempts: 2
temperature: 0.1 state_updates:
verdict: '{{output}}'
done: done:
id: done id: done
-3
View File
@@ -32,9 +32,6 @@ settings:
log_state_snapshots: true log_state_snapshots: true
timeout: 600 timeout: 600
reducers:
output: overwrite
initial_state: initial_state:
language_ecosystem: 'general' language_ecosystem: 'general'
doc_domain_hints: '' doc_domain_hints: ''
+92 -33
View File
@@ -116,9 +116,12 @@ variables:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
settings: settings:
max_loop_iterations: 100 # Per-node visit cap. If one node id is entered more max_loop_iterations: 100 # Per-node visit cap. If one node id is entered more
# than this many times, execution aborts. Default 100. # than this many times, execution aborts. Default 100; 0 = no cap.
timeout: 600 # Optional wall-clock cap (seconds) on the whole run, timeout: 600 # Optional wall-clock cap (seconds) on the whole run,
# checked between node transitions. # checked between node transitions. 0 = no bound.
# Ctrl-C (or the enclosing turn aborting) ends the run
# promptly from anywhere: in-flight branches, tools, and
# child graph agents are cancelled with it.
log_state_snapshots: true # Log state before each node (debug/trace). Default true. log_state_snapshots: true # Log state before each node (debug/trace). Default true.
validate_before_run: true # Run the graph validator at startup. Default true. validate_before_run: true # Run the graph validator at startup. Default true.
max_concurrency: 4 # Cap on simultaneously running branches in any max_concurrency: 4 # Cap on simultaneously running branches in any
@@ -242,7 +245,7 @@ nodes:
- https://example.com/reference - https://example.com/reference
query: "{{topic}}" # Retrieval query (templated). Default: {{initial_prompt}}. query: "{{topic}}" # Retrieval query (templated). Default: {{initial_prompt}}.
top_k: 5 # Chunks to retrieve. Default = the KB's own top_k. top_k: 5 # Chunks to retrieve. Default = the KB's own top_k.
timeout: 120 # Retrieval timeout in seconds. Default 120. timeout: 120 # Retrieval timeout in seconds. Default 120; 0 = no bound.
# Knowledge-base build config (optional; used only when the KB is first # Knowledge-base build config (optional; used only when the KB is first
# built). When embedding_model + chunk_size + chunk_overlap are all set, # built). When embedding_model + chunk_size + chunk_overlap are all set,
# the KB builds with no interactive prompts (works in non-interactive runs). # the KB builds with no interactive prompts (works in non-interactive runs).
@@ -311,7 +314,7 @@ nodes:
id: synthesize id: synthesize
type: script type: script
script: scripts/synthesize.py # Path relative to the agent directory script: scripts/synthesize.py # Path relative to the agent directory
timeout: 30 # Seconds. Default 30. timeout: 30 # Seconds. Default 30; 0 = no bound.
state_updates: # Applied after the stdout JSON is merged state_updates: # Applied after the stdout JSON is merged
decided_for: "{{topic}}" decided_for: "{{topic}}"
next: summarize # Default route if the script emits no `_next` next: summarize # Default route if the script emits no `_next`
@@ -333,7 +336,21 @@ nodes:
prompt: | # User message sent to the child (templated) prompt: | # User message sent to the child (templated)
Research {{topic}} in depth. Existing context: Research {{topic}} in depth. Existing context:
{{context}} {{context}}
timeout: 600 # Optional wall-clock cap, seconds. Default 300. # inputs: # Optional. Only valid when `agent` is a GRAPH agent
# max_parallel: "{{n}}" # (has a graph.yaml; deep-research is one). Each value
# context: "Repo: {{r}}" # is a template against THIS graph's state. The resolved
# # map is overlaid on the child's `initial_state` before
# # its graph starts, so the child can read {{max_parallel}}
# # and {{context}} from its first node on. A lone {{key}}
# # keeps the value's JSON type (numbers stay numbers, lists
# # stay lists, null overlays the child's default); mixed text
# # becomes a string. `initial_prompt` is reserved (always
# # seeded from `prompt:`) and is rejected as an input key.
# # `inputs:` on a config-only agent is a load-time error,
# # and `inputs` values count as reads for the cross-branch
# # read check. See the wiki: Graph Agents -> "Passing state
# # into a child graph agent (inputs)".
timeout: 600 # Optional wall-clock cap, seconds. Default 300; 0 = no bound.
teammates: false # Optional. When true and this node runs concurrently teammates: false # Optional. When true and this node runs concurrently
# with other flagged agent nodes (map fan-out branches # with other flagged agent nodes (map fan-out branches
# or the same super-step), the siblings can message # or the same super-step), the siblings can message
@@ -343,7 +360,13 @@ nodes:
# super-step frontier form two separate teams and cannot # super-step frontier form two separate teams and cannot
# message each other. Peers only span a single map # message each other. Peers only span a single map
# node's branches or a single super-step's flagged # node's branches or a single super-step's flagged
# agent nodes. # agent nodes. Inside a map branch chain the identity
# belongs to the ITEM, not the node: it lives for the
# item's whole chain, is handed to each `teammates: true`
# agent step in that chain (unflagged steps never see it),
# and is retired when the chain ends. A message delivered
# after the chain's last flagged agent step is accepted
# but never read.
output_schema: # Optional. Same extraction as llm nodes output_schema: # Optional. Same extraction as llm nodes
type: object type: object
properties: properties:
@@ -357,9 +380,20 @@ nodes:
next: review # Required for agent nodes next: review # Required for agent nodes
# --- map node (Dynamic fan-out. Think: LangGraph's `Send` API) ---------------- # --- map node (Dynamic fan-out. Think: LangGraph's `Send` API) ----------------
# Spawns one parallel sub-branch per item in `over`. Each sub-branch runs # Spawns one parallel sub-branch per item in `over`. Each sub-branch runs a
# the node referenced by `branch:` with the item bound to `as:`. Outputs # per-item CHAIN of nodes on a private fork of the state: it starts at the
# collect into the array named by `collect_into:`, preserving input order. # node referenced by `branch:` (with the item bound to `as:`) and follows
# that node's `next:` / script `_next` / `fallback` edges until it reaches a
# node with no `next` and no `_next`. When every chain has finished, the
# value of `output_key` from each item's final fork state is collected into
# the array named by `collect_into:`, preserving input order. Everything
# else the chain wrote is fork-local scratch and is discarded at collect.
#
# Not supported inside a branch chain (validator errors): fan-out `next:`
# lists, another `map`, `end`, `approval`, `input`. A branch node must not
# also be reachable from `start` on the main flow. Per-item loops are script
# `_next` loops (bounded by settings.max_loop_iterations on the fork; `0` = unbounded); a
# declared `next`/`fallback` edge back to an earlier chain node is a cycle.
# #
# Reach via `synthesize`'s `_next: subjects_map`. The producer is expected # Reach via `synthesize`'s `_next: subjects_map`. The producer is expected
# to have written a list at `subjects` (e.g. an upstream LLM node with an # to have written a list at `subjects` (e.g. an upstream LLM node with an
@@ -371,31 +405,53 @@ nodes:
# Empty list is allowed. It means no branches spawn, # Empty list is allowed. It means no branches spawn,
# and thus `collect_into` is written as []. # and thus `collect_into` is written as [].
as: subject # Required. Per-branch state key holding the as: subject # Required. Per-branch state key holding the
# current item. Read with {{subject}} inside # current item. Read with {{subject}} from any
# the branch node's prompt. # node in the branch chain. Must differ from
branch: research_subject # Required. Node id to invoke per item. # `output_key` (load-time error otherwise).
# Must point to an llm | agent | rag | script branch: research_subject # Required. ENTRY node of the per-item chain.
# node satisfying the map branch contract: # Every node reachable from it via `next:` /
# - no `next:` (atomic, joined at map exit) # `fallback:` is part of the chain and must be
# - no `state_updates:` other than via the # an llm | agent | rag | script node. Chain
# map's `collect_into` channel # nodes may use `next:`, `state_updates:`
# - no `output_schema:` (top-level merge # (fork-local scratch such as a draft or a
# would clash with collect_into) # retry counter), `output_schema:`, and
# Validator enforces all three. # `fallback:` (honored per item; it must point
# at a node inside the chain, never at an
# `end_*` node). A script's `_next` must also
# stay inside the chain; routing out of it
# fails the item.
collect_into: subject_findings # Required. State key for the array of collect_into: subject_findings # Required. State key for the array of
# per-branch outputs, in input order # per-branch outputs, in input order
# (not spawn-finish order). # (not spawn-finish order).
max_concurrency: 3 # Optional per-map cap. Defaults to max_concurrency: 3 # Optional cap on items in flight (one slot is
# settings.max_concurrency above. # held for an item's WHOLE chain). Defaults to
output_key: output # Optional. State key the branch's output # settings.max_concurrency above. Accepts an
# appears under. Default "output". Useful # integer or a template that is exactly one
# only if the branch reads its own bound # "{{key}}" (surrounding whitespace allowed,
# name back (rare). # nothing else) and resolves to a positive integer,
# e.g. `max_concurrency: "{{research_budget}}"`.
output_key: subject_summary # Optional. State key whose value in the item's
# FINAL fork state becomes the item's result.
# Default "output"; must differ from `as:`. The
# key is removed from the fork before the chain
# starts, so the chain
# must write it (via state_updates, an
# output_schema property, or a script's JSON);
# an item that never writes it is an error, and
# a chain that reads it before writing it sees
# it absent (prompts fail, state_updates render
# "", scripts get no key) - seed per-item input
# through `as:` instead. Prefer a named key: with
# the default, the collector errors if the chain
# never writes it.
next: aggregate_subjects # Where to go after all sub-branches finish. next: aggregate_subjects # Where to go after all sub-branches finish.
# Branch node for subjects_map. Each invocation receives a different # Entry node of subjects_map's per-item chain. Each item gets its own copy
# `subject` in state. The branch is "atomic", meaning it cannot route on # of this node with a different `subject` in state. With no `next:` it is
# its own; the surrounding `map` joins after all invocations finish. # also the LAST chain node, so the chain is a single step; give it `next:`
# (or emit `_next` from a script step) to extend the chain, e.g.
# research -> verify -> gate-script that re-emits `_next: research_subject`
# until the draft passes or a retry counter in fork state runs out.
research_subject: research_subject:
id: research_subject id: research_subject
type: llm type: llm
@@ -403,8 +459,11 @@ nodes:
prompt: "Research {{subject}}: pull the key facts and one citation." prompt: "Research {{subject}}: pull the key facts and one citation."
tools: tools:
- web_search_coyote - web_search_coyote
# No `next:`, `state_updates:`, or `output_schema:` here. Map branches # Recommended pattern: write the completion to a named key via
# have a strict contract (see `subjects_map.branch` comment). # `state_updates` and point the map's `output_key` at that same key.
# `{{output}}` is bound only while these templates render.
state_updates:
subject_summary: "{{output}}"
# Aggregator that runs after the map joins. Reads the collected list. # Aggregator that runs after the map joins. Reads the collected list.
aggregate_subjects: aggregate_subjects:
@@ -434,9 +493,9 @@ nodes:
temperature: 0.3 # Optional per-node sampling override temperature: 0.3 # Optional per-node sampling override
reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high) reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high)
max_attempts: 2 # Retry count on transient errors only. Default 1. max_attempts: 2 # Retry count on transient errors only. Default 1.
max_iterations: 10 # Tool-call-loop turn cap. Default 10. max_iterations: 10 # Tool-call-loop turn cap. Default 10; 0 = no cap.
fallback: review # Route here if all attempts fail fallback: review # Route here if all attempts fail
timeout: 300 # Optional node wall-clock cap, seconds (unset = no timeout) timeout: 300 # Optional node wall-clock cap, seconds (unset or 0 = no timeout)
state_updates: state_updates:
research: "{{output}}" research: "{{output}}"
next: review # Required for llm nodes: the success route next: review # Required for llm nodes: the success route
+1
View File
@@ -633,6 +633,7 @@ impl Agent {
Some(self.name().to_string()), Some(self.name().to_string()),
tool_timeout, tool_timeout,
false, false,
None,
)?; )?;
match value { match value {
Some(v) => Ok(v), Some(v) => Ok(v),
+107 -1
View File
@@ -8076,7 +8076,28 @@ mod tests {
#[serial] #[serial]
fn bundled_graph_agents_parse_and_validate() { fn bundled_graph_agents_parse_and_validate() {
use crate::graph::GraphParser; use crate::graph::GraphParser;
use crate::graph::validator::GraphValidator; use crate::graph::validator::{GraphValidator, ValidationResult};
use std::collections::BTreeMap;
const UNREACHABLE: &str = "Node is unreachable from the start node via declared edges \
(script `_next` routing is not analyzed)";
fn warning_lines(result: &ValidationResult) -> Vec<String> {
let mut lines: Vec<String> = result
.warnings
.iter()
.map(|w| format!("{}: {}", w.node_id.as_deref().unwrap_or("-"), w.message))
.collect();
lines.sort();
lines
}
fn unreachable(node_ids: &[&str]) -> Vec<String> {
node_ids
.iter()
.map(|id| format!("{id}: {UNREACHABLE}"))
.collect()
}
let _guard = TestConfigDirGuard::new(); let _guard = TestConfigDirGuard::new();
@@ -8084,6 +8105,7 @@ mod tests {
Skill::install_builtin_skills(false).unwrap(); Skill::install_builtin_skills(false).unwrap();
let mut checked = Vec::new(); let mut checked = Vec::new();
let mut warnings_by_agent = BTreeMap::new();
for entry in std::fs::read_dir(paths::agents_data_dir()).unwrap() { for entry in std::fs::read_dir(paths::agents_data_dir()).unwrap() {
let dir = entry.unwrap().path(); let dir = entry.unwrap().path();
let graph_path = dir.join("graph.yaml"); let graph_path = dir.join("graph.yaml");
@@ -8100,6 +8122,7 @@ mod tests {
"graph.yaml for '{name}' failed validation: {:#?}", "graph.yaml for '{name}' failed validation: {:#?}",
result.errors result.errors
); );
warnings_by_agent.insert(name.clone(), warning_lines(&result));
checked.push(name); checked.push(name);
} }
checked.sort(); checked.sort();
@@ -8109,6 +8132,89 @@ mod tests {
"expected bundled graph agent '{expected}' to be checked; found {checked:?}" "expected bundled graph agent '{expected}' to be checked; found {checked:?}"
); );
} }
// Warning parity with the pre-subgraph validator: the only warnings any
// bundled graph emits are the `_next`-routed unreachable nodes. A new
// rule that fires on a shipped asset (or a new bundled graph) must be
// recorded here deliberately.
let expected_warnings = BTreeMap::from([
(
"coder".to_string(),
unreachable(&[
"analyze_request",
"end_rejected",
"end_success",
"fix_loop_gate",
"gate_approval",
"implement",
"route_complexity",
"route_review_result",
"self_review",
"verify_build",
"verify_tests",
]),
),
("deep-research".to_string(), unreachable(&["ask_topic"])),
("finding-verifier".to_string(), Vec::new()),
("librarian".to_string(), Vec::new()),
(
"step-runner".to_string(),
unreachable(&[
"check_handoff",
"edge_case_sweep",
"end_blocked",
"end_rejected",
"end_success",
"fix_loop_gate",
"gate_blocked",
"gate_deviation",
"gate_user_review",
"get_revision",
"independent_review",
"revise_from_choice",
"route_review",
"route_sweep",
"verify_build",
"verify_format_lint",
"verify_tests",
"write_handoff",
]),
),
]);
assert_eq!(
warnings_by_agent, expected_warnings,
"bundled graph validator warnings drifted from the recorded baseline"
);
// The shipped reference graph must load through the real parser and
// validate cleanly too; its `scripts/synthesize.py` only has to exist.
let base = _guard.path.join("example-base");
create_dir_all(base.join("scripts")).unwrap();
write(base.join("scripts").join("synthesize.py"), "").unwrap();
let example = GraphParser::new(&base)
.load_from_string(include_str!("../../graph.example.yaml"))
.unwrap_or_else(|e| panic!("graph.example.yaml failed to parse: {e}"));
let result = GraphValidator::new(&base).validate(&example);
assert!(
result.errors.is_empty(),
"graph.example.yaml failed validation: {:#?}",
result.errors
);
let example_warnings = warning_lines(&result);
assert!(
example_warnings.iter().all(|w| !w.contains("output_key")),
"graph.example.yaml's map must declare a writer for its output_key: {example_warnings:#?}"
);
assert_eq!(
example_warnings,
unreachable(&[
"aggregate_subjects",
"deep_dive",
"research_subject",
"subjects_map",
]),
"graph.example.yaml validator warnings drifted from the recorded baseline"
);
} }
#[test] #[test]
+196 -9
View File
@@ -18,6 +18,7 @@ use indexmap::IndexMap;
use log::{debug, warn}; use log::{debug, warn};
use parking_lot::RwLock; use parking_lot::RwLock;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::collections::HashMap;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -535,18 +536,57 @@ pub async fn handle_agent_tool(
} }
pub fn run_child_agent( pub fn run_child_agent(
mut child_ctx: RequestContext, child_ctx: RequestContext,
initial_input: Input, initial_input: Input,
abort_signal: AbortSignal, abort_signal: AbortSignal,
) -> Pin<Box<dyn Future<Output = Result<String>> + Send>> {
run_child_agent_with_graph_inputs(child_ctx, initial_input, abort_signal, None)
}
/// Stops a child agent's own spawned subagents if the future running the
/// child is dropped mid-flight (parent timeout, abort) or the child fails
/// before reaching its own cleanup. Disarmed on the success paths, which
/// already cancel explicitly.
struct CancelOnDrop(Option<Arc<RwLock<Supervisor>>>);
impl CancelOnDrop {
fn disarm(&mut self) {
self.0.take();
}
}
impl Drop for CancelOnDrop {
fn drop(&mut self) {
if let Some(supervisor) = self.0.take() {
supervisor.read().cancel_recursive();
}
}
}
/// Runs a child agent to completion. When the child is a graph agent,
/// `graph_inputs` is overlaid on its `initial_state` before the graph
/// starts. Passing inputs to a config-only agent is rejected before the
/// child runs, since nothing could consume them.
fn run_child_agent_with_graph_inputs(
mut child_ctx: RequestContext,
initial_input: Input,
abort_signal: AbortSignal,
graph_inputs: Option<HashMap<String, Value>>,
) -> Pin<Box<dyn Future<Output = Result<String>> + Send>> { ) -> Pin<Box<dyn Future<Output = Result<String>> + Send>> {
Box::pin(async move { Box::pin(async move {
let mut cancel_on_drop = CancelOnDrop(child_ctx.supervisor.clone());
if graph::active_agent_graph_name(&child_ctx).is_some() { if graph::active_agent_graph_name(&child_ctx).is_some() {
return graph::run_active_agent_graph( let result = graph::run_active_agent_graph_with_inputs(
&mut child_ctx, &mut child_ctx,
&initial_input.text(), &initial_input.text(),
abort_signal, abort_signal,
graph_inputs,
) )
.await; .await;
if result.is_ok() {
cancel_on_drop.disarm();
}
return result;
} }
let mut accumulated_output = String::new(); let mut accumulated_output = String::new();
@@ -606,6 +646,7 @@ pub fn run_child_agent(
if let Some(supervisor) = child_ctx.supervisor.clone() { if let Some(supervisor) = child_ctx.supervisor.clone() {
supervisor.read().cancel_recursive(); supervisor.read().cancel_recursive();
} }
cancel_on_drop.disarm();
Ok(accumulated_output) Ok(accumulated_output)
}) })
@@ -685,10 +726,13 @@ fn effective_max_agent_depth(parent_ctx: &RequestContext) -> usize {
/// output. This is similar to `handle_spawn` but runs the child agent in the /// output. This is similar to `handle_spawn` but runs the child agent in the
/// current task (no tokio::spawn, no supervisor handle registration) so the /// current task (no tokio::spawn, no supervisor handle registration) so the
/// graph executor can sequence agent nodes directly. /// graph executor can sequence agent nodes directly.
/// `graph_inputs` seeds the child's graph state and is only accepted when the
/// target is a graph agent.
pub async fn run_agent_for_graph( pub async fn run_agent_for_graph(
parent_ctx: &mut RequestContext, parent_ctx: &mut RequestContext,
agent_name: &str, agent_name: &str,
prompt: &str, prompt: &str,
graph_inputs: Option<HashMap<String, Value>>,
) -> Result<String> { ) -> Result<String> {
let peer_assignment = parent_ctx.peer_assignment.take(); let peer_assignment = parent_ctx.peer_assignment.take();
let assigned_peer = peer_assignment.is_some(); let assigned_peer = peer_assignment.is_some();
@@ -757,8 +801,22 @@ pub async fn run_agent_for_graph(
Arc::clone(&child_inbox), Arc::clone(&child_inbox),
agent_id.clone(), agent_id.clone(),
); );
child_ctx.session_abort = parent_ctx.session_abort.clone();
child_ctx.rag = agent.rag(); child_ctx.rag = agent.rag();
child_ctx.agent = Some(agent); child_ctx.agent = Some(agent);
if graph_inputs.is_some() && graph::active_agent_graph_name(&child_ctx).is_none() {
bail!(
"agent '{agent_name}' has no graph.yaml; `inputs:` is only valid on agent nodes that target a graph agent"
);
}
if graph_inputs
.as_ref()
.is_some_and(|inputs| inputs.contains_key("initial_prompt"))
{
bail!(
"agent '{agent_name}': `inputs.initial_prompt` is reserved (the dispatcher seeds it from `prompt:`)"
);
}
if assigned_peer { if assigned_peer {
child_ctx.peer_registry = peer_registry.clone(); child_ctx.peer_registry = peer_registry.clone();
} }
@@ -786,13 +844,7 @@ pub async fn run_agent_for_graph(
debug!("Spawning agent '{agent_name}' for graph node as '{agent_id}'"); debug!("Spawning agent '{agent_name}' for graph node as '{agent_id}'");
let result = run_child_agent(child_ctx, input, child_abort).await; run_child_agent_with_graph_inputs(child_ctx, input, child_abort, graph_inputs).await
if let Some(registry) = &peer_registry {
registry.mark_finished(&agent_id);
}
result
} }
async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[String]) -> Result<()> { async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[String]) -> Result<()> {
@@ -1919,6 +1971,141 @@ mod tests {
assert_eq!(effective_max_agent_depth(&ctx), 5); assert_eq!(effective_max_agent_depth(&ctx), 5);
} }
fn unique_agent_name(prefix: &str) -> String {
format!(
"{prefix}_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
)
}
fn write_config_only_agent(agent_name: &str) {
let agent_dir = paths::agent_data_dir(agent_name);
create_dir_all(&agent_dir).unwrap();
write(
agent_dir.join("config.yaml"),
format!("name: {agent_name}\ninstructions: hi\n"),
)
.unwrap();
}
fn write_graph_agent(agent_name: &str) {
let agent_dir = paths::agent_data_dir(agent_name);
create_dir_all(&agent_dir).unwrap();
write(
agent_dir.join("graph.yaml"),
format!(
"name: {agent_name}\nstart: done\nnodes:\n done:\n type: end\n output: done\n"
),
)
.unwrap();
}
fn run_agent_for_graph_error(
agent_name: &str,
graph_inputs: Option<HashMap<String, Value>>,
) -> String {
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
let err = run_async(run_agent_for_graph(
&mut ctx,
agent_name,
"hi",
graph_inputs,
))
.expect_err("run_agent_for_graph should reject the inputs");
format!("{err:#}")
}
#[test]
#[serial]
fn run_agent_for_graph_rejects_inputs_for_config_only_agent() {
let _guard = TestConfigDirGuard::new();
let agent_name = unique_agent_name("test_inputs_plain_agent");
write_config_only_agent(&agent_name);
let chain = run_agent_for_graph_error(&agent_name, Some(HashMap::new()));
assert_eq!(
chain,
format!(
"agent '{agent_name}' has no graph.yaml; `inputs:` is only valid on agent nodes that target a graph agent"
)
);
}
#[test]
#[serial]
fn run_agent_for_graph_rejects_initial_prompt_input_on_graph_agent() {
let _guard = TestConfigDirGuard::new();
let agent_name = unique_agent_name("test_inputs_graph_agent");
write_graph_agent(&agent_name);
let inputs = HashMap::from([("initial_prompt".to_string(), json!("x"))]);
let chain = run_agent_for_graph_error(&agent_name, Some(inputs));
assert_eq!(
chain,
format!(
"agent '{agent_name}': `inputs.initial_prompt` is reserved (the dispatcher seeds it from `prompt:`)"
)
);
}
/// The child ctx must carry the parent's session flag: a session already
/// aborted before the child starts trips the child graph's own pre-check,
/// so it never reaches its (otherwise trivially successful) end node.
/// Without the inheritance the child sees `None` and returns "done".
#[test]
#[serial]
fn run_agent_for_graph_child_inherits_session_abort() {
let _guard = TestConfigDirGuard::new();
let agent_name = unique_agent_name("test_session_abort_graph_agent");
write_graph_agent(&agent_name);
let session = create_abort_signal();
session.set_ctrlc();
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
ctx.session_abort = Some(Arc::clone(&session));
let err = run_async(run_agent_for_graph(&mut ctx, &agent_name, "hi", None))
.expect_err("the inherited session abort must end the child graph");
let chain = format!("{err:#}");
assert!(
chain.contains(&format!("Graph execution failed for agent '{agent_name}'")),
"{chain}"
);
assert!(chain.contains("aborted before super-step"), "{chain}");
assert!(
Arc::ptr_eq(ctx.session_abort.as_ref().unwrap(), &session),
"the parent's own session signal is untouched"
);
}
#[test]
fn cancel_on_drop_guard_cancels_when_armed_and_not_when_disarmed() {
run_async(async {
let mut ctx = ctx_with_supervisor(4, 3);
let armed_abort = register_running_agent(&mut ctx, "a1", "explore");
drop(CancelOnDrop(ctx.supervisor.clone()));
assert!(
armed_abort.aborted(),
"an armed guard cancels the supervisor's agents when dropped"
);
let mut ctx = ctx_with_supervisor(4, 3);
let disarmed_abort = register_running_agent(&mut ctx, "a2", "explore");
let mut guard = CancelOnDrop(ctx.supervisor.clone());
guard.disarm();
drop(guard);
assert!(
!disarmed_abort.aborted(),
"a disarmed guard leaves the supervisor's agents running"
);
});
}
fn auto_continue_ctx() -> RequestContext { fn auto_continue_ctx() -> RequestContext {
let app = AppState { let app = AppState {
config: Arc::new(AppConfig { config: Arc::new(AppConfig {
+53 -1
View File
@@ -1684,6 +1684,7 @@ impl ToolCall {
agent_name, agent_name,
ctx.app.config.tool_timeout, ctx.app.config.tool_timeout,
quiet, quiet,
ctx.session_abort.clone(),
) { ) {
Ok(Some(contents)) => serde_json::from_str(&contents) Ok(Some(contents)) => serde_json::from_str(&contents)
.ok() .ok()
@@ -2293,6 +2294,7 @@ pub fn run_llm_function(
agent_name: Option<String>, agent_name: Option<String>,
tool_timeout: Option<u64>, tool_timeout: Option<u64>,
quiet: bool, quiet: bool,
abort: Option<AbortSignal>,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
let mut bin_dirs: Vec<PathBuf> = vec![]; let mut bin_dirs: Vec<PathBuf> = vec![];
let mut command_name = cmd_name.clone(); let mut command_name = cmd_name.clone();
@@ -2440,6 +2442,21 @@ pub fn run_llm_function(
return Ok(Some(error_json.to_string())); return Ok(Some(error_json.to_string()));
} }
if abort.as_ref().is_some_and(|a| a.aborted()) {
let _ = child.kill();
let _ = child.wait();
drop(stdout_thread);
drop(stderr_thread);
let tool_error_message = format!("Tool call '{command_name}' aborted");
emit_tool_warning(
quiet,
&format!("⚠️ {tool_error_message} ⚠️"),
&tool_error_message,
);
return Ok(Some(
json!({"tool_call_error": tool_error_message}).to_string(),
));
}
thread::sleep(Duration::from_millis(100)); thread::sleep(Duration::from_millis(100));
}; };
let stdout_bytes = stdout_thread.join().unwrap_or_default(); let stdout_bytes = stdout_thread.join().unwrap_or_default();
@@ -3389,7 +3406,8 @@ mod tests {
continue; continue;
} }
let name = path.file_stem().unwrap().to_string_lossy().to_string(); let name = path.file_stem().unwrap().to_string_lossy().to_string();
let declarations = Functions::generate_declarations(&path) let src = std::fs::read_to_string(&path).unwrap();
let (_, declarations) = bash::build_bash_tool(&src, &name)
.unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}")); .unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}"));
assert!( assert!(
!declarations.is_empty(), !declarations.is_empty(),
@@ -4561,6 +4579,7 @@ mod tests {
None, None,
None, None,
false, false,
None,
) )
.unwrap() .unwrap()
.expect("nonzero exit must return an error payload"); .expect("nonzero exit must return an error payload");
@@ -4589,6 +4608,7 @@ mod tests {
None, None,
None, None,
true, true,
None,
) )
.unwrap() .unwrap()
.expect("nonzero exit must return an error payload"); .expect("nonzero exit must return an error payload");
@@ -4604,6 +4624,38 @@ mod tests {
assert_eq!(json["output"], "partial-output\n"); assert_eq!(json["output"], "partial-output\n");
} }
/// An already-aborted signal is noticed on the first wait-loop poll, so a
/// tool that would run for seconds is killed and reported within one poll
/// interval instead of running to completion or `tool_timeout`.
#[cfg(unix)]
#[test]
fn run_llm_function_aborts_running_tool() {
if which::which("bash").is_err() {
eprintln!("skipping: bash not available");
return;
}
let abort = create_abort_signal();
abort.set_ctrlc();
let started = Instant::now();
let result = run_llm_function(
"bash".into(),
vec!["-c".into(), "sleep 5".into()],
HashMap::new(),
None,
None,
true,
Some(abort),
)
.unwrap()
.expect("an aborted tool must return an error payload");
let elapsed = started.elapsed();
assert!(elapsed < Duration::from_secs(1), "took {elapsed:?}");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["tool_call_error"], "Tool call 'bash' aborted");
}
#[test] #[test]
#[serial] #[serial]
fn tool_timeout_secs_prefers_env_then_config_then_default() { fn tool_timeout_secs_prefers_env_then_config_then_default() {
+174 -45
View File
@@ -1,14 +1,16 @@
use super::state::StateManager; use super::state::StateManager;
use super::state_updates;
use super::structured; use super::structured;
use super::types::AgentNode; use super::types::AgentNode;
use super::wall_clock;
use crate::config::RequestContext; use crate::config::RequestContext;
use crate::function::agents::run_agent_for_graph; use crate::function::agents::run_agent_for_graph;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use log::debug;
use serde_json::Value; use serde_json::Value;
use std::time::Duration; use std::collections::HashMap;
use tokio::time::timeout; use tokio::time::timeout;
const OUTPUT_KEY: &str = "output";
const DEFAULT_TIMEOUT_SECS: u64 = 300; const DEFAULT_TIMEOUT_SECS: u64 = 300;
pub struct AgentNodeExecutor; pub struct AgentNodeExecutor;
@@ -18,25 +20,43 @@ impl AgentNodeExecutor {
node: &AgentNode, node: &AgentNode,
state_manager: &mut StateManager, state_manager: &mut StateManager,
parent_ctx: &mut RequestContext, parent_ctx: &mut RequestContext,
retire_peer_on_return: bool,
) -> Result<String> { ) -> Result<String> {
let prompt = state_manager let prompt = state_manager
.interpolate(&node.prompt) .interpolate(&node.prompt)
.with_context(|| format!("Failed to interpolate prompt for agent '{}'", node.agent))?; .with_context(|| format!("Failed to interpolate prompt for agent '{}'", node.agent))?;
let timeout_dur = Duration::from_secs(node.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS)); let graph_inputs = resolve_inputs(node, state_manager)?;
if let Some(inputs) = &graph_inputs {
let mut keys: Vec<&String> = inputs.keys().collect();
keys.sort_unstable();
debug!("Agent '{}' graph inputs: {keys:?}", node.agent);
}
let raw = timeout( let secs = node.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS);
timeout_dur, let bound = wall_clock(secs);
run_agent_for_graph(parent_ctx, &node.agent, &prompt),
) // run_agent_for_graph takes the identity off the ctx; keep a handle so
.await // a frontier peer is retired the moment the agent stops, not after
.with_context(|| { // extraction. Chains re-arm the same identity for later steps and
format!( // leave retirement to the chain runner.
"Agent '{}' timed out after {}s", let peer = parent_ctx.peer_registry.clone().zip(
node.agent, parent_ctx
timeout_dur.as_secs() .peer_assignment
) .as_ref()
})? .map(|(id, _)| id.clone()),
);
let fut = run_agent_for_graph(parent_ctx, &node.agent, &prompt, graph_inputs);
let raw_result = match bound {
Some(d) => timeout(d, fut).await,
None => Ok(fut.await),
};
if retire_peer_on_return && let Some((registry, id)) = &peer {
registry.mark_finished(id);
}
let raw = raw_result
.with_context(|| format!("Agent '{}' timed out after {}s", node.agent, secs))?
.with_context(|| format!("Agent '{}' failed", node.agent))?; .with_context(|| format!("Agent '{}' failed", node.agent))?;
let output_value = match &node.output_schema { let output_value = match &node.output_schema {
@@ -57,46 +77,48 @@ impl AgentNodeExecutor {
} }
} }
fn apply_state_updates(node: &AgentNode, state_manager: &mut StateManager, output: &Value) { /// Resolves each `inputs` template against the parent state. A lone
if node.output_schema.is_some() /// `{{key}}` yields the state value as-is (numbers, arrays, objects and
&& let Some(obj) = output.as_object() /// `null` all survive); anything else renders to a string. Every key is
{ /// strict: a missing reference fails the node before the child starts.
for (k, v) in obj { fn resolve_inputs(
state_manager.state_mut().set(k.clone(), v.clone()); node: &AgentNode,
} state_manager: &StateManager,
} ) -> Result<Option<HashMap<String, Value>>> {
let Some(inputs) = &node.inputs else {
let Some(updates) = &node.state_updates else { return Ok(None);
return;
}; };
let prev_output = state_manager.state().get(OUTPUT_KEY).cloned(); let mut resolved = HashMap::with_capacity(inputs.len());
state_manager for (key, template) in inputs {
.state_mut() let value = state_manager.interpolate_raw(template).with_context(|| {
.set(OUTPUT_KEY.into(), output.clone()); format!(
"Failed to interpolate inputs.{key} for agent '{}'",
for (key, template) in updates { node.agent
let value = state_manager.interpolate_lenient(template); )
state_manager })?;
.state_mut() resolved.insert(key.clone(), value);
.set(key.clone(), Value::String(value)); }
Ok(Some(resolved))
} }
match prev_output { fn apply_state_updates(node: &AgentNode, state_manager: &mut StateManager, output: &Value) {
Some(v) => state_manager.state_mut().set(OUTPUT_KEY.into(), v), state_updates::apply(
None => { state_manager,
state_manager output,
.state_mut() node.output_schema.is_some(),
.set(OUTPUT_KEY.into(), Value::Null); node.state_updates.as_ref(),
} );
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::types::AgentNode; use super::super::types::AgentNode;
use super::*; use super::*;
use crate::config::{AppState, WorkingMode, default_max_agent_depth};
use crate::supervisor::mailbox::{Inbox, PeerRegistry, graph_agent_id};
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
fn manager_with(pairs: &[(&str, Value)]) -> StateManager { fn manager_with(pairs: &[(&str, Value)]) -> StateManager {
let mut map = HashMap::new(); let mut map = HashMap::new();
@@ -113,10 +135,117 @@ mod tests {
state_updates: updates, state_updates: updates,
output_schema: None, output_schema: None,
timeout: None, timeout: None,
inputs: None,
teammates: false, teammates: false,
} }
} }
fn ctx_at_max_depth_with_peer() -> (RequestContext, Arc<PeerRegistry>, String) {
let registry = Arc::new(PeerRegistry::new());
let id = graph_agent_id("test_agent");
let inbox = Arc::new(Inbox::new());
registry.insert(id.clone(), "worker[0]".into(), Arc::clone(&inbox));
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
ctx.current_depth = default_max_agent_depth();
ctx.peer_registry = Some(Arc::clone(&registry));
ctx.peer_assignment = Some((id.clone(), inbox));
(ctx, registry, id)
}
async fn execute_past_max_depth(ctx: &mut RequestContext, retire_peer_on_return: bool) {
let mut node = node_with("hi", None);
node.teammates = true;
let mut state = manager_with(&[]);
let err = AgentNodeExecutor::execute(&node, &mut state, ctx, retire_peer_on_return)
.await
.expect_err("agent past max depth should fail before running");
let chain = format!("{err:#}");
assert!(chain.contains("Agent 'test_agent' failed"), "{chain}");
assert!(chain.contains("Max agent depth exceeded"), "{chain}");
}
#[tokio::test]
async fn execute_retires_peer_identity_on_frontier_when_agent_fails() {
let (mut ctx, registry, id) = ctx_at_max_depth_with_peer();
execute_past_max_depth(&mut ctx, true).await;
assert!(registry.is_finished(&id));
}
#[tokio::test]
async fn execute_leaves_peer_identity_live_in_branch_mode_when_agent_fails() {
let (mut ctx, registry, id) = ctx_at_max_depth_with_peer();
execute_past_max_depth(&mut ctx, false).await;
assert!(!registry.is_finished(&id));
}
fn node_with_inputs(pairs: &[(&str, &str)]) -> AgentNode {
let mut node = node_with("hi", None);
node.inputs = Some(
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
);
node
}
#[test]
fn resolve_inputs_is_none_when_node_has_no_inputs() {
let node = node_with("hi", None);
let state = manager_with(&[("n", json!(3))]);
assert_eq!(resolve_inputs(&node, &state).unwrap(), None);
}
#[test]
fn resolve_inputs_lone_reference_keeps_the_array_value() {
let node = node_with_inputs(&[("items", "{{list}}")]);
let state = manager_with(&[("list", json!(["a", "b"]))]);
let inputs = resolve_inputs(&node, &state).unwrap().unwrap();
assert_eq!(inputs.get("items"), Some(&json!(["a", "b"])));
}
#[test]
fn resolve_inputs_mixed_text_renders_to_a_string() {
let node = node_with_inputs(&[("label", "n={{n}}")]);
let state = manager_with(&[("n", json!(3))]);
let inputs = resolve_inputs(&node, &state).unwrap().unwrap();
assert_eq!(inputs.get("label"), Some(&json!("n=3")));
}
#[test]
fn resolve_inputs_lone_reference_to_null_yields_null() {
let node = node_with_inputs(&[("x", "{{k}}")]);
let state = manager_with(&[("k", Value::Null)]);
let inputs = resolve_inputs(&node, &state).unwrap().unwrap();
assert_eq!(inputs.get("x"), Some(&Value::Null));
}
#[test]
fn resolve_inputs_missing_key_errors_naming_the_input() {
let node = node_with_inputs(&[("width", "{{nope}}")]);
let state = manager_with(&[]);
let err = resolve_inputs(&node, &state).expect_err("missing key must fail");
let chain = format!("{err:#}");
assert!(chain.contains("inputs.width"), "{chain}");
assert!(chain.contains("for agent 'test_agent'"), "{chain}");
assert!(chain.contains("'nope' not found in state"), "{chain}");
}
#[test] #[test]
fn state_updates_use_output_placeholder() { fn state_updates_use_output_placeholder() {
let node = { let node = {
@@ -162,7 +291,7 @@ mod tests {
apply_state_updates(&node, &mut state, &json!("anything")); apply_state_updates(&node, &mut state, &json!("anything"));
assert_eq!(state.state().get("output"), Some(&Value::Null)); assert!(state.state().get("output").is_none());
} }
#[test] #[test]
+102 -3
View File
@@ -5,6 +5,7 @@ use crate::utils::AbortSignal;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use log::info; use log::info;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
pub fn active_agent_graph_name(ctx: &RequestContext) -> Option<String> { pub fn active_agent_graph_name(ctx: &RequestContext) -> Option<String> {
let name = ctx.agent.as_ref()?.name().to_string(); let name = ctx.agent.as_ref()?.name().to_string();
@@ -15,6 +16,18 @@ pub async fn run_active_agent_graph(
ctx: &mut RequestContext, ctx: &mut RequestContext,
prompt: &str, prompt: &str,
abort_signal: AbortSignal, abort_signal: AbortSignal,
) -> Result<String> {
run_active_agent_graph_with_inputs(ctx, prompt, abort_signal, None).await
}
/// Runs the active agent's graph with `inputs` overlaid on its
/// `initial_state` before the executor starts. `None` seeds exactly what
/// `run_active_agent_graph` does: the prompt under `initial_prompt`.
pub async fn run_active_agent_graph_with_inputs(
ctx: &mut RequestContext,
prompt: &str,
abort_signal: AbortSignal,
inputs: Option<HashMap<String, Value>>,
) -> Result<String> { ) -> Result<String> {
let agent_name = let agent_name =
active_agent_graph_name(ctx).ok_or_else(|| anyhow!("Active agent has no graph.yaml"))?; active_agent_graph_name(ctx).ok_or_else(|| anyhow!("Active agent has no graph.yaml"))?;
@@ -29,9 +42,7 @@ pub async fn run_active_agent_graph(
.load_from_file(&graph_path) .load_from_file(&graph_path)
.with_context(|| format!("Failed to load graph.yaml for agent '{agent_name}'"))?; .with_context(|| format!("Failed to load graph.yaml for agent '{agent_name}'"))?;
graph seed_initial_state(&mut graph.initial_state, prompt, inputs);
.initial_state
.insert("initial_prompt".into(), Value::String(prompt.to_string()));
let executor = GraphExecutor::new(graph, agent_dir); let executor = GraphExecutor::new(graph, agent_dir);
let output = executor let output = executor
@@ -45,3 +56,91 @@ pub async fn run_active_agent_graph(
Ok(output) Ok(output)
} }
/// Seeds a child graph's `initial_state`, later writers winning: the graph's
/// own YAML defaults, then the caller's `inputs`, then `initial_prompt`,
/// which the dispatcher always owns.
pub fn seed_initial_state(
initial_state: &mut HashMap<String, Value>,
prompt: &str,
inputs: Option<HashMap<String, Value>>,
) {
if let Some(inputs) = inputs {
initial_state.extend(inputs);
}
initial_state.insert("initial_prompt".into(), Value::String(prompt.to_string()));
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn yaml_defaults() -> HashMap<String, Value> {
HashMap::from([
("width".to_string(), json!(4)),
("mode".to_string(), json!("fast")),
])
}
#[test]
fn seed_initial_state_without_inputs_only_adds_initial_prompt() {
let mut state = yaml_defaults();
seed_initial_state(&mut state, "hello", None);
let mut expected = yaml_defaults();
expected.insert("initial_prompt".into(), json!("hello"));
assert_eq!(state, expected);
}
#[test]
fn seed_initial_state_without_inputs_on_empty_state_inserts_exactly_one_key() {
let mut state = HashMap::new();
seed_initial_state(&mut state, "hello", None);
assert_eq!(
state,
HashMap::from([("initial_prompt".to_string(), json!("hello"))])
);
}
#[test]
fn seed_initial_state_inputs_override_yaml_defaults_and_keep_the_rest() {
let mut state = yaml_defaults();
let inputs = HashMap::from([
("width".to_string(), json!(2)),
("extra".to_string(), json!(["a", "b"])),
]);
seed_initial_state(&mut state, "hello", Some(inputs));
assert_eq!(state.get("width"), Some(&json!(2)));
assert_eq!(state.get("mode"), Some(&json!("fast")));
assert_eq!(state.get("extra"), Some(&json!(["a", "b"])));
assert_eq!(state.get("initial_prompt"), Some(&json!("hello")));
assert_eq!(state.len(), 4);
}
#[test]
fn seed_initial_state_dispatcher_prompt_beats_an_initial_prompt_input() {
let mut state = HashMap::from([("initial_prompt".to_string(), json!("from yaml"))]);
let inputs = HashMap::from([("initial_prompt".to_string(), json!("from inputs"))]);
seed_initial_state(&mut state, "from dispatcher", Some(inputs));
assert_eq!(state.get("initial_prompt"), Some(&json!("from dispatcher")));
assert_eq!(state.len(), 1);
}
#[test]
fn seed_initial_state_null_input_overlays_a_default() {
let mut state = yaml_defaults();
let inputs = HashMap::from([("width".to_string(), Value::Null)]);
seed_initial_state(&mut state, "hello", Some(inputs));
assert_eq!(state.get("width"), Some(&Value::Null));
}
}
+924 -33
View File
File diff suppressed because it is too large. Load diff
+105 -58
View File
@@ -1,6 +1,8 @@
use super::state::StateManager; use super::state::StateManager;
use super::state_updates;
use super::structured; use super::structured;
use super::types::LlmNode; use super::types::LlmNode;
use super::wall_clock;
use crate::client::{Model, ModelType, call_chat_completions}; use crate::client::{Model, ModelType, call_chat_completions};
use crate::config::prompts::DEFAULT_SKILL_INSTRUCTIONS; use crate::config::prompts::DEFAULT_SKILL_INSTRUCTIONS;
use crate::config::{ use crate::config::{
@@ -9,17 +11,14 @@ use crate::config::{
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail}; use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
use crate::function::jobs::reap_jobs; use crate::function::jobs::reap_jobs;
use crate::function::skill::skill_function_declarations; use crate::function::skill::skill_function_declarations;
use crate::utils::create_abort_signal; use crate::utils::AbortSignal;
use anyhow::{Context, Error, Result, anyhow, bail}; use anyhow::{Context, Error, Result, anyhow, bail};
use log::warn; use log::warn;
use serde_json::Value; use serde_json::Value;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout; use tokio::time::timeout;
const OUTPUT_KEY: &str = "output";
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub(super) enum LlmExecutionOutcome { pub(super) enum LlmExecutionOutcome {
Continue, Continue,
@@ -34,8 +33,9 @@ impl LlmNodeExecutor {
node: &LlmNode, node: &LlmNode,
state_manager: &mut StateManager, state_manager: &mut StateManager,
parent_ctx: &mut RequestContext, parent_ctx: &mut RequestContext,
abort: &AbortSignal,
) -> Result<LlmExecutionOutcome> { ) -> Result<LlmExecutionOutcome> {
let result = run(node_id, node, state_manager, parent_ctx).await; let result = run(node_id, node, state_manager, parent_ctx, abort).await;
let (output, failure_reason) = match result { let (output, failure_reason) = match result {
Ok(raw) => match &node.output_schema { Ok(raw) => match &node.output_schema {
Some(schema) => match structured::extract(&raw, schema, parent_ctx).await { Some(schema) => match structured::extract(&raw, schema, parent_ctx).await {
@@ -84,6 +84,7 @@ async fn run(
node: &LlmNode, node: &LlmNode,
state_manager: &mut StateManager, state_manager: &mut StateManager,
parent_ctx: &mut RequestContext, parent_ctx: &mut RequestContext,
abort: &AbortSignal,
) -> Result<String> { ) -> Result<String> {
let mut instructions: Option<String> = match &node.instructions { let mut instructions: Option<String> = match &node.instructions {
Some(s) => Some( Some(s) => Some(
@@ -186,17 +187,12 @@ async fn run(
node.mcp_tools.clone().map(|map| (node_id.to_string(), map)), node.mcp_tools.clone().map(|map| (node_id.to_string(), map)),
); );
parent_ctx.refresh_mcp_tool_filters(); parent_ctx.refresh_mcp_tool_filters();
let result = match node.timeout { let result = match node.timeout.and_then(wall_clock) {
Some(secs) => match timeout( Some(d) => match timeout(d, run_with_retries(node, &prompt, parent_ctx, abort)).await {
Duration::from_secs(secs),
run_with_retries(node, &prompt, parent_ctx),
)
.await
{
Ok(r) => r, Ok(r) => r,
Err(_) => Err(anyhow!("llm node timed out after {secs}s")), Err(_) => Err(anyhow!("llm node timed out after {}s", d.as_secs())),
}, },
None => run_with_retries(node, &prompt, parent_ctx).await, None => run_with_retries(node, &prompt, parent_ctx, abort).await,
}; };
parent_ctx.role = saved_role; parent_ctx.role = saved_role;
let node_jobs = let node_jobs =
@@ -248,10 +244,11 @@ async fn run_with_retries(
node: &LlmNode, node: &LlmNode,
prompt: &str, prompt: &str,
ctx: &mut RequestContext, ctx: &mut RequestContext,
abort: &AbortSignal,
) -> Result<String> { ) -> Result<String> {
let mut last_err: Option<Error> = None; let mut last_err: Option<Error> = None;
for attempt in 1..=node.max_attempts { for attempt in 1..=node.max_attempts {
match run_chat_loop(node, prompt, ctx).await { match run_chat_loop(node, prompt, ctx, abort).await {
Ok(out) => return Ok(out), Ok(out) => return Ok(out),
Err(e) if is_transient(&e) && attempt < node.max_attempts => { Err(e) if is_transient(&e) && attempt < node.max_attempts => {
warn!("llm node attempt {attempt} failed (transient): {e}; retrying"); warn!("llm node attempt {attempt} failed (transient): {e}; retrying");
@@ -263,14 +260,28 @@ async fn run_with_retries(
Err(last_err.unwrap_or_else(|| anyhow!("llm node exhausted retries"))) Err(last_err.unwrap_or_else(|| anyhow!("llm node exhausted retries")))
} }
async fn run_chat_loop(node: &LlmNode, prompt: &str, ctx: &mut RequestContext) -> Result<String> { /// Whether `turn` (0-based) is the final turn the node's cap allows. A cap of 0 means no cap.
let abort = create_abort_signal(); pub(crate) fn is_last_turn(turn: u32, max_iterations: u32) -> bool {
max_iterations > 0 && turn == max_iterations - 1
}
async fn run_chat_loop(
node: &LlmNode,
prompt: &str,
ctx: &mut RequestContext,
abort: &AbortSignal,
) -> Result<String> {
let abort = abort.clone();
let app_cfg = Arc::clone(&ctx.app.config); let app_cfg = Arc::clone(&ctx.app.config);
let role_for_input = ctx.role.clone(); let role_for_input = ctx.role.clone();
let mut input = Input::from_str(ctx, prompt, role_for_input)?; let mut input = Input::from_str(ctx, prompt, role_for_input)?;
let mut accumulated = String::new(); let mut accumulated = String::new();
for turn in 0..node.max_iterations { let mut turn: u32 = 0;
loop {
if abort.aborted() {
bail!("llm node aborted");
}
let client = input.create_client()?; let client = input.create_client()?;
ctx.before_chat_completion(&input)?; ctx.before_chat_completion(&input)?;
let (output, tool_results) = let (output, tool_results) =
@@ -297,7 +308,7 @@ async fn run_chat_loop(node: &LlmNode, prompt: &str, ctx: &mut RequestContext) -
return Ok(accumulated); return Ok(accumulated);
} }
GuardrailAction::Inject(prompt) => { GuardrailAction::Inject(prompt) => {
if turn + 1 == node.max_iterations { if is_last_turn(turn, node.max_iterations) {
bail!( bail!(
"llm node hit max_iterations ({}) before LLM concluded", "llm node hit max_iterations ({}) before LLM concluded",
node.max_iterations node.max_iterations
@@ -305,22 +316,19 @@ async fn run_chat_loop(node: &LlmNode, prompt: &str, ctx: &mut RequestContext) -
} }
let role = ctx.role.clone(); let role = ctx.role.clone();
input = Input::from_str(ctx, &prompt, role)?; input = Input::from_str(ctx, &prompt, role)?;
continue;
} }
} }
} } else {
if is_last_turn(turn, node.max_iterations) {
if turn + 1 == node.max_iterations {
bail!( bail!(
"llm node hit max_iterations ({}) before LLM concluded", "llm node hit max_iterations ({}) before LLM concluded",
node.max_iterations node.max_iterations
); );
} }
input = input.merge_tool_results(output, tool_results); input = input.merge_tool_results(output, tool_results);
} }
turn = turn.saturating_add(1);
bail!("llm node ended without producing output") }
} }
fn build_inline_role( fn build_inline_role(
@@ -456,37 +464,12 @@ fn apply_state_updates_with_output(
state_manager: &mut StateManager, state_manager: &mut StateManager,
output: &Value, output: &Value,
) { ) {
if node.output_schema.is_some() state_updates::apply(
&& let Some(obj) = output.as_object() state_manager,
{ output,
for (k, v) in obj { node.output_schema.is_some(),
state_manager.state_mut().set(k.clone(), v.clone()); node.state_updates.as_ref(),
} );
}
let Some(updates) = &node.state_updates else {
return;
};
let prev_output = state_manager.state().get(OUTPUT_KEY).cloned();
state_manager
.state_mut()
.set(OUTPUT_KEY.into(), output.clone());
for (key, template) in updates {
let value = state_manager.interpolate_lenient(template);
state_manager
.state_mut()
.set(key.clone(), Value::String(value));
}
match prev_output {
Some(v) => state_manager.state_mut().set(OUTPUT_KEY.into(), v),
None => {
state_manager
.state_mut()
.set(OUTPUT_KEY.into(), Value::Null);
}
}
} }
fn format_schema_hint(schema: &Value) -> String { fn format_schema_hint(schema: &Value) -> String {
@@ -499,10 +482,14 @@ fn format_schema_hint(schema: &Value) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::state_updates::OUTPUT_KEY;
use super::super::types::*; use super::super::types::*;
use super::*; use super::*;
use crate::config::{Agent, AgentConfig, AppState, WorkingMode};
use crate::utils::create_abort_signal;
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration;
fn manager_with(pairs: &[(&str, Value)]) -> StateManager { fn manager_with(pairs: &[(&str, Value)]) -> StateManager {
let mut map = HashMap::new(); let mut map = HashMap::new();
@@ -568,7 +555,7 @@ mod tests {
apply_state_updates_with_output(&node, &mut state, &json!("anything")); apply_state_updates_with_output(&node, &mut state, &json!("anything"));
assert_eq!(state.state().get(OUTPUT_KEY), Some(&json!(null))); assert!(state.state().get(OUTPUT_KEY).is_none());
} }
#[test] #[test]
@@ -756,4 +743,64 @@ mod tests {
assert!(!is_transient(&anyhow!("hit max_iterations"))); assert!(!is_transient(&anyhow!("hit max_iterations")));
assert!(!is_transient(&anyhow!("authentication failed"))); assert!(!is_transient(&anyhow!("authentication failed")));
} }
#[test]
fn zero_timeout_resolves_to_no_bound() {
assert!(Some(0u64).and_then(wall_clock).is_none());
assert_eq!(
Some(5u64).and_then(wall_clock),
Some(Duration::from_secs(5))
);
}
#[test]
fn is_last_turn_bounded() {
assert!(is_last_turn(0, 1));
assert!(!is_last_turn(0, 10));
assert!(is_last_turn(9, 10));
assert!(!is_last_turn(10, 10));
}
#[test]
fn is_last_turn_zero_cap_never_fires() {
assert!(!is_last_turn(0, 0));
assert!(!is_last_turn(9, 0));
assert!(!is_last_turn(u32::MAX, 0));
}
#[test]
fn is_last_turn_at_u32_max_does_not_overflow() {
assert!(is_last_turn(u32::MAX - 1, u32::MAX));
assert!(!is_last_turn(u32::MAX, u32::MAX));
}
/// The turn-top abort check runs before any client is created, so an
/// already-aborted graph never issues a model call and the node fails
/// with the abort reason rather than a connection error.
#[tokio::test]
async fn run_chat_loop_bails_before_first_call_when_aborted() {
let node = node_with(None);
let mut state = manager_with(&[]);
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
ctx.agent = Some(Agent::test_new(AgentConfig::default()));
let abort = create_abort_signal();
abort.set_ctrlc();
let err = LlmNodeExecutor::execute("think", &node, &mut state, &mut ctx, &abort)
.await
.expect_err("a pre-set abort must fail the node");
let chain = format!("{err:#}");
assert!(
chain.contains(
"LLM node failed and no fallback declared: LLM call failed: llm node aborted"
),
"{chain}"
);
assert_eq!(
state.state().get(OUTPUT_KEY),
None,
"no output is recorded for an aborted node without state_updates"
);
}
} }
+2285 -83
View File
File diff suppressed because it is too large. Load diff
+25 -1
View File
@@ -10,21 +10,30 @@ pub mod reducer;
pub mod script; pub mod script;
pub mod staging; pub mod staging;
pub mod state; pub mod state;
pub mod state_updates;
pub mod structured; pub mod structured;
pub mod types; pub mod types;
pub mod user_interaction; pub mod user_interaction;
pub mod validator; pub mod validator;
pub use dispatch::{active_agent_graph_name, run_active_agent_graph}; pub use dispatch::{
active_agent_graph_name, run_active_agent_graph, run_active_agent_graph_with_inputs,
};
pub use executor::GraphExecutor; pub use executor::GraphExecutor;
pub use parser::{GraphParser, agent_has_graph}; pub use parser::{GraphParser, agent_has_graph};
use serde_json::Value; use serde_json::Value;
use std::time::Duration;
pub use types::{Graph, NodeType}; pub use types::{Graph, NodeType};
pub const GRAPH_SCHEMA_VERSION: &str = "1.0"; pub const GRAPH_SCHEMA_VERSION: &str = "1.0";
pub const DEFAULT_MAX_LOOP_ITERATIONS: usize = 100; pub const DEFAULT_MAX_LOOP_ITERATIONS: usize = 100;
/// A timeout of `0` disables the wall-clock bound.
pub(crate) fn wall_clock(secs: u64) -> Option<Duration> {
(secs != 0).then(|| Duration::from_secs(secs))
}
pub const MAX_STATE_SIZE_BYTES: usize = 32 * 1024; pub const MAX_STATE_SIZE_BYTES: usize = 32 * 1024;
pub(in crate::graph) fn type_name(value: &Value) -> &'static str { pub(in crate::graph) fn type_name(value: &Value) -> &'static str {
@@ -37,3 +46,18 @@ pub(in crate::graph) fn type_name(value: &Value) -> &'static str {
Value::Object(_) => "object", Value::Object(_) => "object",
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wall_clock_zero_is_no_bound() {
assert!(wall_clock(0).is_none());
}
#[test]
fn wall_clock_nonzero_is_that_many_seconds() {
assert_eq!(wall_clock(7), Some(Duration::from_secs(7)));
}
}
+23 -33
View File
@@ -1,13 +1,13 @@
use super::state::StateManager; use super::state::StateManager;
use super::state_updates;
use super::types::RagNode; use super::types::RagNode;
use super::wall_clock;
use crate::config::RequestContext; use crate::config::RequestContext;
use crate::utils::create_abort_signal; use crate::utils::create_abort_signal;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::time::Duration;
use tokio::time::timeout; use tokio::time::timeout;
const OUTPUT_KEY: &str = "output";
const DEFAULT_QUERY: &str = "{{initial_prompt}}"; const DEFAULT_QUERY: &str = "{{initial_prompt}}";
const DEFAULT_RAG_TIMEOUT_SECS: u64 = 120; const DEFAULT_RAG_TIMEOUT_SECS: u64 = 120;
@@ -34,17 +34,15 @@ impl RagNodeExecutor {
let top_k = node.top_k.unwrap_or_else(|| rag.configured_top_k()); let top_k = node.top_k.unwrap_or_else(|| rag.configured_top_k());
let rerank = rag.configured_reranker(); let rerank = rag.configured_reranker();
let timeout_dur = Duration::from_secs(node.timeout.unwrap_or(DEFAULT_RAG_TIMEOUT_SECS)); let secs = node.timeout.unwrap_or(DEFAULT_RAG_TIMEOUT_SECS);
let bound = wall_clock(secs);
let abort = create_abort_signal(); let abort = create_abort_signal();
let (context, sources_str, _ids) = let fut = rag.search(&query, top_k, rerank, abort);
timeout(timeout_dur, rag.search(&query, top_k, rerank, abort)) let (context, sources_str, _ids) = match bound {
.await Some(d) => timeout(d, fut).await,
.with_context(|| { None => Ok(fut.await),
format!( }
"rag node '{node_id}' timed out after {}s", .with_context(|| format!("rag node '{node_id}' timed out after {secs}s"))?
timeout_dur.as_secs()
)
})?
.with_context(|| format!("rag node '{node_id}' retrieval failed"))?; .with_context(|| format!("rag node '{node_id}' retrieval failed"))?;
let output = build_rag_output(context, &sources_str); let output = build_rag_output(context, &sources_str);
@@ -70,33 +68,14 @@ fn build_rag_output(context: String, sources_str: &str) -> Value {
} }
fn apply_state_updates(node: &RagNode, state_manager: &mut StateManager, output: &Value) { fn apply_state_updates(node: &RagNode, state_manager: &mut StateManager, output: &Value) {
let Some(updates) = &node.state_updates else { state_updates::apply(state_manager, output, false, node.state_updates.as_ref());
return;
};
let prev_output = state_manager.state().get(OUTPUT_KEY).cloned();
state_manager
.state_mut()
.set(OUTPUT_KEY.into(), output.clone());
for (key, template) in updates {
let value = state_manager.interpolate_lenient(template);
state_manager
.state_mut()
.set(key.clone(), Value::String(value));
}
match prev_output {
Some(v) => state_manager.state_mut().set(OUTPUT_KEY.into(), v),
None => state_manager
.state_mut()
.set(OUTPUT_KEY.into(), Value::Null),
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
use std::time::Duration;
#[test] #[test]
fn build_rag_output_splits_bullet_sources_into_array() { fn build_rag_output_splits_bullet_sources_into_array() {
@@ -126,4 +105,15 @@ mod tests {
assert_eq!(out["sources"], json!(["plain/path"])); assert_eq!(out["sources"], json!(["plain/path"]));
} }
/// Mirrors the resolution expression in `execute`.
fn resolve(timeout: Option<u64>) -> Option<Duration> {
wall_clock(timeout.unwrap_or(DEFAULT_RAG_TIMEOUT_SECS))
}
#[test]
fn zero_timeout_resolves_to_no_bound() {
assert!(resolve(Some(0)).is_none());
assert_eq!(resolve(None), Some(Duration::from_secs(120)));
}
} }
+34 -4
View File
@@ -1,5 +1,6 @@
use super::state::{StateManager, StateRepresentation}; use super::state::{StateManager, StateRepresentation};
use super::types::ScriptNode; use super::types::ScriptNode;
use super::wall_clock;
use crate::config::paths; use crate::config::paths;
use crate::function::Language; use crate::function::Language;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
@@ -8,7 +9,6 @@ use std::collections::HashMap;
use std::env; use std::env;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command; use tokio::process::Command;
use tokio::time::timeout; use tokio::time::timeout;
@@ -66,9 +66,12 @@ impl ScriptExecutor {
} }
} }
let timeout_dur = Duration::from_secs(node.timeout); let bound = wall_clock(node.timeout);
let output = timeout(timeout_dur, cmd.output()) let fut = cmd.output();
.await let output = match bound {
Some(d) => timeout(d, fut).await,
None => Ok(fut.await),
}
.with_context(|| { .with_context(|| {
format!( format!(
"Script '{}' timed out after {}s", "Script '{}' timed out after {}s",
@@ -260,6 +263,33 @@ echo '{"quality": 0.85, "issues": 3, "_next": "approve"}'
cleanup(&dir); cleanup(&dir);
} }
#[tokio::test]
async fn zero_timeout_is_unbounded() {
if !cmd_available("bash") {
return;
}
let (dir, path) = write_script(
r#"#!/bin/bash
sleep 1.5
echo '{"ok":true}'
"#,
"sh",
);
let mut state = StateManager::new(HashMap::new());
let executor = ScriptExecutor::new(&dir);
executor
.execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 0),
&mut state,
)
.await
.unwrap_or_else(|e| panic!("zero timeout should not bound the script: {e:#}"));
assert_eq!(state.state().get("ok"), Some(&json!(true)));
cleanup(&dir);
}
#[tokio::test] #[tokio::test]
async fn bash_script_can_read_state_from_env() { async fn bash_script_can_read_state_from_env() {
if !cmd_available("bash") { if !cmd_available("bash") {
+74
View File
@@ -320,6 +320,26 @@ pub(super) fn template_root_keys(template: &str) -> Vec<String> {
.collect() .collect()
} }
// Whether `s` is exactly one `{{key}}` reference and nothing else (after the
// same trim `interpolate_raw` applies) whose path parses with the grammar
// `get_nested_value` uses, so this accepts exactly the strings the runtime
// can resolve as a pure reference.
pub(super) fn is_lone_template(s: &str) -> bool {
let s = s.trim();
let Ok(Some(m)) = TEMPLATE_VAR_RE.captures(s) else {
return false;
};
let whole = m.get(0).expect("group 0 always present");
if whole.start() != 0 || whole.end() != s.len() {
return false;
}
let path = m.get(1).expect("template regex has one group").as_str();
let mut parts = path.split('.');
let first = parts.next().and_then(split_indices);
first.is_some_and(|(name, _)| !name.is_empty())
&& parts.all(|part| split_indices(part).is_some())
}
fn value_to_string(value: &Value) -> String { fn value_to_string(value: &Value) -> String {
match value { match value {
Value::String(s) => s.clone(), Value::String(s) => s.clone(),
@@ -931,6 +951,60 @@ mod tests {
assert_eq!(result, json!("v")); assert_eq!(result, json!("v"));
} }
#[test]
fn is_lone_template_cases() {
for accepted in [
"{{budget}}",
"{{cfg.limits[0]}}",
" {{budget}} ",
"\n{{k}}\t",
] {
assert!(
is_lone_template(accepted),
"{accepted:?} should be a lone template"
);
}
for rejected in [
"",
"4",
"{{}}",
"{{ key }}",
"n={{k}}",
"{{k}}x",
"{{a}} {{b",
"{{a}}{{b}}",
"{{a-b}}",
"{{k",
] {
assert!(
!is_lone_template(rejected),
"{rejected:?} must not be a lone template"
);
}
}
#[test]
fn is_lone_template_matches_runtime_path_grammar() {
for accepted in [
"{{a.b[0][1].c}}",
"{{a[0][1]}}",
"{{a.}}",
"{{a..b}}",
"{{a.[0]}}",
] {
assert!(
is_lone_template(accepted),
"{accepted:?} resolves at run time and must be accepted"
);
}
for rejected in ["{{limits[foo]}}", "{{a[]}}", "{{a[0}}", "{{.a}}"] {
assert!(
!is_lone_template(rejected),
"{rejected:?} never resolves at run time and must be rejected"
);
}
}
#[test] #[test]
fn interpolate_raw_inner_spaces_treated_as_mixed() { fn interpolate_raw_inner_spaces_treated_as_mixed() {
let manager = manager_with(&[("k", json!("v"))]); let manager = manager_with(&[("k", json!("v"))]);
+168
View File
@@ -0,0 +1,168 @@
use super::state::StateManager;
use serde_json::Value;
use std::collections::HashMap;
pub(super) const OUTPUT_KEY: &str = "output";
/// Merge `output_schema` top-level keys (when `has_schema`) and apply `state_updates` with
/// `{{output}}` bound to `output` for the duration of interpolation only. An explicit `output`
/// key in `updates` is honored; otherwise `output` is restored to its prior value, or removed
/// again if it was absent.
pub(super) fn apply(
state_manager: &mut StateManager,
output: &Value,
has_schema: bool,
updates: Option<&HashMap<String, String>>,
) {
if has_schema && let Some(obj) = output.as_object() {
for (k, v) in obj {
state_manager.state_mut().set(k.clone(), v.clone());
}
}
let Some(updates) = updates else {
return;
};
let prev_output = state_manager.state().get(OUTPUT_KEY).cloned();
state_manager
.state_mut()
.set(OUTPUT_KEY.into(), output.clone());
let computed: Vec<(String, Value)> = updates
.iter()
.map(|(key, template)| {
(
key.clone(),
Value::String(state_manager.interpolate_lenient(template)),
)
})
.collect();
match prev_output {
Some(prev) => state_manager.state_mut().set(OUTPUT_KEY.into(), prev),
None => {
state_manager.state_mut().remove(OUTPUT_KEY);
}
}
for (key, value) in computed {
state_manager.state_mut().set(key, value);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn manager_with(pairs: &[(&str, Value)]) -> StateManager {
let mut map = HashMap::new();
for (k, v) in pairs {
map.insert((*k).into(), v.clone());
}
StateManager::new(map)
}
fn updates(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn explicit_output_state_update_is_honored() {
let u = updates(&[("output", "{{output}}")]);
let mut state = manager_with(&[("output", json!("stale"))]);
apply(&mut state, &json!("the completion"), false, Some(&u));
assert_eq!(
state.state().get(OUTPUT_KEY),
Some(&json!("the completion"))
);
}
#[test]
fn explicit_output_is_honored_alongside_other_updates() {
let u = updates(&[("output", "{{output}}"), ("summary", "got: {{output}}")]);
let mut state = manager_with(&[]);
apply(&mut state, &json!("done"), false, Some(&u));
assert_eq!(state.state().get(OUTPUT_KEY), Some(&json!("done")));
assert_eq!(state.state().get("summary"), Some(&json!("got: done")));
}
#[test]
fn no_updates_is_a_noop() {
let mut state = manager_with(&[("keep", json!(1))]);
apply(&mut state, &json!("ignored"), false, None);
assert_eq!(state.state().get("keep"), Some(&json!(1)));
assert!(state.state().get(OUTPUT_KEY).is_none());
}
#[test]
fn output_is_restored_to_previous_value() {
let u = updates(&[("response", "{{output}}")]);
let mut state = manager_with(&[("output", json!("preserved"))]);
apply(&mut state, &json!("new"), false, Some(&u));
assert_eq!(state.state().get("response"), Some(&json!("new")));
assert_eq!(state.state().get(OUTPUT_KEY), Some(&json!("preserved")));
}
#[test]
fn output_is_removed_when_absent() {
let u = updates(&[("response", "{{output}}")]);
let mut state = manager_with(&[]);
apply(&mut state, &json!("new"), false, Some(&u));
assert_eq!(state.state().get("response"), Some(&json!("new")));
assert!(state.state().get(OUTPUT_KEY).is_none());
}
#[test]
fn explicit_null_prior_output_is_restored_as_null() {
let u = updates(&[("response", "{{output}}")]);
let mut state = manager_with(&[("output", json!(null))]);
apply(&mut state, &json!("new"), false, Some(&u));
assert_eq!(state.state().get("response"), Some(&json!("new")));
assert_eq!(state.state().get(OUTPUT_KEY), Some(&json!(null)));
}
#[test]
fn schema_merges_top_level_keys_when_has_schema() {
let mut state = manager_with(&[]);
apply(&mut state, &json!({"summary": "s", "score": 7}), true, None);
assert_eq!(state.state().get("summary"), Some(&json!("s")));
assert_eq!(state.state().get("score"), Some(&json!(7)));
assert!(state.state().get(OUTPUT_KEY).is_none());
}
#[test]
fn object_output_is_not_merged_without_schema() {
let u = updates(&[("ctx", "{{output.context}}")]);
let mut state = manager_with(&[]);
apply(
&mut state,
&json!({"context": "c", "sources": ["a"]}),
false,
Some(&u),
);
assert!(state.state().get("context").is_none());
assert!(state.state().get("sources").is_none());
assert_eq!(state.state().get("ctx"), Some(&json!("c")));
assert!(state.state().get(OUTPUT_KEY).is_none());
}
}
+189 -2
View File
@@ -240,6 +240,9 @@ pub struct AgentNode {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>, pub timeout: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inputs: Option<HashMap<String, String>>,
#[serde(default)] #[serde(default)]
pub teammates: bool, pub teammates: bool,
} }
@@ -426,13 +429,60 @@ pub struct MapNode {
pub collect_into: String, pub collect_into: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<usize>, pub max_concurrency: Option<ConcurrencyCap>,
} }
fn default_map_output_key() -> String { fn default_map_output_key() -> String {
"output".to_string() "output".to_string()
} }
/// A map node's per-item concurrency cap: either a literal integer or a
/// `{{template}}` resolved against graph state when the map runs.
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(untagged)]
pub enum ConcurrencyCap {
Fixed(usize),
Template(String),
}
// Hand-written so a malformed value (`-1`, `4.5`, `true`) reports what was
// received and what is accepted, instead of serde's untagged-enum "did not
// match any variant".
impl<'de> Deserialize<'de> for ConcurrencyCap {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct CapVisitor;
impl serde::de::Visitor<'_> for CapVisitor {
type Value = ConcurrencyCap;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a positive integer or a `{{template}}` string for max_concurrency")
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
usize::try_from(v)
.map(ConcurrencyCap::Fixed)
.map_err(|_| E::invalid_value(serde::de::Unexpected::Unsigned(v), &self))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
usize::try_from(v)
.map(ConcurrencyCap::Fixed)
.map_err(|_| E::invalid_value(serde::de::Unexpected::Signed(v), &self))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(ConcurrencyCap::Template(v.to_owned()))
}
}
deserializer.deserialize_any(CapVisitor)
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Reducer { pub enum Reducer {
@@ -449,6 +499,7 @@ pub enum Reducer {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct GraphState { pub struct GraphState {
data: HashMap<String, Value>, data: HashMap<String, Value>,
#[cfg(test)]
history: Vec<String>, history: Vec<String>,
loop_counts: HashMap<String, usize>, loop_counts: HashMap<String, usize>,
} }
@@ -457,6 +508,7 @@ impl GraphState {
pub fn new(initial: HashMap<String, Value>) -> Self { pub fn new(initial: HashMap<String, Value>) -> Self {
Self { Self {
data: initial, data: initial,
#[cfg(test)]
history: Vec::new(), history: Vec::new(),
loop_counts: HashMap::new(), loop_counts: HashMap::new(),
} }
@@ -470,6 +522,10 @@ impl GraphState {
self.data.insert(key, value); self.data.insert(key, value);
} }
pub fn remove(&mut self, key: &str) -> Option<Value> {
self.data.remove(key)
}
pub fn merge(&mut self, json_obj: &serde_json::Map<String, Value>) { pub fn merge(&mut self, json_obj: &serde_json::Map<String, Value>) {
for (key, value) in json_obj { for (key, value) in json_obj {
self.data.insert(key.clone(), value.clone()); self.data.insert(key.clone(), value.clone());
@@ -481,6 +537,7 @@ impl GraphState {
} }
pub fn visit_node(&mut self, node_id: &str) { pub fn visit_node(&mut self, node_id: &str) {
#[cfg(test)]
self.history.push(node_id.to_string()); self.history.push(node_id.to_string());
*self.loop_counts.entry(node_id.to_string()).or_insert(0) += 1; *self.loop_counts.entry(node_id.to_string()).or_insert(0) += 1;
} }
@@ -583,6 +640,48 @@ nodes:
} }
} }
#[test]
fn agent_node_inputs_parse_optionally_and_empty_map_is_some() {
let yaml = r#"
name: g
start: with_inputs
nodes:
with_inputs:
id: with_inputs
type: agent
agent: helper
prompt: hi
inputs:
width: "{{n}}"
note: "Repo: {{repo}}"
next: without_inputs
without_inputs:
id: without_inputs
type: agent
agent: helper
prompt: hi
next: empty_inputs
empty_inputs:
id: empty_inputs
type: agent
agent: helper
prompt: hi
inputs: {}
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let inputs_of = |id: &str| match &graph.get_node(id).unwrap().node_type {
NodeType::Agent(n) => n.inputs.clone(),
_ => panic!("expected Agent variant"),
};
let with = inputs_of("with_inputs").expect("inputs should parse");
assert_eq!(with.len(), 2);
assert_eq!(with.get("width").map(String::as_str), Some("{{n}}"));
assert_eq!(with.get("note").map(String::as_str), Some("Repo: {{repo}}"));
assert_eq!(inputs_of("without_inputs"), None);
assert_eq!(inputs_of("empty_inputs"), Some(HashMap::new()));
}
#[test] #[test]
fn deserializes_mcp_tools_at_graph_and_node_level() { fn deserializes_mcp_tools_at_graph_and_node_level() {
let yaml = r#" let yaml = r#"
@@ -1232,7 +1331,7 @@ next: rank
assert_eq!(map.branch, "research_subject"); assert_eq!(map.branch, "research_subject");
assert_eq!(map.output_key, "research_result"); assert_eq!(map.output_key, "research_result");
assert_eq!(map.collect_into, "research_results"); assert_eq!(map.collect_into, "research_results");
assert_eq!(map.max_concurrency, Some(5)); assert_eq!(map.max_concurrency, Some(ConcurrencyCap::Fixed(5)));
} }
#[test] #[test]
@@ -1256,6 +1355,78 @@ collect_into: results
assert!(map.max_concurrency.is_none()); assert!(map.max_concurrency.is_none());
} }
fn map_node_yaml_with_cap(cap: &str) -> String {
format!(
"id: fan_out\ntype: map\nover: \"{{{{items}}}}\"\nas: item\nbranch: process\n\
collect_into: results\nmax_concurrency: {cap}\n"
)
}
fn parse_map_cap(cap: &str) -> Option<ConcurrencyCap> {
let node: Node = serde_yaml::from_str(&map_node_yaml_with_cap(cap)).unwrap();
match node.node_type {
NodeType::Map(m) => m.max_concurrency,
_ => panic!("expected Map variant"),
}
}
#[test]
fn map_max_concurrency_integer_parses_as_fixed() {
assert_eq!(parse_map_cap("4"), Some(ConcurrencyCap::Fixed(4)));
}
#[test]
fn map_max_concurrency_zero_parses_as_fixed_zero() {
assert_eq!(parse_map_cap("0"), Some(ConcurrencyCap::Fixed(0)));
}
#[test]
fn map_max_concurrency_string_parses_as_template() {
assert_eq!(
parse_map_cap("\"{{budget}}\""),
Some(ConcurrencyCap::Template("{{budget}}".into()))
);
}
#[test]
fn map_max_concurrency_quoted_integer_parses_as_template() {
assert_eq!(
parse_map_cap("\"4\""),
Some(ConcurrencyCap::Template("4".into()))
);
}
#[test]
fn map_max_concurrency_malformed_values_name_accepted_forms() {
for bad in ["-1", "4.5", "true", "[4]", "{n: 4}"] {
let err = serde_yaml::from_str::<Node>(&map_node_yaml_with_cap(bad))
.expect_err(&format!("max_concurrency: {bad} should fail to parse"));
let msg = err.to_string();
assert!(
msg.contains("a positive integer or a `{{template}}` string for max_concurrency"),
"max_concurrency: {bad} produced an unhelpful error: {msg}"
);
}
}
#[test]
fn map_max_concurrency_negative_error_names_offending_value() {
let err = serde_yaml::from_str::<Node>(&map_node_yaml_with_cap("-1")).unwrap_err();
assert!(err.to_string().contains("-1"), "{err}");
}
#[test]
fn concurrency_cap_serializes_as_bare_scalar() {
assert_eq!(
serde_json::to_value(ConcurrencyCap::Fixed(4)).unwrap(),
serde_json::json!(4)
);
assert_eq!(
serde_json::to_value(ConcurrencyCap::Template("{{k}}".into())).unwrap(),
serde_json::json!("{{k}}")
);
}
#[test] #[test]
fn full_graph_with_all_new_phase_a_fields_parses() { fn full_graph_with_all_new_phase_a_fields_parses() {
let yaml = r#" let yaml = r#"
@@ -1386,4 +1557,20 @@ nodes:
assert!(!serde_yaml::to_string(&graph).unwrap().contains("driver")); assert!(!serde_yaml::to_string(&graph).unwrap().contains("driver"));
} }
#[test]
fn graph_state_remove_returns_previous_value_and_drops_key() {
let mut state = GraphState::new(HashMap::from([("k".to_string(), json!(1))]));
assert_eq!(state.remove("k"), Some(json!(1)));
assert_eq!(state.get("k"), None);
assert!(state.data().is_empty());
}
#[test]
fn graph_state_remove_missing_key_is_none() {
let mut state = GraphState::new(HashMap::new());
assert_eq!(state.remove("k"), None);
}
} }
+1481 -108
View File
File diff suppressed because it is too large. Load diff
+15 -6
View File
@@ -18,23 +18,32 @@ pub fn generate_bash_declarations(
.with_context(|| format!("Failed to load script at '{tool_file:?}'"))?; .with_context(|| format!("Failed to load script at '{tool_file:?}'"))?;
debug!("Building script at '{tool_file:?}'"); debug!("Building script at '{tool_file:?}'");
let (build_script, declarations) = build_bash_tool(&src, file_name)
.with_context(|| format!("Failed to build script '{file_name}'"))?;
function::write_file_atomic(tools_file_path, &build_script, Some(0o755))
.with_context(|| format!("Failed to write built script to '{tools_file_path:?}'"))?;
Ok(declarations)
}
/// Builds the argc script and derives its declarations without touching the
/// filesystem, so callers can inspect a tool source without rewriting it.
pub fn build_bash_tool(src: &str, file_name: &str) -> Result<(String, Vec<FunctionDeclaration>)> {
let build_script = argc::build( let build_script = argc::build(
&src, src,
"", "",
env::var("TERM_WIDTH").ok().and_then(|v| v.parse().ok()), env::var("TERM_WIDTH").ok().and_then(|v| v.parse().ok()),
)?; )?;
let build_script = allow_empty_required_values(&build_script); let build_script = allow_empty_required_values(&build_script);
function::write_file_atomic(tools_file_path, &build_script, Some(0o755))
.with_context(|| format!("Failed to write built script to '{tools_file_path:?}'"))?;
let command_value = argc::export(&build_script, file_name) let command_value = argc::export(&build_script, file_name)
.with_context(|| format!("Failed to parse script at '{tool_file:?}'"))?; .with_context(|| format!("Failed to parse script '{file_name}'"))?;
if command_value.subcommands.is_empty() { if command_value.subcommands.is_empty() {
let function_declaration = let function_declaration =
command_to_function_declaration(&command_value).ok_or_else(|| { command_to_function_declaration(&command_value).ok_or_else(|| {
anyhow::format_err!("Tool definition missing or empty description: {file_name}") anyhow::format_err!("Tool definition missing or empty description: {file_name}")
})?; })?;
Ok(vec![function_declaration]) Ok((build_script, vec![function_declaration]))
} else { } else {
let mut declarations = vec![]; let mut declarations = vec![];
for subcommand in &command_value.subcommands { for subcommand in &command_value.subcommands {
@@ -54,7 +63,7 @@ pub fn generate_bash_declarations(
} }
} }
Ok(declarations) Ok((build_script, declarations))
} }
} }
+97 -1
View File
@@ -2,6 +2,11 @@ use super::{AbortSignal, IS_STDOUT_TERMINAL, poll_abort_signal, wait_abort_signa
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use crossterm::{cursor, queue, style, terminal}; use crossterm::{cursor, queue, style, terminal};
#[cfg(test)]
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::{ use std::{
future::Future, future::Future,
io::{Write, stdout}, io::{Write, stdout},
@@ -19,11 +24,22 @@ use tokio::{
pub struct SpinnerInner { pub struct SpinnerInner {
index: usize, index: usize,
message: String, message: String,
#[cfg(test)]
cleared: Option<Arc<AtomicUsize>>,
} }
impl SpinnerInner { impl SpinnerInner {
const DATA: [&'static str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const DATA: [&'static str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
#[cfg(test)]
fn observed(cleared: Arc<AtomicUsize>) -> Self {
Self {
index: 0,
message: String::new(),
cleared: Some(cleared),
}
}
fn step(&mut self) -> Result<()> { fn step(&mut self) -> Result<()> {
if !*IS_STDOUT_TERMINAL || self.message.is_empty() { if !*IS_STDOUT_TERMINAL || self.message.is_empty() {
return Ok(()); return Ok(());
@@ -50,10 +66,17 @@ impl SpinnerInner {
} }
fn clear_message(&mut self) -> Result<()> { fn clear_message(&mut self) -> Result<()> {
if !*IS_STDOUT_TERMINAL || self.message.is_empty() { if self.message.is_empty() {
return Ok(()); return Ok(());
} }
self.message.clear(); self.message.clear();
#[cfg(test)]
if let Some(cleared) = &self.cleared {
cleared.fetch_add(1, Ordering::SeqCst);
}
if !*IS_STDOUT_TERMINAL {
return Ok(());
}
let mut writer = stdout(); let mut writer = stdout();
queue!( queue!(
writer, writer,
@@ -66,6 +89,17 @@ impl SpinnerInner {
} }
} }
/// Restores the terminal when the spinner goes away by any route, not just the
/// loop's normal exit: a future cancelled mid-spin (the surrounding task was
/// aborted) or an early `?` return would otherwise leave the last frame on
/// screen and the cursor hidden. `clear_message` is a no-op once the message
/// is gone, so a normal-path clear leaves nothing for the drop to redo.
impl Drop for SpinnerInner {
fn drop(&mut self) {
let _ = self.clear_message();
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct Spinner(mpsc::UnboundedSender<SpinnerEvent>); pub struct Spinner(mpsc::UnboundedSender<SpinnerEvent>);
@@ -215,3 +249,65 @@ async fn run_abortable_spinner(
spinner.clear_message()?; spinner.clear_message()?;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
fn observed(message: &str) -> (SpinnerInner, Arc<AtomicUsize>) {
let cleared = Arc::new(AtomicUsize::new(0));
let mut spinner = SpinnerInner::observed(cleared.clone());
spinner.set_message(message.to_string()).unwrap();
(spinner, cleared)
}
#[test]
fn drop_with_active_message_clears_once() {
let (spinner, cleared) = observed("Thinking");
assert_eq!(cleared.load(Ordering::SeqCst), 0);
drop(spinner);
assert_eq!(cleared.load(Ordering::SeqCst), 1);
}
#[test]
fn explicit_clear_then_drop_clears_exactly_once() {
let (mut spinner, cleared) = observed("Thinking");
spinner.clear_message().unwrap();
assert_eq!(cleared.load(Ordering::SeqCst), 1);
drop(spinner);
assert_eq!(cleared.load(Ordering::SeqCst), 1);
}
#[test]
fn drop_without_message_is_silent() {
let (spinner, cleared) = observed("");
drop(spinner);
assert_eq!(cleared.load(Ordering::SeqCst), 0);
}
#[test]
fn set_message_replaces_and_clears_previous_once() {
let (mut spinner, cleared) = observed("Thinking");
spinner.set_message("Fetching".to_string()).unwrap();
assert_eq!(cleared.load(Ordering::SeqCst), 1);
drop(spinner);
assert_eq!(cleared.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn cancelled_task_restores_terminal_on_drop() {
let (spinner, cleared) = observed("Thinking");
let task = tokio::spawn(async move {
let _spinner = spinner;
std::future::pending::<()>().await;
});
tokio::task::yield_now().await;
assert_eq!(cleared.load(Ordering::SeqCst), 0);
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert_eq!(cleared.load(Ordering::SeqCst), 1);
}
}