Skip to main content

greentic_runner_host/runner/
graph_node.rs

1use anyhow::Result;
2use serde_json::Value;
3
4/// Bridges a `DwAgentGraph` flow node into the graph executor.
5///
6/// The concrete impl (constructed in the runner binary / pack loader, Task 8)
7/// wraps [`greentic_aw_runtime::graph::GraphExecutor`]. The engine holds it as
8/// a trait object so `engine.rs` stays free of AW-runtime construction details
9/// — mirroring [`super::agent_node::AgentNodeHandler`].
10#[async_trait::async_trait]
11pub trait GraphNodeHandler: Send + Sync {
12    /// Execute (or resume) a graph run for this session. `flow_input`
13    /// expects `{"user_text": "..."}`; returns
14    /// `{"reply", "trail", "terminated_by"}` — the same envelope as DwAgent.
15    async fn execute(
16        &self,
17        tenant_id: &str,
18        env_id: &str,
19        graph_id: &str,
20        session_id: &str,
21        flow_input: &Value,
22    ) -> Result<Value>;
23}
24
25// ---------------------------------------------------------------------------
26// agentic-worker feature: full DwAgentGraph / GraphExecutor integration
27// ---------------------------------------------------------------------------
28
29#[cfg(feature = "agentic-worker")]
30mod aw {
31    use std::collections::HashMap;
32    use std::str::FromStr;
33    use std::sync::Arc;
34    use std::time::Duration;
35
36    use anyhow::Result;
37    use greentic_aw_runtime::CachingGraphProvider;
38    use greentic_aw_runtime::HttpGraphProvider;
39    use greentic_aw_runtime::ToolLedger;
40    use greentic_aw_runtime::config_provider::InMemoryConfigProvider;
41    use greentic_aw_runtime::error::{AgentError, ConfigError};
42    use greentic_aw_runtime::graph::{
43        AgentTurnFn, AgentTurnRequest, AgentTurnResult, ApprovalFn, ApprovalOutcome,
44        ApprovalRequest, BoxFut, CheckpointError, CheckpointStore, GraphConfig, GraphExecError,
45        GraphExecutor, GraphRole, GraphRunState, RunStatus, SupervisorFn, SupervisorRequest,
46        SupervisorResult, ToolCallRequest, ToolFn,
47    };
48    use greentic_aw_runtime::state::{AgentStateStore, ChatMessage, ConversationState};
49    use greentic_aw_runtime::tools::dispatch_tool_call;
50    use greentic_aw_runtime::{
51        AgentConfig, AgentInput, AgentLimits, AgentOutput, AgentRuntime, LlmBackend,
52        LlmProviderRef, StepObserver, Telemetry, TenantContext, TokenMeter,
53    };
54    use greentic_ext_runtime::ExtensionRuntime;
55    use serde_json::{Value, json};
56
57    use crate::trace::agent_audit::AgentAuditObserver;
58    use crate::trace::audit_sink::AuditSink;
59
60    use super::GraphNodeHandler;
61
62    /// Build a [`greentic_types::TenantCtx`] for the agent-graph audit
63    /// observer from the flow node's plain `tenant_id`/`env_id` strings.
64    ///
65    /// Mirrors `agent_node::tenant_ctx_for_audit` exactly (duplicated rather
66    /// than shared: that helper is module-private to `agent_node`'s `aw`
67    /// module). An id that fails the newtype's validation (should not happen
68    /// once a graph node has been routed to a tenant) still yields a
69    /// well-formed `TenantCtx` rather than panicking — the audit event is
70    /// best-effort, never load-bearing.
71    fn tenant_ctx_for_audit(tenant_id: &str, env_id: &str) -> greentic_types::TenantCtx {
72        let env = greentic_types::EnvId::from_str(env_id)
73            .unwrap_or_else(|_| greentic_types::EnvId::new("local").expect("local env id"));
74        let tenant = greentic_types::TenantId::from_str(tenant_id)
75            .unwrap_or_else(|_| greentic_types::TenantId::new("local").expect("local tenant id"));
76        greentic_types::TenantCtx::new(env, tenant)
77    }
78
79    /// Fixed, user-safe reply returned when a graph run fails. The detailed
80    /// error is logged but never surfaced to the flow output, so internal
81    /// failure modes do not leak to end users. Mirrors
82    /// [`super::super::agent_node`]'s `SANITISED_ERROR_REPLY`.
83    const SANITISED_ERROR_REPLY: &str = "Something went wrong. Please try again.";
84
85    /// Reply returned when the requested graph is not available (no such graph
86    /// in the provider). User-safe; the missing-graph id is logged separately.
87    const GRAPH_UNAVAILABLE_REPLY: &str =
88        "This worker isn't available right now. Please try again later.";
89
90    /// Resolution sentinel emitted by the agent's reply when it considers the
91    /// issue fully resolved. Matched case-insensitively and stripped from the
92    /// visible reply. Copied verbatim from the greentic-designer spike
93    /// (`src/orchestrate/agent_graph/agent_turn.rs`).
94    const RESOLVED_SENTINEL: &str = "[[RESOLVED]]";
95
96    /// Upper bound on the number of fresh-run-id suffixes tried when a session's
97    /// run id is already in a terminal state (see [`derive_run_id`]).
98    const MAX_RUN_ID_SUFFIX: u32 = 100;
99
100    /// Error returned by [`RuntimeGraphNodeHandler::derive_run_id`].
101    ///
102    /// Kept local to `runner-host`: `GraphExecError` (from `greentic-aw-runtime`)
103    /// has no suffix-exhaustion variant and we should not pollute the upstream
104    /// crate with a runner-host-specific concern.
105    #[derive(Debug, thiserror::Error)]
106    enum RunIdError {
107        /// Checkpoint IO failed while scanning for a free slot.
108        ///
109        /// Note: `checkpoint.load` returns `CheckpointError` directly (not
110        /// `GraphExecError`), so we convert both via their respective `From`
111        /// impls. `GraphExecError::Checkpoint` wraps `CheckpointError` upstream;
112        /// here we keep the original for a tighter error surface.
113        #[error(transparent)]
114        Checkpoint(#[from] CheckpointError),
115        /// All `MAX_RUN_ID_SUFFIX` slots for this session are occupied (terminal).
116        /// Distinct from `GraphExecError::IterationCap` (graph visit limit) so
117        /// the caller can surface a targeted user reply.
118        #[error("all run-id slots exhausted for base run `{base_run_id}`")]
119        SuffixExhausted { base_run_id: String },
120    }
121
122    /// `true` when `reply` contains the resolution sentinel (case-insensitive).
123    fn detect_resolved(reply: &str) -> bool {
124        reply
125            .to_ascii_lowercase()
126            .contains(&RESOLVED_SENTINEL.to_ascii_lowercase())
127    }
128
129    /// Strip the sentinel from `reply` (first case-insensitive occurrence) and
130    /// trim surrounding whitespace so the user-visible text is clean.
131    fn strip_sentinel(reply: &str) -> String {
132        let lower = reply.to_ascii_lowercase();
133        if let Some(idx) = lower.find(&RESOLVED_SENTINEL.to_ascii_lowercase()) {
134            let mut out = reply.to_string();
135            out.replace_range(idx..idx + RESOLVED_SENTINEL.len(), "");
136            return out.trim().to_string();
137        }
138        reply.trim().to_string()
139    }
140
141    // -----------------------------------------------------------------------
142    // GraphConfigSource
143    // -----------------------------------------------------------------------
144
145    /// Resolves a [`GraphConfig`] for a `(tenant, graph_id)` pair.
146    ///
147    /// Object-safe (returns [`BoxFut`]) so it can be held as
148    /// `Arc<dyn GraphConfigSource>`. The pack loader (Task 8) fills the
149    /// concrete implementation; tests use [`InMemoryGraphProvider`].
150    pub trait GraphConfigSource: Send + Sync {
151        fn graph_config<'a>(
152            &'a self,
153            tenant: &'a TenantContext,
154            graph_id: &'a str,
155        ) -> BoxFut<'a, Result<GraphConfig, ConfigError>>;
156    }
157
158    /// [`GraphConfigSource`] backed by an in-memory `graph_id -> GraphConfig`
159    /// map. Graphs are operator-global for the MVP: lookup is keyed purely by
160    /// `graph_id`; the `tenant` argument is accepted (to satisfy the trait
161    /// contract) but not used for keying. Reuses [`ConfigError::AgentNotFound`]
162    /// as the crate's canonical "not found" variant.
163    pub struct InMemoryGraphProvider {
164        graphs: HashMap<String, GraphConfig>,
165    }
166
167    impl InMemoryGraphProvider {
168        /// Wrap a `graph_id -> GraphConfig` map in a [`GraphConfigSource`].
169        pub fn new(graphs: HashMap<String, GraphConfig>) -> Self {
170            Self { graphs }
171        }
172    }
173
174    impl GraphConfigSource for InMemoryGraphProvider {
175        fn graph_config<'a>(
176            &'a self,
177            _tenant: &'a TenantContext,
178            graph_id: &'a str,
179        ) -> BoxFut<'a, Result<GraphConfig, ConfigError>> {
180            let found = self.graphs.get(graph_id).cloned();
181            let graph_id_owned = graph_id.to_string();
182            Box::pin(async move { found.ok_or(ConfigError::AgentNotFound(graph_id_owned)) })
183        }
184    }
185
186    // -----------------------------------------------------------------------
187    // GraphConfigSource adapter for CachingGraphProvider<HttpGraphProvider>
188    // -----------------------------------------------------------------------
189
190    /// Adapts [`CachingGraphProvider<HttpGraphProvider>`] into the
191    /// [`GraphConfigSource`] trait so it can be stored as an
192    /// `Arc<dyn GraphConfigSource>` alongside [`InMemoryGraphProvider`].
193    ///
194    /// This is a 5-line adapter that bridges the inherent-method API of
195    /// `CachingGraphProvider` (defined in `greentic-aw-runtime`) into the
196    /// trait object used by `runner-host`.
197    impl GraphConfigSource for CachingGraphProvider<HttpGraphProvider> {
198        fn graph_config<'a>(
199            &'a self,
200            tenant: &'a TenantContext,
201            graph_id: &'a str,
202        ) -> BoxFut<'a, Result<GraphConfig, ConfigError>> {
203            Box::pin(async move { self.graph_config(tenant, graph_id).await })
204        }
205    }
206
207    // -----------------------------------------------------------------------
208    // LayeredGraphProvider
209    // -----------------------------------------------------------------------
210
211    /// Tries the primary [`GraphConfigSource`], falling back to the secondary
212    /// when the primary reports the graph missing or has an infrastructure
213    /// failure. A `Misconfigured` error is propagated, never masked by the
214    /// fallback — matching [`greentic_aw_runtime::LayeredConfigProvider`]'s
215    /// semantics exactly.
216    ///
217    /// Fallback triggers:
218    /// - `ConfigError::AgentNotFound` — graph absent from the primary (HTTP
219    ///   registry doesn't know it yet; local pack may have it).
220    /// - `ConfigError::Internal` — transient infrastructure failure (network
221    ///   timeout, 5xx) — local pack can serve as a degraded fallback.
222    ///
223    /// Non-fallback (surfaces immediately):
224    /// - `ConfigError::Misconfigured` — corrupt document or bad auth token;
225    ///   the operator must fix the document — silently serving stale local
226    ///   data would mask the problem.
227    pub struct LayeredGraphProvider<P: GraphConfigSource, F: GraphConfigSource> {
228        primary: P,
229        fallback: F,
230    }
231
232    impl<P: GraphConfigSource, F: GraphConfigSource> LayeredGraphProvider<P, F> {
233        pub fn new(primary: P, fallback: F) -> Self {
234            Self { primary, fallback }
235        }
236    }
237
238    impl<P: GraphConfigSource, F: GraphConfigSource> GraphConfigSource for LayeredGraphProvider<P, F> {
239        fn graph_config<'a>(
240            &'a self,
241            tenant: &'a TenantContext,
242            graph_id: &'a str,
243        ) -> BoxFut<'a, Result<GraphConfig, ConfigError>> {
244            Box::pin(async move {
245                match self.primary.graph_config(tenant, graph_id).await {
246                    Ok(cfg) => Ok(cfg),
247                    Err(ConfigError::AgentNotFound(_)) | Err(ConfigError::Internal(_)) => {
248                        self.fallback.graph_config(tenant, graph_id).await
249                    }
250                    // Misconfigured + any future ConfigError variant: surface,
251                    // never mask. New variants fall through here and propagate
252                    // by default; revisit only if a new variant should fall back.
253                    Err(other) => Err(other),
254                }
255            })
256        }
257    }
258
259    // -----------------------------------------------------------------------
260    // Admin graph registry wiring
261    // -----------------------------------------------------------------------
262
263    /// Build a cached [`HttpGraphProvider`] from `GREENTIC_AW_ADMIN_ENDPOINT` +
264    /// `GREENTIC_AW_ADMIN_TOKEN`. Returns `None` when either is unset/empty,
265    /// so the runtime keeps using the local pack provider alone.
266    ///
267    /// Mirrors [`super::agent_node::registry_from_env`] exactly — same env
268    /// vars, same "both must be present" semantics.
269    fn graph_registry_from_env() -> Option<CachingGraphProvider<HttpGraphProvider>> {
270        let endpoint = std::env::var("GREENTIC_AW_ADMIN_ENDPOINT")
271            .ok()
272            .filter(|s| !s.is_empty())?;
273        let token = std::env::var("GREENTIC_AW_ADMIN_TOKEN")
274            .ok()
275            .filter(|s| !s.is_empty())?;
276        Some(CachingGraphProvider::new(HttpGraphProvider::new(
277            endpoint, token,
278        )))
279    }
280
281    // -----------------------------------------------------------------------
282    // Pack-sidecar graph loader
283    // -----------------------------------------------------------------------
284
285    /// Parse an `agent-graph.json` sidecar (raw bytes from a `.gtpack`) into a
286    /// validated [`GraphConfig`].
287    ///
288    /// Mirrors the lenient posture of
289    /// [`super::super::agent_node::agent_configs_from_manifest`]: every failure
290    /// mode — invalid UTF-8, malformed JSON, schema-version mismatch, or graph
291    /// validation error — is logged via [`tracing::warn!`] (with `pack_id`) and
292    /// yields `None`, so a single bad graph never prevents the rest of the pack
293    /// from loading. The `pack_id` argument is used only in log messages.
294    pub fn graph_config_from_sidecar(pack_id: &str, bytes: &[u8]) -> Option<GraphConfig> {
295        let raw = match std::str::from_utf8(bytes) {
296            Ok(raw) => raw,
297            Err(error) => {
298                tracing::warn!(
299                    pack_id,
300                    error = %error,
301                    "skipping agent-graph sidecar: not valid UTF-8"
302                );
303                return None;
304            }
305        };
306        match GraphConfig::from_json(raw) {
307            Ok(config) => Some(config),
308            Err(error) => {
309                tracing::warn!(
310                    pack_id,
311                    error = %error,
312                    "skipping malformed agent-graph sidecar in pack"
313                );
314                None
315            }
316        }
317    }
318
319    // -----------------------------------------------------------------------
320    // Production graph-handler construction
321    // -----------------------------------------------------------------------
322
323    /// Build the production `DwAgentGraph` handler if the environment is
324    /// configured. Mirrors
325    /// [`super::super::agent_node::build_agent_node_handler`]: it returns `None`
326    /// (so `DwAgentGraph` flow dispatch errors clearly) under any of these
327    /// graceful-degradation conditions:
328    /// - `graphs` is empty (no graphs from any pack sidecar);
329    /// - `GREENTIC_AW_REDIS_URL` is unset/empty;
330    /// - the AW Redis connection fails;
331    /// - the extension runtime fails to initialise.
332    ///
333    /// The shared runtime Arcs (state store, ext runtime, LLM backend,
334    /// telemetry, token meter, ledger) are built exactly as for the single-agent
335    /// path; the durable checkpoint store reuses the same multiplexed Redis
336    /// connection manager.
337    pub async fn build_graph_node_handler(
338        graphs: HashMap<String, GraphConfig>,
339        audit_sink: Option<AuditSink>,
340    ) -> Option<Arc<dyn GraphNodeHandler>> {
341        use greentic_aw_runtime::cost::RedisTokenMeter;
342        use greentic_aw_runtime::graph::RedisCheckpointStore;
343        use greentic_aw_runtime::tools::RedisToolLedger;
344        use greentic_aw_runtime::{OtelTelemetry, RedisAgentStateStore};
345
346        if graphs.is_empty() {
347            return None; // nothing to serve
348        }
349
350        let redis_url = match std::env::var("GREENTIC_AW_REDIS_URL") {
351            Ok(url) if !url.is_empty() => url,
352            _ => {
353                tracing::info!("GREENTIC_AW_REDIS_URL unset; DwAgentGraph nodes disabled");
354                return None;
355            }
356        };
357
358        let state_store = match RedisAgentStateStore::connect(&redis_url).await {
359            Ok(store) => Arc::new(store),
360            Err(error) => {
361                tracing::warn!(error = %error, "AW Redis connect failed; DwAgentGraph nodes disabled");
362                return None;
363            }
364        };
365
366        // Share the multiplexed connection manager across all Redis-backed
367        // stores (state, checkpoint, token meter, idempotency ledger).
368        let manager = state_store.manager();
369        let checkpoint = Arc::new(RedisCheckpointStore::new(manager.clone()));
370        let token_meter = Arc::new(RedisTokenMeter::new(manager.clone()));
371        let ledger = Arc::new(RedisToolLedger::new(manager));
372
373        // Process-level graph serve path has no per-tenant secrets context;
374        // tool secrets resolve from the env only.
375        let ext_runtime = super::super::agent_node::build_ext_runtime(std::sync::Arc::new(
376            super::super::agent_node::EnvSecretsBackend,
377        ))?;
378        let llm = super::super::agent_node::build_llm_backend(&ext_runtime);
379        let telemetry = Arc::new(OtelTelemetry);
380
381        let graph_count = graphs.len();
382        // Build the graph config source: HTTP registry (cached) primary →
383        // InMemoryGraphProvider (local packs) fallback, when the admin env
384        // vars are configured; otherwise local packs only.
385        //
386        // Mirrors the agent-config composition in
387        // `agent_node::build_agent_node_handler`:
388        //   Some(http) → CachingConfigProvider(Layered(http, overlay))
389        //   None       → CachingConfigProvider(overlay)
390        //
391        // The graph path skips the ManifestToolOverlay (no manifest overlay
392        // exists for graphs) and the CachingGraphProvider is already embedded
393        // inside the `CachingGraphProvider<HttpGraphProvider>` returned by
394        // `graph_registry_from_env`. The local InMemoryGraphProvider needs no
395        // separate cache (it is in-memory and O(1)).
396        let local = InMemoryGraphProvider::new(graphs);
397        let provider: Arc<dyn GraphConfigSource> = match graph_registry_from_env() {
398            Some(http) => Arc::new(LayeredGraphProvider::new(http, local)),
399            None => Arc::new(local),
400        };
401
402        let handler = RuntimeGraphNodeHandler::from_parts(
403            provider,
404            checkpoint,
405            state_store,
406            ext_runtime,
407            llm,
408            telemetry,
409            token_meter,
410            ledger,
411            audit_sink,
412        );
413
414        tracing::info!(graph_count, "AW graph runtime constructed");
415        Some(Arc::new(handler))
416    }
417
418    // -----------------------------------------------------------------------
419    // RuntimeGraphNodeHandler
420    // -----------------------------------------------------------------------
421
422    /// Supplies the per-visit [`AgentTurnFn`]/[`SupervisorFn`] closures used by
423    /// [`RuntimeGraphNodeHandler::execute`].
424    ///
425    /// `execute` already receives the graph run's real `tenant_id`/`env_id`
426    /// (see its signature), but [`AgentTurnFn`]/[`SupervisorFn`] (from
427    /// `greentic-aw-runtime`, not modifiable here) only pass an
428    /// [`AgentTurnRequest`]/[`SupervisorRequest`] at invocation time — neither
429    /// carries a tenant. So the audit sink + real tenant cannot be baked into
430    /// a closure built once at handler-construction time and reused
431    /// call-after-call; instead `execute` asks this seam for a *fresh*
432    /// closure on every call, passing the sink + the real tenant it just
433    /// resolved from its own parameters (EPIC-B B-3b Task 1 plumbing; Task 2
434    /// makes `run_one_agent_turn`/`run_one_supervisor_turn` actually build an
435    /// `AgentAuditObserver` from them instead of ignoring them).
436    ///
437    /// [`RuntimeTurnSource`] (production) rebuilds via
438    /// [`build_agent_turn`]/[`build_supervisor`] from the shared runtime Arcs
439    /// every call. [`FixedTurnSource`] (test-only, via
440    /// [`RuntimeGraphNodeHandler::with_effects`]) ignores the sink/tenant and
441    /// hands back the same injected closures every time, preserving today's
442    /// deterministic-effect test style untouched.
443    trait TurnEffectSource: Send + Sync {
444        fn agent_turn(
445            &self,
446            audit_sink: Option<AuditSink>,
447            real_tenant: greentic_types::TenantCtx,
448        ) -> AgentTurnFn;
449        fn supervisor(
450            &self,
451            audit_sink: Option<AuditSink>,
452            real_tenant: greentic_types::TenantCtx,
453        ) -> SupervisorFn;
454    }
455
456    /// Production [`TurnEffectSource`]: holds the constituent runtime Arcs
457    /// (extension runtime, LLM backend, telemetry, token meter, ledger; the
458    /// state store lives directly on [`RuntimeGraphNodeHandler`]) so it can
459    /// build a lightweight [`AgentRuntime`] *per agent visit* with a fresh
460    /// single-entry [`InMemoryConfigProvider`] — mirroring the
461    /// greentic-designer spike's per-visit runtime construction.
462    struct RuntimeTurnSource {
463        state_store: Arc<dyn AgentStateStore>,
464        ext_runtime: Arc<ExtensionRuntime>,
465        llm: Arc<dyn LlmBackend>,
466        telemetry: Arc<dyn Telemetry>,
467        token_meter: Arc<dyn TokenMeter>,
468        ledger: Arc<dyn ToolLedger>,
469    }
470
471    impl TurnEffectSource for RuntimeTurnSource {
472        fn agent_turn(
473            &self,
474            audit_sink: Option<AuditSink>,
475            real_tenant: greentic_types::TenantCtx,
476        ) -> AgentTurnFn {
477            build_agent_turn(
478                self.state_store.clone(),
479                self.ext_runtime.clone(),
480                self.llm.clone(),
481                self.telemetry.clone(),
482                self.token_meter.clone(),
483                self.ledger.clone(),
484                audit_sink,
485                real_tenant,
486            )
487        }
488
489        fn supervisor(
490            &self,
491            audit_sink: Option<AuditSink>,
492            real_tenant: greentic_types::TenantCtx,
493        ) -> SupervisorFn {
494            build_supervisor(
495                self.state_store.clone(),
496                self.ext_runtime.clone(),
497                self.llm.clone(),
498                self.telemetry.clone(),
499                self.token_meter.clone(),
500                self.ledger.clone(),
501                audit_sink,
502                real_tenant,
503            )
504        }
505    }
506
507    /// **Test-only** [`TurnEffectSource`] that always returns the same
508    /// injected closures, ignoring the audit sink/tenant. Lets
509    /// [`RuntimeGraphNodeHandler::with_effects`] keep injecting deterministic
510    /// effects without needing real `AgentRuntime` Arcs.
511    #[cfg(test)]
512    struct FixedTurnSource {
513        agent_turn: AgentTurnFn,
514        supervisor: SupervisorFn,
515    }
516
517    #[cfg(test)]
518    impl TurnEffectSource for FixedTurnSource {
519        fn agent_turn(
520            &self,
521            _audit_sink: Option<AuditSink>,
522            _real_tenant: greentic_types::TenantCtx,
523        ) -> AgentTurnFn {
524            self.agent_turn.clone()
525        }
526
527        fn supervisor(
528            &self,
529            _audit_sink: Option<AuditSink>,
530            _real_tenant: greentic_types::TenantCtx,
531        ) -> SupervisorFn {
532            self.supervisor.clone()
533        }
534    }
535
536    /// Production [`GraphNodeHandler`] wrapping the durable graph executor.
537    ///
538    /// Tool dispatch goes straight through the shared [`ExtensionRuntime`] via
539    /// [`dispatch_tool_call`].
540    pub struct RuntimeGraphNodeHandler {
541        graphs: Arc<dyn GraphConfigSource>,
542        checkpoint: Arc<dyn CheckpointStore>,
543        state_store: Arc<dyn AgentStateStore>,
544        turn_source: Arc<dyn TurnEffectSource>,
545        tool: ToolFn,
546        approval: ApprovalFn,
547        /// Best-effort agent-step audit sink (EPIC-B B-3b), threaded from
548        /// [`build_graph_node_handler`]. `None` when no NATS audit client is
549        /// configured (`GREENTIC_EVENTS_NATS_URL` unset/unreachable) — the
550        /// `run_agent_step` seam then stays on the plain `.step()` path,
551        /// byte-identical regardless of this field's value.
552        audit_sink: Option<AuditSink>,
553    }
554
555    impl RuntimeGraphNodeHandler {
556        /// Build a handler from the runtime's constituent parts.
557        ///
558        /// **Deviation from the Task-7 `from_runtime(runtime: Arc<AgentRuntime>, …)`
559        /// signature:** `AgentRuntime`'s inner Arcs (`ext_runtime`, `llm`,
560        /// `telemetry`, `token_meter`, `ledger`) are `pub(crate)` and cannot be
561        /// extracted from an `Arc<AgentRuntime>` outside the `greentic-aw-runtime`
562        /// crate, so a per-visit runtime cannot be reconstructed from a shared
563        /// `AgentRuntime`. The task explicitly allows this: "an acceptable
564        /// alternative is a `from_parts(...)` constructor taking those Arcs
565        /// directly." The pack loader (Task 8) already holds these Arcs when it
566        /// would otherwise build an `AgentRuntime`, so it wires them here instead.
567        #[allow(clippy::too_many_arguments)]
568        pub fn from_parts(
569            graphs: Arc<dyn GraphConfigSource>,
570            checkpoint: Arc<dyn CheckpointStore>,
571            state_store: Arc<dyn AgentStateStore>,
572            ext_runtime: Arc<ExtensionRuntime>,
573            llm: Arc<dyn LlmBackend>,
574            telemetry: Arc<dyn Telemetry>,
575            token_meter: Arc<dyn TokenMeter>,
576            ledger: Arc<dyn ToolLedger>,
577            audit_sink: Option<AuditSink>,
578        ) -> Self {
579            let tool = build_tool(ext_runtime.clone());
580            let turn_source: Arc<dyn TurnEffectSource> = Arc::new(RuntimeTurnSource {
581                state_store: state_store.clone(),
582                ext_runtime,
583                llm,
584                telemetry,
585                token_meter,
586                ledger,
587            });
588            // NOTE: the real `ApprovalFn` is designer-provided (it wires the
589            // `greentic.approval.request.v1` / `.response.v1` NATS round trip
590            // so a human decision can arrive asynchronously). This in-process
591            // runner-host path does not yet have that transport wired up, so
592            // it always parks (`Awaiting`) — safe (a graph run simply stays
593            // `AwaitingInput`, and `derive_run_id` resumes rather than forks
594            // it on every later call for the same session) but not yet
595            // resolvable from here. A future designer-side approval bridge
596            // (subscriber + node) replaces this with a real consumer.
597            let approval = default_approval_awaiting();
598            Self {
599                graphs,
600                checkpoint,
601                state_store,
602                turn_source,
603                tool,
604                approval,
605                audit_sink,
606            }
607        }
608
609        /// **Test-only** constructor that injects effect closures directly,
610        /// bypassing per-visit [`AgentRuntime`] construction. Not available
611        /// outside `#[cfg(test)]`; production code must use [`from_parts`].
612        ///
613        /// [`from_parts`]: RuntimeGraphNodeHandler::from_parts
614        #[cfg(test)]
615        #[allow(clippy::too_many_arguments)]
616        pub(crate) fn with_effects(
617            graphs: Arc<dyn GraphConfigSource>,
618            checkpoint: Arc<dyn CheckpointStore>,
619            state_store: Arc<dyn AgentStateStore>,
620            agent_turn: AgentTurnFn,
621            tool: ToolFn,
622            supervisor: SupervisorFn,
623            approval: ApprovalFn,
624        ) -> Self {
625            Self {
626                graphs,
627                checkpoint,
628                state_store,
629                turn_source: Arc::new(FixedTurnSource {
630                    agent_turn,
631                    supervisor,
632                }),
633                tool,
634                approval,
635                audit_sink: None,
636            }
637        }
638
639        /// Resolve the run id to drive for this `(session, graph)` and whether
640        /// the existing record (if any) is mid-flight.
641        ///
642        /// Base run id is `"{safe_session}__{graph_id}"` where `safe_session` is
643        /// the incoming `session_id` with every `':'` replaced by `'_'`.
644        ///
645        /// **Why sanitize `':'`?**  Flow session ids frequently embed channel
646        /// correlation ids that contain colons (e.g. MS Teams thread ids such as
647        /// `msteams:thread:19xyz`). The checkpoint store key contract forbids
648        /// `':'`, so using the raw session id causes every such graph run to fail
649        /// at save-time. The replacement is stable: the same incoming
650        /// `session_id` always maps to the same `safe_session`, so checkpoint
651        /// resume works correctly across calls.
652        ///
653        /// When the base slot is already terminal (`Succeeded`/`Failed`), retry
654        /// `"{safe_session}__{graph_id}__{n}"` for `n = 2..` until a slot is
655        /// absent (→ start fresh) or non-terminal — `Running` or `AwaitingInput`
656        /// (→ resume). Bounded at
657        /// [`MAX_RUN_ID_SUFFIX`]. Exhausting all suffixes returns
658        /// [`RunIdError::SuffixExhausted`] (distinct from
659        /// [`GraphExecError::IterationCap`] so the caller can surface a
660        /// targeted reply).
661        async fn derive_run_id(
662            &self,
663            tenant: &TenantContext,
664            graph_id: &str,
665            session_id: &str,
666        ) -> Result<RunSlot, RunIdError> {
667            // Sanitize colons: flow session ids (e.g. Teams thread correlation
668            // ids) may contain ':' which the checkpoint key contract forbids.
669            let safe_session = session_id.replace(':', "_");
670            let base = format!("{safe_session}__{graph_id}");
671            match self.checkpoint.load(tenant, &base).await? {
672                None => return Ok(RunSlot::start(base)),
673                // `AwaitingInput` is a parked, non-terminal state (the run is
674                // paused at an approval node, not done) — same resume
675                // treatment as `Running`. Without this arm, every call after
676                // a park would fall through to the terminal branch below and
677                // mint a fresh `__n` run id, orphaning the parked run forever
678                // (it can never be decided because nothing ever resumes it)
679                // and silently forking the conversation.
680                Some(rec)
681                    if matches!(rec.status, RunStatus::Running | RunStatus::AwaitingInput) =>
682                {
683                    return Ok(RunSlot::resume(base));
684                }
685                Some(_) => {}
686            }
687            for n in 2..=MAX_RUN_ID_SUFFIX {
688                let candidate = format!("{safe_session}__{graph_id}__{n}");
689                match self.checkpoint.load(tenant, &candidate).await? {
690                    None => return Ok(RunSlot::start(candidate)),
691                    Some(rec)
692                        if matches!(rec.status, RunStatus::Running | RunStatus::AwaitingInput) =>
693                    {
694                        return Ok(RunSlot::resume(candidate));
695                    }
696                    Some(_) => continue,
697                }
698            }
699            // All suffix slots are occupied (terminal). This is a distinct
700            // error from the graph's own IterationCap: it means the session has
701            // exhausted the run-id namespace, not that any single run looped.
702            Err(RunIdError::SuffixExhausted { base_run_id: base })
703        }
704    }
705
706    /// Outcome of [`RuntimeGraphNodeHandler::derive_run_id`]: which run id to
707    /// drive and whether to start it fresh or resume an in-flight record.
708    struct RunSlot {
709        run_id: String,
710        resume: bool,
711    }
712
713    impl RunSlot {
714        fn start(run_id: String) -> Self {
715            Self {
716                run_id,
717                resume: false,
718            }
719        }
720        fn resume(run_id: String) -> Self {
721            Self {
722                run_id,
723                resume: true,
724            }
725        }
726    }
727
728    #[async_trait::async_trait]
729    impl GraphNodeHandler for RuntimeGraphNodeHandler {
730        async fn execute(
731            &self,
732            tenant_id: &str,
733            env_id: &str,
734            graph_id: &str,
735            session_id: &str,
736            flow_input: &Value,
737        ) -> Result<Value> {
738            // Contract mirror of agent_node.rs: a missing/empty `user_text`
739            // resolves to an empty string and the run proceeds (it never returns
740            // Err for this case).
741            let user_text = flow_input
742                .get("user_text")
743                .and_then(Value::as_str)
744                .unwrap_or("")
745                .to_string();
746
747            let tenant = TenantContext::new(tenant_id, env_id);
748
749            // Resolve the graph. A missing graph is a user-facing "unavailable"
750            // outcome, never an Err.
751            let cfg = match self.graphs.graph_config(&tenant, graph_id).await {
752                Ok(cfg) => cfg,
753                Err(error) => {
754                    tracing::warn!(error = %error, graph_id, "graph config not found");
755                    return Ok(error_envelope(GRAPH_UNAVAILABLE_REPLY));
756                }
757            };
758
759            // Acquire the session lock before driving (mirrors the single-agent
760            // loop's default 5s wait). Held across the drive via the guard.
761            let _lock = match self
762                .state_store
763                .acquire_lock(&tenant, session_id, Duration::from_secs(5))
764                .await
765            {
766                Ok(guard) => guard,
767                Err(error) => {
768                    tracing::warn!(error = %error, graph_id, session_id, "session lock failed");
769                    return Ok(error_envelope(SANITISED_ERROR_REPLY));
770                }
771            };
772
773            let slot = match self.derive_run_id(&tenant, graph_id, session_id).await {
774                Ok(slot) => slot,
775                Err(RunIdError::SuffixExhausted { .. }) => {
776                    tracing::warn!(
777                        graph_id,
778                        session_id,
779                        "all run-id slots exhausted for session"
780                    );
781                    return Ok(error_envelope(
782                        "Too many runs for this session. Please start a new conversation.",
783                    ));
784                }
785                Err(RunIdError::Checkpoint(error)) => {
786                    tracing::warn!(error = %error, graph_id, session_id, "run-id derivation failed");
787                    return Ok(error_envelope(SANITISED_ERROR_REPLY));
788                }
789            };
790
791            // The real tenant/env `execute` already received — the same one
792            // driving executor/checkpoint/run-id above. (The synthetic
793            // `"graph"`/`"run"` tenant is built *inside* the turn functions,
794            // for per-visit AgentRuntime *state* only.) Rebuilt fresh on
795            // every call — see `TurnEffectSource` — so the sink + real tenant
796            // reach `run_one_agent_turn`/`run_one_supervisor_turn`, which feed
797            // them into the shared `run_agent_step` seam that builds the
798            // `AgentAuditObserver` (EPIC-B B-3b Task 2).
799            let real_tenant = tenant_ctx_for_audit(tenant_id, env_id);
800            let agent_turn = self
801                .turn_source
802                .agent_turn(self.audit_sink.clone(), real_tenant.clone());
803            let supervisor = self
804                .turn_source
805                .supervisor(self.audit_sink.clone(), real_tenant);
806
807            let executor = GraphExecutor::new(
808                self.checkpoint.clone(),
809                agent_turn,
810                self.tool.clone(),
811                supervisor,
812                self.approval.clone(),
813            );
814
815            let result = if slot.resume {
816                executor.resume(&tenant, &slot.run_id).await
817            } else {
818                executor
819                    .start(&tenant, &slot.run_id, &cfg, &user_text)
820                    .await
821            };
822
823            match result {
824                // `AwaitingInput` means the run parked at an approval node
825                // (see `default_approval_awaiting` below): it is neither a
826                // completed respond nor a failure, so it must not be
827                // reported as `terminated_by: "respond"` (a caller branching
828                // on that value would wrongly treat the pause as done) nor
829                // surfaced as an error. This runner-host path has no
830                // approval-transport integration yet (that lands with the
831                // designer NATS bridge), so a parked run has no other
832                // observable signal beyond this envelope + the warning
833                // below — the run itself remains durably `AwaitingInput` in
834                // the checkpoint store and `derive_run_id` resumes it (not
835                // forks a new run) on every subsequent call for the same
836                // session until a decision arrives.
837                Ok(outcome) if outcome.status == RunStatus::AwaitingInput => {
838                    tracing::warn!(
839                        graph_id,
840                        session_id,
841                        run_id = %slot.run_id,
842                        "graph run parked awaiting human approval; no approval \
843                         transport is wired on this in-process runner-host path, \
844                         so the run stays AwaitingInput until an external decision \
845                         resolves it"
846                    );
847                    Ok(json!({
848                        "reply": outcome.reply,
849                        "trail": outcome.trail,
850                        "terminated_by": "awaiting_approval",
851                    }))
852                }
853                Ok(outcome) => Ok(json!({
854                    "reply": outcome.reply,
855                    "trail": outcome.trail,
856                    "terminated_by": "respond",
857                })),
858                Err(error) => {
859                    tracing::warn!(error = %error, graph_id, session_id, "graph run failed");
860                    // The DwAgent envelope only carries "respond" / "error".
861                    // IterationCap (graph's own node-visit limit) gets a distinct,
862                    // still user-safe reply; every other runtime failure falls back
863                    // to the generic sanitised message.
864                    let reply = match error {
865                        GraphExecError::IterationCap { .. } => {
866                            "This is taking longer than expected. Please try again."
867                        }
868                        _ => SANITISED_ERROR_REPLY,
869                    };
870                    Ok(error_envelope(reply))
871                }
872            }
873        }
874    }
875
876    /// Build the `{"reply", "trail", "terminated_by": "error"}` envelope. The
877    /// trail is empty: a failed/unavailable run surfaces no completed-node trail
878    /// to the flow output (the detail is logged instead).
879    fn error_envelope(reply: &str) -> Value {
880        json!({
881            "reply": reply,
882            "trail": Vec::<Value>::new(),
883            "terminated_by": "error",
884        })
885    }
886
887    /// Seed a fresh [`ConversationState`] from the graph run's message log so the
888    /// per-visit agent has full context (including after a checkpoint resume).
889    ///
890    /// Tool-role graph messages are folded in as a plain user-context line
891    /// rather than a `ChatMessage::Tool` (which needs a `call_id` paired to an
892    /// assistant `tool_calls[]` entry the graph state does not track) — matching
893    /// the designer spike.
894    fn seed_state_from_graph(
895        tenant: &TenantContext,
896        session_id: &str,
897        state: &GraphRunState,
898    ) -> ConversationState {
899        let mut seeded = ConversationState::empty(tenant, session_id);
900        for m in &state.messages {
901            match m.role {
902                GraphRole::User => seeded.messages.push(ChatMessage::User {
903                    content: m.content.clone(),
904                }),
905                GraphRole::Assistant => seeded.messages.push(ChatMessage::Assistant {
906                    content: m.content.clone(),
907                    tool_calls: vec![],
908                }),
909                GraphRole::Tool => seeded.messages.push(ChatMessage::User {
910                    content: format!("Tool result: {}", m.content),
911                }),
912            }
913        }
914        seeded
915    }
916
917    /// Build the [`AgentTurnFn`] effect closure.
918    ///
919    /// Each invocation constructs a lightweight [`AgentRuntime`] with a fresh
920    /// single-entry [`InMemoryConfigProvider`] (agent id `"{graph_id}.{node_id}"`,
921    /// the node's system prompt, no tools — the graph owns tool dispatch via the
922    /// explicit Tool node) reusing the shared runtime Arcs. Conversation context
923    /// is re-seeded from the durable [`GraphRunState`] on every visit; the
924    /// per-visit session id is `"{session_id}__{graph_id}__{node_id}"`.
925    #[allow(clippy::too_many_arguments)]
926    fn build_agent_turn(
927        state_store: Arc<dyn AgentStateStore>,
928        ext_runtime: Arc<ExtensionRuntime>,
929        llm: Arc<dyn LlmBackend>,
930        telemetry: Arc<dyn Telemetry>,
931        token_meter: Arc<dyn TokenMeter>,
932        ledger: Arc<dyn ToolLedger>,
933        audit_sink: Option<AuditSink>,
934        real_tenant: greentic_types::TenantCtx,
935    ) -> AgentTurnFn {
936        Arc::new(move |req: AgentTurnRequest| {
937            let state_store = state_store.clone();
938            let ext_runtime = ext_runtime.clone();
939            let llm = llm.clone();
940            let telemetry = telemetry.clone();
941            let token_meter = token_meter.clone();
942            let ledger = ledger.clone();
943            let audit_sink = audit_sink.clone();
944            let real_tenant = real_tenant.clone();
945            Box::pin(async move {
946                run_one_agent_turn(
947                    req,
948                    state_store,
949                    ext_runtime,
950                    llm,
951                    telemetry,
952                    token_meter,
953                    ledger,
954                    audit_sink.as_ref(),
955                    &real_tenant,
956                )
957                .await
958            }) as BoxFut<'static, Result<AgentTurnResult, GraphExecError>>
959        })
960    }
961
962    /// Drive one graph-node agent step: [`AgentRuntime::step`] when no audit
963    /// sink is configured, or [`AgentRuntime::step_with_observer`] with an
964    /// [`AgentAuditObserver`] when one is (EPIC-B B-3b Task 2). Shared by
965    /// [`run_one_agent_turn`] and [`run_one_supervisor_turn`] so the
966    /// audit-injection seam is defined exactly once.
967    ///
968    /// `tenant`/`session_id`/`agent_id` are the per-visit SYNTHETIC state
969    /// identifiers (`TenantContext::new("graph", "run")` + the derived
970    /// session/agent ids) — unchanged by this wiring, since the
971    /// `AgentRuntime`'s state store must keep using them for per-visit
972    /// durability. `real_tenant` is used ONLY to build the audit observer's
973    /// `TenantCtx`, so audit events publish under the real tenant while turn
974    /// state stays keyed under "graph"/"run".
975    ///
976    /// Off by default: when `audit_sink` is `None` (no NATS audit client
977    /// configured), this is exactly `runtime.step(...)` — byte-identical to
978    /// the call before this observer existed.
979    async fn run_agent_step(
980        runtime: &AgentRuntime,
981        tenant: TenantContext,
982        session_id: &str,
983        agent_id: &str,
984        input: AgentInput,
985        audit_sink: Option<&AuditSink>,
986        real_tenant: &greentic_types::TenantCtx,
987    ) -> std::result::Result<AgentOutput, AgentError> {
988        match audit_sink {
989            Some(sink) => {
990                let observer: Arc<dyn StepObserver> = Arc::new(AgentAuditObserver::new(
991                    sink.clone(),
992                    real_tenant.clone(),
993                    agent_id.to_string(),
994                    session_id.to_string(),
995                ));
996                runtime
997                    .step_with_observer(tenant, session_id, agent_id, input, observer)
998                    .await
999            }
1000            None => runtime.step(tenant, session_id, agent_id, input).await,
1001        }
1002    }
1003
1004    /// Drive one [`AgentRuntime::step`] for an agent-node visit. Derives the
1005    /// agent/session ids, seeds conversation context, runs the step, and maps the
1006    /// reply's resolution sentinel.
1007    ///
1008    /// `audit_sink`/`real_tenant` are forwarded to [`run_agent_step`], which
1009    /// injects an [`AgentAuditObserver`] built from `real_tenant` when a sink
1010    /// is configured (EPIC-B B-3b Task 2).
1011    #[allow(clippy::too_many_arguments)]
1012    async fn run_one_agent_turn(
1013        req: AgentTurnRequest,
1014        state_store: Arc<dyn AgentStateStore>,
1015        ext_runtime: Arc<ExtensionRuntime>,
1016        llm: Arc<dyn LlmBackend>,
1017        telemetry: Arc<dyn Telemetry>,
1018        token_meter: Arc<dyn TokenMeter>,
1019        ledger: Arc<dyn ToolLedger>,
1020        audit_sink: Option<&AuditSink>,
1021        real_tenant: &greentic_types::TenantCtx,
1022    ) -> Result<AgentTurnResult, GraphExecError> {
1023        // The request carries no tenant/graph/session — they live on the
1024        // GraphRunState's seeded session. The executor seeds the run state with
1025        // the tenant-scoped messages, so we reconstruct a turn-scoped tenant +
1026        // session purely for the per-visit runtime. Conversation durability is
1027        // owned by the graph checkpoint, not this ephemeral store.
1028        let tenant = TenantContext::new("graph", "run");
1029        let session_id = format!("graph__{}", req.node_id);
1030        let agent_id = format!("graph.{}", req.node_id);
1031
1032        let seeded = seed_state_from_graph(&tenant, &session_id, &req.state);
1033        if let Err(error) = state_store.save(&tenant, &session_id, &seeded).await {
1034            return Err(GraphExecError::AgentTurn(format!(
1035                "seeding conversation state: {error}"
1036            )));
1037        }
1038
1039        let cfg = AgentConfig {
1040            agent_id: agent_id.clone(),
1041            system_prompt: req.system_prompt.clone(),
1042            tools: vec![],
1043            guardrails: vec![],
1044            llm: LlmProviderRef {
1045                provider: req.provider.clone().unwrap_or_else(|| "openai".into()),
1046                model: req.model.clone(),
1047                credential_ref: None,
1048            },
1049            limits: AgentLimits::default(),
1050            memory: None,
1051            knowledge: None,
1052        };
1053        let mut provider = InMemoryConfigProvider::new();
1054        provider.insert(&tenant, &agent_id, cfg);
1055
1056        // TODO(guardrail): inline graph-node AgentRuntime bypasses guardrails (v1 scope is dw.agent flow nodes only).
1057        let runtime = AgentRuntime::new(
1058            Arc::new(provider),
1059            state_store,
1060            ext_runtime,
1061            llm,
1062            telemetry,
1063            token_meter,
1064            ledger,
1065            None,
1066        );
1067
1068        // The latest user turn is the most recent user message in the seeded log
1069        // (the executor pushes the initial user message and re-seeds it here);
1070        // pass an empty input so we do not duplicate it — the step appends the
1071        // input as a fresh user turn, so an empty string keeps the history intact.
1072        let input = AgentInput {
1073            text: String::new(),
1074        };
1075
1076        let out = run_agent_step(
1077            &runtime,
1078            tenant.clone(),
1079            &session_id,
1080            &agent_id,
1081            input,
1082            audit_sink,
1083            real_tenant,
1084        )
1085        .await
1086        .map_err(|e| GraphExecError::AgentTurn(format!("agent step failed: {e}")))?;
1087
1088        Ok(AgentTurnResult {
1089            resolved: detect_resolved(&out.reply),
1090            reply: strip_sentinel(&out.reply),
1091        })
1092    }
1093
1094    /// Routing sentinel that the supervisor LLM must include in its reply to
1095    /// select a branch. Parsed case-insensitively.
1096    const ROUTE_SENTINEL_PREFIX: &str = "[[ROUTE:";
1097    const ROUTE_SENTINEL_SUFFIX: &str = "]]";
1098
1099    /// Build the [`SupervisorFn`] effect closure.
1100    ///
1101    /// Each invocation constructs a lightweight [`AgentRuntime`] (same pattern
1102    /// as [`build_agent_turn`]) with a system prompt that appends a route menu
1103    /// to the node's own `systemPrompt`. The reply is scanned for
1104    /// `[[ROUTE:<branch>]]`; if the sentinel is absent or the branch is unknown
1105    /// the executor falls back to the FIRST route with a `tracing::warn!`.
1106    #[allow(clippy::too_many_arguments)]
1107    fn build_supervisor(
1108        state_store: Arc<dyn AgentStateStore>,
1109        ext_runtime: Arc<ExtensionRuntime>,
1110        llm: Arc<dyn LlmBackend>,
1111        telemetry: Arc<dyn Telemetry>,
1112        token_meter: Arc<dyn TokenMeter>,
1113        ledger: Arc<dyn ToolLedger>,
1114        audit_sink: Option<AuditSink>,
1115        real_tenant: greentic_types::TenantCtx,
1116    ) -> SupervisorFn {
1117        Arc::new(move |req: SupervisorRequest| {
1118            let state_store = state_store.clone();
1119            let ext_runtime = ext_runtime.clone();
1120            let llm = llm.clone();
1121            let telemetry = telemetry.clone();
1122            let token_meter = token_meter.clone();
1123            let ledger = ledger.clone();
1124            let audit_sink = audit_sink.clone();
1125            let real_tenant = real_tenant.clone();
1126            Box::pin(async move {
1127                run_one_supervisor_turn(
1128                    req,
1129                    state_store,
1130                    ext_runtime,
1131                    llm,
1132                    telemetry,
1133                    token_meter,
1134                    ledger,
1135                    audit_sink.as_ref(),
1136                    &real_tenant,
1137                )
1138                .await
1139            }) as BoxFut<'static, Result<SupervisorResult, GraphExecError>>
1140        })
1141    }
1142
1143    /// Drive one supervisor routing turn via [`AgentRuntime::step`].
1144    ///
1145    /// The routing prompt appends a route menu to the node's `systemPrompt`:
1146    ///
1147    /// ```text
1148    /// <node system_prompt>
1149    ///
1150    /// Available routes:
1151    /// - billing: Billing and payment questions
1152    /// - tech: Technical support issues
1153    ///
1154    /// Reply with [[ROUTE:<branch>]] to select a route.
1155    /// ```
1156    ///
1157    /// The agent reply is scanned for `[[ROUTE:<branch>]]` (case-insensitive).
1158    /// If the sentinel is absent or the branch does not match a declared route,
1159    /// the first route is used as fallback with a `tracing::warn!`.
1160    ///
1161    /// `audit_sink`/`real_tenant` are forwarded to [`run_agent_step`], which
1162    /// injects an [`AgentAuditObserver`] built from `real_tenant` when a sink
1163    /// is configured (EPIC-B B-3b Task 2).
1164    #[allow(clippy::too_many_arguments)]
1165    async fn run_one_supervisor_turn(
1166        req: SupervisorRequest,
1167        state_store: Arc<dyn AgentStateStore>,
1168        ext_runtime: Arc<ExtensionRuntime>,
1169        llm: Arc<dyn LlmBackend>,
1170        telemetry: Arc<dyn Telemetry>,
1171        token_meter: Arc<dyn TokenMeter>,
1172        ledger: Arc<dyn ToolLedger>,
1173        audit_sink: Option<&AuditSink>,
1174        real_tenant: &greentic_types::TenantCtx,
1175    ) -> Result<SupervisorResult, GraphExecError> {
1176        let tenant = TenantContext::new("graph", "run");
1177        let session_id = format!("graph__{}_sup", req.node_id);
1178        let agent_id = format!("graph.{}.supervisor", req.node_id);
1179
1180        // Build the route menu suffix.
1181        let mut route_menu = String::from("\n\nAvailable routes:\n");
1182        for r in &req.routes {
1183            route_menu.push_str(&format!("- {}: {}\n", r.branch, r.description));
1184        }
1185        route_menu.push_str("\nReply with [[ROUTE:<branch>]] to select a route.");
1186
1187        let routing_system_prompt = format!("{}{}", req.system_prompt, route_menu);
1188
1189        let seeded = seed_state_from_graph(&tenant, &session_id, &req.state);
1190        if let Err(error) = state_store.save(&tenant, &session_id, &seeded).await {
1191            return Err(GraphExecError::Supervisor(format!(
1192                "seeding conversation state: {error}"
1193            )));
1194        }
1195
1196        let cfg = AgentConfig {
1197            agent_id: agent_id.clone(),
1198            system_prompt: routing_system_prompt,
1199            tools: vec![],
1200            guardrails: vec![],
1201            llm: LlmProviderRef {
1202                provider: req.provider.clone().unwrap_or_else(|| "openai".into()),
1203                model: req.model.clone(),
1204                credential_ref: None,
1205            },
1206            limits: AgentLimits::default(),
1207            memory: None,
1208            knowledge: None,
1209        };
1210        let mut provider = InMemoryConfigProvider::new();
1211        provider.insert(&tenant, &agent_id, cfg);
1212
1213        // TODO(guardrail): inline graph-node AgentRuntime bypasses guardrails (v1 scope is dw.agent flow nodes only).
1214        let runtime = AgentRuntime::new(
1215            Arc::new(provider),
1216            state_store,
1217            ext_runtime,
1218            llm,
1219            telemetry,
1220            token_meter,
1221            ledger,
1222            // Supervisor routing runs with an empty tool list; MCP tools are
1223            // never offered on this path.
1224            None,
1225        );
1226
1227        let input = AgentInput {
1228            text: String::new(),
1229        };
1230
1231        let out = run_agent_step(
1232            &runtime,
1233            tenant.clone(),
1234            &session_id,
1235            &agent_id,
1236            input,
1237            audit_sink,
1238            real_tenant,
1239        )
1240        .await
1241        .map_err(|e| GraphExecError::Supervisor(format!("supervisor step failed: {e}")))?;
1242
1243        // Parse [[ROUTE:<branch>]] from the reply (case-insensitive).
1244        // Do this BEFORE stripping so we read from the unmodified reply.
1245        let branch = parse_route_branch(&out.reply, &req.routes).unwrap_or_else(|| {
1246            let fallback = req
1247                .routes
1248                .first()
1249                .map(|r| r.branch.clone())
1250                .unwrap_or_default();
1251            tracing::warn!(
1252                node_id = req.node_id,
1253                reply = %out.reply,
1254                fallback = %fallback,
1255                "supervisor reply missing or unknown [[ROUTE:]] sentinel; falling back to first route"
1256            );
1257            fallback
1258        });
1259
1260        // Strip the [[ROUTE:<branch>]] sentinel from the stored assistant message
1261        // so the message log does not expose routing instructions to downstream nodes.
1262        let raw_reply = strip_route_sentinel(&out.reply);
1263
1264        Ok(SupervisorResult { branch, raw_reply })
1265    }
1266
1267    /// Parse `[[ROUTE:<branch>]]` from the reply and return the branch label if
1268    /// it matches one of the declared routes. Returns `None` if the sentinel is
1269    /// absent or the extracted branch is not in the route list.
1270    fn parse_route_branch(
1271        reply: &str,
1272        routes: &[greentic_aw_runtime::graph::SupervisorRoute],
1273    ) -> Option<String> {
1274        let lower = reply.to_ascii_lowercase();
1275        let prefix_lower = ROUTE_SENTINEL_PREFIX.to_ascii_lowercase();
1276        let suffix_lower = ROUTE_SENTINEL_SUFFIX.to_ascii_lowercase();
1277        let start = lower.find(&prefix_lower)?;
1278        let after_prefix = start + prefix_lower.len();
1279        let end = lower[after_prefix..].find(&suffix_lower)?;
1280        let branch = reply[after_prefix..after_prefix + end].trim().to_string();
1281        // Validate against declared routes (case-sensitive match, per spec).
1282        if routes.iter().any(|r| r.branch == branch) {
1283            Some(branch)
1284        } else {
1285            None
1286        }
1287    }
1288
1289    /// Strip the `[[ROUTE:<branch>]]` sentinel (case-insensitive, first occurrence)
1290    /// from a supervisor reply, trimming surrounding whitespace.
1291    ///
1292    /// If no sentinel is present the reply is returned trimmed. Used to clean
1293    /// up the text before it is pushed into the message log as an assistant
1294    /// message.
1295    fn strip_route_sentinel(reply: &str) -> String {
1296        let lower = reply.to_ascii_lowercase();
1297        let prefix_lower = ROUTE_SENTINEL_PREFIX.to_ascii_lowercase();
1298        let suffix_lower = ROUTE_SENTINEL_SUFFIX.to_ascii_lowercase();
1299        if let Some(start) = lower.find(&prefix_lower) {
1300            let after_prefix = start + prefix_lower.len();
1301            if let Some(end) = lower[after_prefix..].find(&suffix_lower) {
1302                let sentinel_end = after_prefix + end + suffix_lower.len();
1303                let mut out = reply.to_string();
1304                out.replace_range(start..sentinel_end, "");
1305                return out.trim().to_string();
1306            }
1307        }
1308        reply.trim().to_string()
1309    }
1310
1311    /// Build the [`ToolFn`] effect closure.
1312    ///
1313    /// `tool_name` is parsed as `"extension_id/tool_name"` (split on the FIRST
1314    /// `'/'`). Anything without a `'/'`, or an empty side, is a
1315    /// [`GraphExecError::Tool`]. Dispatch goes through the shared
1316    /// [`ExtensionRuntime`] via [`dispatch_tool_call`], which wraps the blocking
1317    /// `invoke_tool` in `spawn_blocking`.
1318    fn build_tool(ext_runtime: Arc<ExtensionRuntime>) -> ToolFn {
1319        Arc::new(move |req: ToolCallRequest| {
1320            let ext_runtime = ext_runtime.clone();
1321            Box::pin(async move { run_one_tool(req, ext_runtime).await })
1322                as BoxFut<'static, Result<Value, GraphExecError>>
1323        })
1324    }
1325
1326    /// Default [`ApprovalFn`] for the in-process runner-host path: always
1327    /// reports `Awaiting`.
1328    ///
1329    /// The real `ApprovalFn` is designer-provided and wires the
1330    /// `greentic.approval.request.v1` / `.response.v1` round trip so a human
1331    /// decision can arrive asynchronously and unpark the run. This in-process
1332    /// default has no such transport, so it parks the run safely
1333    /// (`RunStatus::AwaitingInput`) but never resolves it. `derive_run_id`
1334    /// resumes (not forks) the parked run on every subsequent call for the
1335    /// same session, so it stays resolvable once a real `ApprovalFn` is
1336    /// wired in. See the `from_parts` doc comment; the designer-side
1337    /// approval bridge (subscriber + node, cross-repo Phase 2) supplies the
1338    /// real consumer.
1339    fn default_approval_awaiting() -> ApprovalFn {
1340        Arc::new(|_req: ApprovalRequest| Box::pin(async move { Ok(ApprovalOutcome::Awaiting) }))
1341    }
1342
1343    /// Parse + dispatch a single Tool-node call. See [`build_tool`].
1344    async fn run_one_tool(
1345        req: ToolCallRequest,
1346        ext_runtime: Arc<ExtensionRuntime>,
1347    ) -> Result<Value, GraphExecError> {
1348        let (extension_id, tool_name) = req.tool_name.split_once('/').ok_or_else(|| {
1349            GraphExecError::Tool(format!(
1350                "tool name '{}' must be 'extension_id/tool_name'",
1351                req.tool_name
1352            ))
1353        })?;
1354        if extension_id.is_empty() || tool_name.is_empty() {
1355            return Err(GraphExecError::Tool(format!(
1356                "tool name '{}' must be 'extension_id/tool_name' (non-empty parts)",
1357                req.tool_name
1358            )));
1359        }
1360
1361        // The graph Tool node carries no arguments today: dispatch with an empty
1362        // object. A future schema can thread structured args from node config.
1363        let call = greentic_aw_runtime::state::ToolCallRecord {
1364            call_id: format!("{}__{}", req.node_id, tool_name),
1365            extension_id: extension_id.to_string(),
1366            tool_name: tool_name.to_string(),
1367            args: json!({}),
1368        };
1369
1370        // Graph Tool nodes never carry mcp: or component: ids (they use
1371        // 'extension_id/tool' syntax over the WASM runtime), so neither the MCP
1372        // nor the component catalog is threaded here.
1373        // No per-request TenantContext is available at the graph-node layer;
1374        // use a no-op placeholder so the extension's host-LLM port receives an
1375        // empty context (equivalent to the previous `invoke_tool` default).
1376        let tenant = TenantContext::new("", "");
1377        dispatch_tool_call(ext_runtime, None, None, call, &tenant)
1378            .await
1379            .map_err(|e| GraphExecError::Tool(format!("dispatch '{}': {e}", req.tool_name)))
1380    }
1381
1382    #[cfg(all(test, feature = "agentic-worker"))]
1383    mod tests {
1384        use std::collections::HashMap;
1385        use std::sync::Arc;
1386        use std::sync::atomic::{AtomicU32, Ordering};
1387
1388        use greentic_aw_runtime::graph::model::SupervisorRoute;
1389        use greentic_aw_runtime::graph::{
1390            AgentTurnFn, AgentTurnRequest, AgentTurnResult, ApprovalFn, ApprovalOutcome,
1391            ApprovalRequest, GraphConfig, InMemoryCheckpointStore, SupervisorFn, SupervisorRequest,
1392            SupervisorResult, ToolCallRequest, ToolFn,
1393        };
1394        use greentic_aw_runtime::mock::MockAgentStateStore;
1395        use serde_json::json;
1396
1397        use super::*;
1398
1399        // -------------------------------------------------------------------
1400        // Fixtures
1401        // -------------------------------------------------------------------
1402
1403        /// Minimal triage-style graph: agent → lookup tool → router → respond,
1404        /// router maxIterations=3. Mirrors the aw-runtime test fixture (which is
1405        /// `pub(crate)` to that crate and thus not importable here).
1406        fn triage_graph_json(max_iterations: u32) -> String {
1407            json!({
1408                "schemaVersion": 1,
1409                "entry": "agent",
1410                "nodes": [
1411                    {"id": "agent", "kind": "agent", "systemPrompt": "You triage.", "model": "gpt-4o-mini", "tools": []},
1412                    {"id": "lookup", "kind": "tool", "toolName": "kb/search"},
1413                    {"id": "router", "kind": "router", "maxIterations": max_iterations},
1414                    {"id": "respond", "kind": "respond"}
1415                ],
1416                "edges": [
1417                    {"from": "agent", "to": "lookup"},
1418                    {"from": "lookup", "to": "router"},
1419                    {"from": "router", "to": "agent", "branch": "loop"},
1420                    {"from": "router", "to": "respond", "branch": "resolved"}
1421                ]
1422            })
1423            .to_string()
1424        }
1425
1426        fn triage_cfg(max_iterations: u32) -> GraphConfig {
1427            GraphConfig::from_json(&triage_graph_json(max_iterations)).expect("fixture valid")
1428        }
1429
1430        /// Graph with a human-in-the-loop approval gate: `start` (agent) ->
1431        /// `approval` -> `respond` (branch `"approved"`). Mirrors the
1432        /// aw-runtime executor test fixture (`approval_json` in
1433        /// `graph/executor.rs`), which is `pub(crate)` there and thus not
1434        /// importable here.
1435        fn approval_graph_json() -> String {
1436            json!({
1437                "schemaVersion": 2,
1438                "entry": "start",
1439                "nodes": [
1440                    {"id": "start", "kind": "agent", "systemPrompt": "greet the user", "model": "gpt-4o-mini", "tools": []},
1441                    {"id": "approval", "kind": "approval", "title": "Approve refund?", "mode": "always"},
1442                    {"id": "respond", "kind": "respond"}
1443                ],
1444                "edges": [
1445                    {"from": "start", "to": "approval"},
1446                    {"from": "approval", "to": "respond", "branch": "approved"}
1447                ]
1448            })
1449            .to_string()
1450        }
1451
1452        fn approval_cfg() -> GraphConfig {
1453            GraphConfig::from_json(&approval_graph_json()).expect("approval fixture valid")
1454        }
1455
1456        /// An [`ApprovalFn`] whose decision flips based on a shared flag:
1457        /// `Awaiting` while `false`, `Decided { branch: "approved" }` once
1458        /// `true`. Lets a single handler drive both the initial park and a
1459        /// later resume-with-decision within one test.
1460        fn approval_fn_toggle(decide: Arc<std::sync::atomic::AtomicBool>) -> ApprovalFn {
1461            Arc::new(move |_req: ApprovalRequest| {
1462                let decide = decide.clone();
1463                Box::pin(async move {
1464                    if decide.load(Ordering::SeqCst) {
1465                        Ok(ApprovalOutcome::Decided {
1466                            branch: "approved".to_string(),
1467                        })
1468                    } else {
1469                        Ok(ApprovalOutcome::Awaiting)
1470                    }
1471                })
1472            })
1473        }
1474
1475        fn provider_with(graph_id: &str, cfg: GraphConfig) -> Arc<InMemoryGraphProvider> {
1476            let mut graphs = HashMap::new();
1477            graphs.insert(graph_id.to_string(), cfg);
1478            Arc::new(InMemoryGraphProvider::new(graphs))
1479        }
1480
1481        /// Agent closure that resolves on the n-th (non-replayed) call.
1482        fn agent_fn_resolves_on(counter: Arc<AtomicU32>, resolve_on_call: u32) -> AgentTurnFn {
1483            Arc::new(move |req: AgentTurnRequest| {
1484                let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
1485                let resolved = n >= resolve_on_call;
1486                let reply = format!("reply-{n} from {}", req.node_id);
1487                Box::pin(async move { Ok(AgentTurnResult { reply, resolved }) })
1488            })
1489        }
1490
1491        fn tool_fn_ok() -> ToolFn {
1492            Arc::new(move |_req: ToolCallRequest| {
1493                Box::pin(async move { Ok(json!({"found": true})) })
1494            })
1495        }
1496
1497        /// A no-op supervisor fn for tests that do not exercise supervisor nodes.
1498        fn supervisor_fn_noop() -> SupervisorFn {
1499            Arc::new(|_req: SupervisorRequest| {
1500                Box::pin(async move {
1501                    Err(greentic_aw_runtime::graph::GraphExecError::Supervisor(
1502                        "supervisor fn should not be called in this test".into(),
1503                    ))
1504                })
1505            })
1506        }
1507
1508        /// A trivial approval fn that always reports `Awaiting` — none of
1509        /// these fixtures contain an approval node, so this is never invoked.
1510        fn approval_fn_awaiting() -> ApprovalFn {
1511            Arc::new(|_req: ApprovalRequest| Box::pin(async move { Ok(ApprovalOutcome::Awaiting) }))
1512        }
1513
1514        fn handler_with(
1515            graphs: Arc<dyn GraphConfigSource>,
1516            agent_turn: AgentTurnFn,
1517            tool: ToolFn,
1518        ) -> RuntimeGraphNodeHandler {
1519            RuntimeGraphNodeHandler::with_effects(
1520                graphs,
1521                Arc::new(InMemoryCheckpointStore::default()),
1522                Arc::new(MockAgentStateStore::new()),
1523                agent_turn,
1524                tool,
1525                supervisor_fn_noop(),
1526                approval_fn_awaiting(),
1527            )
1528        }
1529
1530        // -------------------------------------------------------------------
1531        // graph_config_from_sidecar tests
1532        // -------------------------------------------------------------------
1533
1534        #[test]
1535        fn sidecar_valid_triage_parses() {
1536            let bytes = triage_graph_json(3).into_bytes();
1537            let cfg = super::graph_config_from_sidecar("test-pack", &bytes);
1538            assert!(cfg.is_some(), "valid triage sidecar must parse");
1539        }
1540
1541        #[test]
1542        fn sidecar_malformed_json_returns_none() {
1543            let bytes = b"{ this is not valid json";
1544            // Must not panic; a malformed sidecar is skipped (None).
1545            let cfg = super::graph_config_from_sidecar("test-pack", bytes);
1546            assert!(cfg.is_none(), "malformed JSON sidecar must yield None");
1547        }
1548
1549        #[test]
1550        fn sidecar_unsupported_schema_version_returns_none() {
1551            let bytes = json!({
1552                "schemaVersion": 99,
1553                "entry": "agent",
1554                "nodes": [
1555                    {"id": "agent", "kind": "agent", "systemPrompt": "x", "model": "gpt-4o-mini", "tools": []},
1556                    {"id": "respond", "kind": "respond"}
1557                ],
1558                "edges": [{"from": "agent", "to": "respond"}]
1559            })
1560            .to_string()
1561            .into_bytes();
1562            let cfg = super::graph_config_from_sidecar("test-pack", &bytes);
1563            assert!(
1564                cfg.is_none(),
1565                "unsupported schemaVersion must be rejected (None)"
1566            );
1567        }
1568
1569        // -------------------------------------------------------------------
1570        // Tests
1571        // -------------------------------------------------------------------
1572
1573        #[tokio::test]
1574        async fn executes_graph_and_returns_dw_agent_envelope() {
1575            let handler = handler_with(
1576                provider_with("triage", triage_cfg(3)),
1577                agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1),
1578                tool_fn_ok(),
1579            );
1580
1581            let out = handler
1582                .execute("t", "e", "triage", "sess-1", &json!({"user_text": "help"}))
1583                .await
1584                .expect("execute should succeed");
1585
1586            assert_eq!(out["terminated_by"].as_str(), Some("respond"));
1587            assert!(
1588                out["reply"].as_str().unwrap_or("").contains("reply-1"),
1589                "reply should carry agent output: {:?}",
1590                out["reply"]
1591            );
1592            assert!(
1593                out["trail"]
1594                    .as_array()
1595                    .map(|a| !a.is_empty())
1596                    .unwrap_or(false),
1597                "trail should be a non-empty array: {:?}",
1598                out["trail"]
1599            );
1600        }
1601
1602        #[tokio::test]
1603        async fn same_session_resumes_completed_run_with_fresh_run() {
1604            // Shared checkpoint + state stores so the second call sees the first
1605            // run's terminal record and mints a fresh `__2` run id.
1606            let graphs = provider_with("triage", triage_cfg(3));
1607            let checkpoint = Arc::new(InMemoryCheckpointStore::default());
1608            let state_store = Arc::new(MockAgentStateStore::new());
1609            let counter = Arc::new(AtomicU32::new(0));
1610
1611            let handler = RuntimeGraphNodeHandler::with_effects(
1612                graphs,
1613                checkpoint.clone(),
1614                state_store,
1615                agent_fn_resolves_on(counter, 1),
1616                tool_fn_ok(),
1617                supervisor_fn_noop(),
1618                approval_fn_awaiting(),
1619            );
1620
1621            let first = handler
1622                .execute("t", "e", "triage", "sess-1", &json!({"user_text": "one"}))
1623                .await
1624                .expect("first call succeeds");
1625            assert_eq!(first["terminated_by"].as_str(), Some("respond"));
1626
1627            let second = handler
1628                .execute("t", "e", "triage", "sess-1", &json!({"user_text": "two"}))
1629                .await
1630                .expect("second call succeeds with fresh run id");
1631            assert_eq!(second["terminated_by"].as_str(), Some("respond"));
1632
1633            // The fresh run id (`__2`) must exist in the checkpoint store.
1634            let tenant = TenantContext::new("t", "e");
1635            let fresh = checkpoint
1636                .load(&tenant, "sess-1__triage__2")
1637                .await
1638                .expect("store ok");
1639            assert!(fresh.is_some(), "fresh __2 run id should be recorded");
1640        }
1641
1642        /// Task C3: a graph run parked at an approval node (`RunStatus::
1643        /// AwaitingInput`) must be surfaced as neither a completed respond
1644        /// nor a failure, and — unlike a terminal (`Succeeded`/`Failed`) run
1645        /// — the SAME run id must be resumed on every later call for the
1646        /// same session, not forked into a fresh `__n` slot (which would
1647        /// orphan the parked run: it could never be decided because nothing
1648        /// would ever resume it again).
1649        #[tokio::test]
1650        async fn awaiting_input_parks_without_orphaning_and_resumes_on_decision() {
1651            let graphs = provider_with("approval-graph", approval_cfg());
1652            let checkpoint = Arc::new(InMemoryCheckpointStore::default());
1653            let state_store = Arc::new(MockAgentStateStore::new());
1654            let decide = Arc::new(std::sync::atomic::AtomicBool::new(false));
1655
1656            let handler = RuntimeGraphNodeHandler::with_effects(
1657                graphs,
1658                checkpoint.clone(),
1659                state_store,
1660                agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1),
1661                tool_fn_ok(),
1662                supervisor_fn_noop(),
1663                approval_fn_toggle(decide.clone()),
1664            );
1665            let tenant = TenantContext::new("t", "e");
1666
1667            // Call 1: the run drives to the approval node and parks.
1668            let first = handler
1669                .execute(
1670                    "t",
1671                    "e",
1672                    "approval-graph",
1673                    "sess-approve",
1674                    &json!({"user_text": "please refund"}),
1675                )
1676                .await
1677                .expect("a parked run must be Ok, not an Err");
1678            assert_ne!(
1679                first["terminated_by"].as_str(),
1680                Some("respond"),
1681                "a parked run must not be reported as a completed respond: {first:?}"
1682            );
1683            assert_ne!(
1684                first["terminated_by"].as_str(),
1685                Some("error"),
1686                "a parked run must not be reported as an error: {first:?}"
1687            );
1688
1689            let rec = checkpoint
1690                .load(&tenant, "sess-approve__approval-graph")
1691                .await
1692                .expect("store accessible")
1693                .expect("base run id must be recorded");
1694            assert_eq!(
1695                rec.status,
1696                RunStatus::AwaitingInput,
1697                "persisted record must reflect the park"
1698            );
1699
1700            // Call 2 (still undecided): must RESUME the base run id in
1701            // place, not fork "__2" (the bug this test guards against).
1702            let second = handler
1703                .execute(
1704                    "t",
1705                    "e",
1706                    "approval-graph",
1707                    "sess-approve",
1708                    &json!({"user_text": "still waiting"}),
1709                )
1710                .await
1711                .expect("a re-parked run must be Ok, not an Err");
1712            assert_ne!(second["terminated_by"].as_str(), Some("respond"));
1713
1714            let forked = checkpoint
1715                .load(&tenant, "sess-approve__approval-graph__2")
1716                .await
1717                .expect("store accessible");
1718            assert!(
1719                forked.is_none(),
1720                "an AwaitingInput run must resume in place, not fork a fresh run id"
1721            );
1722
1723            // Call 3: the decision has arrived — the SAME base run id
1724            // resolves via `executor.resume()`, not a fresh run.
1725            decide.store(true, Ordering::SeqCst);
1726            let third = handler
1727                .execute(
1728                    "t",
1729                    "e",
1730                    "approval-graph",
1731                    "sess-approve",
1732                    &json!({"user_text": "resolve"}),
1733                )
1734                .await
1735                .expect("execute should succeed once decided");
1736            assert_eq!(third["terminated_by"].as_str(), Some("respond"));
1737
1738            let rec = checkpoint
1739                .load(&tenant, "sess-approve__approval-graph")
1740                .await
1741                .expect("store accessible")
1742                .expect("base run id must still be recorded");
1743            assert_eq!(
1744                rec.status,
1745                RunStatus::Succeeded,
1746                "the base run id must resolve once decided, not a forked one"
1747            );
1748        }
1749
1750        #[tokio::test]
1751        async fn missing_graph_returns_structured_error_reply() {
1752            let handler = handler_with(
1753                provider_with("triage", triage_cfg(3)),
1754                agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1),
1755                tool_fn_ok(),
1756            );
1757
1758            let out = handler
1759                .execute("t", "e", "unknown", "sess-1", &json!({"user_text": "hi"}))
1760                .await
1761                .expect("missing graph must NOT return Err");
1762
1763            assert_eq!(out["terminated_by"].as_str(), Some("error"));
1764            assert!(
1765                out["reply"]
1766                    .as_str()
1767                    .unwrap_or("")
1768                    .to_ascii_lowercase()
1769                    .contains("available"),
1770                "reply should mention availability: {:?}",
1771                out["reply"]
1772            );
1773        }
1774
1775        #[tokio::test]
1776        async fn missing_user_text_matches_agent_node_contract() {
1777            // agent_node.rs treats missing `user_text` as an empty string and
1778            // proceeds (never Err). Assert the same contract here.
1779            let handler = handler_with(
1780                provider_with("triage", triage_cfg(3)),
1781                agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1),
1782                tool_fn_ok(),
1783            );
1784
1785            let out = handler
1786                .execute("t", "e", "triage", "sess-1", &json!({}))
1787                .await
1788                .expect("missing user_text must NOT return Err (matches agent_node)");
1789
1790            assert_eq!(out["terminated_by"].as_str(), Some("respond"));
1791        }
1792
1793        #[tokio::test]
1794        async fn iteration_cap_maps_to_error_envelope() {
1795            // maxIterations=1000 on the router → the executor's global
1796            // MAX_NODE_VISITS cap (64) trips first → IterationCap → "error".
1797            let handler = handler_with(
1798                provider_with("triage", triage_cfg(1000)),
1799                // never resolves → loops until the global visit cap
1800                agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), u32::MAX),
1801                tool_fn_ok(),
1802            );
1803
1804            let out = handler
1805                .execute("t", "e", "triage", "sess-1", &json!({"user_text": "loop"}))
1806                .await
1807                .expect("iteration cap must NOT return Err");
1808
1809            assert_eq!(out["terminated_by"].as_str(), Some("error"));
1810            assert_ne!(
1811                out["reply"].as_str(),
1812                Some(SANITISED_ERROR_REPLY),
1813                "iteration cap should use a distinct reply"
1814            );
1815        }
1816
1817        /// Regression test: flow session ids can contain `':'` (e.g. MS Teams
1818        /// channel correlation ids like `"msteams:thread:19xyz"`). The checkpoint
1819        /// store rejects keys that contain `':'`, so without sanitization every
1820        /// such graph run fails at save-time. [`derive_run_id`] must replace every
1821        /// `':'` with `'_'` before composing the checkpoint key.
1822        #[tokio::test]
1823        async fn colon_bearing_session_id_executes_successfully() {
1824            let handler = handler_with(
1825                provider_with("triage", triage_cfg(3)),
1826                agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1),
1827                tool_fn_ok(),
1828            );
1829
1830            // Session id with multiple colons — typical of Teams thread ids.
1831            let out = handler
1832                .execute(
1833                    "t",
1834                    "e",
1835                    "triage",
1836                    "msteams:thread:19xyz",
1837                    &json!({"user_text": "hello"}),
1838                )
1839                .await
1840                .expect("colon-bearing session id must NOT return Err");
1841
1842            assert_eq!(
1843                out["terminated_by"].as_str(),
1844                Some("respond"),
1845                "colon-bearing session id should complete successfully: {:?}",
1846                out
1847            );
1848        }
1849
1850        // -------------------------------------------------------------------
1851        // LayeredGraphProvider tests
1852        // -------------------------------------------------------------------
1853
1854        /// Provider that always returns the given error.
1855        struct ErrGraphProvider(fn() -> ConfigError);
1856
1857        impl GraphConfigSource for ErrGraphProvider {
1858            fn graph_config<'a>(
1859                &'a self,
1860                _tenant: &'a TenantContext,
1861                _graph_id: &'a str,
1862            ) -> BoxFut<'a, Result<GraphConfig, ConfigError>> {
1863                let e = (self.0)();
1864                Box::pin(async move { Err(e) })
1865            }
1866        }
1867
1868        /// Fallback that panics if consulted — proves primary short-circuits.
1869        struct PanicGraphProvider;
1870
1871        impl GraphConfigSource for PanicGraphProvider {
1872            fn graph_config<'a>(
1873                &'a self,
1874                _tenant: &'a TenantContext,
1875                _graph_id: &'a str,
1876            ) -> BoxFut<'a, Result<GraphConfig, ConfigError>> {
1877                Box::pin(
1878                    async move { panic!("fallback must not be consulted when primary returns Ok") },
1879                )
1880            }
1881        }
1882
1883        #[tokio::test]
1884        async fn layered_primary_ok_short_circuits_fallback() {
1885            let mut graphs = HashMap::new();
1886            graphs.insert("g".to_string(), triage_cfg(3));
1887            let primary = InMemoryGraphProvider::new(graphs);
1888            let layered = LayeredGraphProvider::new(primary, PanicGraphProvider);
1889            let tc = TenantContext::new("t", "e");
1890            // If fallback were consulted, PanicGraphProvider panics and fails this.
1891            let cfg = layered.graph_config(&tc, "g").await.unwrap();
1892            assert_eq!(cfg.graph.entry, "agent");
1893        }
1894
1895        #[tokio::test]
1896        async fn layered_falls_back_on_not_found() {
1897            let mut graphs = HashMap::new();
1898            graphs.insert("g".to_string(), triage_cfg(3));
1899            let fb = InMemoryGraphProvider::new(graphs);
1900            let layered = LayeredGraphProvider::new(
1901                ErrGraphProvider(|| ConfigError::AgentNotFound("g".into())),
1902                fb,
1903            );
1904            let tc = TenantContext::new("t", "e");
1905            let cfg = layered.graph_config(&tc, "g").await.unwrap();
1906            assert_eq!(cfg.graph.entry, "agent");
1907        }
1908
1909        #[tokio::test]
1910        async fn layered_falls_back_on_internal() {
1911            let mut graphs = HashMap::new();
1912            graphs.insert("g".to_string(), triage_cfg(3));
1913            let fb = InMemoryGraphProvider::new(graphs);
1914            let layered = LayeredGraphProvider::new(
1915                ErrGraphProvider(|| ConfigError::Internal("down".into())),
1916                fb,
1917            );
1918            let tc = TenantContext::new("t", "e");
1919            let cfg = layered.graph_config(&tc, "g").await.unwrap();
1920            assert_eq!(cfg.graph.entry, "agent");
1921        }
1922
1923        #[tokio::test]
1924        async fn layered_propagates_misconfigured_without_fallback() {
1925            let fb = InMemoryGraphProvider::new(HashMap::new());
1926            let layered = LayeredGraphProvider::new(
1927                ErrGraphProvider(|| ConfigError::Misconfigured("bad schema".into())),
1928                fb,
1929            );
1930            let tc = TenantContext::new("t", "e");
1931            let result = layered.graph_config(&tc, "g").await;
1932            assert!(
1933                matches!(result, Err(ConfigError::Misconfigured(_))),
1934                "Misconfigured must NOT fall back: {result:?}"
1935            );
1936        }
1937
1938        // -------------------------------------------------------------------
1939        // graph_registry_from_env tests
1940        // -------------------------------------------------------------------
1941
1942        #[test]
1943        #[serial_test::serial]
1944        #[allow(unsafe_code)]
1945        fn graph_registry_from_env_requires_both_vars() {
1946            // SAFETY: #[serial] serializes env-mutating tests; vars cleaned up.
1947            unsafe {
1948                std::env::remove_var("GREENTIC_AW_ADMIN_ENDPOINT");
1949                std::env::remove_var("GREENTIC_AW_ADMIN_TOKEN");
1950            }
1951            assert!(super::graph_registry_from_env().is_none());
1952
1953            unsafe {
1954                std::env::set_var("GREENTIC_AW_ADMIN_ENDPOINT", "http://localhost:9999");
1955            }
1956            assert!(
1957                super::graph_registry_from_env().is_none(),
1958                "endpoint alone is not enough"
1959            );
1960
1961            unsafe {
1962                std::env::set_var("GREENTIC_AW_ADMIN_TOKEN", "gtc_live_x");
1963            }
1964            assert!(super::graph_registry_from_env().is_some());
1965
1966            // Token-alone (no endpoint) must also be None.
1967            unsafe {
1968                std::env::remove_var("GREENTIC_AW_ADMIN_ENDPOINT");
1969            }
1970            assert!(
1971                super::graph_registry_from_env().is_none(),
1972                "token alone is not enough"
1973            );
1974
1975            unsafe {
1976                std::env::remove_var("GREENTIC_AW_ADMIN_TOKEN");
1977            }
1978        }
1979
1980        // -------------------------------------------------------------------
1981        // Supervisor fixture
1982        // -------------------------------------------------------------------
1983
1984        /// Supervisor graph (schemaVersion 2): sup → agent_billing / agent_tech →
1985        /// router → respond. Mirrors the aw-runtime `supervisor_json()` fixture
1986        /// (which is `pub(crate)` to that crate and cannot be imported here).
1987        ///
1988        /// Topology:
1989        ///   sup (supervisor: routes=[billing, tech])
1990        ///    ├─[billing]─► agent_billing ─► router_billing ─┬─[loop]──► sup
1991        ///    │                                               └─[resolved]─► respond
1992        ///    └─[tech]────► agent_tech ───► router_tech    ─┬─[loop]──► sup
1993        ///                                                   └─[resolved]─► respond
1994        fn supervisor_graph_json() -> String {
1995            json!({
1996                "schemaVersion": 2,
1997                "entry": "sup",
1998                "nodes": [
1999                    {
2000                        "id": "sup",
2001                        "kind": "supervisor",
2002                        "systemPrompt": "Route the request to the correct specialist.",
2003                        "model": "gpt-4o-mini",
2004                        "routes": [
2005                            {"branch": "billing", "description": "Billing and payment questions"},
2006                            {"branch": "tech",    "description": "Technical support issues"}
2007                        ]
2008                    },
2009                    {"id": "agent_billing", "kind": "agent", "systemPrompt": "Billing.", "model": "gpt-4o-mini", "tools": []},
2010                    {"id": "router_billing", "kind": "router", "maxIterations": 2},
2011                    {"id": "agent_tech",    "kind": "agent", "systemPrompt": "Tech.",    "model": "gpt-4o-mini", "tools": []},
2012                    {"id": "router_tech",   "kind": "router", "maxIterations": 2},
2013                    {"id": "respond",       "kind": "respond"}
2014                ],
2015                "edges": [
2016                    {"from": "sup",           "to": "agent_billing",  "branch": "billing"},
2017                    {"from": "sup",           "to": "agent_tech",     "branch": "tech"},
2018                    {"from": "agent_billing", "to": "router_billing"},
2019                    {"from": "router_billing","to": "agent_billing",  "branch": "loop"},
2020                    {"from": "router_billing","to": "respond",        "branch": "resolved"},
2021                    {"from": "agent_tech",    "to": "router_tech"},
2022                    {"from": "router_tech",   "to": "agent_tech",     "branch": "loop"},
2023                    {"from": "router_tech",   "to": "respond",        "branch": "resolved"}
2024                ]
2025            })
2026            .to_string()
2027        }
2028
2029        fn supervisor_cfg() -> GraphConfig {
2030            GraphConfig::from_json(&supervisor_graph_json()).expect("supervisor fixture valid")
2031        }
2032
2033        /// Helpers for building [`SupervisorRoute`] values inline.
2034        fn route(branch: &str, description: &str) -> SupervisorRoute {
2035            SupervisorRoute {
2036                branch: branch.to_string(),
2037                description: description.to_string(),
2038            }
2039        }
2040
2041        /// Build a supervisor fn that always routes to `fixed_branch`.
2042        fn supervisor_fn_routes_to(fixed_branch: &str) -> SupervisorFn {
2043            let branch = fixed_branch.to_string();
2044            Arc::new(move |_req: SupervisorRequest| {
2045                let branch = branch.clone();
2046                Box::pin(async move {
2047                    Ok(SupervisorResult {
2048                        branch: branch.clone(),
2049                        raw_reply: format!("I'll route this. [[ROUTE:{branch}]] Done."),
2050                    })
2051                })
2052            })
2053        }
2054
2055        // Build a handler wired with the given supervisor fn (and a no-op agent
2056        // that always resolves immediately).
2057        fn supervisor_handler(
2058            graphs: Arc<dyn GraphConfigSource>,
2059            supervisor: SupervisorFn,
2060        ) -> RuntimeGraphNodeHandler {
2061            let agent: AgentTurnFn = agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1);
2062            RuntimeGraphNodeHandler::with_effects(
2063                graphs,
2064                Arc::new(InMemoryCheckpointStore::default()),
2065                Arc::new(MockAgentStateStore::new()),
2066                agent,
2067                tool_fn_ok(),
2068                supervisor,
2069                approval_fn_awaiting(),
2070            )
2071        }
2072
2073        // -------------------------------------------------------------------
2074        // parse_route_branch unit tests
2075        // -------------------------------------------------------------------
2076
2077        /// Two routes for the parse tests.
2078        fn billing_tech_routes() -> Vec<SupervisorRoute> {
2079            vec![
2080                route("billing", "Billing and payment questions"),
2081                route("tech", "Technical support issues"),
2082            ]
2083        }
2084
2085        #[test]
2086        fn parse_route_extracts_billing_from_reply() {
2087            // Standard case: sentinel embedded in a longer reply.
2088            let reply = "I analysed the message. [[ROUTE:billing]] Go ahead.";
2089            let result = parse_route_branch(reply, &billing_tech_routes());
2090            assert_eq!(result, Some("billing".to_string()));
2091        }
2092
2093        #[test]
2094        fn parse_route_case_insensitive_prefix() {
2095            // The prefix [[ROUTE: is matched case-insensitively; the extracted
2096            // branch label must still match the declared route exactly
2097            // (case-sensitive per spec).
2098            let reply = "[[route:tech]] is the answer";
2099            let result = parse_route_branch(reply, &billing_tech_routes());
2100            assert_eq!(result, Some("tech".to_string()));
2101        }
2102
2103        #[test]
2104        fn parse_route_missing_sentinel_returns_none() {
2105            // No sentinel at all → None (caller's unwrap_or_else picks first route).
2106            let reply = "I think billing would be best here.";
2107            let result = parse_route_branch(reply, &billing_tech_routes());
2108            assert_eq!(result, None);
2109        }
2110
2111        #[test]
2112        fn parse_route_unknown_branch_returns_none() {
2113            // Sentinel present but branch label does not match any declared route.
2114            let reply = "[[ROUTE:unknown_branch]] routing";
2115            let result = parse_route_branch(reply, &billing_tech_routes());
2116            assert_eq!(result, None);
2117        }
2118
2119        #[test]
2120        fn parse_route_multiple_sentinels_uses_first() {
2121            // When multiple sentinels are present the function uses the FIRST one
2122            // (find on the lowercased string returns the first occurrence).
2123            let reply = "[[ROUTE:billing]] or [[ROUTE:tech]]";
2124            let result = parse_route_branch(reply, &billing_tech_routes());
2125            assert_eq!(
2126                result,
2127                Some("billing".to_string()),
2128                "multiple sentinels: first occurrence wins"
2129            );
2130        }
2131
2132        #[test]
2133        fn parse_route_branch_label_whitespace_trimmed() {
2134            // Whitespace around the branch label inside the sentinel is trimmed.
2135            let reply = "[[ROUTE:  billing  ]]";
2136            let result = parse_route_branch(reply, &billing_tech_routes());
2137            assert_eq!(result, Some("billing".to_string()));
2138        }
2139
2140        // -------------------------------------------------------------------
2141        // strip_route_sentinel unit tests
2142        // -------------------------------------------------------------------
2143
2144        #[test]
2145        fn strip_sentinel_removes_token_leaves_rest() {
2146            let reply = "I'll route this. [[ROUTE:billing]] Proceeding.";
2147            let stripped = strip_route_sentinel(reply);
2148            assert!(
2149                !stripped.contains("[[ROUTE:billing]]"),
2150                "sentinel must be removed: {stripped:?}"
2151            );
2152            assert!(
2153                stripped.contains("Proceeding"),
2154                "text after sentinel must survive: {stripped:?}"
2155            );
2156        }
2157
2158        #[test]
2159        fn strip_sentinel_no_sentinel_returns_trimmed() {
2160            let reply = "  No routing needed here.  ";
2161            let stripped = strip_route_sentinel(reply);
2162            assert_eq!(stripped, "No routing needed here.");
2163        }
2164
2165        #[test]
2166        fn strip_sentinel_case_insensitive_removal() {
2167            let reply = "Decision: [[route:tech]] done.";
2168            let stripped = strip_route_sentinel(reply);
2169            assert!(
2170                !stripped.to_ascii_lowercase().contains("[[route:"),
2171                "sentinel must be stripped case-insensitively: {stripped:?}"
2172            );
2173        }
2174
2175        #[test]
2176        fn strip_sentinel_only_first_occurrence_removed() {
2177            // If two sentinels appear, only the first is stripped.
2178            let reply = "[[ROUTE:billing]] first [[ROUTE:tech]] second";
2179            let stripped = strip_route_sentinel(reply);
2180            assert!(
2181                !stripped.contains("[[ROUTE:billing]]"),
2182                "first sentinel must be gone: {stripped:?}"
2183            );
2184            // The second sentinel remains (we only strip the first occurrence).
2185            assert!(
2186                stripped.contains("[[ROUTE:tech]]"),
2187                "second sentinel must remain: {stripped:?}"
2188            );
2189        }
2190
2191        // -------------------------------------------------------------------
2192        // Fallback: parse_route_branch None → first-route fallback
2193        // -------------------------------------------------------------------
2194
2195        #[test]
2196        fn parse_route_none_documents_fallback_to_first_route() {
2197            // This is a pure-fn test of the fallback path used in
2198            // `run_one_supervisor_turn` via `unwrap_or_else(|| first_route)`.
2199            // When parse_route_branch returns None, the build_supervisor closure
2200            // picks req.routes.first().branch as the fallback.
2201            let routes = billing_tech_routes();
2202            let garbage = "absolutely no routing sentinel here";
2203            let parsed = parse_route_branch(garbage, &routes);
2204            assert_eq!(parsed, None, "no sentinel → None from parser");
2205
2206            // Simulate the fallback: first route is "billing".
2207            let fallback = parsed
2208                .unwrap_or_else(|| routes.first().map(|r| r.branch.clone()).unwrap_or_default());
2209            assert_eq!(
2210                fallback, "billing",
2211                "None → first route 'billing' as fallback"
2212            );
2213        }
2214
2215        // -------------------------------------------------------------------
2216        // Handler-level supervisor routing tests
2217        // -------------------------------------------------------------------
2218
2219        #[tokio::test]
2220        async fn supervisor_routes_to_billing_returns_respond_envelope() {
2221            // The mock supervisor always picks "billing". The billing agent
2222            // resolves immediately. Expect terminated_by="respond" and a
2223            // non-empty reply.
2224            let handler = supervisor_handler(
2225                provider_with("sup_graph", supervisor_cfg()),
2226                supervisor_fn_routes_to("billing"),
2227            );
2228
2229            let out = handler
2230                .execute(
2231                    "t",
2232                    "e",
2233                    "sup_graph",
2234                    "sess-sup-1",
2235                    &json!({"user_text": "I need billing help"}),
2236                )
2237                .await
2238                .expect("supervisor execute must not Err");
2239
2240            assert_eq!(
2241                out["terminated_by"].as_str(),
2242                Some("respond"),
2243                "billing path must terminate at respond: {out:?}"
2244            );
2245            assert!(
2246                out["reply"].as_str().is_some_and(|r| !r.is_empty()),
2247                "reply must be non-empty: {out:?}"
2248            );
2249            assert!(
2250                out["trail"].as_array().is_some_and(|t| !t.is_empty()),
2251                "trail must be non-empty: {out:?}"
2252            );
2253        }
2254
2255        #[tokio::test]
2256        async fn supervisor_routes_to_tech_returns_respond_envelope() {
2257            // Same topology, supervisor picks "tech" branch instead.
2258            let handler = supervisor_handler(
2259                provider_with("sup_graph", supervisor_cfg()),
2260                supervisor_fn_routes_to("tech"),
2261            );
2262
2263            let out = handler
2264                .execute(
2265                    "t",
2266                    "e",
2267                    "sup_graph",
2268                    "sess-sup-2",
2269                    &json!({"user_text": "my device is broken"}),
2270                )
2271                .await
2272                .expect("supervisor tech-route execute must not Err");
2273
2274            assert_eq!(
2275                out["terminated_by"].as_str(),
2276                Some("respond"),
2277                "tech path must terminate at respond: {out:?}"
2278            );
2279        }
2280
2281        #[tokio::test]
2282        async fn supervisor_fallback_on_missing_sentinel_still_completes() {
2283            // A supervisor fn that returns a raw_reply with NO [[ROUTE:]] sentinel.
2284            // The build_supervisor closure maps None→first-route ("billing").
2285            // The handler-level execute() must still complete (not Err).
2286            //
2287            // Because with_effects injects the supervisor fn directly (bypassing
2288            // build_supervisor's parse), we simulate the fallback by having the
2289            // fn return the first route explicitly — matching what build_supervisor
2290            // does in production via parse_route_branch(...)
2291            //   .unwrap_or_else(|| routes.first().branch).
2292            // The pure-fn fallback path is fully covered by
2293            // `parse_route_none_documents_fallback_to_first_route`.
2294            let supervisor: SupervisorFn = Arc::new(|_req: SupervisorRequest| {
2295                // No sentinel in raw_reply; we return first-route directly as
2296                // the mock of the fallback behaviour.
2297                Box::pin(async move {
2298                    Ok(SupervisorResult {
2299                        branch: "billing".to_string(),
2300                        raw_reply: "I think billing is right.".to_string(),
2301                    })
2302                })
2303            });
2304
2305            let handler =
2306                supervisor_handler(provider_with("sup_graph", supervisor_cfg()), supervisor);
2307
2308            let out = handler
2309                .execute(
2310                    "t",
2311                    "e",
2312                    "sup_graph",
2313                    "sess-sup-3",
2314                    &json!({"user_text": "help me"}),
2315                )
2316                .await
2317                .expect("fallback supervisor must not Err");
2318
2319            assert_eq!(
2320                out["terminated_by"].as_str(),
2321                Some("respond"),
2322                "fallback path must still reach respond: {out:?}"
2323            );
2324        }
2325
2326        // -------------------------------------------------------------------
2327        // EPIC-B B-3b Task 2: `AgentAuditObserver` injection at the graph
2328        // turn `.step` seam (`run_agent_step`, shared by `run_one_agent_turn`
2329        // and `run_one_supervisor_turn`).
2330        // -------------------------------------------------------------------
2331
2332        fn real_tenant_ctx(tenant: &str, env: &str) -> greentic_types::TenantCtx {
2333            greentic_types::TenantCtx::new(
2334                greentic_types::EnvId::try_from(env).expect("valid env id"),
2335                greentic_types::TenantId::try_from(tenant).expect("valid tenant id"),
2336            )
2337        }
2338
2339        /// Build an [`AgentRuntime`] scripted to make one `remember` (host
2340        /// built-in short-term-memory) tool call before replying "done".
2341        /// Mirrors `agent_node`'s `runtime_with_scripted_remember_call`
2342        /// fixture — the host built-in path fires
2343        /// `StepObserver::on_tool_call`/`on_tool_result` without needing a
2344        /// real WASM extension dispatch, so it is the cheapest way to drive a
2345        /// genuine tool call through the seam.
2346        ///
2347        /// `run_one_agent_turn`/`run_one_supervisor_turn` hardcode
2348        /// `tools: vec![]` + `memory: None` on their ephemeral per-visit
2349        /// `AgentConfig` (graph-node agents have no tool/memory access in v1
2350        /// scope — see the `TODO(guardrail)` comment at each call site), so
2351        /// no tool call can ever reach the observer through those two
2352        /// functions today. This fixture instead drives the shared
2353        /// `run_agent_step` seam directly with its own memory-enabled
2354        /// config — the exact same `match audit_sink { .. }` code both turn
2355        /// functions delegate to — so the real-tenant wiring is verified
2356        /// against genuine tool-call traffic rather than asserted in the
2357        /// abstract.
2358        fn agent_runtime_with_scripted_remember_call(
2359            tenant: &TenantContext,
2360            agent_id: &str,
2361        ) -> AgentRuntime {
2362            use greentic_aw_runtime::cost::MockTokenMeter;
2363            use greentic_aw_runtime::llm::LlmResponse;
2364            use greentic_aw_runtime::mock::{MockLlmBackend, MockTelemetry, NoopToolLedger};
2365            use greentic_aw_runtime::state::ToolCallRecord;
2366            use greentic_aw_runtime::{InMemoryMemoryProvider, MemoryProviderRef, MemorySettings};
2367
2368            let llm = Arc::new(MockLlmBackend::new(vec![
2369                Ok(LlmResponse {
2370                    content: None,
2371                    tool_calls: vec![ToolCallRecord {
2372                        call_id: "c1".into(),
2373                        extension_id: "host".into(),
2374                        tool_name: "remember".into(),
2375                        args: json!({"key": "k", "value": "v"}),
2376                    }],
2377                    tokens_in: 1,
2378                    tokens_out: 1,
2379                }),
2380                Ok(LlmResponse {
2381                    content: Some("done".into()),
2382                    tool_calls: vec![],
2383                    tokens_in: 1,
2384                    tokens_out: 1,
2385                }),
2386            ]));
2387
2388            let cfg = AgentConfig {
2389                agent_id: agent_id.to_string(),
2390                system_prompt: "test".into(),
2391                tools: vec![],
2392                guardrails: vec![],
2393                llm: LlmProviderRef {
2394                    provider: "openai".into(),
2395                    model: "gpt-4o-mini".into(),
2396                    credential_ref: None,
2397                },
2398                limits: AgentLimits::default(),
2399                memory: Some(MemorySettings {
2400                    short_term: Some(MemoryProviderRef {
2401                        provider: "inmemory".into(),
2402                        capability: "cap://memory/short-term".into(),
2403                        params: Default::default(),
2404                        credential_ref: None,
2405                    }),
2406                    long_term: None,
2407                }),
2408                knowledge: None,
2409            };
2410            let mut provider = InMemoryConfigProvider::new();
2411            provider.insert(tenant, agent_id, cfg);
2412
2413            AgentRuntime::new(
2414                Arc::new(provider),
2415                Arc::new(MockAgentStateStore::new()),
2416                Arc::new(crate::runner::agent_node::test_extension_runtime()),
2417                llm,
2418                Arc::new(MockTelemetry::new()),
2419                Arc::new(MockTokenMeter::new(0)),
2420                Arc::new(NoopToolLedger),
2421                None,
2422            )
2423            .with_short_term_memory(Arc::new(InMemoryMemoryProvider::new()))
2424        }
2425
2426        #[tokio::test]
2427        async fn run_agent_step_with_audit_sink_enqueues_events_under_real_tenant_not_state_tenant()
2428        {
2429            // The SYNTHETIC state tenant every graph-node turn uses today —
2430            // must NOT leak into the audit subject.
2431            let state_tenant = TenantContext::new("graph", "run");
2432            let agent_id = "graph.agent-1";
2433            let session_id = "graph__agent-1";
2434            let runtime = agent_runtime_with_scripted_remember_call(&state_tenant, agent_id);
2435
2436            let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2437            let sink = AuditSink::from_sender(tx);
2438            let real_tenant = real_tenant_ctx("acme", "prod");
2439
2440            let input = AgentInput {
2441                text: String::new(),
2442            };
2443            let out = run_agent_step(
2444                &runtime,
2445                state_tenant.clone(),
2446                session_id,
2447                agent_id,
2448                input,
2449                Some(&sink),
2450                &real_tenant,
2451            )
2452            .await
2453            .expect("step should succeed");
2454            assert_eq!(out.reply, "done");
2455
2456            let (subject, bytes) = rx.try_recv().expect("tool_call audit event enqueued");
2457            assert_eq!(
2458                subject, "audit.acme.agent.tool_call",
2459                "audit subject must carry the REAL tenant (\"acme\"), not the synthetic state tenant (\"graph\")"
2460            );
2461            let value: Value = serde_json::from_slice(&bytes).expect("valid JSON");
2462            assert_eq!(value["payload"]["tool"], json!("remember"));
2463            assert_eq!(value["payload"]["agent_id"], json!(agent_id));
2464
2465            let (subject, bytes) = rx.try_recv().expect("tool_result audit event enqueued");
2466            assert_eq!(subject, "audit.acme.agent.tool_result");
2467            let value: Value = serde_json::from_slice(&bytes).expect("valid JSON");
2468            assert_eq!(value["payload"]["tool"], json!("remember"));
2469
2470            assert!(
2471                rx.try_recv().is_err(),
2472                "exactly two audit events enqueued (one tool_call, one tool_result)"
2473            );
2474        }
2475
2476        #[tokio::test]
2477        async fn run_agent_step_without_audit_sink_uses_plain_step_path_unchanged() {
2478            // Same scripted tool call as the audited test above, but no audit
2479            // sink at all — proves the "off" branch (runtime.step, no
2480            // observer constructed) still dispatches the tool call and
2481            // returns the same reply, exactly as it did before
2482            // AgentAuditObserver existed.
2483            let state_tenant = TenantContext::new("graph", "run");
2484            let agent_id = "graph.agent-1";
2485            let session_id = "graph__agent-1";
2486            let runtime = agent_runtime_with_scripted_remember_call(&state_tenant, agent_id);
2487            let real_tenant = real_tenant_ctx("acme", "prod");
2488
2489            let input = AgentInput {
2490                text: String::new(),
2491            };
2492            let out = run_agent_step(
2493                &runtime,
2494                state_tenant.clone(),
2495                session_id,
2496                agent_id,
2497                input,
2498                None,
2499                &real_tenant,
2500            )
2501            .await
2502            .expect("step should succeed");
2503
2504            assert_eq!(out.reply, "done");
2505        }
2506
2507        /// Minimal [`MockLlmBackend`] that always replies with `reply` and no
2508        /// tool calls — enough to drive `run_one_agent_turn`/
2509        /// `run_one_supervisor_turn` end to end without needing tool access
2510        /// (which their hardcoded ephemeral `AgentConfig` does not grant).
2511        fn plain_reply_llm(reply: &str) -> Arc<dyn LlmBackend> {
2512            use greentic_aw_runtime::llm::LlmResponse;
2513            use greentic_aw_runtime::mock::MockLlmBackend;
2514
2515            Arc::new(MockLlmBackend::new(vec![Ok(LlmResponse {
2516                content: Some(reply.to_string()),
2517                tool_calls: vec![],
2518                tokens_in: 1,
2519                tokens_out: 1,
2520            })]))
2521        }
2522
2523        fn agent_turn_request(node_id: &str, system_prompt: &str) -> AgentTurnRequest {
2524            AgentTurnRequest {
2525                node_id: node_id.to_string(),
2526                system_prompt: system_prompt.to_string(),
2527                model: "gpt-4o-mini".to_string(),
2528                state: GraphRunState::default(),
2529                provider: None,
2530            }
2531        }
2532
2533        /// Shared effect Arcs for `run_one_agent_turn`/`run_one_supervisor_turn`
2534        /// test calls: state store, ext runtime, telemetry, token meter, ledger.
2535        type AgentTurnEffects = (
2536            Arc<dyn AgentStateStore>,
2537            Arc<ExtensionRuntime>,
2538            Arc<dyn Telemetry>,
2539            Arc<dyn TokenMeter>,
2540            Arc<dyn ToolLedger>,
2541        );
2542
2543        fn agent_turn_effects() -> AgentTurnEffects {
2544            use greentic_aw_runtime::cost::MockTokenMeter;
2545            use greentic_aw_runtime::mock::{MockTelemetry, NoopToolLedger};
2546
2547            (
2548                Arc::new(MockAgentStateStore::new()),
2549                Arc::new(crate::runner::agent_node::test_extension_runtime()),
2550                Arc::new(MockTelemetry::new()),
2551                Arc::new(MockTokenMeter::new(0)),
2552                Arc::new(NoopToolLedger),
2553            )
2554        }
2555
2556        #[tokio::test]
2557        async fn run_one_agent_turn_without_audit_sink_completes_unchanged() {
2558            let (state_store, ext_runtime, telemetry, token_meter, ledger) = agent_turn_effects();
2559            let real_tenant = real_tenant_ctx("acme", "prod");
2560
2561            let result = run_one_agent_turn(
2562                agent_turn_request("agent", "You triage."),
2563                state_store,
2564                ext_runtime,
2565                plain_reply_llm("all good [[RESOLVED]]"),
2566                telemetry,
2567                token_meter,
2568                ledger,
2569                None,
2570                &real_tenant,
2571            )
2572            .await
2573            .expect("turn should succeed");
2574
2575            assert!(result.resolved);
2576            assert_eq!(result.reply, "all good");
2577        }
2578
2579        #[tokio::test]
2580        async fn run_one_agent_turn_with_audit_sink_completes_same_reply_as_without() {
2581            // No tool call fires on this path (graph-node agent turns are
2582            // built with an empty tool list today), so no audit event is
2583            // expected here either — this test guards that routing through
2584            // `step_with_observer` does not change the turn's outcome.
2585            // Genuine tool-call audit-under-real-tenant coverage lives in
2586            // `run_agent_step_with_audit_sink_enqueues_events_under_real_tenant_not_state_tenant`.
2587            let (state_store, ext_runtime, telemetry, token_meter, ledger) = agent_turn_effects();
2588            let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2589            let sink = AuditSink::from_sender(tx);
2590            let real_tenant = real_tenant_ctx("acme", "prod");
2591
2592            let result = run_one_agent_turn(
2593                agent_turn_request("agent", "You triage."),
2594                state_store,
2595                ext_runtime,
2596                plain_reply_llm("all good [[RESOLVED]]"),
2597                telemetry,
2598                token_meter,
2599                ledger,
2600                Some(&sink),
2601                &real_tenant,
2602            )
2603            .await
2604            .expect("turn should succeed");
2605
2606            assert!(result.resolved);
2607            assert_eq!(result.reply, "all good");
2608            assert!(
2609                rx.try_recv().is_err(),
2610                "no tool call happens on this path, so no audit event is expected"
2611            );
2612        }
2613
2614        #[tokio::test]
2615        async fn run_one_supervisor_turn_without_audit_sink_completes_unchanged() {
2616            let (state_store, ext_runtime, telemetry, token_meter, ledger) = agent_turn_effects();
2617            let real_tenant = real_tenant_ctx("acme", "prod");
2618            let routes = vec![
2619                SupervisorRoute {
2620                    branch: "billing".into(),
2621                    description: "Billing questions".into(),
2622                },
2623                SupervisorRoute {
2624                    branch: "tech".into(),
2625                    description: "Technical support".into(),
2626                },
2627            ];
2628            let req = SupervisorRequest {
2629                node_id: "router".to_string(),
2630                system_prompt: "Route the user.".to_string(),
2631                model: "gpt-4o-mini".to_string(),
2632                routes,
2633                state: GraphRunState::default(),
2634                provider: None,
2635            };
2636
2637            // No [[ROUTE:..]] sentinel in the reply -> falls back to the
2638            // first declared route.
2639            let result = run_one_supervisor_turn(
2640                req,
2641                state_store,
2642                ext_runtime,
2643                plain_reply_llm("I'm not sure, let me think."),
2644                telemetry,
2645                token_meter,
2646                ledger,
2647                None,
2648                &real_tenant,
2649            )
2650            .await
2651            .expect("supervisor turn should succeed");
2652
2653            assert_eq!(result.branch, "billing");
2654        }
2655
2656        #[tokio::test]
2657        async fn run_one_supervisor_turn_with_audit_sink_completes_same_branch_as_without() {
2658            let (state_store, ext_runtime, telemetry, token_meter, ledger) = agent_turn_effects();
2659            let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2660            let sink = AuditSink::from_sender(tx);
2661            let real_tenant = real_tenant_ctx("acme", "prod");
2662            let routes = vec![
2663                SupervisorRoute {
2664                    branch: "billing".into(),
2665                    description: "Billing questions".into(),
2666                },
2667                SupervisorRoute {
2668                    branch: "tech".into(),
2669                    description: "Technical support".into(),
2670                },
2671            ];
2672            let req = SupervisorRequest {
2673                node_id: "router".to_string(),
2674                system_prompt: "Route the user.".to_string(),
2675                model: "gpt-4o-mini".to_string(),
2676                routes,
2677                state: GraphRunState::default(),
2678                provider: None,
2679            };
2680
2681            let result = run_one_supervisor_turn(
2682                req,
2683                state_store,
2684                ext_runtime,
2685                plain_reply_llm("I'm not sure, let me think."),
2686                telemetry,
2687                token_meter,
2688                ledger,
2689                Some(&sink),
2690                &real_tenant,
2691            )
2692            .await
2693            .expect("supervisor turn should succeed");
2694
2695            assert_eq!(result.branch, "billing");
2696            assert!(
2697                rx.try_recv().is_err(),
2698                "no tool call happens on this path, so no audit event is expected"
2699            );
2700        }
2701    }
2702}
2703
2704#[cfg(feature = "agentic-worker")]
2705pub use aw::{
2706    GraphConfigSource, InMemoryGraphProvider, LayeredGraphProvider, RuntimeGraphNodeHandler,
2707    build_graph_node_handler, graph_config_from_sidecar,
2708};