Compare commits
7
Commits
cfb83a629e
...
9e2f3c0f12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2f3c0f12 | ||
|
|
8746e3e29a | ||
|
|
5b9852d80e | ||
|
|
99d4b20ac3 | ||
|
|
17864c8864 | ||
|
|
2a04c4f5e1 | ||
|
|
ca58176ef9 |
No files matched your search
+110
@@ -1,3 +1,113 @@
|
||||
## v0.10.3 (2026-09-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- create a config alias for default_reasoning_level (to match some wording and make the config flexible)
|
||||
- support custom resource definitions for openai-compatible Oauth customization
|
||||
- Support specifying the compression model to use when compressing a session for greater cost controls
|
||||
- Added full cost tracking to Coyote and added it to the default right prompt
|
||||
- added support for input/output token cache cost tracking
|
||||
- Support persisting paused todo lists to sessions so users can exit and resume paused todo sessions; Also fixed a bug so todo list pausing is only supported when being used in a session since it typically requires multi-turn communication
|
||||
- Support token caching to reduce LLM costs in Anthropic models (not typically handled automatically by APIs)
|
||||
- **TASK-030**: callers escalate on GAUNTLET_REVIEW_INCOMPLETE instead of findings-routing
|
||||
- **TASK-032**: review-gauntlet incomplete-review machine line + ×N attempt details
|
||||
- **TASK-029**: review-gauntlet fault-only lane re-run loop
|
||||
- **TASK-028**: escalation_timeout 0 waits indefinitely and is the default
|
||||
- **graph**: stream llm-node completions so node timeout governs long generations
|
||||
- **TASK-023**: deep-research hardening — fail-safe scripts, fault fallbacks, pipeline notes
|
||||
- **TASK-020**: finding-verifier + step-runner fail-closed hardening
|
||||
- **TASK-019**: code-reviewer fail-closed review pipeline
|
||||
- **TASK-018**: review-gauntlet lane degradation + verification passthrough
|
||||
- **TASK-017**: adversary execution evidence + fail-closed routing
|
||||
- **TASK-016**: engine + test fixes from the Phase-1 re-review
|
||||
- **TASK-011**: traverse agent fallback edges in map peer-discovery BFS
|
||||
- **TASK-010**: add max_attempts to read-only agent lanes in bundled graphs
|
||||
- **TASK-009**: add fallback routing to agent nodes
|
||||
- **TASK-008**: add shared transient matcher and retry support to agent nodes
|
||||
- Fixed graph agents with new agent variable passing functionality
|
||||
- **graph**: warn when a declared variable is shadowed by an initial_state key
|
||||
- **agents**: advertise declared agent variables in agent__list_available
|
||||
- **agents**: allow passing agent variables to agent__spawn and agent__task_create
|
||||
- seed variables into initial graph state
|
||||
- Significant code review suite rewrite with improved graph agent features
|
||||
- let install commands remove files that have been removed from bundles, both internal and external
|
||||
- update the web_search_coyote tool to attempt to use the current model first then fallback to the configured model for web searches
|
||||
- expose the current coyote model to tools
|
||||
- removed temperature specification in deep-research and finding-verifier agents to make them more univerally usable
|
||||
- **graph**: thread the graph abort into llm nodes and tool execution
|
||||
- **graph**: abort a graph run on Ctrl-C from anywhere
|
||||
- **graph**: warn when an llm node's turn cap is disabled
|
||||
- **graph**: treat llm max_iterations: 0 as no turn cap
|
||||
- **graph**: warn when the visit cap is disabled
|
||||
- **graph**: treat max_loop_iterations: 0 as no visit cap
|
||||
- **graph**: warn when a timeout is disabled
|
||||
- **graph**: treat timeout: 0 as no wall-clock bound
|
||||
- **graph**: validate agent node inputs at load time
|
||||
- **graph**: seed a child graph agent's state from an agent node's inputs map
|
||||
- **graph**: validate map branch subgraphs instead of single branch nodes
|
||||
- **graph**: run map branches as per-item subgraph chains
|
||||
- **graph**: accept a `{{template}}` for a map node's `max_concurrency`
|
||||
- created the finding-verifier and whetstone agents for continuous code quality improvements
|
||||
- teammates flag enabling peer messaging between concurrent graph agent nodes
|
||||
- support explicit can_spawn_agents, max_concurrent_agents, and max_agent_depth keys in graph.yaml
|
||||
- support setting the tool_timeout in the main config file instead of only via an environment variable
|
||||
- Added new environment variable to force showing tool call logging when not in a tty
|
||||
|
||||
### Fix
|
||||
|
||||
- empty tool results can't cause errors in bedrock, claude, openai, or vertexai clients anymore
|
||||
- proper support for reasoning/thinking in bedrock models
|
||||
- **TASK-032**: enforce incomplete⇒BLOCKED structurally; anchor adversary degraded detection
|
||||
- **TASK-028**: pin example-doc wording; make not-found escalation hint match queue semantics
|
||||
- **TASK-028**: pin global-template exclusion, tighten timeout boundary test, reword stale hint
|
||||
- **adversary**: red verification runs block CONFORMS; re-derive timeout envelope; robust runner
|
||||
- **review-suite**: detect nested finding-verifier faults; harden render/fault markers
|
||||
- **review-gauntlet**: summary-line critical count, nested-fault hook, fail-closed lane fallback
|
||||
- **graph**: typed ApiStatusError transient classification; warn on bare output_schema
|
||||
- **review-suite**: mark example verification commands as placeholders; pin variable description and prompt note
|
||||
- **graph**: auto-merge only schema-declared keys from llm output
|
||||
- **review-suite**: pass verification_commands as a declared variable, never LLM-extracted from prose
|
||||
- **agents**: scrub GRAPH_STATE* from adversary verification commands; raise review-suite max_agent_depth
|
||||
- **adversary**: narrow died-marker predicate, degraded banner for died criteria, hermetic script tests
|
||||
- **adversary**: fail closed on PIPELINE-FAULT evidence regardless of status; pin bail-chain and validator shape
|
||||
- **adversary**: fail closed per criterion when check_criterion dies
|
||||
- **engine**: carry full error chain in llm node fault text
|
||||
- **TASK-020**: skip POSIX bash script tests on Windows
|
||||
- **TASK-020**: surface step-runner LLM-fault captures where the fallback lands
|
||||
- **TASK-019**: carry PIPELINE-FAULT prefix through verdict.py's crash guard
|
||||
- **TASK-018**: harden verdict_gate fault routing and widen default_lanes fallback
|
||||
- **TASK-017**: bound run_checks runtime and fail-close its node-level death
|
||||
- **tests**: collision-proof parser test temp files to deflake macOS CI
|
||||
- Don't infinitely compress a session once it passes a threshold when the threshold is close to the max token count
|
||||
- fix spinner ctrl-c early exit not clearing message and restoring cursor
|
||||
- **graph**: build peer retirement guards before spawning their tasks
|
||||
- **graph**: reject agents that ship both config.yaml and graph.yaml at load
|
||||
- **graph**: bound concurrency caps by the semaphore permit limit
|
||||
- **graph**: make turn-cap and template checks consistent across validation modes
|
||||
- **graph**: leave output absent after state_updates when it was absent before
|
||||
- **graph**: validate the whole max_concurrency template string
|
||||
- **graph**: reject as == output_key on map nodes
|
||||
- **graph**: inherit the session abort in agent-node children
|
||||
- **graph**: retire the teammate identity before structured extraction
|
||||
- **graph**: reject a literal max_concurrency of 0 instead of clamping it
|
||||
- **graph**: clear peer identity fields after every map chain step
|
||||
- **graph**: pick the map teammate identity name by BFS from the branch entry
|
||||
- **graph**: name the offending node type when a map branch reaches a disallowed node
|
||||
- **function**: build bundled bash tools without writing back to the source tree
|
||||
- **agents**: name the map-branch output key in deep-research and finding-verifier
|
||||
- **graph**: honor an explicit `output` key in state_updates
|
||||
- Fixed the bad formatting in the finding-verifier agent
|
||||
- properly anchored the role frontmatter regex so it doesn't catch in-instruction separators
|
||||
- support non-object tool result outputs for bedrock
|
||||
- exclude null bytes from fs_grep output on binaries
|
||||
- suppress subagent error and warning output to stderr/stdout
|
||||
|
||||
### Refactor
|
||||
|
||||
- **graph**: keep the ordered visit log test-only
|
||||
- **graph**: move the cfg(test) OUTPUT_KEY import into llm's test module
|
||||
- **graph**: drop the duplicated locator prefix from map chain step errors
|
||||
|
||||
## v0.10.2 (2026-09-04)
|
||||
|
||||
### Feat
|
||||
|
||||
Generated
+299
-215
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coyote-ai"
|
||||
version = "0.10.2"
|
||||
version = "0.10.3"
|
||||
edition = "2024"
|
||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||
description = "The batteries-included runtime for LLMs"
|
||||
|
||||
@@ -13,7 +13,7 @@ description: >
|
||||
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
||||
|
||||
sandbox:
|
||||
image: 'darkalex17/coyote:v0.10.2'
|
||||
image: 'darkalex17/coyote:v0.10.3'
|
||||
entrypoint: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
||||
|
||||
permissions:
|
||||
|
||||
+36
-53
@@ -1762,9 +1762,11 @@
|
||||
- grok-cli:access
|
||||
- api:access
|
||||
token_request_format: form_url_encoded
|
||||
resource: null
|
||||
extra_authorize_params:
|
||||
plan: generic
|
||||
referrer: coyote
|
||||
extra_token_params: {}
|
||||
extra_token_headers: {}
|
||||
extra_request_headers: {}
|
||||
echo_pkce_in_token_exchange: true
|
||||
@@ -5407,7 +5409,6 @@
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.25
|
||||
output_price: 2.0
|
||||
cache_read_price: 0.1563
|
||||
max_output_tokens: 65536
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
@@ -6905,9 +6906,9 @@
|
||||
- name: deepseek/deepseek-v4-flash
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 0.0794
|
||||
output_price: 0.1588
|
||||
cache_read_price: 0.0159
|
||||
input_price: 0.0886
|
||||
output_price: 0.1772
|
||||
cache_read_price: 0.0177
|
||||
max_output_tokens: 384000
|
||||
supports_function_calling: true
|
||||
- name: deepseek/deepseek-v4-flash-0731
|
||||
@@ -6955,10 +6956,10 @@
|
||||
- name: deepseek/deepseek-v4-pro-0813
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 0.5795
|
||||
output_price: 1.7384
|
||||
cache_read_price: 0.0184
|
||||
max_output_tokens: 393216
|
||||
input_price: 0.9834
|
||||
output_price: 2.9502
|
||||
cache_read_price: 0.0328
|
||||
max_output_tokens: 384000
|
||||
supports_function_calling: true
|
||||
- name: deepseek/deepseek-v4-pro-0813:batch
|
||||
type: chat
|
||||
@@ -7062,16 +7063,6 @@
|
||||
max_output_tokens: 65536
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: google/gemini-2.5-pro-preview-05-06
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 1.25
|
||||
output_price: 10.0
|
||||
cache_read_price: 0.125
|
||||
cache_write_price: 0.375
|
||||
max_output_tokens: 65535
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: google/gemini-2.5-pro:batch
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
@@ -8182,13 +8173,6 @@
|
||||
max_output_tokens: 4096
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: openai/gpt-4-turbo-preview
|
||||
type: chat
|
||||
max_input_tokens: 128000
|
||||
input_price: 10.0
|
||||
output_price: 30.0
|
||||
max_output_tokens: 4096
|
||||
supports_function_calling: true
|
||||
- name: openai/gpt-4-turbo:batch
|
||||
type: chat
|
||||
max_input_tokens: 128000
|
||||
@@ -9372,7 +9356,7 @@
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.15
|
||||
output_price: 1.2
|
||||
max_output_tokens: 235929
|
||||
max_output_tokens: 32768
|
||||
supports_function_calling: true
|
||||
- name: qwen/qwen3-vl-235b-a22b-instruct
|
||||
type: chat
|
||||
@@ -9450,10 +9434,9 @@
|
||||
- name: qwen/qwen3.5-35b-a3b
|
||||
type: chat
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.3125
|
||||
output_price: 1.25
|
||||
cache_read_price: 0.1563
|
||||
max_output_tokens: 16384
|
||||
input_price: 0.1625
|
||||
output_price: 1.3
|
||||
max_output_tokens: 65536
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: qwen/qwen3.5-397b-a17b
|
||||
@@ -9749,9 +9732,9 @@
|
||||
- name: tencent/hy3
|
||||
type: chat
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.0825
|
||||
output_price: 0.33
|
||||
cache_read_price: 0.0206
|
||||
input_price: 0.132
|
||||
output_price: 0.528
|
||||
cache_read_price: 0.033
|
||||
max_output_tokens: 128000
|
||||
supports_function_calling: true
|
||||
- name: tencent/hy3-preview
|
||||
@@ -10034,10 +10017,10 @@
|
||||
- name: z-ai/glm-5.2
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 0.6832
|
||||
output_price: 2.1472
|
||||
cache_read_price: 0.1269
|
||||
max_output_tokens: 131072
|
||||
input_price: 1.4
|
||||
output_price: 4.4
|
||||
cache_read_price: 0.14
|
||||
max_output_tokens: 128000
|
||||
supports_function_calling: true
|
||||
- name: z-ai/glm-5.2:batch
|
||||
type: chat
|
||||
@@ -10135,17 +10118,17 @@
|
||||
max_input_tokens: 1048576
|
||||
input_price: 0.15
|
||||
output_price: 0.6
|
||||
cache_read_price: 0.003
|
||||
cache_read_price: 0.015
|
||||
max_output_tokens: 943718
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: ~deepseek/deepseek-pro-latest
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 0.5795
|
||||
output_price: 1.7384
|
||||
cache_read_price: 0.0184
|
||||
max_output_tokens: 393216
|
||||
input_price: 0.66
|
||||
output_price: 1.98
|
||||
cache_read_price: 0.022
|
||||
max_output_tokens: 384000
|
||||
supports_function_calling: true
|
||||
- name: ~deepseek/deepseek-v4-flash-latest
|
||||
type: chat
|
||||
@@ -10178,9 +10161,9 @@
|
||||
- name: ~moonshotai/kimi-latest
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 1.975
|
||||
output_price: 11.06
|
||||
cache_read_price: 0.2291
|
||||
input_price: 2.1
|
||||
output_price: 10.95
|
||||
cache_read_price: 0.23
|
||||
max_output_tokens: 943718
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
@@ -10254,10 +10237,10 @@
|
||||
- name: ~z-ai/glm-latest
|
||||
type: chat
|
||||
max_input_tokens: 1310720
|
||||
input_price: 0.9
|
||||
output_price: 3.069
|
||||
cache_read_price: 0.18
|
||||
max_output_tokens: 943718
|
||||
input_price: 0.8775
|
||||
output_price: 2.97
|
||||
cache_read_price: 0.1755
|
||||
max_output_tokens: 235929
|
||||
supports_function_calling: true
|
||||
- provider: github
|
||||
models:
|
||||
@@ -10416,9 +10399,9 @@
|
||||
- name: Qwen/Qwen3.8-27B
|
||||
type: chat
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.4
|
||||
output_price: 3.0
|
||||
cache_read_price: 0.04
|
||||
input_price: 0.2
|
||||
output_price: 2.5
|
||||
cache_read_price: 0.05
|
||||
max_output_tokens: 32768
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
@@ -10431,7 +10414,7 @@
|
||||
max_input_tokens: 1048576
|
||||
input_price: 1.2
|
||||
output_price: 4.0
|
||||
cache_read_price: 0.12
|
||||
cache_read_price: 0.2
|
||||
max_output_tokens: 131072
|
||||
supports_function_calling: true
|
||||
reasoning_levels:
|
||||
|
||||
@@ -327,6 +327,7 @@ pub struct RequestContext {
|
||||
/// Set while a graph LLM node with `mcp_tools` is executing; re-applied as
|
||||
/// the last filter layer by every `refresh_mcp_tool_filters` recompute.
|
||||
pub active_node_mcp_tools: Option<(String, IndexMap<String, Vec<String>>)>,
|
||||
pub in_graph_llm_node: bool,
|
||||
|
||||
pub supervisor: Option<Arc<RwLock<Supervisor>>>,
|
||||
pub parent_supervisor: Option<Arc<RwLock<Supervisor>>>,
|
||||
@@ -378,6 +379,7 @@ impl RequestContext {
|
||||
declared_function_names: Default::default(),
|
||||
node_job_scope: None,
|
||||
active_node_mcp_tools: None,
|
||||
in_graph_llm_node: false,
|
||||
supervisor: None,
|
||||
parent_supervisor: None,
|
||||
self_agent_id: None,
|
||||
@@ -448,6 +450,7 @@ impl RequestContext {
|
||||
declared_function_names: Default::default(),
|
||||
node_job_scope: None,
|
||||
active_node_mcp_tools: None,
|
||||
in_graph_llm_node: false,
|
||||
supervisor: None,
|
||||
parent_supervisor: None,
|
||||
self_agent_id: None,
|
||||
@@ -507,6 +510,7 @@ impl RequestContext {
|
||||
declared_function_names: self.declared_function_names.clone(),
|
||||
node_job_scope: None,
|
||||
active_node_mcp_tools: self.active_node_mcp_tools.clone(),
|
||||
in_graph_llm_node: self.in_graph_llm_node,
|
||||
supervisor: self.supervisor.clone(),
|
||||
parent_supervisor: self.parent_supervisor.clone(),
|
||||
self_agent_id: self.self_agent_id.clone(),
|
||||
@@ -562,6 +566,7 @@ impl RequestContext {
|
||||
declared_function_names: Default::default(),
|
||||
node_job_scope: None,
|
||||
active_node_mcp_tools: None,
|
||||
in_graph_llm_node: false,
|
||||
supervisor: None,
|
||||
parent_supervisor: parent.supervisor.clone(),
|
||||
self_agent_id: Some(self_agent_id),
|
||||
@@ -2485,10 +2490,11 @@ impl RequestContext {
|
||||
.declarations()
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
(v.name.starts_with(USER_FUNCTION_PREFIX)
|
||||
((!self.in_graph_llm_node && v.name.starts_with(USER_FUNCTION_PREFIX))
|
||||
|| (!matches!(role.skills_enabled(), Some(false))
|
||||
&& v.name.starts_with(SKILL_FUNCTION_PREFIX))
|
||||
|| (self.auto_continue_config().enabled
|
||||
|| (!self.in_graph_llm_node
|
||||
&& self.auto_continue_config().enabled
|
||||
&& v.name.starts_with(TODO_FUNCTION_PREFIX))
|
||||
|| v.name.starts_with(RAG_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(JOB_FUNCTION_PREFIX))
|
||||
@@ -2510,15 +2516,17 @@ impl RequestContext {
|
||||
|
||||
if let Some(ref tool_names) = role_filter {
|
||||
agent_functions.retain(|v| {
|
||||
tool_names.contains(&v.name)
|
||||
!(self.in_graph_llm_node && v.name.starts_with(TODO_FUNCTION_PREFIX))
|
||||
&& (tool_names.contains(&v.name)
|
||||
|| (!matches!(agent.skills_enabled(), Some(false))
|
||||
&& v.name.starts_with(SKILL_FUNCTION_PREFIX))
|
||||
|| v.name.starts_with(USER_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(TODO_FUNCTION_PREFIX)
|
||||
|| (!self.in_graph_llm_node
|
||||
&& (v.name.starts_with(USER_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(TODO_FUNCTION_PREFIX)))
|
||||
|| v.name.starts_with(AGENT_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(MEMORY_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(RAG_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(JOB_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(JOB_FUNCTION_PREFIX))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6436,6 +6444,19 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_for_branch_copies_in_graph_llm_node() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.in_graph_llm_node = true;
|
||||
|
||||
let branch = ctx.fork_for_branch();
|
||||
|
||||
assert!(
|
||||
branch.in_graph_llm_node,
|
||||
"parallel map branches inside a node must keep the node's tool gating"
|
||||
);
|
||||
}
|
||||
|
||||
fn app_state_with_mcp_config(mcp_server_support: bool, server_names: &[&str]) -> Arc<AppState> {
|
||||
app_state_with_mcp_command(mcp_server_support, server_names, "echo")
|
||||
}
|
||||
@@ -7033,6 +7054,209 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn select_functions_graph_llm_node_gates_user_and_todo_tools_under_agent_filter() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let mut ctx = create_test_ctx();
|
||||
let app = ctx.app.config.clone();
|
||||
let agent_name = format!(
|
||||
"test_node_gate_agent_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
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\nauto_continue: true\ncan_spawn_agents: true\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let abort = utils::create_abort_signal();
|
||||
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["foo".to_string()]));
|
||||
|
||||
ctx.in_graph_llm_node = true;
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(
|
||||
!names.iter().any(|n| n.starts_with("user__")),
|
||||
"user__ tools must not leak into a graph llm node, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.iter().any(|n| n.starts_with("todo__")),
|
||||
"todo__ tools must not leak into a graph llm node, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"agent__spawn"),
|
||||
"agent__ tools keep implicit availability inside a node, got: {names:?}"
|
||||
);
|
||||
|
||||
ctx.in_graph_llm_node = false;
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"user__select"),
|
||||
"outside a node, subagent escalation via user__ must keep working, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"todo__init"),
|
||||
"outside a node, todo__ tools must survive the agent filter, got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn select_functions_graph_llm_node_user_tool_opt_in_is_exact() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let mut ctx = create_test_ctx();
|
||||
let app = ctx.app.config.clone();
|
||||
let agent_name = format!(
|
||||
"test_node_optin_agent_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
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();
|
||||
|
||||
let abort = utils::create_abort_signal();
|
||||
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["user__select".to_string()]));
|
||||
|
||||
ctx.in_graph_llm_node = true;
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"user__select"),
|
||||
"an explicitly listed user tool must be offered, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"user__confirm"),
|
||||
"opt-in must not drag in sibling user tools, got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn select_functions_graph_llm_node_todo_tools_cannot_be_opted_in() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let mut ctx = create_test_ctx();
|
||||
let app = ctx.app.config.clone();
|
||||
let agent_name = format!(
|
||||
"test_node_todo_optin_agent_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
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\nauto_continue: true\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let abort = utils::create_abort_signal();
|
||||
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec![
|
||||
"todo__init".to_string(),
|
||||
"user__select".to_string(),
|
||||
]));
|
||||
|
||||
ctx.in_graph_llm_node = true;
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(
|
||||
!names.iter().any(|n| n.starts_with("todo__")),
|
||||
"todo tools must be denied inside a node even when explicitly listed, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"user__select"),
|
||||
"an explicitly listed user tool must still be offered, got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_functions_all_enabled_tools_exposes_user_tools_despite_node_flag() {
|
||||
// `all` is a role/session `enabled_tools` semantic — graph llm nodes
|
||||
// cannot declare it (validate_tools_subset rejects it at run()). The
|
||||
// flag being set documents that the `all` expansion path bypasses the
|
||||
// in-node gate by design.
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope.functions.append_user_interaction_functions();
|
||||
ctx.in_graph_llm_node = true;
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["all".to_string()]));
|
||||
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"user__select"),
|
||||
"`all` enabled_tools must expose user tools even inside a node, got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_functions_graph_llm_node_suppresses_user_tools_without_agent() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope.functions.append_user_interaction_functions();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["foo".to_string()]));
|
||||
|
||||
ctx.in_graph_llm_node = true;
|
||||
assert!(
|
||||
ctx.select_functions(&role).is_none(),
|
||||
"user__ tools must not leak into a graph llm node without an agent"
|
||||
);
|
||||
|
||||
ctx.in_graph_llm_node = false;
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"user__select"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_functions_graph_llm_node_suppresses_todo_tools_without_agent() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.update_app_config(|app| app.auto_continue = true);
|
||||
ctx.tool_scope.functions.append_todo_functions();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["foo".to_string()]));
|
||||
|
||||
ctx.in_graph_llm_node = true;
|
||||
assert!(
|
||||
ctx.select_functions(&role).is_none(),
|
||||
"todo__ tools must not leak into a graph llm node without an agent"
|
||||
);
|
||||
|
||||
ctx.in_graph_llm_node = false;
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"todo__init"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn select_functions_preserves_job_tools_under_agent_filter() {
|
||||
|
||||
@@ -126,6 +126,16 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value)
|
||||
if !ctx.app.config.function_calling_support {
|
||||
bail!("Cannot use todo tools: function calling is disabled.");
|
||||
}
|
||||
if ctx.in_graph_llm_node {
|
||||
return Ok(json!({
|
||||
"error": "todo tools are unavailable inside graph llm nodes: they drive chat turn \
|
||||
loops (auto_continue), and llm nodes have no turn loop. Node \
|
||||
continuation is owned by the node loop, max_iterations, and graph \
|
||||
edges. For driven multi-step work, use an agent node whose agent sets \
|
||||
auto_continue: true."
|
||||
}));
|
||||
}
|
||||
|
||||
let auto_config = ctx.auto_continue_config();
|
||||
if !auto_config.enabled {
|
||||
bail!(
|
||||
@@ -245,4 +255,28 @@ mod tests {
|
||||
assert!(error.contains("requires an active session"));
|
||||
assert!(ctx.auto_continue_paused.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_llm_node_returns_teaching_error() {
|
||||
let mut app_state = AppState::test_default();
|
||||
app_state.config = Arc::new(AppConfig {
|
||||
auto_continue: true,
|
||||
..AppConfig::default()
|
||||
});
|
||||
let mut ctx = RequestContext::new(Arc::new(app_state), WorkingMode::Cmd);
|
||||
ctx.in_graph_llm_node = true;
|
||||
|
||||
let result = handle_todo_tool(&mut ctx, "todo__init", &json!({"goal": "g"})).unwrap();
|
||||
|
||||
let error = result["error"].as_str().unwrap();
|
||||
assert!(error.contains("graph llm nodes"));
|
||||
assert!(
|
||||
ctx.todo_list.is_default(),
|
||||
"the guard must fire before any todo state mutation (goal included)"
|
||||
);
|
||||
|
||||
ctx.in_graph_llm_node = false;
|
||||
let result = handle_todo_tool(&mut ctx, "todo__init", &json!({"goal": "g"})).unwrap();
|
||||
assert_eq!(result["status"], "ok");
|
||||
}
|
||||
}
|
||||
+101
-9
@@ -12,6 +12,7 @@ use crate::function::ToolResult;
|
||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||
use crate::function::jobs::reap_jobs;
|
||||
use crate::function::skill::skill_function_declarations;
|
||||
use crate::function::todo::TODO_FUNCTION_PREFIX;
|
||||
use crate::utils::AbortSignal;
|
||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||
use log::warn;
|
||||
@@ -38,7 +39,15 @@ impl LlmNodeExecutor {
|
||||
parent_ctx: &mut RequestContext,
|
||||
abort: &AbortSignal,
|
||||
) -> Result<LlmExecutionOutcome> {
|
||||
let result = run(node_id, node, state_manager, parent_ctx, abort).await;
|
||||
let result = run(
|
||||
node_id,
|
||||
node,
|
||||
state_manager,
|
||||
parent_ctx,
|
||||
abort,
|
||||
&mut default_completion_runner(),
|
||||
)
|
||||
.await;
|
||||
finish(node, state_manager, parent_ctx, result, abort).await
|
||||
}
|
||||
}
|
||||
@@ -94,12 +103,15 @@ fn outcome_from(
|
||||
}
|
||||
}
|
||||
|
||||
/// The runner is a parameter for the same reason `finish` is split out:
|
||||
/// tests drive the node scope without a model.
|
||||
async fn run(
|
||||
node_id: &str,
|
||||
node: &LlmNode,
|
||||
state_manager: &mut StateManager,
|
||||
parent_ctx: &mut RequestContext,
|
||||
abort: &AbortSignal,
|
||||
runner: &mut CompletionRunner<'_>,
|
||||
) -> Result<String> {
|
||||
let mut instructions: Option<String> = match &node.instructions {
|
||||
Some(s) => Some(
|
||||
@@ -202,14 +214,11 @@ async fn run(
|
||||
node.mcp_tools.clone().map(|map| (node_id.to_string(), map)),
|
||||
);
|
||||
parent_ctx.refresh_mcp_tool_filters();
|
||||
let result = bounded_run(
|
||||
node,
|
||||
&prompt,
|
||||
parent_ctx,
|
||||
abort,
|
||||
&mut default_completion_runner(),
|
||||
)
|
||||
.await;
|
||||
// Inside the node, user__/todo__ builtins require explicit opt-in via the
|
||||
// node's `tools:` list; mem::replace so nested execution restores cleanly.
|
||||
let saved_in_node = std::mem::replace(&mut parent_ctx.in_graph_llm_node, true);
|
||||
let result = bounded_run(node, &prompt, parent_ctx, abort, runner).await;
|
||||
parent_ctx.in_graph_llm_node = saved_in_node;
|
||||
parent_ctx.role = saved_role;
|
||||
let node_jobs =
|
||||
std::mem::replace(&mut parent_ctx.node_job_scope, saved_job_scope).unwrap_or_default();
|
||||
@@ -477,6 +486,14 @@ fn validate_tools_subset(
|
||||
.map(|d| d.name.as_str())
|
||||
.collect();
|
||||
for name in regular {
|
||||
if name.starts_with(TODO_FUNCTION_PREFIX) {
|
||||
bail!(
|
||||
"llm node cannot enable '{name}': todo tools drive chat turn loops \
|
||||
(auto_continue), which llm nodes don't have. Node continuation is \
|
||||
owned by the node loop, max_iterations, and graph edges. For driven \
|
||||
multi-step work, use an agent node whose agent sets auto_continue: true."
|
||||
);
|
||||
}
|
||||
if !known.contains(name.as_str()) {
|
||||
let mut avail: Vec<&str> = known.iter().copied().collect();
|
||||
avail.sort();
|
||||
@@ -786,6 +803,24 @@ mod tests {
|
||||
assert!(mcp.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_tools_subset_rejects_todo_tools() {
|
||||
let mut ctx = plain_ctx();
|
||||
let mut agent = Agent::test_new(AgentConfig {
|
||||
auto_continue: true,
|
||||
..AgentConfig::default()
|
||||
});
|
||||
agent.functions_mut().append_todo_functions();
|
||||
ctx.agent = Some(agent);
|
||||
|
||||
let err = validate_tools_subset(&["todo__init".to_string()], &[], &ctx).unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("todo tools drive chat turn loops"),
|
||||
"expected the todo teaching error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_timeout_resolves_to_no_bound() {
|
||||
assert!(Some(0u64).and_then(wall_clock).is_none());
|
||||
@@ -1205,4 +1240,61 @@ mod tests {
|
||||
"{captured}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_scope_flag_is_set_during_the_run_and_restored_on_ok() {
|
||||
let node = node_with(None);
|
||||
let mut state = manager_with(&[]);
|
||||
let mut ctx = plain_ctx();
|
||||
ctx.agent = Some(Agent::test_new(AgentConfig::default()));
|
||||
let abort = create_abort_signal();
|
||||
|
||||
let out = run(
|
||||
"n",
|
||||
&node,
|
||||
&mut state,
|
||||
&mut ctx,
|
||||
&abort,
|
||||
&mut completion_runner(|_input, ctx, _abort| {
|
||||
assert!(
|
||||
ctx.in_graph_llm_node,
|
||||
"flag must be set while the node runs"
|
||||
);
|
||||
boxed_completion(async { Ok(("done".to_string(), vec![])) })
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out, "done");
|
||||
assert!(!ctx.in_graph_llm_node, "flag must not outlive the node");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_scope_flag_restores_prior_value_when_the_run_fails() {
|
||||
let node = node_with(None);
|
||||
let mut state = manager_with(&[]);
|
||||
let mut ctx = plain_ctx();
|
||||
ctx.agent = Some(Agent::test_new(AgentConfig::default()));
|
||||
ctx.in_graph_llm_node = true;
|
||||
let abort = create_abort_signal();
|
||||
|
||||
run(
|
||||
"n",
|
||||
&node,
|
||||
&mut state,
|
||||
&mut ctx,
|
||||
&abort,
|
||||
&mut completion_runner(|_input, _ctx, _abort| {
|
||||
boxed_completion(async { Err(anyhow!("boom")) })
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("the runner failure must surface");
|
||||
|
||||
assert!(
|
||||
ctx.in_graph_llm_node,
|
||||
"a nested run must restore the outer scope's flag"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ use super::types::{ConcurrencyCap, Graph, NextTargets, Node, NodeType};
|
||||
use crate::client::{Model, ModelType};
|
||||
use crate::config;
|
||||
use crate::config::{Agent, AppConfig, paths};
|
||||
use crate::function::todo::TODO_FUNCTION_PREFIX;
|
||||
use crate::function::user_interaction::USER_FUNCTION_PREFIX;
|
||||
use crate::rag::{GraphRagConfig, RagData};
|
||||
use anyhow::{Result, bail};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
@@ -179,6 +181,8 @@ impl GraphValidator {
|
||||
self.validate_max_attempts(graph, &mut result);
|
||||
self.validate_fallback_capture(graph, &mut result);
|
||||
self.validate_output_schema_properties(graph, &mut result);
|
||||
self.validate_structured_output_user_tools(graph, &mut result);
|
||||
self.validate_llm_node_todo_tools(graph, &mut result);
|
||||
self.validate_max_concurrency(graph, &mut result);
|
||||
self.validate_max_concurrency_template(graph, &mut result);
|
||||
self.validate_orchestration_limits(graph, &mut result);
|
||||
@@ -458,6 +462,51 @@ impl GraphValidator {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_structured_output_user_tools(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||
for (node_id, node) in &graph.nodes {
|
||||
let NodeType::Llm(llm) = &node.node_type else {
|
||||
continue;
|
||||
};
|
||||
if llm.output_schema.is_none() {
|
||||
continue;
|
||||
}
|
||||
let exposes_user_tools = llm.tools.as_ref().is_some_and(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.any(|t| t.trim().starts_with(USER_FUNCTION_PREFIX))
|
||||
});
|
||||
if exposes_user_tools {
|
||||
result.warning(ValidationError::with_node(
|
||||
node_id,
|
||||
"structured-output llm node exposes interactive user tools (`user__*`); \
|
||||
in headless runs a user__ call blocks the node as an escalation",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_llm_node_todo_tools(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||
for (node_id, node) in &graph.nodes {
|
||||
let NodeType::Llm(llm) = &node.node_type else {
|
||||
continue;
|
||||
};
|
||||
let exposes_todo_tools = llm.tools.as_ref().is_some_and(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.any(|t| t.trim().starts_with(TODO_FUNCTION_PREFIX))
|
||||
});
|
||||
if exposes_todo_tools {
|
||||
result.error(ValidationError::with_node(
|
||||
node_id,
|
||||
"llm node exposes todo tools (`todo__*`): todo tools drive chat turn \
|
||||
loops (auto_continue), which llm nodes don't have. Node continuation \
|
||||
is owned by the node loop, max_iterations, and graph edges. For driven \
|
||||
multi-step work, use an agent node whose agent sets `auto_continue: true`",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_node_references(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||
for (node_id, node) in &graph.nodes {
|
||||
for (target, label) in declared_targets(node) {
|
||||
@@ -4685,6 +4734,151 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_output_node_with_user_tools_warns() {
|
||||
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
|
||||
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||
n.tools = Some(vec!["user__select".into()]);
|
||||
}
|
||||
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(
|
||||
result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|e| e.node_id.as_deref() == Some("l")
|
||||
&& e.message.contains("interactive user tools")),
|
||||
"expected user-tool warning: {:?}",
|
||||
result.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_output_node_with_all_tools_does_not_warn() {
|
||||
// `all` is not a valid llm-node tools entry (validate_tools_subset
|
||||
// rejects it at run()), so this advisory stays silent about it.
|
||||
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
|
||||
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||
n.tools = Some(vec!["all".into()]);
|
||||
}
|
||||
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(
|
||||
!result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|e| e.message.contains("interactive user tools")),
|
||||
"unexpected user-tool warning: {:?}",
|
||||
result.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_output_node_without_user_tools_does_not_warn() {
|
||||
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
|
||||
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||
n.tools = Some(vec![]);
|
||||
}
|
||||
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(
|
||||
!result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|e| e.message.contains("interactive user tools")),
|
||||
"unexpected user-tool warning: {:?}",
|
||||
result.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_structured_node_with_user_tools_does_not_warn() {
|
||||
let mut node = llm_node("l", None, Some("end"));
|
||||
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||
n.tools = Some(vec!["user__select".into()]);
|
||||
}
|
||||
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(
|
||||
!result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|e| e.message.contains("interactive user tools")),
|
||||
"unexpected user-tool warning: {:?}",
|
||||
result.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_output_node_with_todo_tools_errors() {
|
||||
let mut node = llm_with_output_schema("l", &["verdict"], Some("end"));
|
||||
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||
n.tools = Some(vec!["todo__init".into()]);
|
||||
}
|
||||
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(
|
||||
result
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.node_id.as_deref() == Some("l")
|
||||
&& e.message.contains("todo tools drive chat turn loops")),
|
||||
"expected todo-tool error: {:?}",
|
||||
result.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_structured_node_with_todo_tools_errors() {
|
||||
let mut node = llm_node("l", None, Some("end"));
|
||||
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||
n.tools = Some(vec!["todo__pause".into()]);
|
||||
}
|
||||
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(
|
||||
result
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.node_id.as_deref() == Some("l")
|
||||
&& e.message.contains("todo tools drive chat turn loops")),
|
||||
"expected todo-tool error: {:?}",
|
||||
result.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_node_without_todo_tools_does_not_error() {
|
||||
let mut node = llm_node("l", None, Some("end"));
|
||||
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||
n.tools = Some(vec!["user__select".into()]);
|
||||
}
|
||||
let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l");
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(
|
||||
!result
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message.contains("todo tools drive chat turn loops")),
|
||||
"unexpected todo-tool error: {:?}",
|
||||
result.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_with_output_schema_and_empty_state_updates_warns() {
|
||||
let mut node = llm_with_output_schema("l", &["summary"], Some("end"));
|
||||
|
||||
Reference in new issue
Block a user