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};