Skip to content

Commit cc9e7e6

Browse files
authored
Merge pull request #54 from Mail-0/devin/1789271167-swarm-controls
* Add Keiki swarm controls * Refine Keiki swarm controls --------- Co-authored-by: Adam <github@ajxb.net>
2 parents 639bc92 + 37f5796 commit cc9e7e6

7 files changed

Lines changed: 935 additions & 32 deletions

File tree

‎crates/keiki-api/src/lib.rs‎

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ use std::time::{Duration, Instant};
22

33
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
44
pub use keiki_model::{
5-
AgentGroupSummary, AgentInput, AgentSummary, AgentTemplateSummary, AvatarState, AvatarTheme,
6-
BlockConversationResponse, ClearConversationResponse, ConversationDetail, ConversationLocator,
7-
ConversationSearchHit, ConversationSummary, ConversationTakeover, ConversationThreadPeer,
8-
CreateAgentFromTemplate, CreateAgentResponse, EndConversationResponse, McpPreset,
5+
AgentGroupRoster, AgentGroupSummary, AgentInput, AgentSummary, AgentTemplateSummary,
6+
AvatarState, AvatarTheme, BlockConversationResponse, CancelSubagentTaskResponse,
7+
ClearConversationResponse, ConversationDetail, ConversationLocator, ConversationSearchHit,
8+
ConversationSummary, ConversationTakeover, ConversationThreadPeer, CreateAgentFromTemplate,
9+
CreateAgentResponse, EndAgentGroupResponse, EndConversationResponse, McpPreset,
910
OrganizationSummary, SendConversationMessageResponse, SessionResponse, SessionUser,
1011
SteerConversationResponse, SwitchOrgResponse, TakeoverResponse,
1112
};
@@ -601,6 +602,54 @@ impl Client {
601602
Ok(response.groups)
602603
}
603604

605+
pub async fn agent_group_roster(
606+
&self,
607+
access_token: &str,
608+
group_id: &str,
609+
) -> Result<AgentGroupRoster, Error> {
610+
self.send_json(
611+
self.http
612+
.get(self.endpoint(&format!(
613+
"/api/webapp/agent-groups/{}/roster",
614+
utf8_percent_encode(group_id, NON_ALPHANUMERIC)
615+
)))
616+
.bearer_auth(access_token),
617+
)
618+
.await
619+
}
620+
621+
pub async fn end_agent_group(
622+
&self,
623+
access_token: &str,
624+
group_id: &str,
625+
) -> Result<EndAgentGroupResponse, Error> {
626+
self.send_json(
627+
self.http
628+
.post(self.endpoint(&format!(
629+
"/api/webapp/agent-groups/{}/end",
630+
utf8_percent_encode(group_id, NON_ALPHANUMERIC)
631+
)))
632+
.bearer_auth(access_token),
633+
)
634+
.await
635+
}
636+
637+
pub async fn cancel_subagent_task(
638+
&self,
639+
access_token: &str,
640+
task_id: &str,
641+
) -> Result<CancelSubagentTaskResponse, Error> {
642+
self.send_json(
643+
self.http
644+
.post(self.endpoint(&format!(
645+
"/api/webapp/subagent-tasks/{}/cancel",
646+
utf8_percent_encode(task_id, NON_ALPHANUMERIC)
647+
)))
648+
.bearer_auth(access_token),
649+
)
650+
.await
651+
}
652+
604653
/// Ask the daemon to (re)start a saved preset's authorization. The
605654
/// returned preset carries the provider URL to open in the user's browser
606655
/// when one is needed — the same response the dashboard's connect button

‎crates/keiki-model/src/lib.rs‎

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,71 @@ pub struct AgentGroupsResponse {
112112
pub groups: Vec<AgentGroupSummary>,
113113
}
114114

115+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116+
#[serde(rename_all = "lowercase")]
117+
pub enum AgentGroupMemberRole {
118+
Hub,
119+
Spoke,
120+
Peer,
121+
}
122+
123+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124+
#[serde(rename_all = "lowercase")]
125+
pub enum AgentGroupMemberState {
126+
Running,
127+
Waiting,
128+
Idle,
129+
Ended,
130+
}
131+
132+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133+
#[serde(rename_all = "camelCase")]
134+
pub struct AgentGroupMemberStatus {
135+
pub agent_id: String,
136+
pub name: String,
137+
pub role: AgentGroupMemberRole,
138+
pub status: AgentGroupMemberState,
139+
pub running_turns: u32,
140+
pub pending_asks: u32,
141+
pub waiting_on_peer: u32,
142+
pub background_tasks: u32,
143+
pub tokens_in: u64,
144+
pub tokens_out: u64,
145+
pub cost_usd: f64,
146+
pub last_activity_at: Option<String>,
147+
}
148+
149+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150+
#[serde(rename_all = "camelCase")]
151+
pub struct AgentGroupRosterTotals {
152+
pub members: u32,
153+
pub hubs: u32,
154+
pub spokes: u32,
155+
pub running: u32,
156+
pub waiting: u32,
157+
pub ended: u32,
158+
pub background_tasks: u32,
159+
pub cost_usd: f64,
160+
}
161+
162+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163+
pub struct AgentGroupRoster {
164+
pub members: Vec<AgentGroupMemberStatus>,
165+
pub totals: AgentGroupRosterTotals,
166+
}
167+
168+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169+
#[serde(rename_all = "camelCase")]
170+
pub struct EndAgentGroupResponse {
171+
pub threads_ended: u32,
172+
pub tasks_cancelled: u32,
173+
}
174+
175+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176+
pub struct CancelSubagentTaskResponse {
177+
pub cancelled: bool,
178+
}
179+
115180
/// One saved MCP/service preset on an agent, as the connect and status
116181
/// endpoints return it. `authorization_url` is the provider page to open when
117182
/// `status` is `needs_auth`.
@@ -322,6 +387,36 @@ pub enum MessageDirection {
322387
Outbound,
323388
}
324389

390+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391+
#[serde(rename_all = "lowercase")]
392+
pub enum ConversationLiveness {
393+
Working,
394+
Waiting,
395+
}
396+
397+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
398+
#[serde(rename_all = "lowercase")]
399+
pub enum SubagentTaskStatus {
400+
Running,
401+
Suspended,
402+
Completed,
403+
Error,
404+
Cancelled,
405+
}
406+
407+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408+
#[serde(rename_all = "camelCase")]
409+
pub struct SubagentTaskSummary {
410+
pub id: String,
411+
pub subagent: String,
412+
pub status: SubagentTaskStatus,
413+
pub request: String,
414+
pub resumptions: u32,
415+
pub trace_id: Option<String>,
416+
pub created_at: String,
417+
pub ended_at: Option<String>,
418+
}
419+
325420
impl MessageDirection {
326421
pub fn as_str(self) -> &'static str {
327422
match self {
@@ -345,6 +440,8 @@ pub struct ConversationSummary {
345440
pub message_count: u32,
346441
pub is_active: bool,
347442
pub has_errors: bool,
443+
#[serde(default)]
444+
pub liveness: Option<ConversationLiveness>,
348445
/// The asker behind the thread — set only for inter-agent (`agent:`)
349446
/// conversations, where another agent is the contact.
350447
#[serde(default)]
@@ -397,6 +494,7 @@ impl std::fmt::Debug for ConversationSummary {
397494
.field("message_count", &self.message_count)
398495
.field("is_active", &self.is_active)
399496
.field("has_errors", &self.has_errors)
497+
.field("liveness", &self.liveness)
400498
.field("peer", &self.peer)
401499
.finish()
402500
}
@@ -447,6 +545,10 @@ pub struct ConversationDetail {
447545
/// while the agent is idle. Older backends omit it.
448546
#[serde(default)]
449547
pub active_turn_started_at: Option<String>,
548+
#[serde(default)]
549+
pub liveness: Option<ConversationLiveness>,
550+
#[serde(default)]
551+
pub tasks: Vec<SubagentTaskSummary>,
450552
/// The asker behind the thread when this is an inter-agent
451553
/// (`agent:`) conversation.
452554
#[serde(default)]
@@ -855,4 +957,130 @@ mod tests {
855957
assert!(!format!("{agentless:?}").contains("agentless-secret"));
856958
assert!(!format!("{locator:?}").contains("agentless-secret"));
857959
}
960+
961+
#[test]
962+
fn swarm_observability_models_round_trip() {
963+
let liveness = ConversationLiveness::Waiting;
964+
assert_eq!(
965+
serde_json::from_value::<ConversationLiveness>(serde_json::to_value(liveness).unwrap())
966+
.unwrap(),
967+
liveness
968+
);
969+
970+
let task = SubagentTaskSummary {
971+
id: "task-1".into(),
972+
subagent: "worker".into(),
973+
status: SubagentTaskStatus::Suspended,
974+
request: "Inspect the deployment".into(),
975+
resumptions: 2,
976+
trace_id: Some("trace-1".into()),
977+
created_at: "2026-08-28T12:00:00Z".into(),
978+
ended_at: None,
979+
};
980+
assert_eq!(
981+
serde_json::from_value::<SubagentTaskSummary>(serde_json::to_value(&task).unwrap())
982+
.unwrap(),
983+
task
984+
);
985+
986+
let roster = AgentGroupRoster {
987+
members: vec![AgentGroupMemberStatus {
988+
agent_id: "agent-1".into(),
989+
name: "Worker".into(),
990+
role: AgentGroupMemberRole::Spoke,
991+
status: AgentGroupMemberState::Running,
992+
running_turns: 1,
993+
pending_asks: 0,
994+
waiting_on_peer: 0,
995+
background_tasks: 2,
996+
tokens_in: 10,
997+
tokens_out: 20,
998+
cost_usd: 0.42,
999+
last_activity_at: Some("2026-08-28T12:00:00Z".into()),
1000+
}],
1001+
totals: AgentGroupRosterTotals {
1002+
members: 1,
1003+
hubs: 0,
1004+
spokes: 1,
1005+
running: 1,
1006+
waiting: 0,
1007+
ended: 0,
1008+
background_tasks: 2,
1009+
cost_usd: 0.42,
1010+
},
1011+
};
1012+
assert_eq!(
1013+
serde_json::from_value::<AgentGroupRoster>(serde_json::to_value(&roster).unwrap())
1014+
.unwrap(),
1015+
roster
1016+
);
1017+
1018+
for response in [
1019+
EndAgentGroupResponse {
1020+
threads_ended: 2,
1021+
tasks_cancelled: 1,
1022+
},
1023+
EndAgentGroupResponse {
1024+
threads_ended: 0,
1025+
tasks_cancelled: 0,
1026+
},
1027+
] {
1028+
assert_eq!(
1029+
serde_json::from_value::<EndAgentGroupResponse>(
1030+
serde_json::to_value(&response).unwrap()
1031+
)
1032+
.unwrap(),
1033+
response
1034+
);
1035+
}
1036+
let response = CancelSubagentTaskResponse { cancelled: true };
1037+
assert_eq!(
1038+
serde_json::from_value::<CancelSubagentTaskResponse>(
1039+
serde_json::to_value(&response).unwrap()
1040+
)
1041+
.unwrap(),
1042+
response
1043+
);
1044+
}
1045+
1046+
#[test]
1047+
fn missing_swarm_observability_fields_default() {
1048+
let summary: ConversationSummary = serde_json::from_value(serde_json::json!({
1049+
"phone": "tg:123",
1050+
"contactName": null,
1051+
"agentName": "Orchid",
1052+
"agentId": "agent-1",
1053+
"apiKey": "secret",
1054+
"lastMessage": "Latest",
1055+
"lastMessageAt": "2026-08-28T12:00:00Z",
1056+
"lastDirection": "inbound",
1057+
"messageCount": 1,
1058+
"isActive": true,
1059+
"hasErrors": false
1060+
}))
1061+
.unwrap();
1062+
assert_eq!(summary.liveness, None);
1063+
1064+
let detail: ConversationDetail = serde_json::from_value(serde_json::json!({
1065+
"phone": "tg:123",
1066+
"meta": {
1067+
"contactName": null,
1068+
"contactEmail": null,
1069+
"agentName": "Orchid",
1070+
"agentId": "agent-1",
1071+
"apiKey": "secret",
1072+
"messageCount": 0,
1073+
"firstSeen": "2026-08-28T11:00:00Z",
1074+
"lastSeen": "2026-08-28T12:00:00Z"
1075+
},
1076+
"messages": [],
1077+
"spans": {},
1078+
"agent": null,
1079+
"blocked": false,
1080+
"takeover": null
1081+
}))
1082+
.unwrap();
1083+
assert_eq!(detail.liveness, None);
1084+
assert!(detail.tasks.is_empty());
1085+
}
8581086
}

0 commit comments

Comments
 (0)