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