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