cellstate/entity/
agent.rs

1//! `cellstate agent` — Manage AI agents.
2//!
3//! Subcommands: create, list, get, update, delete, kill, heartbeat,
4//! deliberate, goals, activate-goal, plans, complete-step.
5
6use anyhow::{Context, Result};
7use clap::{Arg, ArgMatches, Command};
8
9use cellstate_core::api_types::{
10    AgentResponse, CompleteStepRequest, CreateGoalRequest, CreatePlanRequest, DeliberationResponse,
11    GoalResponse, ListAgentsResponse, PlanResponse, PlanStepInput, RegisterAgentRequest,
12    UpdateAgentRequest,
13};
14use cellstate_core::GoalType;
15
16use super::client::ApiClient;
17use super::output::OutputConfig;
18
19/// Parse a serde-deserialized enum from a snake_case string (wraps in JSON quotes).
20fn parse_goal_type(s: &str) -> Result<GoalType> {
21    let json = format!("\"{s}\"");
22    serde_json::from_str::<GoalType>(&json).with_context(|| {
23        format!("invalid goal type: '{s}' (try: terminal, subgoal, milestone, invariant)")
24    })
25}
26
27const BASE: &str = "/api/v1/agents";
28
29pub fn build_command() -> Command {
30    Command::new("agent")
31        .about("Manage AI agents (registration, lifecycle, BDI)")
32        .subcommand_required(true)
33        .subcommand(
34            Command::new("create")
35                .about("Register a new agent")
36                .arg(
37                    Arg::new("type")
38                        .required(true)
39                        .help("Agent type (e.g. coordinator, worker, specialist)"),
40                )
41                .arg(
42                    Arg::new("capabilities")
43                        .long("capabilities")
44                        .short('c')
45                        .num_args(1..)
46                        .help("Agent capabilities (space-separated)"),
47                )
48                .arg(
49                    Arg::new("reports-to")
50                        .long("reports-to")
51                        .help("Supervisor agent ID"),
52                ),
53        )
54        .subcommand(
55            Command::new("list")
56                .about("List agents")
57                .arg(
58                    Arg::new("status")
59                        .long("status")
60                        .help("Filter by status (active, idle, offline, killed)"),
61                )
62                .arg(Arg::new("type").long("type").help("Filter by agent type"))
63                .arg(
64                    Arg::new("active-only")
65                        .long("active-only")
66                        .action(clap::ArgAction::SetTrue)
67                        .help("Only return active agents"),
68                ),
69        )
70        .subcommand(
71            Command::new("get")
72                .about("Get agent details")
73                .arg(Arg::new("id").required(true).help("Agent ID")),
74        )
75        .subcommand(
76            Command::new("update")
77                .about("Update an agent")
78                .arg(Arg::new("id").required(true).help("Agent ID"))
79                .arg(
80                    Arg::new("status")
81                        .long("status")
82                        .help("New status (active, idle, offline)"),
83                )
84                .arg(
85                    Arg::new("trajectory-id")
86                        .long("trajectory-id")
87                        .help("New current trajectory ID"),
88                )
89                .arg(
90                    Arg::new("scope-id")
91                        .long("scope-id")
92                        .help("New current scope ID"),
93                ),
94        )
95        .subcommand(
96            Command::new("delete")
97                .about("Delete an agent")
98                .arg(Arg::new("id").required(true).help("Agent ID")),
99        )
100        .subcommand(
101            Command::new("kill")
102                .about("Kill an agent (force-stop)")
103                .arg(Arg::new("id").required(true).help("Agent ID")),
104        )
105        .subcommand(
106            Command::new("heartbeat")
107                .about("Send agent heartbeat")
108                .arg(Arg::new("id").required(true).help("Agent ID")),
109        )
110        .subcommand(
111            Command::new("deliberate")
112                .about("Trigger agent deliberation cycle")
113                .arg(Arg::new("id").required(true).help("Agent ID")),
114        )
115        .subcommand(
116            Command::new("goals")
117                .about("Create a goal for an agent")
118                .arg(Arg::new("id").required(true).help("Agent ID"))
119                .arg(
120                    Arg::new("description")
121                        .required(true)
122                        .help("Goal description"),
123                )
124                .arg(
125                    Arg::new("goal-type")
126                        .long("type")
127                        .default_value("terminal")
128                        .help("Goal type (terminal, subgoal, milestone, invariant)"),
129                )
130                .arg(
131                    Arg::new("priority")
132                        .long("priority")
133                        .help("Priority (higher = more important)"),
134                ),
135        )
136        .subcommand(
137            Command::new("activate-goal")
138                .about("Activate a goal")
139                .arg(Arg::new("id").required(true).help("Agent ID"))
140                .arg(
141                    Arg::new("goal-id")
142                        .required(true)
143                        .help("Goal ID to activate"),
144                ),
145        )
146        .subcommand(
147            Command::new("plans")
148                .about("Create a plan for a goal")
149                .arg(Arg::new("id").required(true).help("Agent ID"))
150                .arg(
151                    Arg::new("goal-id")
152                        .required(true)
153                        .help("Goal ID to plan for"),
154                )
155                .arg(
156                    Arg::new("description")
157                        .required(true)
158                        .help("Plan description"),
159                )
160                .arg(
161                    Arg::new("steps")
162                        .long("steps")
163                        .num_args(1..)
164                        .help("Step descriptions (space-separated, all typed as 'operation')"),
165                ),
166        )
167        .subcommand(
168            Command::new("complete-step")
169                .about("Complete a plan step")
170                .arg(Arg::new("id").required(true).help("Agent ID"))
171                .arg(Arg::new("plan-id").required(true).help("Plan ID"))
172                .arg(Arg::new("step-id").required(true).help("Step ID"))
173                .arg(
174                    Arg::new("outcome")
175                        .required(true)
176                        .help("Step outcome (success, failure)"),
177                ),
178        )
179}
180
181pub async fn dispatch(
182    matches: &ArgMatches,
183    client: &ApiClient,
184    output: &OutputConfig,
185    session: &crate::session::CliSession,
186) -> Result<()> {
187    match matches.subcommand() {
188        Some(("create", sub)) => {
189            let agent_type_str = sub.get_one::<String>("type").unwrap();
190            let capabilities: Vec<String> = sub
191                .get_many::<String>("capabilities")
192                .map(|vals| vals.cloned().collect())
193                .unwrap_or_default();
194
195            let req = RegisterAgentRequest {
196                agent_type: agent_type_str.parse()?,
197                capabilities,
198                memory_access: cellstate_core::api_types::MemoryAccessRequest {
199                    read: vec![],
200                    write: vec![],
201                },
202                can_delegate_to: vec![],
203                reports_to: sub
204                    .get_one::<String>("reports-to")
205                    .map(|s| s.parse())
206                    .transpose()?,
207                owner_principal_id: None,
208            };
209            let resp: AgentResponse = client.post(BASE, &req).await?;
210            output.print(&resp);
211        }
212        Some(("list", sub)) => {
213            let mut path = format!("{BASE}?");
214            let mut params = Vec::new();
215            if let Some(status) = sub.get_one::<String>("status") {
216                params.push(format!("status={status}"));
217            }
218            if let Some(agent_type) = sub.get_one::<String>("type") {
219                params.push(format!("agent_type={agent_type}"));
220            }
221            if sub.get_flag("active-only") {
222                params.push("active_only=true".to_string());
223            }
224            path.push_str(&params.join("&"));
225            let resp: ListAgentsResponse = client.get(&path).await?;
226            output.print_list(&resp.agents, resp.total);
227        }
228        Some(("get", sub)) => {
229            let id = sub.get_one::<String>("id").unwrap();
230            let resp: AgentResponse = client.get(&format!("{BASE}/{id}")).await?;
231            output.print(&resp);
232        }
233        Some(("update", sub)) => {
234            let id = sub.get_one::<String>("id").unwrap();
235            let req = UpdateAgentRequest {
236                status: sub
237                    .get_one::<String>("status")
238                    .map(|s| s.parse())
239                    .transpose()?,
240                current_trajectory_id: super::resolve_arg(
241                    sub,
242                    "trajectory-id",
243                    session.trajectory_id.as_deref(),
244                )
245                .map(|s| s.parse())
246                .transpose()?,
247                current_scope_id: super::resolve_arg(sub, "scope-id", session.scope_id.as_deref())
248                    .map(|s| s.parse())
249                    .transpose()?,
250                capabilities: None,
251                memory_access: None,
252            };
253            let resp: AgentResponse = client.patch(&format!("{BASE}/{id}"), &req).await?;
254            output.print(&resp);
255        }
256        Some(("delete", sub)) => {
257            let id = sub.get_one::<String>("id").unwrap();
258            client.delete(&format!("{BASE}/{id}")).await?;
259            if output.json {
260                println!(r#"{{"deleted": "{id}"}}"#);
261            } else {
262                println!("Deleted agent {id}");
263            }
264        }
265        Some(("kill", sub)) => {
266            let id = sub.get_one::<String>("id").unwrap();
267            let resp: serde_json::Value = client
268                .post_no_body_raw(&format!("{BASE}/{id}/kill"))
269                .await?;
270            output.print_value(&resp);
271        }
272        Some(("heartbeat", sub)) => {
273            let id = sub.get_one::<String>("id").unwrap();
274            let resp: serde_json::Value = client
275                .post_no_body_raw(&format!("{BASE}/{id}/heartbeat"))
276                .await?;
277            output.print_value(&resp);
278        }
279        Some(("deliberate", sub)) => {
280            let id = sub.get_one::<String>("id").unwrap();
281            let resp: DeliberationResponse = client
282                .post_no_body(&format!("{BASE}/{id}/deliberate"))
283                .await?;
284            output.print(&resp);
285        }
286        Some(("goals", sub)) => {
287            let id = sub.get_one::<String>("id").unwrap();
288            let goal_type_str = sub.get_one::<String>("goal-type").unwrap();
289            let req = CreateGoalRequest {
290                description: sub.get_one::<String>("description").unwrap().clone(),
291                goal_type: parse_goal_type(goal_type_str)?,
292                priority: sub
293                    .get_one::<String>("priority")
294                    .map(|s| s.parse())
295                    .transpose()?,
296                deadline: None,
297                parent_goal_id: None,
298                success_criteria: None,
299            };
300            let resp: GoalResponse = client.post(&format!("{BASE}/{id}/goals"), &req).await?;
301            output.print(&resp);
302        }
303        Some(("activate-goal", sub)) => {
304            let id = sub.get_one::<String>("id").unwrap();
305            let goal_id = sub.get_one::<String>("goal-id").unwrap();
306            let resp: serde_json::Value = client
307                .post_no_body_raw(&format!("{BASE}/{id}/goals/{goal_id}/activate"))
308                .await?;
309            output.print_value(&resp);
310        }
311        Some(("plans", sub)) => {
312            let id = sub.get_one::<String>("id").unwrap();
313            let goal_id = sub.get_one::<String>("goal-id").unwrap();
314            let step_descriptions: Vec<String> = sub
315                .get_many::<String>("steps")
316                .map(|vals| vals.cloned().collect())
317                .unwrap_or_default();
318            let steps: Vec<PlanStepInput> = step_descriptions
319                .into_iter()
320                .map(|desc| PlanStepInput {
321                    description: desc,
322                    action_type: cellstate_core::ActionType::Operation,
323                    depends_on: None,
324                })
325                .collect();
326            let req = CreatePlanRequest {
327                description: sub.get_one::<String>("description").unwrap().clone(),
328                steps,
329            };
330            let resp: PlanResponse = client
331                .post(&format!("{BASE}/{id}/goals/{goal_id}/plans"), &req)
332                .await?;
333            output.print(&resp);
334        }
335        Some(("complete-step", sub)) => {
336            let id = sub.get_one::<String>("id").unwrap();
337            let plan_id = sub.get_one::<String>("plan-id").unwrap();
338            let step_id = sub.get_one::<String>("step-id").unwrap();
339            let req = CompleteStepRequest {
340                outcome: sub.get_one::<String>("outcome").unwrap().clone(),
341                output: None,
342            };
343            let resp: serde_json::Value = client
344                .post_raw(
345                    &format!("{BASE}/{id}/plans/{plan_id}/steps/{step_id}/complete"),
346                    &req,
347                )
348                .await?;
349            output.print_value(&resp);
350        }
351        _ => unreachable!("subcommand_required(true) prevents this"),
352    }
353    Ok(())
354}