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