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}