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, SkipListener};
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] = &["items", "file", "markdown"];
198
199impl PlanUpdateContent {
200    /// Builds a plan update that replaces the itemized entries for a plan.
201    #[must_use]
202    pub fn items(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
203        Self::Items(PlanItems::new(plan_id, entries))
204    }
205
206    /// Builds a plan update that points clients at an external plan file URI.
207    #[cfg(feature = "unstable_plan_operations")]
208    #[must_use]
209    pub fn file(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
210        Self::File(PlanFile::new(plan_id, uri))
211    }
212
213    /// Builds a plan update whose plan content is inline Markdown.
214    #[cfg(feature = "unstable_plan_operations")]
215    #[must_use]
216    pub fn markdown(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
217        Self::Markdown(PlanMarkdown::new(plan_id, content))
218    }
219}
220
221/// A plan represented as structured entries.
222#[serde_as]
223#[skip_serializing_none]
224#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(rename_all = "camelCase")]
227#[non_exhaustive]
228pub struct PlanItems {
229    /// The plan ID to update.
230    pub plan_id: PlanId,
231    /// The list of tasks to be accomplished.
232    ///
233    /// When updating an item-based plan, the agent must send a complete list of all entries
234    /// with their current status. The client replaces that plan with each update.
235    #[serde_as(deserialize_as = "DefaultOnError<VecSkipError<_, SkipListener>>")]
236    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true)))]
237    pub entries: Vec<PlanEntry>,
238    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
239    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
240    /// these keys.
241    ///
242    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
243    #[serde_as(deserialize_as = "DefaultOnError")]
244    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
245    #[serde(default)]
246    #[serde(rename = "_meta")]
247    pub meta: Option<Meta>,
248}
249
250impl PlanItems {
251    /// Builds [`PlanItems`] with the required fields set; optional fields start unset or empty.
252    #[must_use]
253    pub fn new(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
254        Self {
255            plan_id: plan_id.into(),
256            entries,
257            meta: None,
258        }
259    }
260
261    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
262    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
263    /// these keys.
264    ///
265    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
266    #[must_use]
267    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
268        self.meta = meta.into_option();
269        self
270    }
271}
272
273/// **UNSTABLE**
274///
275/// This capability is not part of the spec yet, and may be removed or changed at any point.
276///
277/// A plan represented by a file URI.
278#[cfg(feature = "unstable_plan_operations")]
279#[serde_as]
280#[skip_serializing_none]
281#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283#[serde(rename_all = "camelCase")]
284#[non_exhaustive]
285pub struct PlanFile {
286    /// The plan ID to update.
287    pub plan_id: PlanId,
288    /// The URI of the file containing the plan.
289    #[cfg_attr(feature = "schemars", schemars(url))]
290    pub uri: String,
291    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
292    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
293    /// these keys.
294    ///
295    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
296    #[serde_as(deserialize_as = "DefaultOnError")]
297    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
298    #[serde(default)]
299    #[serde(rename = "_meta")]
300    pub meta: Option<Meta>,
301}
302
303#[cfg(feature = "unstable_plan_operations")]
304impl PlanFile {
305    /// Builds [`PlanFile`] with the required fields set; optional fields start unset or empty.
306    #[must_use]
307    pub fn new(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
308        Self {
309            plan_id: plan_id.into(),
310            uri: uri.into(),
311            meta: None,
312        }
313    }
314
315    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
316    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
317    /// these keys.
318    ///
319    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
320    #[must_use]
321    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
322        self.meta = meta.into_option();
323        self
324    }
325}
326
327/// **UNSTABLE**
328///
329/// This capability is not part of the spec yet, and may be removed or changed at any point.
330///
331/// A plan represented as raw markdown content.
332#[cfg(feature = "unstable_plan_operations")]
333#[serde_as]
334#[skip_serializing_none]
335#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
337#[serde(rename_all = "camelCase")]
338#[non_exhaustive]
339pub struct PlanMarkdown {
340    /// The plan ID to update.
341    pub plan_id: PlanId,
342    /// Markdown content for the plan.
343    pub content: String,
344    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
345    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
346    /// these keys.
347    ///
348    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
349    #[serde_as(deserialize_as = "DefaultOnError")]
350    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
351    #[serde(default)]
352    #[serde(rename = "_meta")]
353    pub meta: Option<Meta>,
354}
355
356#[cfg(feature = "unstable_plan_operations")]
357impl PlanMarkdown {
358    /// Builds [`PlanMarkdown`] with the required fields set; optional fields start unset or empty.
359    #[must_use]
360    pub fn new(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
361        Self {
362            plan_id: plan_id.into(),
363            content: content.into(),
364            meta: None,
365        }
366    }
367
368    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
369    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
370    /// these keys.
371    ///
372    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
373    #[must_use]
374    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
375        self.meta = meta.into_option();
376        self
377    }
378}
379
380/// **UNSTABLE**
381///
382/// This capability is not part of the spec yet, and may be removed or changed at any point.
383///
384/// Removal notice for a plan identified by ID.
385#[cfg(feature = "unstable_plan_operations")]
386#[serde_as]
387#[skip_serializing_none]
388#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390#[serde(rename_all = "camelCase")]
391#[non_exhaustive]
392pub struct PlanRemoved {
393    /// The plan ID to remove.
394    pub plan_id: PlanId,
395    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
396    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
397    /// these keys.
398    ///
399    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
400    #[serde_as(deserialize_as = "DefaultOnError")]
401    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
402    #[serde(default)]
403    #[serde(rename = "_meta")]
404    pub meta: Option<Meta>,
405}
406
407#[cfg(feature = "unstable_plan_operations")]
408impl PlanRemoved {
409    /// Builds [`PlanRemoved`] with the required fields set; optional fields start unset or empty.
410    #[must_use]
411    pub fn new(plan_id: impl Into<PlanId>) -> Self {
412        Self {
413            plan_id: plan_id.into(),
414            meta: None,
415        }
416    }
417
418    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
419    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
420    /// these keys.
421    ///
422    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
423    #[must_use]
424    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
425        self.meta = meta.into_option();
426        self
427    }
428}
429
430/// A single entry in the execution plan.
431///
432/// Represents a task or goal that the assistant intends to accomplish
433/// as part of fulfilling the user's request.
434/// See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)
435#[serde_as]
436#[skip_serializing_none]
437#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
439#[serde(rename_all = "camelCase")]
440#[non_exhaustive]
441pub struct PlanEntry {
442    /// Human-readable description of what this task aims to accomplish.
443    pub content: String,
444    /// The relative importance of this task.
445    /// Used to indicate which tasks are most critical to the overall goal.
446    pub priority: PlanEntryPriority,
447    /// Current execution status of this task.
448    pub status: PlanEntryStatus,
449    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
450    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
451    /// these keys.
452    ///
453    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
454    #[serde_as(deserialize_as = "DefaultOnError")]
455    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
456    #[serde(default)]
457    #[serde(rename = "_meta")]
458    pub meta: Option<Meta>,
459}
460
461impl PlanEntry {
462    /// Builds [`PlanEntry`] with the required fields set; optional fields start unset or empty.
463    #[must_use]
464    pub fn new(
465        content: impl Into<String>,
466        priority: PlanEntryPriority,
467        status: PlanEntryStatus,
468    ) -> Self {
469        Self {
470            content: content.into(),
471            priority,
472            status,
473            meta: None,
474        }
475    }
476
477    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
478    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
479    /// these keys.
480    ///
481    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
482    #[must_use]
483    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
484        self.meta = meta.into_option();
485        self
486    }
487}
488
489/// Priority levels for plan entries.
490///
491/// Used to indicate the relative importance or urgency of different
492/// tasks in the execution plan.
493/// See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)
494#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
495#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
496#[serde(rename_all = "snake_case")]
497#[non_exhaustive]
498pub enum PlanEntryPriority {
499    /// High priority task - critical to the overall goal.
500    High,
501    /// Medium priority task - important but not critical.
502    Medium,
503    /// Low priority task - nice to have but not essential.
504    Low,
505    /// Custom or future plan entry priority.
506    ///
507    /// Values beginning with `_` are reserved for implementation-specific
508    /// extensions. Unknown values that do not begin with `_` are reserved for
509    /// future ACP variants.
510    #[serde(untagged)]
511    Other(String),
512}
513
514/// Status of a plan entry in the execution flow.
515///
516/// Tracks the lifecycle of each task from planning through completion.
517/// See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)
518#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
519#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
520#[serde(rename_all = "snake_case")]
521#[non_exhaustive]
522pub enum PlanEntryStatus {
523    /// The task has not started yet.
524    Pending,
525    /// The task is currently being worked on.
526    InProgress,
527    /// The task has been successfully completed.
528    Completed,
529    /// The task was cancelled before it completed.
530    Cancelled,
531    /// Custom or future plan entry status.
532    ///
533    /// Values beginning with `_` are reserved for implementation-specific
534    /// extensions. Unknown values that do not begin with `_` are reserved for
535    /// future ACP variants.
536    #[serde(untagged)]
537    Other(String),
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn plan_entry_priority_preserves_unknown_variant() {
546        let priority: PlanEntryPriority = serde_json::from_str("\"urgent\"").unwrap();
547        assert_eq!(priority, PlanEntryPriority::Other("urgent".to_string()));
548        assert_eq!(serde_json::to_value(&priority).unwrap(), "urgent");
549    }
550
551    #[test]
552    fn plan_entry_status_preserves_unknown_variant() {
553        let status: PlanEntryStatus = serde_json::from_str("\"blocked\"").unwrap();
554        assert_eq!(status, PlanEntryStatus::Other("blocked".to_string()));
555        assert_eq!(serde_json::to_value(&status).unwrap(), "blocked");
556    }
557
558    #[test]
559    fn plan_entry_status_recognizes_cancelled_variant() {
560        let status: PlanEntryStatus = serde_json::from_str("\"cancelled\"").unwrap();
561        assert_eq!(status, PlanEntryStatus::Cancelled);
562        assert_eq!(serde_json::to_value(&status).unwrap(), "cancelled");
563    }
564
565    #[test]
566    fn plan_update_content_preserves_unknown_variant() {
567        let content: PlanUpdateContent = serde_json::from_value(serde_json::json!({
568            "type": "_timeline",
569            "planId": "plan-1",
570            "events": []
571        }))
572        .unwrap();
573
574        let PlanUpdateContent::Other(unknown) = content else {
575            panic!("expected unknown plan update content");
576        };
577
578        assert_eq!(unknown.type_, "_timeline");
579        assert_eq!(unknown.plan_id.to_string(), "plan-1");
580        assert!(!unknown.fields.contains_key("planId"));
581        assert_eq!(
582            serde_json::to_value(PlanUpdateContent::Other(unknown)).unwrap(),
583            serde_json::json!({
584                "type": "_timeline",
585                "planId": "plan-1",
586                "events": []
587            })
588        );
589    }
590
591    #[test]
592    fn plan_update_content_does_not_hide_malformed_known_variant() {
593        assert!(
594            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
595                "type": "items"
596            }))
597            .is_err()
598        );
599        assert!(
600            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
601                "type": "file",
602                "planId": "plan-1"
603            }))
604            .is_err()
605        );
606        assert!(
607            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
608                "type": "markdown",
609                "planId": "plan-1"
610            }))
611            .is_err()
612        );
613    }
614
615    #[test]
616    fn plan_update_content_requires_id_for_unknown_variant() {
617        assert!(
618            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
619                "type": "_timeline"
620            }))
621            .is_err()
622        );
623    }
624}