Skip to main content

adk_graph/
agent.rs

1//! GraphAgent - ADK Agent integration for graph workflows
2//!
3//! Provides a builder pattern similar to LlmAgent and RealtimeAgent.
4
5use crate::checkpoint::Checkpointer;
6use crate::deferred::DeferredNodeConfig;
7use crate::edge::{END, Edge, EdgeTarget, START};
8use crate::error::{GraphError, Result};
9use crate::graph::{CompiledGraph, StateGraph};
10use crate::node::{ExecutionConfig, FunctionNode, Node, NodeContext, NodeOutput};
11use crate::state::{State, StateSchema};
12use crate::stream::{StreamEvent, StreamMode};
13use crate::timeout::TimeoutPolicy;
14use adk_core::{Agent, Content, Event, EventStream, InvocationContext};
15use async_trait::async_trait;
16use serde_json::json;
17use std::collections::HashMap;
18use std::future::Future;
19use std::pin::Pin;
20use std::sync::Arc;
21
22/// Type alias for callbacks
23pub type BeforeAgentCallback = Arc<
24    dyn Fn(Arc<dyn InvocationContext>) -> Pin<Box<dyn Future<Output = adk_core::Result<()>> + Send>>
25        + Send
26        + Sync,
27>;
28
29pub type AfterAgentCallback = Arc<
30    dyn Fn(
31            Arc<dyn InvocationContext>,
32            Event,
33        ) -> Pin<Box<dyn Future<Output = adk_core::Result<()>> + Send>>
34        + Send
35        + Sync,
36>;
37
38/// Type alias for input mapper function
39pub type InputMapper = Arc<dyn Fn(&dyn InvocationContext) -> State + Send + Sync>;
40
41/// Type alias for output mapper function
42pub type OutputMapper = Arc<dyn Fn(&State) -> Vec<Event> + Send + Sync>;
43
44/// GraphAgent wraps a CompiledGraph as an ADK Agent
45pub struct GraphAgent {
46    name: String,
47    description: String,
48    graph: Arc<CompiledGraph>,
49    /// Map InvocationContext to graph input state
50    input_mapper: InputMapper,
51    /// Map graph output state to ADK Events
52    output_mapper: OutputMapper,
53    /// Before agent callback
54    before_callback: Option<BeforeAgentCallback>,
55    /// After agent callback
56    after_callback: Option<AfterAgentCallback>,
57}
58
59impl GraphAgent {
60    /// Create a new GraphAgent builder
61    pub fn builder(name: &str) -> GraphAgentBuilder {
62        GraphAgentBuilder::new(name)
63    }
64
65    /// Create directly from a compiled graph
66    pub fn from_graph(name: &str, graph: CompiledGraph) -> Self {
67        Self {
68            name: name.to_string(),
69            description: String::new(),
70            graph: Arc::new(graph),
71            input_mapper: Arc::new(default_input_mapper),
72            output_mapper: Arc::new(default_output_mapper),
73            before_callback: None,
74            after_callback: None,
75        }
76    }
77
78    /// Build a `GraphAgent` from a `WorkflowSchema`.
79    ///
80    /// Delegates to `schema.build_graph()` to construct the graph from the
81    /// workflow schema's action nodes, edges, and conditions.
82    #[cfg(feature = "action")]
83    pub fn from_workflow_schema(
84        name: &str,
85        schema: &crate::workflow::WorkflowSchema,
86    ) -> Result<Self> {
87        schema.build_graph(name)
88    }
89
90    /// Get the underlying compiled graph
91    pub fn graph(&self) -> &CompiledGraph {
92        &self.graph
93    }
94
95    /// Execute the graph directly (bypassing Agent trait)
96    pub async fn invoke(&self, input: State, config: ExecutionConfig) -> Result<State> {
97        self.graph.invoke(input, config).await
98    }
99
100    /// Stream execution
101    pub fn stream(
102        &self,
103        input: State,
104        config: ExecutionConfig,
105        mode: StreamMode,
106    ) -> impl futures::Stream<Item = Result<StreamEvent>> + '_ {
107        self.graph.stream(input, config, mode)
108    }
109}
110
111#[async_trait]
112impl Agent for GraphAgent {
113    fn name(&self) -> &str {
114        &self.name
115    }
116
117    fn description(&self) -> &str {
118        &self.description
119    }
120
121    fn sub_agents(&self) -> &[Arc<dyn Agent>] {
122        &[]
123    }
124
125    async fn run(&self, ctx: Arc<dyn InvocationContext>) -> adk_core::Result<EventStream> {
126        // Call before callback
127        if let Some(callback) = &self.before_callback {
128            callback(ctx.clone()).await?;
129        }
130
131        // Map context to input state
132        let input = (self.input_mapper)(ctx.as_ref());
133
134        // Create execution config from context, carrying the invocation so an
135        // `AgentNode` inside the graph presents this run's identity, services, and
136        // cancellation rather than a synthetic standalone context.
137        let config = ExecutionConfig::new(ctx.session_id()).with_parent_context(ctx.clone());
138
139        // Execute graph
140        let graph = self.graph.clone();
141        let output_mapper = self.output_mapper.clone();
142        let after_callback = self.after_callback.clone();
143        let ctx_clone = ctx.clone();
144
145        let stream = async_stream::stream! {
146            match graph.invoke(input, config).await {
147                Ok(state) => {
148                    let events = output_mapper(&state);
149                    for event in events {
150                        // Call after callback for each event
151                        if let Some(callback) = &after_callback
152                            && let Err(e) = callback(ctx_clone.clone(), event.clone()).await {
153                                yield Err(e);
154                                return;
155                            }
156                        yield Ok(event);
157                    }
158                }
159                Err(GraphError::Interrupted(interrupt)) => {
160                    // The `Agent` trait yields events, so an interrupt cannot be
161                    // returned as an error without ending the invocation. Emit one
162                    // event carrying the structured pause so a caller can read the
163                    // node, the payload, and the checkpoint to resume from.
164                    let payload = crate::interrupt::GraphInterruptPayload::new(
165                        &interrupt.interrupt,
166                        &interrupt.thread_id,
167                        &interrupt.checkpoint_id,
168                    );
169                    let mut event = Event::new("graph_interrupted");
170                    event.set_content(
171                        Content::new("assistant").with_text(interrupt.interrupt.to_string()),
172                    );
173                    event.provider_metadata.insert(
174                        crate::interrupt::INTERRUPT_METADATA_KEY.to_string(),
175                        payload.to_metadata_value(),
176                    );
177                    yield Ok(event);
178                }
179                Err(e) => {
180                    yield Err(adk_core::AdkError::agent(e.to_string()));
181                }
182            }
183        };
184
185        Ok(Box::pin(stream))
186    }
187}
188
189/// Default input mapper - extracts content from InvocationContext
190fn default_input_mapper(ctx: &dyn InvocationContext) -> State {
191    let mut state = State::new();
192
193    // Get user content
194    let content = ctx.user_content();
195    let text: String = content.parts.iter().filter_map(|p| p.text()).collect::<Vec<_>>().join("\n");
196
197    if !text.is_empty() {
198        state.insert("input".to_string(), json!(text));
199        state.insert("messages".to_string(), json!([{"role": "user", "content": text}]));
200    }
201
202    // Add session ID
203    state.insert("session_id".to_string(), json!(ctx.session_id()));
204
205    state
206}
207
208/// Default output mapper - creates events from state
209fn default_output_mapper(state: &State) -> Vec<Event> {
210    let mut events = Vec::new();
211
212    // Try to get output from common fields
213    let output_text = state
214        .get("output")
215        .and_then(|v| v.as_str())
216        .or_else(|| state.get("result").and_then(|v| v.as_str()))
217        .or_else(|| {
218            state
219                .get("messages")
220                .and_then(|v| v.as_array())
221                .and_then(|arr| arr.last())
222                .and_then(|msg| msg.get("content"))
223                .and_then(|c| c.as_str())
224        });
225
226    let text = if let Some(text) = output_text {
227        text.to_string()
228    } else {
229        // Return the full state as JSON
230        serde_json::to_string_pretty(state).unwrap_or_default()
231    };
232
233    let mut event = Event::new("graph_output");
234    event.set_content(Content::new("assistant").with_text(&text));
235    events.push(event);
236
237    events
238}
239
240/// Builder for GraphAgent
241pub struct GraphAgentBuilder {
242    name: String,
243    description: String,
244    schema: StateSchema,
245    nodes: Vec<Arc<dyn Node>>,
246    edges: Vec<Edge>,
247    checkpointer: Option<Arc<dyn Checkpointer>>,
248    interrupt_before: Vec<String>,
249    interrupt_after: Vec<String>,
250    recursion_limit: usize,
251    max_concurrency: Option<usize>,
252    input_mapper: Option<InputMapper>,
253    output_mapper: Option<OutputMapper>,
254    before_callback: Option<BeforeAgentCallback>,
255    after_callback: Option<AfterAgentCallback>,
256    timeout_policies: HashMap<String, TimeoutPolicy>,
257    default_timeout: Option<TimeoutPolicy>,
258    deferred_configs: HashMap<String, DeferredNodeConfig>,
259    #[cfg(feature = "node-cache")]
260    cache_policies: HashMap<String, crate::cache::NodeCachePolicy>,
261}
262
263impl GraphAgentBuilder {
264    /// Create a new builder
265    pub fn new(name: &str) -> Self {
266        Self {
267            name: name.to_string(),
268            description: String::new(),
269            schema: StateSchema::simple(&["input", "output", "messages"]),
270            nodes: vec![],
271            edges: vec![],
272            checkpointer: None,
273            interrupt_before: vec![],
274            interrupt_after: vec![],
275            recursion_limit: 100,
276            max_concurrency: None,
277            input_mapper: None,
278            output_mapper: None,
279            before_callback: None,
280            after_callback: None,
281            timeout_policies: HashMap::new(),
282            default_timeout: None,
283            deferred_configs: HashMap::new(),
284            #[cfg(feature = "node-cache")]
285            cache_policies: HashMap::new(),
286        }
287    }
288
289    /// Set description
290    pub fn description(mut self, desc: &str) -> Self {
291        self.description = desc.to_string();
292        self
293    }
294
295    /// Set state schema
296    pub fn state_schema(mut self, schema: StateSchema) -> Self {
297        self.schema = schema;
298        self
299    }
300
301    /// Add channels to state schema
302    pub fn channels(mut self, channels: &[&str]) -> Self {
303        self.schema = StateSchema::simple(channels);
304        self
305    }
306
307    /// Add a node
308    pub fn node<N: Node + 'static>(mut self, node: N) -> Self {
309        self.nodes.push(Arc::new(node));
310        self
311    }
312
313    /// Add a function as a node
314    pub fn node_fn<F, Fut>(mut self, name: &str, func: F) -> Self
315    where
316        F: Fn(NodeContext) -> Fut + Send + Sync + 'static,
317        Fut: Future<Output = Result<NodeOutput>> + Send + 'static,
318    {
319        self.nodes.push(Arc::new(FunctionNode::new(name, func)));
320        self
321    }
322
323    /// Add a direct edge
324    pub fn edge(mut self, source: &str, target: &str) -> Self {
325        let target =
326            if target == END { EdgeTarget::End } else { EdgeTarget::Node(target.to_string()) };
327
328        if source == START {
329            let entry_idx = self.edges.iter().position(|e| matches!(e, Edge::Entry { .. }));
330            match entry_idx {
331                Some(idx) => {
332                    if let Edge::Entry { targets } = &mut self.edges[idx]
333                        && let EdgeTarget::Node(node) = &target
334                        && !targets.contains(node)
335                    {
336                        targets.push(node.clone());
337                    }
338                }
339                None => {
340                    if let EdgeTarget::Node(node) = target {
341                        self.edges.push(Edge::Entry { targets: vec![node] });
342                    }
343                }
344            }
345        } else {
346            self.edges.push(Edge::Direct { source: source.to_string(), target });
347        }
348
349        self
350    }
351
352    /// Add a conditional edge
353    pub fn conditional_edge<F, I>(mut self, source: &str, router: F, targets: I) -> Self
354    where
355        F: Fn(&State) -> String + Send + Sync + 'static,
356        I: IntoIterator<Item = (&'static str, &'static str)>,
357    {
358        let targets_map: HashMap<String, EdgeTarget> = targets
359            .into_iter()
360            .map(|(k, v)| {
361                let target =
362                    if v == END { EdgeTarget::End } else { EdgeTarget::Node(v.to_string()) };
363                (k.to_string(), target)
364            })
365            .collect();
366
367        self.edges.push(Edge::Conditional {
368            source: source.to_string(),
369            router: Arc::new(router),
370            targets: targets_map,
371        });
372
373        self
374    }
375
376    /// Set checkpointer
377    pub fn checkpointer<C: Checkpointer + 'static>(mut self, checkpointer: C) -> Self {
378        self.checkpointer = Some(Arc::new(checkpointer));
379        self
380    }
381
382    /// Set checkpointer with Arc
383    pub fn checkpointer_arc(mut self, checkpointer: Arc<dyn Checkpointer>) -> Self {
384        self.checkpointer = Some(checkpointer);
385        self
386    }
387
388    /// Set nodes to interrupt before
389    pub fn interrupt_before(mut self, nodes: &[&str]) -> Self {
390        self.interrupt_before = nodes.iter().map(|s| s.to_string()).collect();
391        self
392    }
393
394    /// Set nodes to interrupt after
395    pub fn interrupt_after(mut self, nodes: &[&str]) -> Self {
396        self.interrupt_after = nodes.iter().map(|s| s.to_string()).collect();
397        self
398    }
399
400    /// Set recursion limit
401    /// Cap how many nodes execute concurrently within one super-step.
402    ///
403    /// See [`CompiledGraph::with_max_concurrency`](crate::graph::CompiledGraph::with_max_concurrency).
404    pub fn max_concurrency(mut self, limit: usize) -> Self {
405        self.max_concurrency = Some(limit.max(1));
406        self
407    }
408
409    pub fn recursion_limit(mut self, limit: usize) -> Self {
410        self.recursion_limit = limit;
411        self
412    }
413
414    /// Set a timeout policy for a specific node.
415    ///
416    /// The policy is applied when the named node executes, enforcing
417    /// wall-clock and/or idle timeouts with the configured recovery action.
418    ///
419    /// # Example
420    ///
421    /// ```rust,ignore
422    /// use std::time::Duration;
423    /// use adk_graph::timeout::{TimeoutPolicy, OnTimeout};
424    ///
425    /// let agent = GraphAgent::builder("my_graph")
426    ///     .node_timeout("slow_node", TimeoutPolicy {
427    ///         run_timeout: Some(Duration::from_secs(10)),
428    ///         idle_timeout: None,
429    ///         on_timeout: OnTimeout::Fail,
430    ///     })
431    ///     .build()?;
432    /// ```
433    pub fn node_timeout(mut self, node_name: &str, policy: TimeoutPolicy) -> Self {
434        self.timeout_policies.insert(node_name.to_string(), policy);
435        self
436    }
437
438    /// Set a default timeout policy applied to all nodes without an explicit override.
439    ///
440    /// Nodes that have a per-node policy set via [`node_timeout`](Self::node_timeout)
441    /// will use their specific policy instead of this default.
442    ///
443    /// # Example
444    ///
445    /// ```rust,ignore
446    /// use std::time::Duration;
447    /// use adk_graph::timeout::{TimeoutPolicy, OnTimeout};
448    ///
449    /// let agent = GraphAgent::builder("my_graph")
450    ///     .default_timeout(TimeoutPolicy {
451    ///         run_timeout: Some(Duration::from_secs(30)),
452    ///         idle_timeout: Some(Duration::from_secs(5)),
453    ///         on_timeout: OnTimeout::Skip,
454    ///     })
455    ///     .build()?;
456    /// ```
457    pub fn default_timeout(mut self, policy: TimeoutPolicy) -> Self {
458        self.default_timeout = Some(policy);
459        self
460    }
461
462    /// Add a deferred (fan-in barrier) node to the graph.
463    ///
464    /// A deferred node waits for all upstream parallel paths to complete before
465    /// executing. The provided function is wrapped as a [`FunctionNode`] and the
466    /// [`DeferredNodeConfig`] controls how upstream outputs are merged and how
467    /// long the node waits for all paths.
468    ///
469    /// # Arguments
470    ///
471    /// * `name` - The name of the deferred node.
472    /// * `func` - The async function to execute once all upstream paths complete.
473    /// * `config` - Configuration controlling merge strategy and fan-in timeout.
474    ///
475    /// # Example
476    ///
477    /// ```rust,ignore
478    /// use std::time::Duration;
479    /// use adk_graph::deferred::{DeferredNodeConfig, MergeStrategy};
480    /// use adk_graph::node::NodeOutput;
481    ///
482    /// let agent = GraphAgent::builder("scatter_gather")
483    ///     .deferred_node("aggregator", |_ctx| async {
484    ///         Ok(NodeOutput::new().with_update("status", serde_json::json!("merged")))
485    ///     }, DeferredNodeConfig {
486    ///         merge_strategy: MergeStrategy::Collect,
487    ///         fan_in_timeout: Some(Duration::from_secs(30)),
488    ///     })
489    ///     .build()?;
490    /// ```
491    /// Configure fan-in for a node already added with [`node`](Self::node).
492    ///
493    /// [`deferred_node`](Self::deferred_node) both adds and configures a node, so
494    /// a custom `Node` added through `node` had no way to set a merge strategy or
495    /// a fan-in timeout.
496    ///
497    /// A node reached by more than one unconditional edge is deferred
498    /// automatically; this overrides that default.
499    pub fn mark_deferred(mut self, name: &str, config: DeferredNodeConfig) -> Self {
500        self.deferred_configs.insert(name.to_string(), config);
501        self
502    }
503
504    pub fn deferred_node<F, Fut>(mut self, name: &str, func: F, config: DeferredNodeConfig) -> Self
505    where
506        F: Fn(NodeContext) -> Fut + Send + Sync + 'static,
507        Fut: Future<Output = Result<NodeOutput>> + Send + 'static,
508    {
509        self.nodes.push(Arc::new(FunctionNode::new(name, func)));
510        self.deferred_configs.insert(name.to_string(), config);
511        self
512    }
513
514    /// Set a cache policy for a specific node.
515    ///
516    /// When a node has a cache policy, its execution results are cached keyed
517    /// by a blake3 hash of the node name and input state. Subsequent executions
518    /// with identical inputs return the cached result without re-executing the
519    /// node.
520    ///
521    /// # Arguments
522    ///
523    /// * `name` — the name of the node to cache
524    /// * `policy` — the cache policy specifying backend and TTL
525    ///
526    /// # Example
527    ///
528    /// ```rust,ignore
529    /// use std::time::Duration;
530    /// use adk_graph::cache::{CacheBackend, NodeCachePolicy};
531    ///
532    /// let agent = GraphAgent::builder("cached_graph")
533    ///     .node_cache("expensive_node", NodeCachePolicy {
534    ///         backend: CacheBackend::InMemory { max_entries: 128 },
535    ///         ttl: Some(Duration::from_secs(300)),
536    ///     })
537    ///     .build()?;
538    /// ```
539    #[cfg(feature = "node-cache")]
540    pub fn node_cache(mut self, name: &str, policy: crate::cache::NodeCachePolicy) -> Self {
541        self.cache_policies.insert(name.to_string(), policy);
542        self
543    }
544
545    /// Set custom input mapper
546    pub fn input_mapper<F>(mut self, mapper: F) -> Self
547    where
548        F: Fn(&dyn InvocationContext) -> State + Send + Sync + 'static,
549    {
550        self.input_mapper = Some(Arc::new(mapper));
551        self
552    }
553
554    /// Set custom output mapper
555    pub fn output_mapper<F>(mut self, mapper: F) -> Self
556    where
557        F: Fn(&State) -> Vec<Event> + Send + Sync + 'static,
558    {
559        self.output_mapper = Some(Arc::new(mapper));
560        self
561    }
562
563    /// Set before agent callback
564    pub fn before_agent_callback<F, Fut>(mut self, callback: F) -> Self
565    where
566        F: Fn(Arc<dyn InvocationContext>) -> Fut + Send + Sync + 'static,
567        Fut: Future<Output = adk_core::Result<()>> + Send + 'static,
568    {
569        self.before_callback = Some(Arc::new(move |ctx| Box::pin(callback(ctx))));
570        self
571    }
572
573    /// Set after agent callback
574    ///
575    /// Note: The callback receives a cloned Event to avoid lifetime issues.
576    pub fn after_agent_callback<F, Fut>(mut self, callback: F) -> Self
577    where
578        F: Fn(Arc<dyn InvocationContext>, Event) -> Fut + Send + Sync + 'static,
579        Fut: Future<Output = adk_core::Result<()>> + Send + 'static,
580    {
581        self.after_callback = Some(Arc::new(move |ctx, event| {
582            let event_clone = event.clone();
583            Box::pin(callback(ctx, event_clone))
584        }));
585        self
586    }
587
588    /// Add an action node to the graph.
589    ///
590    /// Wraps the `ActionNodeConfig` in an `ActionNodeExecutor` and registers it
591    /// as a node. If the config is a `SwitchNodeConfig`, conditional edges are
592    /// also auto-registered from the switch conditions.
593    #[cfg(feature = "action")]
594    pub fn action_node(mut self, config: adk_action::ActionNodeConfig) -> Self {
595        use crate::action::ActionNodeExecutor;
596
597        // If this is a Switch node, register conditional edges
598        if let adk_action::ActionNodeConfig::Switch(ref switch_config) = config {
599            let conditions = switch_config.conditions.clone();
600            let eval_mode = switch_config.evaluation_mode.clone();
601            let default_branch = switch_config.default_branch.clone();
602            let source = config.standard().id.clone();
603
604            let mut targets_map: HashMap<String, EdgeTarget> = HashMap::new();
605            for condition in &conditions {
606                targets_map.insert(
607                    condition.output_port.clone(),
608                    EdgeTarget::Node(condition.output_port.clone()),
609                );
610            }
611            if let Some(ref default) = default_branch {
612                let target = if default == END {
613                    EdgeTarget::End
614                } else {
615                    EdgeTarget::Node(default.clone())
616                };
617                targets_map.insert(default.clone(), target);
618            }
619            targets_map.insert(END.to_string(), EdgeTarget::End);
620
621            let router = Arc::new(move |state: &State| -> String {
622                match crate::action::switch::evaluate_switch_conditions(
623                    &conditions,
624                    state,
625                    &eval_mode,
626                    default_branch.as_deref(),
627                ) {
628                    Ok(ports) => ports.into_iter().next().unwrap_or_else(|| END.to_string()),
629                    Err(_) => END.to_string(),
630                }
631            });
632
633            self.edges.push(Edge::Conditional { source, router, targets: targets_map });
634        }
635
636        let executor = ActionNodeExecutor::new(config);
637        self.nodes.push(Arc::new(executor));
638        self
639    }
640
641    /// Build the GraphAgent
642    pub fn build(self) -> Result<GraphAgent> {
643        // Build the graph
644        let mut graph = StateGraph::new(self.schema);
645
646        // Add nodes
647        for node in self.nodes {
648            graph.nodes.insert(node.name().to_string(), node);
649        }
650
651        // Add edges
652        graph.edges = self.edges;
653
654        // Compile
655        let mut compiled = graph.compile()?;
656
657        // Configure
658        if let Some(cp) = self.checkpointer {
659            compiled.checkpointer = Some(cp);
660        }
661        compiled.interrupt_before = self.interrupt_before.into_iter().collect();
662        compiled.interrupt_after = self.interrupt_after.into_iter().collect();
663        compiled.recursion_limit = self.recursion_limit;
664        compiled.max_concurrency = self.max_concurrency;
665        compiled.timeout_policies = self.timeout_policies;
666        compiled.default_timeout = self.default_timeout;
667        compiled.deferred_configs = self.deferred_configs;
668
669        #[cfg(feature = "node-cache")]
670        {
671            compiled.cache_policies = self.cache_policies;
672        }
673
674        Ok(GraphAgent {
675            name: self.name,
676            description: self.description,
677            graph: Arc::new(compiled),
678            input_mapper: self.input_mapper.unwrap_or(Arc::new(default_input_mapper)),
679            output_mapper: self.output_mapper.unwrap_or(Arc::new(default_output_mapper)),
680            before_callback: self.before_callback,
681            after_callback: self.after_callback,
682        })
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use serde_json::json;
690
691    #[tokio::test]
692    async fn test_graph_agent_builder() {
693        let agent = GraphAgent::builder("test")
694            .description("Test agent")
695            .channels(&["value"])
696            .node_fn("set", |_ctx| async { Ok(NodeOutput::new().with_update("value", json!(42))) })
697            .edge(START, "set")
698            .edge("set", END)
699            .build()
700            .unwrap();
701
702        assert_eq!(agent.name(), "test");
703        assert_eq!(agent.description(), "Test agent");
704
705        // Test direct invocation
706        let result = agent.invoke(State::new(), ExecutionConfig::new("test")).await.unwrap();
707
708        assert_eq!(result.get("value"), Some(&json!(42)));
709    }
710}