Skip to main content

zeroclaw_api/
attribution.rs

1//! Alias-bound attribution surface used by every emission in the
2//! workspace. Each "thing" that participates in an event (channel,
3//! agent, tool, cron job, model provider, memory backend, peer group,
4//! skill bundle, MCP bundle, session) implements [`Attributable`].
5//! Entry points open `attribution_span!(thing)` once at the start of
6//! their work; the `LogCaptureLayer` in `zeroclaw-log` walks the span
7//! scope and fills the typed attribution slots automatically.
8//!
9//! Adding a new variant: extend the relevant `Kind` enum (the variant
10//! name's snake_case form is the canonical `<type>` string via
11//! `strum::IntoStaticStr`), and — only if a new role family is needed —
12//! update the [`Role::composite_prefix`] / [`Role::attribution_field`]
13//! / [`Role::default_category`] match arms. No call-site changes.
14
15use strum_macros::IntoStaticStr;
16
17/// Trait every alias-bound "thing" implements once next to its struct.
18pub trait Attributable {
19    fn role(&self) -> Role;
20    fn alias(&self) -> &str;
21}
22
23impl<T: Attributable + ?Sized> Attributable for std::sync::Arc<T> {
24    fn role(&self) -> Role {
25        (**self).role()
26    }
27    fn alias(&self) -> &str {
28        (**self).alias()
29    }
30}
31
32impl<T: Attributable + ?Sized> Attributable for Box<T> {
33    fn role(&self) -> Role {
34        (**self).role()
35    }
36    fn alias(&self) -> &str {
37        (**self).alias()
38    }
39}
40
41impl<T: Attributable + ?Sized> Attributable for &T {
42    fn role(&self) -> Role {
43        (**self).role()
44    }
45    fn alias(&self) -> &str {
46        (**self).alias()
47    }
48}
49
50/// Closed taxonomy of every role a thing can fill.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Role {
53    Swarm,
54    Agent,
55    Channel(ChannelKind),
56    Tool(ToolKind),
57    Cron(CronKind),
58    Provider(ProviderKind),
59    Memory(MemoryKind),
60    PeerGroup,
61    Skill,
62    Mcp,
63    Sop,
64    Session,
65    System,
66}
67
68/// Channel implementations.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
70#[strum(serialize_all = "snake_case")]
71pub enum ChannelKind {
72    #[strum(serialize = "acp")]
73    AcpChannel,
74    #[strum(serialize = "amqp")]
75    Amqp,
76    Bluesky,
77    #[strum(serialize = "clawdtalk")]
78    ClawdTalk,
79    Cli,
80    #[strum(serialize = "dingtalk")]
81    DingTalk,
82    Discord,
83    Email,
84    GmailPush,
85    #[strum(serialize = "imessage")]
86    IMessage,
87    Irc,
88    Lark,
89    Line,
90    Linq,
91    Matrix,
92    Mattermost,
93    #[strum(serialize = "mochat")]
94    MoChat,
95    NextcloudTalk,
96    Nostr,
97    Notion,
98    Qq,
99    Reddit,
100    Signal,
101    Slack,
102    Telegram,
103    Twitch,
104    Twitter,
105    VoiceCall,
106    VoiceWake,
107    Wati,
108    #[strum(serialize = "wecom")]
109    WeCom,
110    #[strum(serialize = "wecom_ws")]
111    WeComWs,
112    Webhook,
113    Wechat,
114    WhatsappBusiness,
115    WhatsappWeb,
116}
117
118/// Built-in tool implementations. Closed set — plugins that need their
119/// own attribution add a variant here.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
121#[strum(serialize_all = "snake_case")]
122pub enum ToolKind {
123    Shell,
124    HttpRequest,
125    HttpServer,
126    FetchUrl,
127    Search,
128    Memory,
129    SpawnSubagent,
130    SopList,
131    SopExecute,
132    SopApprove,
133    SopAdvance,
134    SopStatus,
135    SopHistory,
136    Wait,
137    Plugin,
138}
139
140/// Cron schedule shapes.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
142#[strum(serialize_all = "snake_case")]
143pub enum CronKind {
144    Interval,
145    At,
146    Cron,
147    Once,
148}
149
150/// Provider family. The inner enum carries the specific implementation;
151/// the outer family drives which composite prefix (`model_provider` /
152/// `tts_provider` / …) the layer populates.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum ProviderKind {
155    Model(ModelProviderKind),
156    Tts(TtsProviderKind),
157    Transcription(TranscriptionProviderKind),
158    Tunnel(TunnelProviderKind),
159}
160
161impl ProviderKind {
162    #[must_use]
163    pub fn type_str(self) -> &'static str {
164        match self {
165            Self::Model(k) => k.into(),
166            Self::Tts(k) => k.into(),
167            Self::Transcription(k) => k.into(),
168            Self::Tunnel(k) => k.into(),
169        }
170    }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
174#[strum(serialize_all = "snake_case")]
175pub enum ModelProviderKind {
176    Anthropic,
177    #[strum(serialize = "openai")]
178    OpenAi,
179    #[strum(serialize = "openai_codex")]
180    OpenAiCodex,
181    Azure,
182    Together,
183    Bedrock,
184    Ollama,
185    Gemini,
186    GeminiCli,
187    GoogleAi,
188    Mistral,
189    Groq,
190    OpenRouter,
191    Telnyx,
192    Copilot,
193    Glm,
194    KiloCli,
195    Kilo,
196    Router,
197    Reliable,
198    Moonshot,
199    Qwen,
200    Minimax,
201    Zai,
202    Doubao,
203    Yi,
204    Hunyuan,
205    Qianfan,
206    Baichuan,
207    Fireworks,
208    Deepseek,
209    AtomicChat,
210    Cohere,
211    Perplexity,
212    Xai,
213    Cerebras,
214    Sambanova,
215    Hyperbolic,
216    Deepinfra,
217    Huggingface,
218    Ai21,
219    Reka,
220    Baseten,
221    Nscale,
222    Anyscale,
223    Nebius,
224    Friendli,
225    Stepfun,
226    Aihubmix,
227    Siliconflow,
228    Astrai,
229    Avian,
230    Deepmyst,
231    Venice,
232    Novita,
233    Nvidia,
234    Vercel,
235    Cloudflare,
236    Ovh,
237    Lmstudio,
238    Llamacpp,
239    Sglang,
240    Vllm,
241    Osaurus,
242    Litellm,
243    Lepton,
244    Synthetic,
245    Opencode,
246    Custom,
247    Plugin,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
251#[strum(serialize_all = "snake_case")]
252pub enum TtsProviderKind {
253    #[strum(serialize = "openai")]
254    OpenAi,
255    #[strum(serialize = "elevenlabs")]
256    ElevenLabs,
257    Cartesia,
258    Google,
259    Edge,
260    Piper,
261    Plugin,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
265#[strum(serialize_all = "snake_case")]
266pub enum TranscriptionProviderKind {
267    Whisper,
268    #[strum(serialize = "openai")]
269    OpenAi,
270    Deepgram,
271    Groq,
272    AssemblyAi,
273    Google,
274    Plugin,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
278#[strum(serialize_all = "snake_case")]
279pub enum TunnelProviderKind {
280    Ngrok,
281    Cloudflared,
282    OpenVpn,
283    Pinggy,
284    Tailscale,
285    None,
286    Custom,
287    Plugin,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
291#[strum(serialize_all = "snake_case")]
292pub enum MemoryKind {
293    Sqlite,
294    Json,
295    InMemory,
296    Markdown,
297    AgentScopedMarkdown,
298    AgentScoped,
299    Qdrant,
300    Postgres,
301    Lucid,
302    None,
303    Plugin,
304}
305
306impl Role {
307    /// Composite prefix this role populates (`channel`, `model_provider`,
308    /// `tts_provider`, `transcription_provider`, `tunnel_provider`),
309    /// or `None` for roles that use a plain attribution field.
310    #[must_use]
311    pub fn composite_prefix(self) -> Option<&'static str> {
312        match self {
313            Self::Channel(_) => Some("channel"),
314            Self::Provider(ProviderKind::Model(_)) => Some("model_provider"),
315            Self::Provider(ProviderKind::Tts(_)) => Some("tts_provider"),
316            Self::Provider(ProviderKind::Transcription(_)) => Some("transcription_provider"),
317            Self::Provider(ProviderKind::Tunnel(_)) => Some("tunnel_provider"),
318            _ => None,
319        }
320    }
321
322    /// The `<type>` portion of the composite, when this role contributes
323    /// to one.
324    #[must_use]
325    pub fn composite_type(self) -> Option<&'static str> {
326        match self {
327            Self::Channel(k) => Some(k.into()),
328            Self::Provider(p) => Some(p.type_str()),
329            _ => None,
330        }
331    }
332
333    /// Plain-attribution-field key this role populates for roles that
334    /// don't use a composite. `Tool` writes `tool`; `Agent` writes
335    /// `agent_alias`; `Cron` writes `cron_job_id`; …
336    #[must_use]
337    pub fn attribution_field(self) -> Option<&'static str> {
338        match self {
339            Self::Agent => Some("agent_alias"),
340            Self::Tool(_) => Some("tool"),
341            Self::Cron(_) => Some("cron_job_id"),
342            Self::Memory(_) => Some("memory_namespace"),
343            Self::PeerGroup => Some("peer_group"),
344            Self::Skill => Some("skill_bundle"),
345            Self::Mcp => Some("mcp_bundle"),
346            Self::Sop => Some("sop_name"),
347            Self::Session => Some("session_key"),
348            _ => None,
349        }
350    }
351
352    /// Stable string tag used by the span layer to identify the role's
353    /// family. The inner Kind (when applicable) is rendered alongside in
354    /// [`Role::composite_type`].
355    #[must_use]
356    pub fn family_str(self) -> &'static str {
357        match self {
358            Self::Swarm => "swarm",
359            Self::Agent => "agent",
360            Self::Channel(_) => "channel",
361            Self::Tool(_) => "tool",
362            Self::Cron(_) => "cron",
363            Self::Provider(ProviderKind::Model(_)) => "provider.model",
364            Self::Provider(ProviderKind::Tts(_)) => "provider.tts",
365            Self::Provider(ProviderKind::Transcription(_)) => "provider.transcription",
366            Self::Provider(ProviderKind::Tunnel(_)) => "provider.tunnel",
367            Self::Memory(_) => "memory",
368            Self::PeerGroup => "peer_group",
369            Self::Skill => "skill",
370            Self::Mcp => "mcp",
371            Self::Sop => "sop",
372            Self::Session => "session",
373            Self::System => "system",
374        }
375    }
376
377    /// Closest `zeroclaw_log::EventCategory` for this role, used by
378    /// the layer to default `event.category` when the call site doesn't
379    /// override. Returned as a `&'static str` to keep `zeroclaw-api`
380    /// free of a back-dep on `zeroclaw-log`.
381    #[must_use]
382    pub fn default_category(self) -> &'static str {
383        match self {
384            Self::Swarm | Self::Agent => "agent",
385            Self::Channel(_) => "channel",
386            Self::Tool(_) => "tool",
387            Self::Cron(_) => "cron",
388            Self::Provider(ProviderKind::Model(_)) => "model_provider",
389            Self::Provider(ProviderKind::Tts(_)) => "tts_provider",
390            Self::Provider(ProviderKind::Transcription(_)) => "transcription_provider",
391            Self::Provider(ProviderKind::Tunnel(_)) => "tunnel_provider",
392            Self::Memory(_) => "memory",
393            Self::Session => "session",
394            Self::Sop => "sop",
395            Self::PeerGroup | Self::Skill | Self::Mcp | Self::System => "system",
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn channel_kind_snake_case() {
406        assert_eq!(<&'static str>::from(ChannelKind::Telegram), "telegram");
407        assert_eq!(
408            <&'static str>::from(ChannelKind::WhatsappBusiness),
409            "whatsapp_business"
410        );
411    }
412
413    #[test]
414    fn provider_kind_delegates_to_inner() {
415        assert_eq!(
416            ProviderKind::Model(ModelProviderKind::Anthropic).type_str(),
417            "anthropic"
418        );
419        assert_eq!(
420            ProviderKind::Tts(TtsProviderKind::ElevenLabs).type_str(),
421            "elevenlabs"
422        );
423    }
424
425    #[test]
426    fn role_composite_prefix() {
427        assert_eq!(
428            Role::Channel(ChannelKind::Discord).composite_prefix(),
429            Some("channel")
430        );
431        assert_eq!(
432            Role::Provider(ProviderKind::Model(ModelProviderKind::Anthropic)).composite_prefix(),
433            Some("model_provider"),
434        );
435        assert!(Role::Agent.composite_prefix().is_none());
436    }
437
438    #[test]
439    fn role_attribution_field() {
440        assert_eq!(Role::Agent.attribution_field(), Some("agent_alias"));
441        assert_eq!(
442            Role::Tool(ToolKind::Shell).attribution_field(),
443            Some("tool")
444        );
445        assert!(
446            Role::Channel(ChannelKind::Telegram)
447                .attribution_field()
448                .is_none()
449        );
450    }
451}