Skip to main content

ascii_agents_core/source/
mod.rs

1use std::path::PathBuf;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use tokio::sync::mpsc;
6
7use crate::id::AgentId;
8
9/// Which transport produced an event — used by the reducer for hook-wins
10/// dedup. Lives on the source side because every `Source` implementor must
11/// tag its own events; the reducer is downstream.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Transport {
14    Hook,
15    Jsonl,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19pub enum Activity {
20    Typing,
21    Reading,
22    Thinking,
23}
24
25/// Structured tool detail. Replaces the free-form `Option<String>` so the
26/// reducer can pattern-match (instead of string-scanning) on semantic
27/// categories like Task-delegation, which is load-bearing for subagent
28/// suppression.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub enum ToolDetail {
31    /// CC `Task` tool — kicks off a subagent. Reducer suppresses
32    /// hook-sourced Activity events for the parent until the matching
33    /// `ActivityEnd` arrives (subagent leak suppression).
34    Task,
35    /// Any other tool. `display` is the user-facing label
36    /// (e.g. `"Bash: ls"`, `"Edit foo.rs"`) used for the AgentSlot detail.
37    Generic { display: String },
38}
39
40impl ToolDetail {
41    pub fn display(&self) -> &str {
42        match self {
43            ToolDetail::Task => "Delegating",
44            ToolDetail::Generic { display } => display,
45        }
46    }
47    pub fn is_task(&self) -> bool {
48        matches!(self, ToolDetail::Task)
49    }
50}
51
52/// Test-ergonomic conversion. `"Task"` maps to `Task`; everything else
53/// maps to `Generic`. Production code should call `decoder::make_tool_detail`
54/// directly so it sees `tool_name` and `target` as separate inputs, but
55/// tests that build `AgentEvent::ActivityStart` manually benefit from
56/// `Some("Task".into())` working as expected.
57impl From<&str> for ToolDetail {
58    fn from(s: &str) -> Self {
59        if s == "Task" {
60            ToolDetail::Task
61        } else {
62            ToolDetail::Generic {
63                display: s.to_string(),
64            }
65        }
66    }
67}
68
69impl From<String> for ToolDetail {
70    fn from(s: String) -> Self {
71        Self::from(s.as_str())
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub enum AgentEvent {
77    SessionStart {
78        agent_id: AgentId,
79        source: String,
80        session_id: String,
81        cwd: PathBuf,
82        parent_id: Option<AgentId>,
83    },
84    ActivityStart {
85        agent_id: AgentId,
86        activity: Activity,
87        tool_use_id: Option<String>,
88        detail: Option<ToolDetail>,
89    },
90    ActivityEnd {
91        agent_id: AgentId,
92        tool_use_id: Option<String>,
93    },
94    Waiting {
95        agent_id: AgentId,
96        reason: String,
97    },
98    /// Late-discovered display name (e.g. CC subagent `attributionAgent`).
99    /// Reducer overrides the slot label; noop if the slot doesn't exist.
100    Rename {
101        agent_id: AgentId,
102        label: String,
103    },
104    SessionEnd {
105        agent_id: AgentId,
106    },
107}
108
109impl AgentEvent {
110    pub fn agent_id(&self) -> AgentId {
111        match self {
112            AgentEvent::SessionStart { agent_id, .. } => *agent_id,
113            AgentEvent::ActivityStart { agent_id, .. } => *agent_id,
114            AgentEvent::ActivityEnd { agent_id, .. } => *agent_id,
115            AgentEvent::Waiting { agent_id, .. } => *agent_id,
116            AgentEvent::Rename { agent_id, .. } => *agent_id,
117            AgentEvent::SessionEnd { agent_id, .. } => *agent_id,
118        }
119    }
120}
121
122/// Events sent on a tagged channel so the reducer knows which transport produced them.
123pub type TaggedSender = mpsc::Sender<(Transport, AgentEvent)>;
124pub type TaggedReceiver = mpsc::Receiver<(Transport, AgentEvent)>;
125
126/// A `Source` produces `AgentEvent`s from one agent CLI flavor (Claude Code,
127/// Codex, Cursor, Gemini, Copilot, etc.) and sends them on a `Transport`-
128/// tagged channel.
129///
130/// ## Implementor contract
131///
132/// 1. **`name()`** — returns a stable, lowercase identifier for this source
133///    (e.g. `"claude-code"`, `"codex"`, `"cursor"`). Used both as the
134///    `AgentSlot.source` field and as the first argument to
135///    [`AgentId::from_parts`] so two sources with the same opaque session
136///    id never collide.
137///
138/// 2. **`AgentId` derivation** — every `AgentEvent::SessionStart` MUST carry
139///    an `agent_id` constructed via [`AgentId::from_parts(self.name(),
140///    opaque_id)`][`AgentId::from_parts`]. `opaque_id` is whatever your source uses to uniquely
141///    identify a session: a JSONL transcript path for CC, a session UUID
142///    for SDK-based sources, the socket path for hook-based sources.
143///    Constructing `AgentId`s any other way risks cross-source collisions.
144///
145/// 3. **Transport tagging** — every event you send must be tagged with the
146///    appropriate [`Transport`] enum variant. The reducer relies on this
147///    tag for hook-vs-JSONL dedup; sending the wrong tag silently breaks
148///    that logic.
149///
150/// 4. **Never panic** — sources run inside a tokio task that doesn't
151///    propagate panics cleanly. Log + continue on malformed input rather
152///    than `unwrap`.
153///
154/// [`AgentId::from_parts`]: crate::AgentId::from_parts
155#[async_trait]
156pub trait Source: Send + 'static {
157    fn name(&self) -> &str;
158    async fn run(self: Box<Self>, tx: TaggedSender) -> anyhow::Result<()>;
159}
160
161pub mod antigravity;
162pub mod claude_code;
163pub mod decoder;
164pub mod hook;
165pub mod jsonl;
166pub mod manager;