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