Skip to main content

lean_ctx/core/a2a/task/
model.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7use super::authority::{AgentArtifactRefV1, TaskDescriptorV1};
8use super::{MAX_HISTORY, TASK_DESCRIPTOR_VERSION, TASK_STATUS_VERSION};
9
10use crate::core::context_admission::carrier::{Withheld, admit_for_store};
11use crate::core::context_admission::stored::serve_stored;
12
13/// Transition reasons the Engine writes itself. They are fixed text, not
14/// caller text: neither admitted on the way in nor withheld on the way out.
15const REASON_CREATED: &str = "task created";
16const REASON_MATERIALIZED: &str = "remote task materialized";
17pub(crate) const REASON_INPUT_RECEIVED: &str = "input received via A2A";
18pub(crate) const REASON_CANCELED_VIA_A2A: &str = "canceled via A2A";
19
20fn engine_reason(reason: &str) -> bool {
21    matches!(
22        reason,
23        REASON_CREATED | REASON_MATERIALIZED | REASON_INPUT_RECEIVED | REASON_CANCELED_VIA_A2A
24    )
25}
26
27/// Stored task text as a new delivery. A task carries no source binding, so
28/// this is the originless rule of `context_admission::stored`: withheld while
29/// protection is active, re-admitted under the current content rules
30/// otherwise.
31fn serve_text(text: &str) -> Result<String, Withheld> {
32    serve_stored(text, None, None)
33}
34
35/// [`serve_text`], with the typed, content-free gateway notice in place of
36/// text that is withheld.
37fn served_text(text: &str) -> String {
38    serve_text(text).unwrap_or_else(|withheld| withheld.notice())
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub enum TaskState {
43    Created,
44    Working,
45    InputRequired,
46    Completed,
47    Failed,
48    Canceled,
49}
50
51impl std::fmt::Display for TaskState {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            TaskState::Created => write!(f, "created"),
55            TaskState::Working => write!(f, "working"),
56            TaskState::InputRequired => write!(f, "input-required"),
57            TaskState::Completed => write!(f, "completed"),
58            TaskState::Failed => write!(f, "failed"),
59            TaskState::Canceled => write!(f, "canceled"),
60        }
61    }
62}
63
64impl TaskState {
65    pub fn parse_str(s: &str) -> Option<Self> {
66        match s {
67            "created" => Some(Self::Created),
68            "working" => Some(Self::Working),
69            "input-required" | "input_required" => Some(Self::InputRequired),
70            "completed" => Some(Self::Completed),
71            "failed" => Some(Self::Failed),
72            "canceled" | "cancelled" => Some(Self::Canceled),
73            _ => None,
74        }
75    }
76
77    pub fn is_terminal(&self) -> bool {
78        matches!(self, Self::Completed | Self::Failed | Self::Canceled)
79    }
80
81    pub fn can_transition_to(&self, next: &TaskState) -> bool {
82        match self {
83            TaskState::Created => matches!(
84                next,
85                TaskState::Working | TaskState::Canceled | TaskState::Failed
86            ),
87            TaskState::Working => matches!(
88                next,
89                TaskState::InputRequired
90                    | TaskState::Completed
91                    | TaskState::Failed
92                    | TaskState::Canceled
93            ),
94            TaskState::InputRequired => matches!(
95                next,
96                TaskState::Working | TaskState::Canceled | TaskState::Failed
97            ),
98            TaskState::Completed | TaskState::Failed | TaskState::Canceled => false,
99        }
100    }
101}
102
103/// Versioned task status as published on the A2A wire.
104///
105/// `state` keeps the established lower-case A2A spelling (`created`,
106/// `input-required`, …) instead of the Rust variant name, so adding the
107/// version field does not change any value an existing client parses, and the
108/// persisted [`TaskState`] representation in the store stays untouched.
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(deny_unknown_fields)]
111pub struct TaskStatusV1 {
112    pub schema_version: u32,
113    #[serde(with = "task_state_wire")]
114    pub state: TaskState,
115    pub timestamp: DateTime<Utc>,
116}
117
118mod task_state_wire {
119    use super::TaskState;
120    use serde::{Deserialize, Deserializer, Serializer};
121
122    pub(super) fn serialize<S: Serializer>(
123        state: &TaskState,
124        serializer: S,
125    ) -> Result<S::Ok, S::Error> {
126        serializer.serialize_str(&state.to_string())
127    }
128
129    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
130        deserializer: D,
131    ) -> Result<TaskState, D::Error> {
132        let raw = String::deserialize(deserializer)?;
133        TaskState::parse_str(&raw)
134            .ok_or_else(|| serde::de::Error::custom(format!("unknown task state: {raw}")))
135    }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct TaskMessage {
140    pub role: String,
141    pub parts: Vec<TaskPart>,
142    pub timestamp: DateTime<Utc>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(tag = "type")]
147pub enum TaskPart {
148    #[serde(rename = "text")]
149    Text { text: String },
150    #[serde(rename = "data")]
151    Data { mime_type: String, data: String },
152    #[serde(rename = "file")]
153    File {
154        name: String,
155        mime_type: Option<String>,
156        data: Option<String>,
157        uri: Option<String>,
158    },
159}
160
161impl TaskPart {
162    /// The part with every caller-chosen string passed through `text`, or the
163    /// first refusal.
164    fn try_map(&self, text: fn(&str) -> Result<String, Withheld>) -> Result<Self, Withheld> {
165        Ok(match self {
166            Self::Text { text: value } => Self::Text { text: text(value)? },
167            Self::Data { mime_type, data } => Self::Data {
168                mime_type: text(mime_type)?,
169                data: text(data)?,
170            },
171            Self::File {
172                name,
173                mime_type,
174                data,
175                uri,
176            } => Self::File {
177                name: text(name)?,
178                mime_type: mime_type.as_deref().map(text).transpose()?,
179                data: data.as_deref().map(text).transpose()?,
180                uri: uri.as_deref().map(text).transpose()?,
181            },
182        })
183    }
184
185    /// NEW caller content on its way into the task store: what the gateway
186    /// masks is stored masked; restricted or withheld content is refused and
187    /// nothing of the part is stored (E3).
188    pub(crate) fn admitted(&self) -> Result<Self, Withheld> {
189        self.try_map(admit_for_store)
190    }
191
192    /// The stored part as a new delivery. A part that is withheld — whatever
193    /// its kind — becomes a text part carrying the gateway notice, in place.
194    fn served(&self) -> Self {
195        self.try_map(serve_text)
196            .unwrap_or_else(|withheld| Self::Text {
197                text: withheld.notice(),
198            })
199    }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct TaskTransition {
204    pub from: TaskState,
205    pub to: TaskState,
206    pub timestamp: DateTime<Utc>,
207    pub reason: Option<String>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct Task {
212    pub id: String,
213    pub from_agent: String,
214    pub to_agent: String,
215    pub state: TaskState,
216    pub description: String,
217    pub messages: Vec<TaskMessage>,
218    pub artifacts: Vec<TaskPart>,
219    pub history: Vec<TaskTransition>,
220    pub metadata: HashMap<String, String>,
221    pub created_at: DateTime<Utc>,
222    pub updated_at: DateTime<Utc>,
223    #[serde(default)]
224    pub tenant_id: Option<String>,
225    #[serde(default)]
226    pub project_id: Option<String>,
227    #[serde(default)]
228    pub action: Option<String>,
229    #[serde(default)]
230    pub idempotency_key: Option<String>,
231    #[serde(default)]
232    pub authority_key_id: Option<String>,
233    #[serde(default)]
234    pub descriptor_digest: Option<String>,
235    #[serde(default)]
236    pub artifact_refs: Vec<AgentArtifactRefV1>,
237}
238
239impl Task {
240    pub fn new(from_agent: &str, to_agent: &str, description: &str) -> Self {
241        let now = Utc::now();
242        let id = format!("task-{}", uuid::Uuid::new_v4());
243
244        Self {
245            id,
246            from_agent: from_agent.to_string(),
247            to_agent: to_agent.to_string(),
248            state: TaskState::Created,
249            description: description.to_string(),
250            messages: vec![TaskMessage {
251                role: from_agent.to_string(),
252                parts: vec![TaskPart::Text {
253                    text: description.to_string(),
254                }],
255                timestamp: now,
256            }],
257            artifacts: Vec::new(),
258            history: vec![TaskTransition {
259                from: TaskState::Created,
260                to: TaskState::Created,
261                timestamp: now,
262                reason: Some(REASON_CREATED.to_string()),
263            }],
264            metadata: HashMap::new(),
265            created_at: now,
266            updated_at: now,
267            tenant_id: None,
268            project_id: None,
269            action: None,
270            idempotency_key: None,
271            authority_key_id: None,
272            descriptor_digest: None,
273            artifact_refs: Vec::new(),
274        }
275    }
276
277    /// The description is NEW caller text from the remote peer: it is
278    /// admitted for the store here (masked text is stored masked; restricted
279    /// or withheld text refuses the task and nothing is materialized).
280    pub(super) fn from_remote_descriptor(
281        descriptor: &TaskDescriptorV1,
282        task_id: String,
283    ) -> Result<Self, Withheld> {
284        let now = Utc::now();
285        let description = admit_for_store(&descriptor.description)?;
286        let parts = vec![TaskPart::Text {
287            text: description.clone(),
288        }];
289        let mut metadata = HashMap::new();
290        metadata.insert(
291            "schema_version".to_string(),
292            TASK_DESCRIPTOR_VERSION.to_string(),
293        );
294        metadata.insert("action".to_string(), descriptor.action.clone());
295        Ok(Self {
296            id: task_id,
297            from_agent: descriptor.sender.clone(),
298            to_agent: descriptor.recipient.clone(),
299            state: TaskState::Created,
300            description,
301            messages: vec![TaskMessage {
302                role: descriptor.sender.clone(),
303                parts,
304                timestamp: now,
305            }],
306            artifacts: Vec::new(),
307            history: vec![TaskTransition {
308                from: TaskState::Created,
309                to: TaskState::Created,
310                timestamp: now,
311                reason: Some(REASON_MATERIALIZED.to_string()),
312            }],
313            metadata,
314            created_at: now,
315            updated_at: now,
316            tenant_id: Some(descriptor.tenant_id.clone()),
317            project_id: Some(descriptor.project_id.clone()),
318            action: Some(descriptor.action.clone()),
319            idempotency_key: Some(descriptor.idempotency_key.clone()),
320            authority_key_id: Some(descriptor.key_id.clone()),
321            descriptor_digest: Some(descriptor.content_digest()),
322            artifact_refs: descriptor.artifact_refs.clone(),
323        })
324    }
325
326    /// A copy of the task for delivery to a tool result or a peer.
327    ///
328    /// Description, message parts, artifacts, metadata values and
329    /// caller-typed transition reasons are each served on their own; a part
330    /// that is withheld is replaced in place by the typed gateway notice, so
331    /// the shape of the task (counts, order, states, timestamps, ids) is what
332    /// is stored. The stored task is not changed.
333    #[must_use]
334    pub fn served(&self) -> Self {
335        let mut task = self.clone();
336        task.description = self.served_description();
337        for message in &mut task.messages {
338            for part in &mut message.parts {
339                *part = part.served();
340            }
341        }
342        for artifact in &mut task.artifacts {
343            *artifact = artifact.served();
344        }
345        for value in task.metadata.values_mut() {
346            *value = served_text(value);
347        }
348        for transition in &mut task.history {
349            if let Some(reason) = transition
350                .reason
351                .as_mut()
352                .filter(|reason| !engine_reason(reason.as_str()))
353            {
354                *reason = served_text(reason);
355            }
356        }
357        task
358    }
359
360    /// The description alone as [`Self::served`] delivers it, for a listing
361    /// that prints nothing else of the task's text.
362    #[must_use]
363    pub fn served_description(&self) -> String {
364        served_text(&self.description)
365    }
366
367    pub fn status_v1(&self) -> TaskStatusV1 {
368        TaskStatusV1 {
369            schema_version: TASK_STATUS_VERSION,
370            state: self.state.clone(),
371            timestamp: self.updated_at,
372        }
373    }
374
375    pub fn transition(&mut self, new_state: TaskState, reason: Option<&str>) -> Result<(), String> {
376        if self.history.len() >= MAX_HISTORY {
377            return Err("task transition history limit exceeded".to_string());
378        }
379        // Keep one slot for cancellation/failure, not unbounded further work.
380        if self.history.len() == MAX_HISTORY - 1 && !new_state.is_terminal() {
381            return Err("task transition history reserved for terminal state".to_string());
382        }
383        if !self.state.can_transition_to(&new_state) {
384            return Err(format!(
385                "invalid transition: {} → {}",
386                self.state, new_state
387            ));
388        }
389
390        let now = Utc::now();
391        // Equal timestamps are valid; a backward clock must not poison reload.
392        if now < self.updated_at {
393            return Err("task clock precedes stored state".to_string());
394        }
395        // A reason typed by a caller is new text for the store: admitted
396        // before anything changes. The refusal is typed and content-free.
397        let reason = match reason {
398            Some(reason) if !engine_reason(reason) => {
399                Some(admit_for_store(reason).map_err(|withheld| withheld.message("task reason"))?)
400            }
401            other => other.map(str::to_owned),
402        };
403        self.history.push(TaskTransition {
404            from: self.state.clone(),
405            to: new_state.clone(),
406            timestamp: now,
407            reason,
408        });
409
410        self.state = new_state;
411        self.updated_at = now;
412        Ok(())
413    }
414
415    pub fn add_message(&mut self, role: &str, parts: Vec<TaskPart>) {
416        self.messages.push(TaskMessage {
417            role: role.to_string(),
418            parts,
419            timestamp: Utc::now(),
420        });
421        self.updated_at = Utc::now();
422    }
423
424    /// [`Self::add_message`] for NEW caller content: every part is admitted
425    /// for the store first ([`TaskPart::admitted`]). One refused part refuses
426    /// the message and nothing of it is appended.
427    pub(crate) fn append_message(
428        &mut self,
429        role: &str,
430        parts: &[TaskPart],
431    ) -> Result<(), Withheld> {
432        let parts = parts
433            .iter()
434            .map(TaskPart::admitted)
435            .collect::<Result<Vec<_>, _>>()?;
436        self.add_message(role, parts);
437        Ok(())
438    }
439
440    pub fn add_artifact(&mut self, artifact: TaskPart) {
441        self.artifacts.push(artifact);
442        self.updated_at = Utc::now();
443    }
444}