1use 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
19fn 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(¶ms.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}