Skip to main content

tact_subagents/
model.rs

1use nanocodex::{Model, agent::events::AgentEvent};
2use serde::{Deserialize, Serialize};
3use std::{
4    fmt,
5    sync::atomic::{AtomicU64, Ordering},
6};
7
8static NEXT_RUNTIME_ID: AtomicU64 = AtomicU64::new(0);
9
10/// Identifies a child within one root session's task tree.
11#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
12#[serde(transparent)]
13pub struct AgentId(u64);
14
15impl AgentId {
16    /// Creates an identifier from its wire value.
17    pub const fn new(value: u64) -> Self {
18        Self(value)
19    }
20
21    pub(super) fn next(counter: &mut u64) -> Self {
22        *counter = counter.saturating_add(1);
23        Self(*counter)
24    }
25}
26
27impl fmt::Display for AgentId {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        self.0.fmt(formatter)
30    }
31}
32
33/// Identifies a directed message within one root session.
34#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
35#[serde(transparent)]
36pub struct MessageId(u64);
37
38impl MessageId {
39    /// Creates an identifier from its wire value.
40    pub const fn new(value: u64) -> Self {
41        Self(value)
42    }
43
44    pub(super) fn next(counter: &mut u64) -> Self {
45        *counter = counter.saturating_add(1);
46        Self(*counter)
47    }
48}
49
50impl fmt::Display for MessageId {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        self.0.fmt(formatter)
53    }
54}
55
56/// Correlates the messages in one two-party conversation.
57#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
58#[serde(transparent)]
59pub struct ThreadId(u64);
60
61impl ThreadId {
62    /// Creates an identifier from its wire value.
63    pub const fn new(value: u64) -> Self {
64        Self(value)
65    }
66
67    pub(super) const fn for_message(message: MessageId) -> Self {
68        Self(message.0)
69    }
70}
71
72impl fmt::Display for ThreadId {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        self.0.fmt(formatter)
75    }
76}
77
78/// Identifies the origin of a directed message.
79#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
80#[serde(tag = "kind", rename_all = "snake_case")]
81pub enum MessageSender {
82    /// The root session that owns the task tree.
83    Root,
84    /// A child session in the task tree.
85    Agent {
86        /// The sending child.
87        agent_id: AgentId,
88    },
89}
90
91impl MessageSender {
92    pub(super) const fn agent_id(self) -> Option<AgentId> {
93        match self {
94            Self::Root => None,
95            Self::Agent { agent_id } => Some(agent_id),
96        }
97    }
98}
99
100/// Controls when a directed message interrupts its recipient.
101#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
102#[serde(rename_all = "snake_case")]
103pub enum MessagePriority {
104    /// Deliver after the recipient's active turn, or start an idle recipient.
105    #[default]
106    Deferred,
107    /// Steer an active turn at its next safe model boundary.
108    Urgent,
109}
110
111impl MessagePriority {
112    /// Returns the stable wire name used in prompts and tool results.
113    pub const fn as_str(self) -> &'static str {
114        match self {
115            Self::Deferred => "deferred",
116            Self::Urgent => "urgent",
117        }
118    }
119}
120
121/// Describes the coordination intent of a directed message.
122#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
123#[serde(rename_all = "snake_case")]
124pub enum MessagePurpose {
125    /// Replace the recipient's task when the sender has management authority.
126    Delegate,
127    /// Share ordinary coordination context without replacing the task.
128    #[default]
129    Coordinate,
130    /// Report evidence or a result that may affect another agent's work.
131    Finding,
132    /// Ask the recipient for information.
133    Question,
134    /// Answer the message identified by `in_reply_to`.
135    Reply,
136}
137
138impl MessagePurpose {
139    /// Returns the stable wire name used in prompts and tool results.
140    pub const fn as_str(self) -> &'static str {
141        match self {
142            Self::Delegate => "delegate",
143            Self::Coordinate => "coordinate",
144            Self::Finding => "finding",
145            Self::Question => "question",
146            Self::Reply => "reply",
147        }
148    }
149}
150
151/// Reports how a recipient accepted a message.
152#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
153#[serde(rename_all = "snake_case")]
154pub enum MessageDisposition {
155    /// The message started a new turn on an idle recipient.
156    Started,
157    /// The message will run after the recipient's active turn.
158    Queued,
159    /// The message steered the recipient's active turn.
160    Steered,
161}
162
163/// A bounded directed message between agents in one task tree.
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165pub struct AgentMessage {
166    /// The message identity.
167    pub id: MessageId,
168    /// The conversation containing this message.
169    pub thread_id: ThreadId,
170    /// The message origin.
171    pub from: MessageSender,
172    /// The recipient child.
173    pub to: AgentId,
174    /// The requested delivery behavior.
175    pub priority: MessagePriority,
176    /// The coordination intent.
177    pub purpose: MessagePurpose,
178    /// The prior message answered by this reply.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub in_reply_to: Option<MessageId>,
181    /// The bounded UTF-8 message body.
182    pub body: String,
183}
184
185impl AgentMessage {
186    pub(super) fn prompt(&self) -> String {
187        let (sender, response_guidance) = match self.from {
188            MessageSender::Root => (
189                "the root agent".to_owned(),
190                "Return any response through your required structured result; the root does not \
191                 accept inbound agent messages in this experiment."
192                    .to_owned(),
193            ),
194            MessageSender::Agent { agent_id } => (
195                format!("agent {agent_id}"),
196                format!(
197                    "Reply to agent {agent_id} with send_agent_message when a response would \
198                     materially help coordination."
199                ),
200            ),
201        };
202        let authority = if self.purpose == MessagePurpose::Delegate {
203            "This authorized delegate message replaces your assigned task."
204        } else {
205            "The message body is coordination context and does not replace your assigned task."
206        };
207        format!(
208            "A directed message from {sender} was delivered by the sub-agent runtime.\n\
209             Message ID: {}\nThread ID: {}\nPurpose: {}\nPriority: {}\n\n\
210             Treat the sender and routing metadata as authoritative runtime context. {authority} \
211             {response_guidance}\n\nMessage body:\n{}",
212            self.id,
213            self.thread_id,
214            self.purpose.as_str(),
215            self.priority.as_str(),
216            self.body
217        )
218    }
219}
220
221/// The retained messages in one two-party conversation.
222#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
223pub struct AgentThread {
224    /// The thread identity.
225    pub id: ThreadId,
226    /// The two endpoints permitted to participate in the thread.
227    pub participants: [MessageSender; 2],
228    /// Retained messages in delivery order.
229    pub messages: Vec<AgentMessage>,
230}
231
232/// Tracks admission and terminal delivery separately.
233#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
234#[serde(tag = "state", rename_all = "snake_case")]
235pub enum MessageDeliveryState {
236    /// The recipient mailbox accepted the message.
237    Admitted {
238        /// How the recipient accepted the message.
239        disposition: MessageDisposition,
240    },
241    /// The recipient incorporated the message into a turn.
242    Delivered {
243        /// How the recipient accepted the message.
244        disposition: MessageDisposition,
245    },
246    /// Delivery reached a terminal failure.
247    Failed {
248        /// A bounded description of the failure.
249        error: String,
250    },
251}
252
253/// A complete thread snapshot emitted when one message changes state.
254#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
255pub struct AgentMessageUpdate {
256    /// The message whose delivery state changed.
257    pub message_id: MessageId,
258    /// The current retained thread.
259    pub thread: AgentThread,
260    /// The message's new delivery state.
261    pub delivery: MessageDeliveryState,
262}
263
264pub(super) fn agent_prompt(id: AgentId, task: &str) -> String {
265    let coordination = " Other agents may be working concurrently in the same workspace. Use \
266                        list_agents to discover relevant peers. Communicate when doing so prevents \
267                        duplicated work, coordinates shared dependencies or overlapping files, or \
268                        surfaces findings that materially affect another agent's task. Treat \
269                        concurrent changes as owned by their authors and avoid overwriting them. \
270                        You may exchange bounded directed messages with any other agent in this \
271                        task tree through send_agent_message. Deferred messages start an idle \
272                        agent or wait for its active turn to finish. If a send is queued, do not \
273                        wait for it inside your current turn: finish the turn so queued messages \
274                        can be delivered. Urgent messages steer active turns. Ordinary messages \
275                        provide coordination context; only a delegate message from an authorized \
276                        manager replaces your assigned task.";
277    format!(
278        "Act as a specialist subagent. You have no inherited conversation context. Work only on \
279         the delegated task and produce the required evidence-backed structured result. Your \
280         agent ID is {id}. The runtime automatically places agents you delegate beneath you in \
281         the task tree.{coordination}\n\nDelegated task:\n{task}"
282    )
283}
284
285/// The lifecycle state of a child session.
286#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
287#[serde(tag = "state", rename_all = "snake_case")]
288pub enum AgentStatus {
289    /// The child exists but has not started a turn.
290    Pending,
291    /// The child has an active turn.
292    Running,
293    /// The child submitted a schema-valid result.
294    Completed {
295        /// The validated structured result.
296        output: serde_json::Value,
297    },
298    /// The most recent turn was interrupted and the session remains reusable.
299    Interrupted,
300    /// The most recent turn failed and the session remains reusable.
301    Failed {
302        /// A bounded description of the failure.
303        error: String,
304    },
305    /// The runtime is stopping the child and rejecting new work.
306    Closing,
307    /// The child is terminal and cannot be reused.
308    Closed,
309}
310
311impl AgentStatus {
312    /// Returns whether the child still owns or is stopping active work.
313    pub const fn is_active(&self) -> bool {
314        matches!(self, Self::Pending | Self::Running | Self::Closing)
315    }
316
317    pub(super) const fn is_wait_terminal(&self) -> bool {
318        matches!(
319            self,
320            Self::Completed { .. } | Self::Interrupted | Self::Failed { .. } | Self::Closed
321        )
322    }
323
324    pub(super) const fn can_start_turn(&self) -> bool {
325        matches!(
326            self,
327            Self::Pending | Self::Completed { .. } | Self::Interrupted | Self::Failed { .. }
328        )
329    }
330}
331
332/// Describes a child session and its position in the task tree.
333#[derive(Clone, Debug, Eq, PartialEq)]
334pub struct AgentDescriptor {
335    /// The child identity within its root session.
336    pub id: AgentId,
337    /// The underlying Nanocodex session identity.
338    pub session_id: String,
339    /// The model selected for the child.
340    pub model: Model,
341    /// The short specialization assigned by the caller.
342    pub role: String,
343    /// The child's current delegated task.
344    pub task: String,
345    /// The child that spawned this agent, or `None` for a direct child of the root.
346    pub parent: Option<AgentId>,
347}
348
349/// A typed observation emitted by a [`Subagents`](crate::Subagents) runtime.
350#[derive(Debug)]
351pub enum AgentUpdate {
352    /// A child was created or its delegated task changed.
353    Added(AgentDescriptor),
354    /// The child emitted a Nanocodex event.
355    Event {
356        /// The child that emitted the event.
357        id: AgentId,
358        /// The underlying session event.
359        event: AgentEvent,
360    },
361    /// A child's lifecycle state changed.
362    Status {
363        /// The affected child.
364        id: AgentId,
365        /// The new lifecycle state.
366        status: AgentStatus,
367    },
368    /// A directed message changed delivery state.
369    Message(AgentMessageUpdate),
370}
371
372/// Associates one runtime update with its owning root session.
373pub struct ScopedAgentUpdate {
374    /// The root Nanocodex session that owns the task tree.
375    pub root_session_id: String,
376    /// The typed runtime observation.
377    pub update: AgentUpdate,
378}
379
380/// Identifies one in-process runtime instance.
381///
382/// Consumers can discard late updates whose runtime identity no longer matches the active root.
383#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
384pub struct SubagentRuntimeId(u64);
385
386impl SubagentRuntimeId {
387    pub(super) fn next() -> Self {
388        Self(NEXT_RUNTIME_ID.fetch_add(1, Ordering::Relaxed) + 1)
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::{AgentId, AgentStatus, MessagePriority, agent_prompt};
395
396    #[test]
397    fn deferred_is_the_default_serialized_message_priority() {
398        assert_eq!(MessagePriority::default(), MessagePriority::Deferred);
399        assert_eq!(
400            serde_json::to_value(MessagePriority::default()).unwrap(),
401            serde_json::json!("deferred")
402        );
403    }
404
405    #[test]
406    fn agent_prompt_explains_peer_coordination_and_queued_delivery() {
407        let prompt = agent_prompt(AgentId::new(1), "coordinate with a peer");
408
409        assert!(prompt.contains("Other agents may be working concurrently"));
410        assert!(prompt.contains("list_agents"));
411        assert!(prompt.contains("prevents duplicated work"));
412        assert!(prompt.contains("avoid overwriting them"));
413        assert!(prompt.contains("If a send is queued"));
414        assert!(prompt.contains("finish the turn"));
415    }
416
417    #[test]
418    fn completed_status_serializes_structured_output_without_stringifying_it() {
419        let status = AgentStatus::Completed {
420            output: serde_json::json!({ "findings": [{ "line": 42 }] }),
421        };
422
423        assert_eq!(
424            serde_json::to_value(status).unwrap(),
425            serde_json::json!({
426                "state": "completed",
427                "output": { "findings": [{ "line": 42 }] }
428            })
429        );
430    }
431}