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