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