Skip to main content

agent_client_protocol_schema/v2/
plan.rs

1//! Execution plans for complex tasks that require multiple steps.
2//!
3//! Plans are strategies that agents share with clients through session updates,
4//! providing real-time visibility into their thinking and progress.
5//!
6//! See: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)
7
8use std::{collections::BTreeMap, sync::Arc};
9
10use derive_more::{Display, From};
11use schemars::JsonSchema;
12use schemars::Schema;
13use serde::{Deserialize, Serialize};
14use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none};
15
16use super::Meta;
17use crate::{IntoOption, SkipListener};
18
19/// Unique identifier for a plan within a session.
20#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash, Display, From)]
21#[serde(transparent)]
22#[from(Arc<str>, String, &'static str)]
23#[non_exhaustive]
24pub struct PlanId(pub Arc<str>);
25
26impl PlanId {
27    /// Wraps a protocol string as a typed [`PlanId`].
28    #[must_use]
29    pub fn new(id: impl Into<Arc<str>>) -> Self {
30        Self(id.into())
31    }
32}
33
34/// A content update for a plan identified by ID.
35#[skip_serializing_none]
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
37#[serde(rename_all = "camelCase")]
38#[non_exhaustive]
39pub struct PlanUpdate {
40    /// The updated plan content.
41    pub plan: PlanUpdateContent,
42    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
43    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
44    /// these keys.
45    ///
46    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
47    #[serde(rename = "_meta")]
48    pub meta: Option<Meta>,
49}
50
51impl PlanUpdate {
52    /// Builds [`PlanUpdate`] with the required fields set; optional fields start unset or empty.
53    #[must_use]
54    pub fn new(plan: PlanUpdateContent) -> Self {
55        Self { plan, meta: None }
56    }
57
58    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
59    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
60    /// these keys.
61    ///
62    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
63    #[must_use]
64    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
65        self.meta = meta.into_option();
66        self
67    }
68}
69
70/// Updated content for a plan.
71#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
72#[serde(tag = "type", rename_all = "snake_case")]
73#[schemars(extend("discriminator" = {"propertyName": "type"}))]
74#[non_exhaustive]
75pub enum PlanUpdateContent {
76    /// Structured plan entries.
77    Items(PlanItems),
78    /// **UNSTABLE**
79    ///
80    /// This capability is not part of the spec yet, and may be removed or changed at any point.
81    ///
82    /// A URI pointing to a file containing the plan.
83    #[cfg(feature = "unstable_plan_operations")]
84    File(PlanFile),
85    /// **UNSTABLE**
86    ///
87    /// This capability is not part of the spec yet, and may be removed or changed at any point.
88    ///
89    /// Raw markdown content for the plan.
90    #[cfg(feature = "unstable_plan_operations")]
91    Markdown(PlanMarkdown),
92    /// Custom or future plan update content.
93    ///
94    /// Values beginning with `_` are reserved for implementation-specific
95    /// extensions. Unknown values that do not begin with `_` are reserved for
96    /// future ACP variants.
97    ///
98    /// Receivers that do not understand this content type should preserve the
99    /// raw payload when storing, replaying, proxying, or forwarding plans, and
100    /// otherwise ignore it or display it generically.
101    #[serde(untagged)]
102    Other(OtherPlanUpdateContent),
103}
104
105/// Custom or future plan update content payload.
106#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
107#[schemars(inline)]
108#[schemars(transform = other_plan_update_content_schema)]
109#[serde(rename_all = "camelCase")]
110#[non_exhaustive]
111pub struct OtherPlanUpdateContent {
112    /// Custom or future plan update content type.
113    ///
114    /// Values beginning with `_` are reserved for implementation-specific
115    /// extensions. Unknown values that do not begin with `_` are reserved for
116    /// future ACP variants.
117    #[serde(rename = "type")]
118    pub type_: String,
119    /// The plan ID to update.
120    pub id: PlanId,
121    /// Additional fields from the unknown plan update content payload.
122    #[serde(flatten)]
123    pub fields: BTreeMap<String, serde_json::Value>,
124}
125
126impl OtherPlanUpdateContent {
127    /// Builds [`OtherPlanUpdateContent`] from an unknown discriminator and preserves the remaining extension fields.
128    #[must_use]
129    pub fn new(
130        type_: impl Into<String>,
131        id: impl Into<PlanId>,
132        mut fields: BTreeMap<String, serde_json::Value>,
133    ) -> Self {
134        fields.remove("type");
135        fields.remove("id");
136        Self {
137            type_: type_.into(),
138            id: id.into(),
139            fields,
140        }
141    }
142}
143
144impl<'de> Deserialize<'de> for OtherPlanUpdateContent {
145    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
146    where
147        D: serde::Deserializer<'de>,
148    {
149        let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
150        let type_ = fields
151            .remove("type")
152            .ok_or_else(|| serde::de::Error::missing_field("type"))?;
153        let serde_json::Value::String(type_) = type_ else {
154            return Err(serde::de::Error::custom("`type` must be a string"));
155        };
156        let id = fields
157            .remove("id")
158            .ok_or_else(|| serde::de::Error::missing_field("id"))?;
159        let serde_json::Value::String(id) = id else {
160            return Err(serde::de::Error::custom("`id` must be a string"));
161        };
162
163        if is_known_plan_update_content_type(&type_) {
164            return Err(serde::de::Error::custom(format!(
165                "known plan update content `{type_}` did not match its schema"
166            )));
167        }
168
169        Ok(Self {
170            type_,
171            id: PlanId::new(id),
172            fields,
173        })
174    }
175}
176
177fn is_known_plan_update_content_type(type_: &str) -> bool {
178    KNOWN_PLAN_UPDATE_CONTENT_TYPES.contains(&type_)
179}
180
181fn other_plan_update_content_schema(schema: &mut Schema) {
182    super::schema_util::reject_known_string_discriminators(
183        schema,
184        "type",
185        KNOWN_PLAN_UPDATE_CONTENT_TYPES,
186    );
187}
188
189const KNOWN_PLAN_UPDATE_CONTENT_TYPES: &[&str] = &["items", "file", "markdown"];
190
191impl PlanUpdateContent {
192    /// Builds a plan update that replaces the itemized entries for a plan.
193    #[must_use]
194    pub fn items(id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
195        Self::Items(PlanItems::new(id, entries))
196    }
197
198    /// Builds a plan update that points clients at an external plan file URI.
199    #[cfg(feature = "unstable_plan_operations")]
200    #[must_use]
201    pub fn file(id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
202        Self::File(PlanFile::new(id, uri))
203    }
204
205    /// Builds a plan update whose plan content is inline Markdown.
206    #[cfg(feature = "unstable_plan_operations")]
207    #[must_use]
208    pub fn markdown(id: impl Into<PlanId>, content: impl Into<String>) -> Self {
209        Self::Markdown(PlanMarkdown::new(id, content))
210    }
211}
212
213/// A plan represented as structured entries.
214#[serde_as]
215#[skip_serializing_none]
216#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
217#[serde(rename_all = "camelCase")]
218#[non_exhaustive]
219pub struct PlanItems {
220    /// The plan ID to update.
221    pub id: PlanId,
222    /// The list of tasks to be accomplished.
223    ///
224    /// When updating an item-based plan, the agent must send a complete list of all entries
225    /// with their current status. The client replaces that plan with each update.
226    #[serde_as(deserialize_as = "DefaultOnError<VecSkipError<_, SkipListener>>")]
227    #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
228    pub entries: Vec<PlanEntry>,
229    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
230    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
231    /// these keys.
232    ///
233    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
234    #[serde(rename = "_meta")]
235    pub meta: Option<Meta>,
236}
237
238impl PlanItems {
239    /// Builds [`PlanItems`] with the required fields set; optional fields start unset or empty.
240    #[must_use]
241    pub fn new(id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
242        Self {
243            id: id.into(),
244            entries,
245            meta: None,
246        }
247    }
248
249    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
250    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
251    /// these keys.
252    ///
253    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
254    #[must_use]
255    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
256        self.meta = meta.into_option();
257        self
258    }
259}
260
261/// **UNSTABLE**
262///
263/// This capability is not part of the spec yet, and may be removed or changed at any point.
264///
265/// A plan represented by a file URI.
266#[cfg(feature = "unstable_plan_operations")]
267#[skip_serializing_none]
268#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
269#[serde(rename_all = "camelCase")]
270#[non_exhaustive]
271pub struct PlanFile {
272    /// The plan ID to update.
273    pub id: PlanId,
274    /// The URI of the file containing the plan.
275    pub uri: String,
276    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
277    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
278    /// these keys.
279    ///
280    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
281    #[serde(rename = "_meta")]
282    pub meta: Option<Meta>,
283}
284
285#[cfg(feature = "unstable_plan_operations")]
286impl PlanFile {
287    /// Builds [`PlanFile`] with the required fields set; optional fields start unset or empty.
288    #[must_use]
289    pub fn new(id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
290        Self {
291            id: id.into(),
292            uri: uri.into(),
293            meta: None,
294        }
295    }
296
297    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
298    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
299    /// these keys.
300    ///
301    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
302    #[must_use]
303    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
304        self.meta = meta.into_option();
305        self
306    }
307}
308
309/// **UNSTABLE**
310///
311/// This capability is not part of the spec yet, and may be removed or changed at any point.
312///
313/// A plan represented as raw markdown content.
314#[cfg(feature = "unstable_plan_operations")]
315#[skip_serializing_none]
316#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
317#[serde(rename_all = "camelCase")]
318#[non_exhaustive]
319pub struct PlanMarkdown {
320    /// The plan ID to update.
321    pub id: PlanId,
322    /// Markdown content for the plan.
323    pub content: String,
324    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
325    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
326    /// these keys.
327    ///
328    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
329    #[serde(rename = "_meta")]
330    pub meta: Option<Meta>,
331}
332
333#[cfg(feature = "unstable_plan_operations")]
334impl PlanMarkdown {
335    /// Builds [`PlanMarkdown`] with the required fields set; optional fields start unset or empty.
336    #[must_use]
337    pub fn new(id: impl Into<PlanId>, content: impl Into<String>) -> Self {
338        Self {
339            id: id.into(),
340            content: content.into(),
341            meta: None,
342        }
343    }
344
345    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
346    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
347    /// these keys.
348    ///
349    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
350    #[must_use]
351    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
352        self.meta = meta.into_option();
353        self
354    }
355}
356
357/// **UNSTABLE**
358///
359/// This capability is not part of the spec yet, and may be removed or changed at any point.
360///
361/// Removal notice for a plan identified by ID.
362#[cfg(feature = "unstable_plan_operations")]
363#[skip_serializing_none]
364#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
365#[serde(rename_all = "camelCase")]
366#[non_exhaustive]
367pub struct PlanRemoved {
368    /// The plan ID to remove.
369    pub id: PlanId,
370    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
371    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
372    /// these keys.
373    ///
374    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
375    #[serde(rename = "_meta")]
376    pub meta: Option<Meta>,
377}
378
379#[cfg(feature = "unstable_plan_operations")]
380impl PlanRemoved {
381    /// Builds [`PlanRemoved`] with the required fields set; optional fields start unset or empty.
382    #[must_use]
383    pub fn new(id: impl Into<PlanId>) -> Self {
384        Self {
385            id: id.into(),
386            meta: None,
387        }
388    }
389
390    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
391    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
392    /// these keys.
393    ///
394    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
395    #[must_use]
396    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
397        self.meta = meta.into_option();
398        self
399    }
400}
401
402/// A single entry in the execution plan.
403///
404/// Represents a task or goal that the assistant intends to accomplish
405/// as part of fulfilling the user's request.
406/// See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)
407#[skip_serializing_none]
408#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
409#[serde(rename_all = "camelCase")]
410#[non_exhaustive]
411pub struct PlanEntry {
412    /// Human-readable description of what this task aims to accomplish.
413    pub content: String,
414    /// The relative importance of this task.
415    /// Used to indicate which tasks are most critical to the overall goal.
416    pub priority: PlanEntryPriority,
417    /// Current execution status of this task.
418    pub status: PlanEntryStatus,
419    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
420    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
421    /// these keys.
422    ///
423    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
424    #[serde(rename = "_meta")]
425    pub meta: Option<Meta>,
426}
427
428impl PlanEntry {
429    /// Builds [`PlanEntry`] with the required fields set; optional fields start unset or empty.
430    #[must_use]
431    pub fn new(
432        content: impl Into<String>,
433        priority: PlanEntryPriority,
434        status: PlanEntryStatus,
435    ) -> Self {
436        Self {
437            content: content.into(),
438            priority,
439            status,
440            meta: None,
441        }
442    }
443
444    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
445    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
446    /// these keys.
447    ///
448    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
449    #[must_use]
450    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
451        self.meta = meta.into_option();
452        self
453    }
454}
455
456/// Priority levels for plan entries.
457///
458/// Used to indicate the relative importance or urgency of different
459/// tasks in the execution plan.
460/// See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)
461#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
462#[serde(rename_all = "snake_case")]
463#[non_exhaustive]
464pub enum PlanEntryPriority {
465    /// High priority task - critical to the overall goal.
466    High,
467    /// Medium priority task - important but not critical.
468    Medium,
469    /// Low priority task - nice to have but not essential.
470    Low,
471    /// Custom or future plan entry priority.
472    ///
473    /// Values beginning with `_` are reserved for implementation-specific
474    /// extensions. Unknown values that do not begin with `_` are reserved for
475    /// future ACP variants.
476    #[serde(untagged)]
477    Other(String),
478}
479
480/// Status of a plan entry in the execution flow.
481///
482/// Tracks the lifecycle of each task from planning through completion.
483/// See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)
484#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
485#[serde(rename_all = "snake_case")]
486#[non_exhaustive]
487pub enum PlanEntryStatus {
488    /// The task has not started yet.
489    Pending,
490    /// The task is currently being worked on.
491    InProgress,
492    /// The task has been successfully completed.
493    Completed,
494    /// Custom or future plan entry status.
495    ///
496    /// Values beginning with `_` are reserved for implementation-specific
497    /// extensions. Unknown values that do not begin with `_` are reserved for
498    /// future ACP variants.
499    #[serde(untagged)]
500    Other(String),
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn plan_entry_priority_preserves_unknown_variant() {
509        let priority: PlanEntryPriority = serde_json::from_str("\"urgent\"").unwrap();
510        assert_eq!(priority, PlanEntryPriority::Other("urgent".to_string()));
511        assert_eq!(serde_json::to_value(&priority).unwrap(), "urgent");
512    }
513
514    #[test]
515    fn plan_entry_status_preserves_unknown_variant() {
516        let status: PlanEntryStatus = serde_json::from_str("\"blocked\"").unwrap();
517        assert_eq!(status, PlanEntryStatus::Other("blocked".to_string()));
518        assert_eq!(serde_json::to_value(&status).unwrap(), "blocked");
519    }
520
521    #[test]
522    fn plan_update_content_preserves_unknown_variant() {
523        let content: PlanUpdateContent = serde_json::from_value(serde_json::json!({
524            "type": "_timeline",
525            "id": "plan-1",
526            "events": []
527        }))
528        .unwrap();
529
530        let PlanUpdateContent::Other(unknown) = content else {
531            panic!("expected unknown plan update content");
532        };
533
534        assert_eq!(unknown.type_, "_timeline");
535        assert_eq!(unknown.id.to_string(), "plan-1");
536        assert!(!unknown.fields.contains_key("id"));
537        assert_eq!(
538            serde_json::to_value(PlanUpdateContent::Other(unknown)).unwrap(),
539            serde_json::json!({
540                "type": "_timeline",
541                "id": "plan-1",
542                "events": []
543            })
544        );
545    }
546
547    #[test]
548    fn plan_update_content_does_not_hide_malformed_known_variant() {
549        assert!(
550            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
551                "type": "items"
552            }))
553            .is_err()
554        );
555        assert!(
556            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
557                "type": "file",
558                "id": "plan-1"
559            }))
560            .is_err()
561        );
562        assert!(
563            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
564                "type": "markdown",
565                "id": "plan-1"
566            }))
567            .is_err()
568        );
569    }
570
571    #[test]
572    fn plan_update_content_requires_id_for_unknown_variant() {
573        assert!(
574            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
575                "type": "_timeline"
576            }))
577            .is_err()
578        );
579    }
580}