Skip to main content

zeph_core/
json_event_layer.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `JsonEventLayer`: a [`crate::runtime_layer::RuntimeLayer`] that emits tool events via [`JsonEventSink`].
5//!
6//! Install this layer on the agent when `--json` is active. It is the *canonical*
7//! emitter for `tool_call` and `tool_result` events — `JsonCliChannel` intentionally
8//! no-ops its corresponding channel methods to avoid double-emission.
9//!
10//! All tool arguments and outputs pass through [`crate::redact::scrub_content`] before
11//! emission so secrets (API keys, bearer tokens, passwords) are not written to the JSONL
12//! stream.
13
14use std::future::Future;
15use std::pin::Pin;
16use std::sync::Arc;
17
18use zeph_tools::ToolError;
19use zeph_tools::executor::{ToolCall, ToolOutput};
20
21use crate::json_event_sink::{JsonEvent, JsonEventSink};
22use crate::runtime_layer::{BeforeToolResult, LayerContext, RuntimeLayer};
23
24/// `RuntimeLayer` that forwards tool events to a [`JsonEventSink`].
25pub struct JsonEventLayer {
26    sink: Arc<JsonEventSink>,
27}
28
29impl JsonEventLayer {
30    /// Create a new layer sharing `sink` with `JsonCliChannel`.
31    #[must_use]
32    pub fn new(sink: Arc<JsonEventSink>) -> Self {
33        Self { sink }
34    }
35}
36
37impl RuntimeLayer for JsonEventLayer {
38    fn before_tool<'a>(
39        &'a self,
40        _ctx: &'a LayerContext<'_>,
41        call: &'a ToolCall,
42    ) -> Pin<Box<dyn Future<Output = BeforeToolResult> + Send + 'a>> {
43        // Serialize args, scrub secrets, then re-parse so the sink receives a clean Value.
44        let raw = serde_json::Value::Object(call.params.clone());
45        let raw_str = raw.to_string();
46        let scrubbed_str = crate::redact::scrub_content(&raw_str);
47        let args_value: serde_json::Value =
48            serde_json::from_str(&scrubbed_str).unwrap_or(serde_json::Value::Null);
49        self.sink.emit(&JsonEvent::ToolCall {
50            tool: call.tool_id.as_ref(),
51            args: &args_value,
52            id: call.tool_id.as_ref(),
53        });
54        Box::pin(std::future::ready(None))
55    }
56
57    fn after_tool<'a>(
58        &'a self,
59        _ctx: &'a LayerContext<'_>,
60        call: &'a ToolCall,
61        result: &'a Result<Option<ToolOutput>, ToolError>,
62    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
63        let err_str;
64        let scrubbed_err;
65        let scrubbed_out;
66        let (output, is_error) = match result {
67            Ok(Some(out)) => {
68                scrubbed_out = crate::redact::scrub_content(&out.summary);
69                (scrubbed_out.as_ref(), false)
70            }
71            Ok(None) => ("", false),
72            Err(e) => {
73                err_str = e.to_string();
74                scrubbed_err = crate::redact::scrub_content(&err_str);
75                (scrubbed_err.as_ref(), true)
76            }
77        };
78        self.sink.emit(&JsonEvent::ToolResult {
79            tool: call.tool_id.as_ref(),
80            id: call.tool_id.as_ref(),
81            output,
82            is_error,
83        });
84        Box::pin(std::future::ready(()))
85    }
86}