Skip to main content

agentcat_sdk/
client.rs

1//! [`AgentCat`]: the handle that every [`Tracked`] server of a project shares, and [`track`].
2
3use std::any::TypeId;
4use std::collections::HashMap;
5use std::fmt;
6use std::sync::{Arc, Mutex, PoisonError};
7use std::time::{Duration, SystemTime};
8
9use rmcp::ServerHandler;
10
11use crate::config::Options;
12#[cfg(test)]
13use crate::config::env::MapEnv;
14#[cfg(not(test))]
15use crate::config::env::ProcessEnv;
16use crate::config::env::{AGENTCAT_PROJECT_ID, Env, resolve_api_base_url, sdk_enabled};
17use crate::custom::{CustomEvent, publish_string};
18#[cfg(feature = "test-util")]
19use crate::delivery::Observer;
20use crate::delivery::http::ApiPublisher;
21use crate::delivery::{Delivery, DeliveryConfig, LineWriter, Publisher};
22use crate::error::Error;
23use crate::event::CallRecord;
24use crate::event::assemble::EventJob;
25use crate::logging;
26use crate::registry::ToolRegistry;
27use crate::sink::Sink;
28use crate::tracked::Tracked;
29
30#[cfg(test)]
31mod tests;
32
33/// A handle to `AgentCat` for one project. Clones are cheap and share everything, so one handle
34/// serves every server instance: a streamable HTTP service builds one handler per session or per
35/// request, and each of them is wrapped with [`AgentCat::track`].
36///
37/// ```
38/// use agentcat_sdk::{AgentCat, Options};
39///
40/// # struct MyServer;
41/// # impl rmcp::ServerHandler for MyServer {}
42/// let agentcat = AgentCat::new("proj_123", Options::default());
43/// let make_server = {
44///     let agentcat = agentcat.clone();
45///     move || agentcat.track(MyServer)
46/// };
47/// # let _ = make_server();
48/// ```
49///
50/// Its `Debug` output shows the project ID and the options only.
51///
52/// # Dropping the last handle
53///
54/// A handle delivers its events from its own thread, which the first event or log line starts.
55/// When the last clone drops (the [`Tracked`] servers hold clones too) while that thread runs,
56/// the drop runs [`shutdown`](Self::shutdown) with a 5 s timeout and blocks the dropping thread
57/// until the delivery thread answers: at most about 5 s, less when the events finish sooner. The
58/// events that have not finished at the deadline are dropped.
59///
60/// The hooks of [`Options`] (`resolve_session_id`, `identify`, `event_tags`, `event_properties`,
61/// `redact_sensitive_information` and `redact_event`) run on the tokio runtime of the tool call.
62/// On a current-thread runtime, such as `#[tokio::main(flavor = "current_thread")]`, a drop on
63/// the runtime's thread blocks its only thread, so no hook can run during the wait: an event that
64/// still needs a hook waits for the whole 5 s and is then dropped. Call `shutdown(timeout).await`
65/// before the runtime ends, while the hooks can still run:
66///
67/// ```no_run
68/// # async fn run(agentcat: agentcat_sdk::AgentCat) {
69/// use std::time::Duration;
70///
71/// // ... serve until the client goes away ...
72/// let delivered = agentcat.shutdown(Duration::from_secs(5)).await;
73/// # let _ = delivered;
74/// # }
75/// ```
76///
77/// A hook of [`Options`] that holds a clone of the handle makes a reference cycle: the handle
78/// holds its options, and the options hold the hook. The handle then never drops, so the drain
79/// of the last drop never runs. Call [`shutdown`](Self::shutdown) before the process ends, or let
80/// the hook capture only what it needs. A tool reaches the handle through
81/// [`CallContext::agentcat`](crate::CallContext::agentcat), so it needs no clone.
82///
83/// A drop on the delivery thread itself does not wait. A handle whose delivery thread never
84/// started has nothing to drain. The drain does not run at all after `std::process::exit` or a
85/// panic under `panic = "abort"`.
86#[derive(Clone)]
87pub struct AgentCat {
88    inner: Arc<Inner>,
89}
90
91impl fmt::Debug for AgentCat {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        self.inner.debug_fields(f, "AgentCat")
94    }
95}
96
97/// What the clones of one [`AgentCat`] share.
98pub(crate) struct Inner {
99    /// The project that receives the events; `""` means telemetry only.
100    pub(crate) project_id: String,
101    pub(crate) options: Options,
102    /// Whether the SDK is on for this handle. `AGENTCAT_ENABLED` turns it off: then the
103    /// [`Tracked`] servers forward every method untouched and the sink drops everything.
104    pub(crate) enabled: bool,
105    /// Where the SDK hands its own log lines, its records and its events.
106    pub(crate) sink: Arc<dyn Sink>,
107    /// What the listings taught the SDK, one registry per inner handler type.
108    registries: Mutex<HashMap<TypeId, ToolRegistry>>,
109}
110
111impl fmt::Debug for Inner {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        self.debug_fields(f, "Inner")
114    }
115}
116
117impl Inner {
118    /// `Debug` output under the name `name` with the project ID and the options only, not the
119    /// registries or the sink.
120    fn debug_fields(&self, f: &mut fmt::Formatter<'_>, name: &str) -> fmt::Result {
121        f.debug_struct(name)
122            .field("project_id", &self.project_id)
123            .field("options", &self.options)
124            .finish_non_exhaustive()
125    }
126
127    /// Runs `f` on the registry of the handler type `H`, made on first use. A poisoned lock is
128    /// taken over. The lock is held for `f` only, never across an `.await`.
129    pub(crate) fn with_registry<H: 'static, R>(&self, f: impl FnOnce(&mut ToolRegistry) -> R) -> R {
130        let mut registries = self
131            .registries
132            .lock()
133            .unwrap_or_else(PoisonError::into_inner);
134        f(registries.entry(TypeId::of::<H>()).or_default())
135    }
136}
137
138/// The warning a handle logs when no project ID is given.
139const NO_PROJECT_WARNING: &str = "Warning: No project_id provided (AgentCat::from_env reads it from AGENTCAT_PROJECT_ID). Events will not be sent anywhere.";
140
141/// What a handle posts with, the lines it logs first and where its worker writes them.
142struct Transport {
143    publisher: Arc<dyn Publisher>,
144    startup_lines: Vec<String>,
145    /// Where the worker writes its lines; `None` is the process logger.
146    write: Option<LineWriter>,
147}
148
149impl Transport {
150    /// The transport of the handles that [`AgentCat::new`] and [`AgentCat::from_env`] build: the
151    /// real one, [`Transport::api`].
152    #[cfg(not(test))]
153    fn process(options: &Options, env: &dyn Env) -> Self {
154        Self::api(options, env)
155    }
156
157    /// The real transport: the process logger is set to the destination of `options` (the first
158    /// handle wins) and events go to the API, with the home directory and the API URL
159    /// of `env`. A unit test reaches it through `AgentCat::with_api_transport` only.
160    fn api(options: &Options, env: &dyn Env) -> Self {
161        logging::configure(options.log_to(), env.home_dir());
162        let mut startup_lines = Vec::new();
163        let publisher = ApiPublisher::new(resolve_api_base_url(options, env), &mut startup_lines);
164        Self {
165            publisher: Arc::new(publisher),
166            startup_lines,
167            write: None,
168        }
169    }
170
171    /// The transport of a handle that [`AgentCat::new`] builds in a unit test: events are
172    /// accepted and dropped, nothing reaches a network, and the process logger stays as it is
173    /// (unset, so the real home directory is not written to). A test that needs the real
174    /// publisher builds it itself, or its handle with [`AgentCat::with_api_transport`], against a
175    /// loopback server and a temp home.
176    #[cfg(test)]
177    fn process(options: &Options, env: &dyn Env) -> Self {
178        let _ = (options, env);
179        Self {
180            publisher: Arc::new(OfflinePublisher),
181            startup_lines: Vec::new(),
182            write: None,
183        }
184    }
185}
186
187/// A publisher that accepts every event and sends none: the one of unit-test handles and of the
188/// test fake.
189#[cfg(any(test, feature = "test-util"))]
190struct OfflinePublisher;
191
192#[cfg(any(test, feature = "test-util"))]
193impl Publisher for OfflinePublisher {
194    fn publish(
195        &self,
196        _request: agentcat_api::models::PublishEventRequest,
197    ) -> crate::sink::BoxFuture<'static, Result<(), String>> {
198        Box::pin(std::future::ready(Ok(())))
199    }
200}
201
202/// The environment that [`AgentCat::new`] and [`AgentCat::from_env`] read: the process's.
203#[cfg(not(test))]
204fn default_env() -> ProcessEnv {
205    ProcessEnv
206}
207
208/// The environment that [`AgentCat::new`] and [`AgentCat::from_env`] read in unit tests: an empty
209/// one, so an `AGENTCAT_ENABLED` or `AGENTCAT_PROJECT_ID` in the developer's shell cannot change
210/// the handles of tests, as [`Transport::process`] keeps them off the network and the log
211/// file. A test that needs a variable builds its handle with [`AgentCat::with_env`].
212#[cfg(test)]
213fn default_env() -> MapEnv {
214    MapEnv::new(&[])
215}
216
217/// The sink of a handle that `AGENTCAT_ENABLED` turned off: it drops every line, record and
218/// event, so nothing starts a thread, and it answers `flush` and `shutdown` with `true`.
219struct DisabledSink;
220
221impl Sink for DisabledSink {
222    fn log(&self, _message: String) {}
223
224    fn record(&self, _record: CallRecord) {}
225
226    fn event(&self, _job: EventJob) {}
227}
228
229/// The sink of a handle that publishes: a [`Delivery`] for `project_id` with the redaction hooks
230/// of `options`, posting through `publisher`.
231#[cfg(test)]
232fn delivery_sink(
233    project_id: &str,
234    options: &Options,
235    publisher: Arc<dyn Publisher>,
236    config: DeliveryConfig,
237) -> Arc<dyn Sink> {
238    Arc::new(Delivery::new(
239        project_id.to_owned(),
240        options,
241        publisher,
242        None,
243        config,
244    ))
245}
246
247const _: () = {
248    const fn send_sync_static<T: Send + Sync + 'static>() {}
249    send_sync_static::<AgentCat>();
250};
251
252impl AgentCat {
253    /// A handle for `project_id`. Building one cannot fail and starts no thread until the first
254    /// event or log line. An empty `project_id` means telemetry only: events are built but never
255    /// sent to `AgentCat`.
256    ///
257    /// The environment variable [`AGENTCAT_ENABLED`](crate::AGENTCAT_ENABLED) set to `false`, `0`,
258    /// `no` or `off` (any case, surrounding white space ignored) turns the handle off: it never
259    /// starts a thread, its [`Tracked`] servers forward every method to the wrapped handler
260    /// untouched (a tool still gets its [`CallContext`](crate::CallContext), with no session and
261    /// no agent), custom events return `Ok(())` at once and publish nothing (also for an empty
262    /// project ID), and the lifecycle methods return `true`. The same holds for [`track`] and
263    /// [`AgentCat::from_env`].
264    pub fn new(project_id: impl Into<String>, options: Options) -> Self {
265        Self::build(project_id.into(), options, &default_env())
266    }
267
268    /// A handle like [`AgentCat::new`] for the project that the environment variable
269    /// [`AGENTCAT_PROJECT_ID`] names. When it is unset or empty, the handle is telemetry only, as
270    /// `AgentCat::new` with an empty project ID.
271    ///
272    /// ```no_run
273    /// use agentcat_sdk::{AgentCat, Options};
274    ///
275    /// // AGENTCAT_PROJECT_ID=proj_123 in the environment.
276    /// let agentcat = AgentCat::from_env(Options::default());
277    /// # let _ = agentcat;
278    /// ```
279    pub fn from_env(options: Options) -> Self {
280        Self::from_env_in(options, &default_env())
281    }
282
283    /// [`AgentCat::from_env`] in the environment `env`.
284    fn from_env_in(options: Options, env: &dyn Env) -> Self {
285        let project_id = env.var(AGENTCAT_PROJECT_ID).unwrap_or_default();
286        Self::build(project_id, options, env)
287    }
288
289    /// A handle like [`AgentCat::new`] in the environment `env`.
290    #[cfg(test)]
291    pub(crate) fn with_env(project_id: &str, options: Options, env: &dyn Env) -> Self {
292        Self::build(project_id.to_owned(), options, env)
293    }
294
295    /// A handle like [`AgentCat::with_env`] with the transport that `AgentCat::new` uses outside
296    /// unit tests ([`Transport::api`]), for a test that points `env` at a loopback server and a
297    /// temp home.
298    #[cfg(test)]
299    pub(crate) fn with_api_transport(project_id: &str, options: Options, env: &dyn Env) -> Self {
300        Self::build_with(project_id.to_owned(), options, env, Transport::api)
301    }
302
303    /// [`AgentCat::new`] in the environment `env`: a handle that is off when `env` turns the SDK
304    /// off, else one that publishes through the process transport.
305    fn build(project_id: String, options: Options, env: &dyn Env) -> Self {
306        Self::build_with(project_id, options, env, Transport::process)
307    }
308
309    /// [`AgentCat::build`] with the transport that `transport` makes.
310    fn build_with(
311        project_id: String,
312        options: Options,
313        env: &dyn Env,
314        transport: fn(&Options, &dyn Env) -> Transport,
315    ) -> Self {
316        if !sdk_enabled(env) {
317            return Self::from_inner(Inner {
318                project_id,
319                options,
320                enabled: false,
321                sink: Arc::new(DisabledSink),
322                registries: Mutex::default(),
323            });
324        }
325        let transport = transport(&options, env);
326        Self::assemble(project_id, options, transport, DeliveryConfig::default())
327    }
328
329    /// A handle whose events go through a [`Delivery`] over `transport`. An empty `project_id`
330    /// queues [`NO_PROJECT_WARNING`], then the transport's own lines; the first line starts the
331    /// worker, which writes it.
332    fn assemble(
333        project_id: String,
334        options: Options,
335        transport: Transport,
336        config: DeliveryConfig,
337    ) -> Self {
338        let project = project_id.clone();
339        let delivery = match transport.write {
340            None => Delivery::new(project, &options, transport.publisher, None, config),
341            Some(write) => {
342                Delivery::with_writer(project, &options, transport.publisher, None, config, write)
343            }
344        };
345        if project_id.is_empty() {
346            delivery.log(NO_PROJECT_WARNING.to_owned());
347        }
348        for line in transport.startup_lines {
349            delivery.log(line);
350        }
351        Self::from_inner(Inner {
352            project_id,
353            options,
354            enabled: true,
355            sink: Arc::new(delivery),
356            registries: Mutex::default(),
357        })
358    }
359
360    /// A handle like [`AgentCat::new`] whose events go through a delivery queue that posts with
361    /// `publisher` and uses `config`.
362    #[cfg(test)]
363    pub(crate) fn with_delivery(
364        project_id: &str,
365        options: Options,
366        publisher: Arc<dyn Publisher>,
367        config: DeliveryConfig,
368    ) -> Self {
369        let sink = delivery_sink(project_id, &options, publisher, config);
370        Self::with_sink(project_id, options, sink)
371    }
372
373    /// A handle for the test fake: always enabled, whatever `AGENTCAT_ENABLED` says;
374    /// it never configures the process logger; its publisher never sends; `observer` is told
375    /// about every event that finishes the pipeline. Built here, not through [`Transport`],
376    /// because a customer's tests compile the library without `cfg(test)`.
377    #[cfg(feature = "test-util")]
378    pub(crate) fn offline_observed(
379        project_id: String,
380        options: Options,
381        observer: Arc<dyn Observer>,
382    ) -> Self {
383        let delivery = Delivery::new(
384            project_id.clone(),
385            &options,
386            Arc::new(OfflinePublisher),
387            Some(observer),
388            DeliveryConfig::default(),
389        );
390        Self::from_inner(Inner {
391            project_id,
392            options,
393            enabled: true,
394            sink: Arc::new(delivery),
395            registries: Mutex::default(),
396        })
397    }
398
399    /// A handle like [`AgentCat::new`] that hands its log lines to `sink`.
400    #[cfg(test)]
401    pub(crate) fn with_sink(project_id: &str, options: Options, sink: Arc<dyn Sink>) -> Self {
402        Self::from_inner(Inner {
403            project_id: project_id.to_owned(),
404            options,
405            enabled: true,
406            sink,
407            registries: Mutex::default(),
408        })
409    }
410
411    /// A handle that `AGENTCAT_ENABLED` turned off, whose sink is `sink`, so a test sees
412    /// that nothing reaches it.
413    #[cfg(test)]
414    pub(crate) fn disabled_with_sink(
415        project_id: &str,
416        options: Options,
417        sink: Arc<dyn Sink>,
418    ) -> Self {
419        Self::from_inner(Inner {
420            project_id: project_id.to_owned(),
421            options,
422            enabled: false,
423            sink,
424            registries: Mutex::default(),
425        })
426    }
427
428    fn from_inner(inner: Inner) -> Self {
429        Self {
430            inner: Arc::new(inner),
431        }
432    }
433
434    /// Wraps `handler` so that this handle sees its tool listings and tool calls. Cheap: it
435    /// clones the handle and does no other work.
436    pub fn track<H: ServerHandler>(&self, handler: H) -> Tracked<H> {
437        Tracked::new(handler, self.clone())
438    }
439
440    /// Publishes a custom event to the project `project_id` for the session `session_id`. It only
441    /// queues the event, so it returns at once.
442    ///
443    /// The session is the event's own [`session_id`](CustomEvent::session_id) when it is not
444    /// empty, else `session_id` verbatim (it may be empty for none). The event has no server or
445    /// client fields and goes through no redaction hook. It is queued even while tracing is off.
446    /// A handle that `AGENTCAT_ENABLED` turned off returns `Ok(())` at once and publishes nothing.
447    ///
448    /// ```no_run
449    /// use agentcat_sdk::{AgentCat, CustomEvent, Options};
450    ///
451    /// let agentcat = AgentCat::new("proj_123", Options::default());
452    /// let event = CustomEvent::new().resource_name("cache-warmed").message("40 keys");
453    /// agentcat.publish_custom_event("ses_1", "proj_123", event)?;
454    /// # Ok::<(), agentcat_sdk::Error>(())
455    /// ```
456    ///
457    /// # Errors
458    ///
459    /// [`Error::ProjectIdRequired`] when `project_id` is empty, except on a handle that
460    /// `AGENTCAT_ENABLED` turned off, which returns `Ok(())` and does nothing.
461    pub fn publish_custom_event(
462        &self,
463        session_id: &str,
464        project_id: &str,
465        event: CustomEvent,
466    ) -> Result<(), Error> {
467        publish_string(self, session_id, project_id, event, SystemTime::now())
468    }
469
470    /// What the clones of this handle share.
471    pub(crate) fn inner(&self) -> &Inner {
472        &self.inner
473    }
474
475    /// Waits until every event this handle took so far is delivered, for at most `timeout`;
476    /// `true` when that happened in time. Events taken while it waits are waited for too.
477    ///
478    /// The SDK's delivery thread keeps the time, so the calling runtime needs no timer. Events
479    /// still go out after a `false`. Use it before a short-lived process ends, or in a test.
480    ///
481    /// It returns `true` at once when the handle has taken no event yet, and after a
482    /// [`shutdown`](Self::shutdown) has ended, also one that timed out: nothing is left to
483    /// deliver. Once polled, the wait stays with the delivery thread until the events are
484    /// delivered or `timeout` passes, also when the future is dropped first (for example by a
485    /// timeout).
486    ///
487    /// ```no_run
488    /// # async fn run(agentcat: agentcat_sdk::AgentCat) {
489    /// use std::time::Duration;
490    ///
491    /// let delivered = agentcat.flush(Duration::from_secs(5)).await;
492    /// # let _ = delivered;
493    /// # }
494    /// ```
495    pub async fn flush(&self, timeout: Duration) -> bool {
496        self.inner.sink.flush(timeout).await
497    }
498
499    /// Like [`flush`](Self::flush), then stops this handle for good: every event it takes from
500    /// the call on is dropped, for all its clones. When `timeout` passes first, the events left
501    /// are abandoned and it returns `false`.
502    ///
503    /// Once polled, the shutdown runs to its end, also when the future is dropped first (for
504    /// example by a timeout): the handle drops later events and stops once its events are
505    /// delivered or `timeout` passes. A second `shutdown` after the first has ended returns
506    /// `true` at once.
507    ///
508    /// Without it, the drop of the last clone runs the same shutdown with a 5 s timeout and
509    /// blocks the dropping thread while it runs: see [dropping the last
510    /// handle](AgentCat#dropping-the-last-handle). `std::process::exit` and a panic under
511    /// `panic = "abort"` skip that drain.
512    pub async fn shutdown(&self, timeout: Duration) -> bool {
513        self.inner.sink.shutdown(timeout).await
514    }
515
516    /// [`flush`](Self::flush) that blocks the calling thread for at most `timeout` (and a few
517    /// milliseconds more). It needs no tokio runtime; on a runtime thread it blocks that thread.
518    pub fn flush_blocking(&self, timeout: Duration) -> bool {
519        self.inner.sink.flush_blocking(timeout)
520    }
521
522    /// [`shutdown`](Self::shutdown) that blocks the calling thread for at most `timeout` (and a
523    /// few milliseconds more). It needs no tokio runtime; on a runtime thread it blocks that
524    /// thread.
525    pub fn shutdown_blocking(&self, timeout: Duration) -> bool {
526        self.inner.sink.shutdown_blocking(timeout)
527    }
528}
529
530/// Wraps `handler` with a new [`AgentCat`] handle for `project_id`: the one-line setup for a
531/// server with one handler, such as a stdio server. The handle is [`AgentCat::new`]'s, so
532/// `AGENTCAT_ENABLED` turns it off in the same way.
533///
534/// The returned server holds the only clone of the handle, so its drop drains the events and can
535/// block the dropping thread for up to 5 s: see [dropping the last
536/// handle](AgentCat#dropping-the-last-handle). On a current-thread runtime, clone the handle with
537/// [`Tracked::agentcat`] before you serve, and call its [`shutdown`](AgentCat::shutdown) before
538/// the runtime ends.
539///
540/// ```no_run
541/// use agentcat_sdk::{Options, track};
542/// use rmcp::ServiceExt;
543///
544/// # struct MyServer;
545/// # impl rmcp::ServerHandler for MyServer {}
546/// # async fn run(transport: tokio::io::DuplexStream) -> Result<(), Box<dyn std::error::Error>> {
547/// let server = track(MyServer, "proj_123", Options::default());
548/// let running = server.serve(transport).await?;
549/// running.waiting().await?;
550/// # Ok(())
551/// # }
552/// ```
553pub fn track<H: ServerHandler>(
554    handler: H,
555    project_id: impl Into<String>,
556    options: Options,
557) -> Tracked<H> {
558    AgentCat::new(project_id, options).track(handler)
559}