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;