Skip to main content

agentd/runtime/
activity.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Live activity**: what each working unit is doing *right now*, for the
3//! display clients' working row.
4//!
5//! The turn worker reports coarse progress upward as [`AgentMsg::Event`]
6//! frames — `turn.think`, `turn.round`, `turn.tool`. Here they fold into a
7//! per-unit [`Activity`] record — phase, current tool, round, tokens so far,
8//! start time — and publish as `activity` feed events.
9//!
10//! Deliberately **coarse**: an event is emitted only when something the
11//! operator would notice CHANGES (phase, tool, round). Elapsed time is not
12//! streamed — the record carries `started_ms` and clients tick their own
13//! clock — so a long think emits nothing at all. That keeps the feed's replay
14//! ring meaningful: a handful of activity events per turn rather than one per
15//! second, so a client that reconnects can still see the whole turn in the
16//! ring instead of a second's worth of noise.
17
18use super::children::ChildKind;
19use super::reactor::Runtime;
20use crate::state::now_ms;
21use crate::supervisor::tree::NodeId;
22use serde_json::{Value, json};
23
24/// What a unit is doing.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Phase {
27    /// A model call is in flight.
28    Thinking,
29    /// A tool is executing.
30    Tool,
31    /// Parked on a deferred wait (timer, subagent, human gate).
32    Waiting,
33}
34
35impl Phase {
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Phase::Thinking => "thinking",
39            Phase::Tool => "tool",
40            Phase::Waiting => "waiting",
41        }
42    }
43}
44
45/// One working unit's live activity.
46#[derive(Debug, Clone)]
47pub struct Activity {
48    /// The A2A task this unit answers (the client keys on it), when it has one.
49    pub task: Option<String>,
50    /// The conversation, when the unit is a turn.
51    pub ctx: Option<String>,
52    pub phase: Phase,
53    /// The tool executing right now (phase `tool`).
54    pub tool: Option<String>,
55    /// The model round this unit is on (1-based).
56    pub round: u32,
57    /// Tokens this unit has spent so far.
58    pub tokens_in: u64,
59    pub tokens_out: u64,
60    /// When the unit started (clients tick elapsed from this).
61    pub started_ms: u64,
62    pub updated_ms: u64,
63}
64
65impl Activity {
66    fn new(task: Option<String>, ctx: Option<String>) -> Activity {
67        let now = now_ms();
68        Activity {
69            task,
70            ctx,
71            phase: Phase::Thinking,
72            tool: None,
73            round: 0,
74            tokens_in: 0,
75            tokens_out: 0,
76            started_ms: now,
77            updated_ms: now,
78        }
79    }
80
81    /// Fold one progress frame in. Returns `true` when the change is worth
82    /// telling clients about (phase / tool / round moved) — token-only and
83    /// clock-only updates are silent.
84    pub fn apply(&mut self, event: &str, fields: &Value) -> bool {
85        let before = (self.phase, self.tool.clone(), self.round);
86        match event {
87            "turn.think" => {
88                self.phase = Phase::Thinking;
89                self.tool = None;
90                if let Some(r) = fields["round"].as_u64() {
91                    self.round = r as u32;
92                }
93            }
94            "turn.round" => {
95                if let Some(r) = fields["round"].as_u64() {
96                    self.round = r as u32;
97                }
98                self.tokens_in += fields["tokens_in"].as_u64().unwrap_or(0);
99                self.tokens_out += fields["tokens_out"].as_u64().unwrap_or(0);
100                // The model answered; tools (if any) announce themselves next.
101                self.phase = Phase::Thinking;
102                self.tool = None;
103            }
104            "turn.tool" => {
105                self.phase = Phase::Tool;
106                self.tool = fields["tool"].as_str().map(str::to_string);
107            }
108            _ => return false,
109        }
110        self.updated_ms = now_ms();
111        (self.phase, self.tool.clone(), self.round) != before
112    }
113
114    /// Park the unit (a deferred tool: sleep, subagent, human gate).
115    pub fn park(&mut self, what: &str) -> bool {
116        let changed = self.phase != Phase::Waiting || self.tool.as_deref() != Some(what);
117        self.phase = Phase::Waiting;
118        self.tool = Some(what.to_string());
119        self.updated_ms = now_ms();
120        changed
121    }
122
123    pub fn to_value(&self) -> Value {
124        json!({
125            "task": self.task,
126            "ctx": self.ctx,
127            "phase": self.phase.as_str(),
128            "tool": self.tool,
129            "round": self.round,
130            "tokens_in": self.tokens_in,
131            "tokens_out": self.tokens_out,
132            "started_ms": self.started_ms,
133            "updated_ms": self.updated_ms,
134        })
135    }
136}
137
138impl Runtime {
139    /// A child's progress frame (`AgentMsg::Event`) — fold it into the unit's
140    /// activity and publish the change.
141    pub(crate) fn on_child_progress(&mut self, node: NodeId, event: &str, fields: &Value) {
142        let (task, ctx) = self.unit_of(node);
143        let entry = self
144            .activity
145            .entry(node.0)
146            .or_insert_with(|| Activity::new(task.clone(), ctx.clone()));
147        // A restored/late binding (the task id is minted after the spawn).
148        if entry.task.is_none() && task.is_some() {
149            entry.task = task;
150        }
151        if entry.apply(event, fields) {
152            let v = entry.to_value();
153            self.publish_activity(node, v);
154        }
155    }
156
157    /// Mark the unit parked on a deferred wait.
158    pub(crate) fn activity_park(&mut self, node: NodeId, what: &str) {
159        let Some(entry) = self.activity.get_mut(&node.0) else {
160            return;
161        };
162        if entry.park(what) {
163            let v = entry.to_value();
164            self.publish_activity(node, v);
165        }
166    }
167
168    /// The unit finished: drop the record and tell clients it is gone.
169    pub(crate) fn activity_end(&mut self, node: NodeId) {
170        if self.activity.remove(&node.0).is_some() {
171            #[cfg(feature = "a2a")]
172            self.feed_push(
173                "activity.removed",
174                crate::runtime::a2a_server::FeedVis::Operator,
175                json!({"id": node.0.to_string()}),
176            );
177        }
178    }
179
180    /// The A2A task + conversation a child answers, when it has one. Without
181    /// the `a2a` feature there are no tasks to bind to — the record still
182    /// tracks the unit's phase for `status`.
183    fn unit_of(&self, node: NodeId) -> (Option<String>, Option<String>) {
184        match self.children.get(node).map(|c| c.kind.clone()) {
185            Some(ChildKind::RootTurn { ctx, event, .. }) => {
186                #[cfg(feature = "a2a")]
187                let task = event.and_then(|e| self.event_to_task.get(&e).cloned());
188                #[cfg(not(feature = "a2a"))]
189                let task = {
190                    let _ = event;
191                    None
192                };
193                (task, Some(ctx))
194            }
195            Some(ChildKind::StepTurn { run, .. }) => {
196                let task = self.runs.get(&run).and_then(|r| r.task.clone());
197                (
198                    task,
199                    self.runs.get(&run).and_then(|r| r.conversation.clone()),
200                )
201            }
202            _ => (None, None),
203        }
204    }
205
206    /// Publish to the interface feed — a no-op without the `a2a` feature (no
207    /// feed exists to publish to; `status.activity` still carries the record).
208    #[allow(unused_mut, unused_variables)]
209    fn publish_activity(&self, node: NodeId, mut v: Value) {
210        #[cfg(feature = "a2a")]
211        {
212            v["id"] = json!(node.0.to_string());
213            // Owner-scoped when the unit answers a task; else operator-only.
214            let owner = v["task"]
215                .as_str()
216                .and_then(|t| self.tasks.get(t))
217                .and_then(|t| t.principal.clone());
218            let vis = match owner {
219                Some(p) => crate::runtime::a2a_server::FeedVis::Owner(Some(p)),
220                None => crate::runtime::a2a_server::FeedVis::Operator,
221            };
222            self.feed_push("activity", vis, v);
223        }
224    }
225
226    /// The live activity view for `status` (the poll-fallback path sees the
227    /// same information the feed carries).
228    pub(crate) fn activity_value(&self) -> Value {
229        json!(
230            self.activity
231                .iter()
232                .map(|(node, a)| {
233                    let mut v = a.to_value();
234                    v["id"] = json!(node.to_string());
235                    v
236                })
237                .collect::<Vec<_>>()
238        )
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn activity_folds_progress_and_reports_only_notable_changes() {
248        let mut a = Activity::new(Some("task-1".into()), Some("c1".into()));
249        assert_eq!(a.phase, Phase::Thinking);
250        // A think announcement on round 1: the round moved ⇒ notable.
251        assert!(a.apply("turn.think", &json!({"round": 1})));
252        assert_eq!(a.round, 1);
253        // The same announcement again changes nothing ⇒ silent.
254        assert!(!a.apply("turn.think", &json!({"round": 1})));
255        // The round lands with usage: tokens accrue, phase/tool/round unchanged
256        // ⇒ silent (tokens alone must not spam the feed).
257        assert!(!a.apply(
258            "turn.round",
259            &json!({"round": 1, "tokens_in": 100, "tokens_out": 20})
260        ));
261        assert_eq!((a.tokens_in, a.tokens_out), (100, 20));
262        // A tool starts ⇒ notable, and names itself.
263        assert!(a.apply("turn.tool", &json!({"tool": "read_file"})));
264        assert_eq!(a.phase, Phase::Tool);
265        assert_eq!(a.tool.as_deref(), Some("read_file"));
266        // A different tool ⇒ notable.
267        assert!(a.apply("turn.tool", &json!({"tool": "memory.set"})));
268        // Back to thinking on the next round ⇒ notable; tokens keep accruing.
269        assert!(a.apply(
270            "turn.round",
271            &json!({"round": 2, "tokens_in": 50, "tokens_out": 10})
272        ));
273        assert_eq!(a.phase, Phase::Thinking);
274        assert_eq!(a.tool, None);
275        assert_eq!((a.tokens_in, a.tokens_out), (150, 30));
276        // Parking is notable once.
277        assert!(a.park("subagent"));
278        assert!(!a.park("subagent"));
279        assert_eq!(a.phase, Phase::Waiting);
280        // Unknown events are ignored entirely.
281        assert!(!a.apply("something.else", &json!({})));
282        let v = a.to_value();
283        assert_eq!(v["phase"], "waiting");
284        assert_eq!(v["task"], "task-1");
285        assert!(v["started_ms"].as_u64().is_some_and(|t| t > 0));
286    }
287}