nexo_poller/poller.rs
1//! Trait + context every Poller implements. Kept Laravel-style:
2//! the runner is a dumb scheduler — the trait knows nothing about
3//! channels, credentials, or outbound topics. Pollers reach the world
4//! through [`PollerHost`], which is the single point of egress.
5//!
6//! Compared to the pre-Phase-96 shape, this module no longer exports
7//! `OutboundDelivery` / `TickOutcome` / channel-typed bundle fields.
8//! Those concerns live with the poller now.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use nexo_llm::ToolDef;
16use serde_json::Value;
17use tokio_util::sync::CancellationToken;
18
19use crate::error::PollerError;
20use crate::host::{PollerHost, TickAck};
21use crate::PollerRunner;
22
23/// Implemented by every poller module (built-in or out-of-tree).
24/// `Send + Sync + 'static` because the runner stores `Arc<dyn Poller>`
25/// and spawns tasks against them.
26#[async_trait]
27pub trait Poller: Send + Sync + 'static {
28 /// Discriminator used in YAML (`kind: rss`) and metrics.
29 fn kind(&self) -> &'static str;
30
31 /// Human label for `agent pollers list`. Defaults to empty.
32 fn description(&self) -> &'static str {
33 ""
34 }
35
36 /// Validate the per-job `config` JSON at boot. Errors fail loading
37 /// of that job only; siblings keep going. Default: accept anything.
38 fn validate(&self, _config: &Value) -> Result<(), PollerError> {
39 Ok(())
40 }
41
42 /// Run one tick. The runner persists the returned cursor and
43 /// honors the interval hint; anything the poller wants to send
44 /// outbound (broker publish, LLM call, credential lookup) goes
45 /// through `ctx.host`.
46 async fn tick(&self, ctx: &PollContext) -> Result<TickAck, PollerError>;
47
48 /// Optional per-kind LLM tools registered alongside the generic
49 /// `pollers_*` tools. Pollers without custom tools leave this
50 /// empty (default).
51 fn custom_tools(&self) -> Vec<CustomToolSpec> {
52 Vec::new()
53 }
54}
55
56/// Per-kind LLM tool spec — adapted into an `nexo-core` `ToolHandler`
57/// by the `nexo-poller-tools` crate at registration time.
58pub struct CustomToolSpec {
59 pub def: ToolDef,
60 pub handler: Arc<dyn CustomToolHandler>,
61}
62
63/// Local handler trait — kept inside `nexo-poller` so the crate
64/// stays free of `nexo-core`. The adapter lives in `nexo-poller-tools`.
65#[async_trait]
66pub trait CustomToolHandler: Send + Sync + 'static {
67 async fn call(&self, runner: Arc<PollerRunner>, args: Value) -> anyhow::Result<Value>;
68}
69
70/// What the runner hands a poller on every tick. Compared to V1:
71/// dropped `credentials`, `bundle`, `broker`, `llm_registry`,
72/// `llm_config`. Added `host` — the single egress.
73pub struct PollContext {
74 pub job_id: String,
75 pub agent_id: String,
76 pub kind: &'static str,
77 /// The `config:` block from `pollers.yaml` for this job.
78 pub config: Value,
79 /// Opaque cursor returned by the previous successful tick. `None`
80 /// on first run, after `agent pollers reset <id>`, or after a
81 /// `Permanent` error that cleared state.
82 pub cursor: Option<Vec<u8>>,
83 pub now: DateTime<Utc>,
84 /// Schedule's nominal interval, before jitter. Useful for pollers
85 /// that pace internally (RSS feeds honoring `<ttl>`).
86 pub interval_hint: Duration,
87 /// Cancelled when the runner is shutting down OR when this job is
88 /// hot-reloaded. Pollers with long inner loops (HTTP retries,
89 /// pagination) should `tokio::select!` on this so reload doesn't
90 /// hang behind a stuck request.
91 pub cancel: CancellationToken,
92 /// Single egress for runtime-level concerns. Pollers use this for
93 /// broker publishes, credential resolution, LLM invocations,
94 /// logging, and metrics.
95 pub host: Arc<dyn PollerHost>,
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 fn assert_send_sync<T: Send + Sync>() {}
103
104 #[test]
105 fn poll_context_is_send_sync() {
106 assert_send_sync::<PollContext>();
107 }
108}