1use axum::{
4 extract::{Path, State},
5 http::StatusCode,
6 response::sse::{Event, Sse},
7 Json,
8};
9use std::collections::HashMap;
10use std::convert::Infallible;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13use std::time::SystemTime;
14use tokio::sync::broadcast;
15use tokio_stream::{wrappers::BroadcastStream, StreamExt};
16
17use crate::event::{AgentEvent, ChannelSink, NullSink};
18use crate::runtime::AgentRuntimeBuilder;
19
20use super::{
21 build_openapi_spec, AppState, CreateSessionRequest, CreateSessionResponse, ErrorResponse,
22 ListSessionsQuery, RunRequest, RunResponse, SessionDetailResponse, SessionInfo,
23 SessionMessageRequest, SessionMessageResponse, SessionState, SetGoalRequest, SlashCommandInfo,
24 SseContentBlock, SseEvent, ToolInfo, UsageInfo,
25};
26
27pub(super) async fn health() -> &'static str {
28 "ok"
29}
30
31pub(super) async fn openapi_spec() -> Json<serde_json::Value> {
32 Json(build_openapi_spec())
33}
34
35pub(super) async fn list_tools(State(state): State<Arc<AppState>>) -> Json<Vec<ToolInfo>> {
36 Json(state.tools.clone())
37}
38
39pub(super) async fn run_agent(
40 State(state): State<Arc<AppState>>,
41 Json(body): Json<RunRequest>,
42) -> Result<Json<RunResponse>, (StatusCode, Json<ErrorResponse>)> {
43 if body.goal.trim().is_empty() {
45 return Err((
46 StatusCode::BAD_REQUEST,
47 Json(ErrorResponse {
48 status: "error".into(),
49 error: "missing or empty 'goal' field".into(),
50 }),
51 ));
52 }
53
54 let max_steps = body.max_steps.unwrap_or(state.config.max_steps as u32) as usize;
55 let system_prompt = body
56 .system_prompt
57 .unwrap_or_else(|| state.config.system_prompt.clone());
58
59 let mut runtime = AgentRuntimeBuilder::new()
60 .llm(state.provider.clone())
61 .tools(state.tool_registry.clone())
62 .system_prompt(system_prompt)
63 .max_steps(max_steps)
64 .build()
65 .map_err(|e| {
66 (
67 StatusCode::INTERNAL_SERVER_ERROR,
68 Json(ErrorResponse {
69 status: "error".into(),
70 error: format!("failed to build runtime: {e}"),
71 }),
72 )
73 })?;
74
75 let outcome = runtime.run(&body.goal).await.map_err(|e| {
76 state
77 .metrics
78 .agent_runs_total
79 .fetch_add(1, Ordering::Relaxed);
80 state
81 .metrics
82 .agent_runs_failed
83 .fetch_add(1, Ordering::Relaxed);
84 (
85 StatusCode::INTERNAL_SERVER_ERROR,
86 Json(ErrorResponse {
87 status: "error".into(),
88 error: format!("agent run failed: {e}"),
89 }),
90 )
91 })?;
92
93 state
95 .metrics
96 .agent_runs_total
97 .fetch_add(1, Ordering::Relaxed);
98 state
99 .metrics
100 .agent_runs_success
101 .fetch_add(1, Ordering::Relaxed);
102 state
103 .metrics
104 .agent_steps_total
105 .fetch_add(outcome.steps as u64, Ordering::Relaxed);
106 state
107 .metrics
108 .tokens_prompt_total
109 .fetch_add(outcome.total_usage.prompt_tokens as u64, Ordering::Relaxed);
110 state.metrics.tokens_completion_total.fetch_add(
111 outcome.total_usage.completion_tokens as u64,
112 Ordering::Relaxed,
113 );
114
115 let messages: Vec<serde_json::Value> = runtime
117 .transcript()
118 .iter()
119 .filter_map(|msg| serde_json::to_value(msg).ok())
120 .collect();
121
122 let finish_reason = format!("{:?}", outcome.finish_reason);
123
124 Ok(Json(RunResponse {
125 status: "success".into(),
126 finish_reason,
127 messages,
128 usage: UsageInfo {
129 total_steps: outcome.steps as u32,
130 total_tokens: outcome.total_usage.total_tokens as u64,
131 },
132 }))
133}
134
135fn generate_session_id() -> String {
139 static COUNTER: AtomicU64 = AtomicU64::new(0);
140
141 let count = COUNTER.fetch_add(1, Ordering::Relaxed);
142 let now = SystemTime::now()
143 .duration_since(SystemTime::UNIX_EPOCH)
144 .unwrap_or_default();
145 let input = format!("{}-{}", now.as_nanos(), count);
146 let hash = blake3::hash(input.as_bytes());
147 hash.to_hex()[..16].to_string()
149}
150
151fn format_timestamp(t: SystemTime) -> String {
153 let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
154 let secs = dur.as_secs();
155 let days = secs / 86400;
158 let remaining = secs % 86400;
159 let hours = remaining / 3600;
160 let minutes = (remaining % 3600) / 60;
161 let seconds = remaining % 60;
162
163 let (year, month, day) = days_to_ymd(days);
165 format!(
166 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
167 year, month, day, hours, minutes, seconds
168 )
169}
170
171fn days_to_ymd(days: u64) -> (u64, u64, u64) {
173 let mut y = 1970;
175 let mut remaining = days;
176 loop {
177 let days_in_year = if is_leap(y) { 366 } else { 365 };
178 if remaining < days_in_year {
179 break;
180 }
181 remaining -= days_in_year;
182 y += 1;
183 }
184 let months_days: [u64; 12] = if is_leap(y) {
185 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
186 } else {
187 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
188 };
189 let mut m = 1;
190 for &md in &months_days {
191 if remaining < md {
192 break;
193 }
194 remaining -= md;
195 m += 1;
196 }
197 (y, m, remaining + 1)
198}
199
200fn is_leap(y: u64) -> bool {
201 (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
202}
203
204pub(super) async fn create_session(
206 State(state): State<Arc<AppState>>,
207 Json(body): Json<CreateSessionRequest>,
208) -> Result<(StatusCode, Json<CreateSessionResponse>), (StatusCode, Json<ErrorResponse>)> {
209 let id = generate_session_id();
210 let created_at = format_timestamp(SystemTime::now());
211 let system_prompt = body
212 .system_prompt
213 .unwrap_or_else(|| state.config.system_prompt.clone());
214
215 let mut runtime = AgentRuntimeBuilder::new()
216 .llm(state.provider.clone())
217 .tools(state.tool_registry.clone())
218 .system_prompt(system_prompt)
219 .max_steps(state.config.max_steps)
220 .build()
221 .map_err(|e| {
222 (
223 StatusCode::INTERNAL_SERVER_ERROR,
224 Json(ErrorResponse {
225 status: "error".into(),
226 error: format!("failed to build session runtime: {e}"),
227 }),
228 )
229 })?;
230
231 runtime.set_session_id(&id);
234
235 let plan_approval_gate = runtime.plan_approval_gate();
238
239 let session = SessionState {
240 id: id.clone(),
241 created_at: created_at.clone(),
242 title: None,
243 runtime: Arc::new(tokio::sync::Mutex::new(runtime)),
244 plan_approval_gate,
245 interrupt_token: Arc::new(tokio::sync::Mutex::new(None)),
246 };
247
248 state.sessions.write().await.insert(id.clone(), session);
249
250 Ok((
251 StatusCode::CREATED,
252 Json(CreateSessionResponse { id, created_at }),
253 ))
254}
255
256pub(super) async fn list_sessions(
260 State(state): State<Arc<AppState>>,
261 axum::extract::Query(params): axum::extract::Query<ListSessionsQuery>,
262) -> Json<Vec<SessionInfo>> {
263 let sessions = state.sessions.read().await;
264 let mut infos = Vec::with_capacity(sessions.len());
265 for s in sessions.values() {
266 let message_count = s
268 .runtime
269 .lock()
270 .await
271 .transcript()
272 .iter()
273 .filter(|m| m.role != crate::message::Role::System)
274 .count();
275 infos.push(SessionInfo {
276 id: s.id.clone(),
277 created_at: s.created_at.clone(),
278 message_count,
279 title: s.title.clone(),
280 });
281 }
282 let offset = params.offset.unwrap_or(0);
284 let page: Vec<SessionInfo> = infos
285 .into_iter()
286 .skip(offset)
287 .take(params.limit.unwrap_or(usize::MAX))
288 .collect();
289 Json(page)
290}
291
292pub(super) async fn get_session(
299 State(state): State<Arc<AppState>>,
300 Path(id): Path<String>,
301) -> Result<Json<SessionDetailResponse>, StatusCode> {
302 let sessions = state.sessions.read().await;
303 let session = sessions.get(&id).ok_or(StatusCode::NOT_FOUND)?;
304
305 let pending_plan = session
308 .plan_approval_gate
309 .pending_plan
310 .read()
311 .ok()
312 .and_then(|g| g.clone());
313 let status = if pending_plan.is_some() {
314 "plan_pending_approval".to_string()
315 } else {
316 "idle".to_string()
317 };
318
319 let (messages, todos, goal) = match session.runtime.try_lock() {
321 Ok(runtime) => {
322 let msgs = runtime
323 .transcript()
324 .iter()
325 .filter_map(|msg| serde_json::to_value(msg).ok())
326 .collect();
327 let todos = runtime.current_todos();
328 let goal = runtime.current_goal();
329 (msgs, todos, goal)
330 }
331 Err(_) => (vec![], vec![], None),
332 };
333
334 let (first_prompt, last_prompt) = {
336 let user_msgs: Vec<String> = messages
337 .iter()
338 .filter_map(|m| {
339 if m.get("role")?.as_str()? == "user" {
340 m.get("content")?.as_str().map(|s| s.to_string())
341 } else {
342 None
343 }
344 })
345 .collect();
346 let first = user_msgs.first().cloned();
347 let last = user_msgs.last().cloned();
348 (first, last)
349 };
350
351 Ok(Json(SessionDetailResponse {
352 id: session.id.clone(),
353 created_at: session.created_at.clone(),
354 title: session.title.clone(),
355 messages,
356 todos,
357 status,
358 pending_plan,
359 goal,
360 first_prompt,
361 last_prompt,
362 }))
363}
364
365pub(super) async fn delete_session(
367 State(state): State<Arc<AppState>>,
368 Path(id): Path<String>,
369) -> StatusCode {
370 let mut sessions = state.sessions.write().await;
371 if sessions.remove(&id).is_some() {
372 StatusCode::NO_CONTENT
373 } else {
374 StatusCode::NOT_FOUND
375 }
376}
377
378#[derive(serde::Deserialize, Debug)]
382pub(super) struct PatchSessionRequest {
383 title: Option<String>,
385}
386
387pub(super) async fn patch_session(
397 State(state): State<Arc<AppState>>,
398 Path(id): Path<String>,
399 Json(body): Json<PatchSessionRequest>,
400) -> Result<Json<SessionInfo>, StatusCode> {
401 let mut sessions = state.sessions.write().await;
402 let session = sessions.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
403
404 if let Some(title) = body.title {
405 session.title = if title.is_empty() { None } else { Some(title) };
406 }
407
408 Ok(Json(SessionInfo {
410 id: session.id.clone(),
411 created_at: session.created_at.clone(),
412 message_count: 0, title: session.title.clone(),
414 }))
415}
416
417#[derive(serde::Serialize)]
421pub(super) struct ForkSessionResponse {
422 id: String,
424 created_at: String,
426 message_count: usize,
428}
429
430pub(super) async fn fork_session(
438 State(state): State<Arc<AppState>>,
439 Path(id): Path<String>,
440) -> Result<(StatusCode, Json<ForkSessionResponse>), StatusCode> {
441 let transcript_snapshot = {
443 let sessions = state.sessions.read().await;
444 let src = sessions.get(&id).ok_or(StatusCode::NOT_FOUND)?;
445 let rt = src.runtime.try_lock().map_err(|_| StatusCode::CONFLICT)?;
446 rt.transcript().to_vec()
447 };
448
449 let message_count = transcript_snapshot.len();
450
451 let new_id = generate_session_id();
453 let created_at = format_timestamp(SystemTime::now());
454 let system_prompt = state.config.system_prompt.clone();
455
456 let mut runtime = AgentRuntimeBuilder::new()
457 .llm(state.provider.clone())
458 .tools(state.tool_registry.clone())
459 .system_prompt(system_prompt)
460 .max_steps(state.config.max_steps)
461 .build()
462 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
463
464 runtime.set_transcript(transcript_snapshot);
465
466 let plan_approval_gate = runtime.plan_approval_gate();
467 let session = SessionState {
468 id: new_id.clone(),
469 created_at: created_at.clone(),
470 title: None,
471 runtime: Arc::new(tokio::sync::Mutex::new(runtime)),
472 plan_approval_gate,
473 interrupt_token: Arc::new(tokio::sync::Mutex::new(None)),
474 };
475
476 state.sessions.write().await.insert(new_id.clone(), session);
477
478 Ok((
479 StatusCode::CREATED,
480 Json(ForkSessionResponse {
481 id: new_id,
482 created_at,
483 message_count,
484 }),
485 ))
486}
487
488#[derive(serde::Deserialize)]
491pub(super) struct PlanConfirmRequest {
492 edits: Option<String>,
494}
495
496#[derive(serde::Deserialize)]
497pub(super) struct PlanRejectRequest {
498 reason: Option<String>,
500}
501
502pub(super) async fn session_plan_confirm(
504 State(state): State<Arc<AppState>>,
505 Path(session_id): Path<String>,
506 Json(body): Json<PlanConfirmRequest>,
507) -> (StatusCode, Json<serde_json::Value>) {
508 let sessions = state.sessions.read().await;
509 let Some(session) = sessions.get(&session_id) else {
510 return (
511 StatusCode::NOT_FOUND,
512 Json(serde_json::json!({"error": "session not found"})),
513 );
514 };
515 let pending = session
516 .plan_approval_gate
517 .pending_plan
518 .read()
519 .ok()
520 .and_then(|g| g.clone());
521 if pending.is_none() {
522 return (
523 StatusCode::CONFLICT,
524 Json(serde_json::json!({"error": "session is not awaiting plan approval"})),
525 );
526 }
527 if let Some(edited) = body.edits {
529 if let Ok(mut w) = session.plan_approval_gate.pending_plan.write() {
530 *w = Some(edited);
531 }
532 }
533 session.plan_approval_gate.approve();
534 (
535 StatusCode::OK,
536 Json(serde_json::json!({"status": "approved", "session_id": session_id})),
537 )
538}
539
540pub(super) async fn session_plan_reject(
542 State(state): State<Arc<AppState>>,
543 Path(session_id): Path<String>,
544 Json(body): Json<PlanRejectRequest>,
545) -> (StatusCode, Json<serde_json::Value>) {
546 let sessions = state.sessions.read().await;
547 let Some(session) = sessions.get(&session_id) else {
548 return (
549 StatusCode::NOT_FOUND,
550 Json(serde_json::json!({"error": "session not found"})),
551 );
552 };
553 let pending = session
554 .plan_approval_gate
555 .pending_plan
556 .read()
557 .ok()
558 .and_then(|g| g.clone());
559 if pending.is_none() {
560 return (
561 StatusCode::CONFLICT,
562 Json(serde_json::json!({"error": "session is not awaiting plan approval"})),
563 );
564 }
565 let reason = body.reason.unwrap_or_default();
566 session.plan_approval_gate.reject(&reason);
567 (
568 StatusCode::OK,
569 Json(serde_json::json!({"status": "rejected", "session_id": session_id})),
570 )
571}
572
573pub(super) async fn session_set_goal(
577 State(state): State<Arc<AppState>>,
578 Path(session_id): Path<String>,
579 Json(body): Json<SetGoalRequest>,
580) -> (StatusCode, Json<serde_json::Value>) {
581 let runtime_arc = {
582 let sessions = state.sessions.read().await;
583 let Some(session) = sessions.get(&session_id) else {
584 return (
585 StatusCode::NOT_FOUND,
586 Json(serde_json::json!({"error": "session not found"})),
587 );
588 };
589 session.runtime.clone()
590 };
591
592 let condition = body.condition.clone();
593 let max_turns = body.max_turns.unwrap_or(20);
594
595 match runtime_arc.try_lock() {
597 Ok(runtime) => {
598 runtime.set_goal(condition, max_turns).await;
599 }
600 Err(_) => {
601 return (
602 StatusCode::CONFLICT,
603 Json(serde_json::json!({"error": "session runtime is busy"})),
604 );
605 }
606 }
607
608 (
609 StatusCode::OK,
610 Json(serde_json::json!({"status": "pursuing", "session_id": session_id})),
611 )
612}
613
614pub(super) async fn session_clear_goal(
616 State(state): State<Arc<AppState>>,
617 Path(session_id): Path<String>,
618) -> (StatusCode, Json<serde_json::Value>) {
619 let sessions = state.sessions.read().await;
620 let Some(session) = sessions.get(&session_id) else {
621 return (
622 StatusCode::NOT_FOUND,
623 Json(serde_json::json!({"error": "session not found"})),
624 );
625 };
626
627 match session.runtime.try_lock() {
628 Ok(runtime) => {
629 runtime.clear_goal().await;
630 }
631 Err(_) => {
632 let _ = runtime_goal_state_clear(&session.runtime).await;
634 }
635 }
636
637 (
638 StatusCode::OK,
639 Json(serde_json::json!({"status": "cleared", "session_id": session_id})),
640 )
641}
642
643async fn runtime_goal_state_clear(runtime: &Arc<tokio::sync::Mutex<crate::runtime::AgentRuntime>>) {
645 for _ in 0..5u8 {
647 if let Ok(rt) = runtime.try_lock() {
648 rt.clear_goal().await;
649 return;
650 }
651 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
652 }
653}
654
655pub(super) async fn session_interrupt(
664 State(state): State<Arc<AppState>>,
665 Path(session_id): Path<String>,
666) -> (StatusCode, Json<serde_json::Value>) {
667 let token_arc = {
668 let sessions = state.sessions.read().await;
669 let Some(session) = sessions.get(&session_id) else {
670 return (
671 StatusCode::NOT_FOUND,
672 Json(serde_json::json!({"error": "session not found"})),
673 );
674 };
675 session.interrupt_token.clone()
676 };
677
678 let token_opt = token_arc.lock().await.clone();
680 if let Some(token) = token_opt {
681 token.cancel();
682 }
683
684 (
685 StatusCode::OK,
686 Json(serde_json::json!({"status": "interrupted", "session_id": session_id})),
687 )
688}
689
690pub(super) async fn list_slash_commands(
694 State(state): State<Arc<AppState>>,
695) -> Json<Vec<SlashCommandInfo>> {
696 Json((*state.slash_commands).clone())
697}
698
699pub(super) async fn send_session_message(
701 State(state): State<Arc<AppState>>,
702 Path(id): Path<String>,
703 Json(body): Json<SessionMessageRequest>,
704) -> Result<Json<SessionMessageResponse>, (StatusCode, Json<ErrorResponse>)> {
705 let (runtime_arc, interrupt_token_arc) = {
707 let sessions = state.sessions.read().await;
708 let session = sessions.get(&id).ok_or((
709 StatusCode::NOT_FOUND,
710 Json(ErrorResponse {
711 status: "error".into(),
712 error: "session not found".into(),
713 }),
714 ))?;
715 (session.runtime.clone(), session.interrupt_token.clone())
716 };
717
718 let broadcast_tx = {
720 let mut channels = state.event_channels.write().await;
721 let tx = channels.entry(id.clone()).or_insert_with(|| {
722 let (tx, _) = broadcast::channel(64);
723 tx
724 });
725 tx.clone()
726 };
727
728 let mut runtime = runtime_arc.lock().await;
730
731 let interrupt_token = tokio_util::sync::CancellationToken::new();
734 {
735 let mut stored = interrupt_token_arc.lock().await;
736 *stored = Some(interrupt_token.clone());
737 }
738 runtime.set_interrupt_token(interrupt_token);
739
740 let (sink, mut event_rx) = ChannelSink::new();
742 runtime.set_event_sink(Arc::new(sink));
743
744 let forward_handle = tokio::spawn(async move {
748 let mut tool_start_times: HashMap<String, std::time::Instant> = HashMap::new();
749 while let Some(ref agent_event) = event_rx.recv().await {
750 if let AgentEvent::ToolCall { id, .. } = agent_event {
753 tool_start_times.insert(id.clone(), std::time::Instant::now());
754 }
755 if let Some(sse_event) = map_agent_event(agent_event) {
756 let _ = broadcast_tx.send(sse_event);
757 }
758 if let AgentEvent::ToolResult { id, name, .. } = agent_event {
760 let elapsed_ms = tool_start_times
761 .remove(id)
762 .map(|start| start.elapsed().as_millis() as u64)
763 .unwrap_or(0);
764 let _ = broadcast_tx.send(SseEvent::ToolProgress {
765 tool_use_id: id.clone(),
766 tool_name: name.clone(),
767 elapsed_ms,
768 });
769 }
770 }
771 });
772
773 let run_result = runtime.enqueue(&body.content).await.map(|opt| {
775 opt.unwrap_or_else(|| crate::runtime::RuntimeOutcome {
776 final_text: None,
777 finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
778 total_usage: crate::TokenUsage::default(),
779 steps: 0,
780 llm_latency_ms: 0,
781 checkpoint_id: None,
782 })
783 });
784
785 {
787 let mut stored = interrupt_token_arc.lock().await;
788 *stored = None;
789 }
790
791 runtime.set_event_sink(Arc::new(NullSink));
793 let _ = forward_handle.await;
794
795 let _outcome = run_result.map_err(|e| {
796 (
797 StatusCode::INTERNAL_SERVER_ERROR,
798 Json(ErrorResponse {
799 status: "error".into(),
800 error: format!("agent run failed: {e}"),
801 }),
802 )
803 })?;
804
805 let last_assistant = runtime
807 .transcript()
808 .iter()
809 .rev()
810 .find(|m| m.role == crate::message::Role::Assistant)
811 .map(|m| m.content.clone())
812 .unwrap_or_default();
813
814 Ok(Json(SessionMessageResponse {
815 role: "assistant".into(),
816 content: last_assistant,
817 }))
818}
819
820pub(super) async fn session_events(
824 State(state): State<Arc<AppState>>,
825 Path(id): Path<String>,
826) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, StatusCode> {
827 {
829 let sessions = state.sessions.read().await;
830 if !sessions.contains_key(&id) {
831 return Err(StatusCode::NOT_FOUND);
832 }
833 }
834
835 let rx = {
837 let mut channels = state.event_channels.write().await;
838 let tx = channels.entry(id.clone()).or_insert_with(|| {
839 let (tx, _) = broadcast::channel(64);
840 tx
841 });
842 tx.subscribe()
843 };
844
845 let stream = BroadcastStream::new(rx).filter_map(|result| match result {
846 Ok(sse_event) => {
847 let event_type = match &sse_event {
848 SseEvent::Message { .. } => "message",
849 SseEvent::PartialMessage { .. } => "partial_message",
850 SseEvent::ToolCall { .. } => "tool_call",
851 SseEvent::ToolResult { .. } => "tool_result",
852 SseEvent::Done { .. } => "done",
853 SseEvent::Error { .. } => "error",
854 SseEvent::PlanProposed { .. } => "plan_proposed",
855 SseEvent::GoalContinuing { .. } => "goal_continuing",
856 SseEvent::GoalAchieved { .. } => "goal_achieved",
857 SseEvent::ToolProgress { .. } => "tool_progress",
858 };
859 let data = serde_json::to_string(&sse_event).unwrap_or_default();
860 Some(Ok(Event::default().event(event_type).data(data)))
861 }
862 Err(_) => None,
863 });
864
865 Ok(Sse::new(stream))
866}
867
868pub fn map_agent_event(event: &AgentEvent) -> Option<SseEvent> {
874 match event {
875 AgentEvent::PartialToken { text, step } => Some(SseEvent::PartialMessage {
878 text: text.clone(),
879 step: *step,
880 }),
881 AgentEvent::MessageAppended { message, .. }
892 | AgentEvent::MessageAppendedWithAudit { message, .. } => {
893 sse_message_from_canonical(message)
894 }
895 AgentEvent::ToolCall { name, step, .. } => Some(SseEvent::ToolCall {
896 name: name.clone(),
897 step: *step,
898 }),
899 AgentEvent::ToolResult { name, output, .. } => {
900 let success = !output.starts_with("ERROR: ");
901 Some(SseEvent::ToolResult {
902 name: name.clone(),
903 success,
904 })
905 }
906 AgentEvent::TurnFinished { reason, steps } => Some(SseEvent::Done {
907 finish_reason: reason.clone(),
908 total_steps: *steps,
909 }),
910 AgentEvent::PlanProposed { plan_text, .. } => Some(SseEvent::PlanProposed {
911 plan: plan_text.clone(),
912 }),
913 AgentEvent::GoalContinuing { reason, turns } => Some(SseEvent::GoalContinuing {
915 reason: reason.clone(),
916 turns: *turns,
917 }),
918 AgentEvent::GoalAchieved { condition, turns } => Some(SseEvent::GoalAchieved {
919 condition: condition.clone(),
920 turns: *turns,
921 }),
922 _ => None,
926 }
927}
928
929fn sse_message_from_canonical(msg: &crate::message::Message) -> Option<SseEvent> {
935 use crate::message::Role;
936 let role = match msg.role {
937 Role::Assistant => "assistant",
938 Role::User => "user",
939 Role::System | Role::Tool => return None,
940 };
941
942 let mut content: Vec<SseContentBlock> = Vec::new();
943 if !msg.content.is_empty() {
944 content.push(SseContentBlock::Text {
945 text: msg.content.clone(),
946 });
947 }
948 for tc in &msg.tool_calls {
949 content.push(SseContentBlock::ToolUse {
950 id: tc.id.clone(),
951 name: tc.name.clone(),
952 input: tc.arguments.clone(),
953 });
954 }
955 if content.is_empty() {
956 return None;
957 }
958 Some(SseEvent::Message {
959 role: role.into(),
960 content,
961 })
962}
963
964#[derive(Default)]
974struct AguiConverter {
975 open_message_id: Option<String>,
979 last_assistant_message_id: Option<String>,
984}
985
986impl AguiConverter {
987 fn new() -> Self {
988 Self::default()
989 }
990
991 fn convert(&mut self, ev: &AgentEvent) -> Vec<agui_protocol::Event> {
994 use agui_protocol as ag;
995 let mut out = Vec::new();
996 match ev {
997 AgentEvent::AssistantText { text, .. } => {
998 if let Some(id) = self.open_message_id.take() {
1000 out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1001 message_id: id,
1002 base: ag::BaseEvent::default(),
1003 }));
1004 }
1005 let id = uuid::Uuid::new_v4().to_string();
1006 out.push(ag::Event::TextMessageStart(ag::TextMessageStart {
1007 message_id: id.clone(),
1008 role: Some("assistant".into()),
1009 base: ag::BaseEvent::default(),
1010 }));
1011 out.push(ag::Event::TextMessageContent(ag::TextMessageContent {
1012 message_id: id.clone(),
1013 delta: text.clone(),
1014 base: ag::BaseEvent::default(),
1015 }));
1016 out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1017 message_id: id.clone(),
1018 base: ag::BaseEvent::default(),
1019 }));
1020 self.last_assistant_message_id = Some(id);
1021 self.open_message_id = None;
1022 }
1023 AgentEvent::PartialToken { text, .. } => {
1024 let id = if let Some(id) = self.open_message_id.clone() {
1025 id
1026 } else {
1027 let id = uuid::Uuid::new_v4().to_string();
1028 out.push(ag::Event::TextMessageStart(ag::TextMessageStart {
1029 message_id: id.clone(),
1030 role: Some("assistant".into()),
1031 base: ag::BaseEvent::default(),
1032 }));
1033 self.open_message_id = Some(id.clone());
1034 self.last_assistant_message_id = Some(id.clone());
1035 id
1036 };
1037 out.push(ag::Event::TextMessageContent(ag::TextMessageContent {
1038 message_id: id,
1039 delta: text.clone(),
1040 base: ag::BaseEvent::default(),
1041 }));
1042 }
1043 AgentEvent::ToolCall {
1044 id,
1045 name,
1046 arguments,
1047 ..
1048 } => {
1049 if let Some(open) = self.open_message_id.take() {
1052 out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1053 message_id: open,
1054 base: ag::BaseEvent::default(),
1055 }));
1056 }
1057 out.push(ag::Event::ToolCallStart(ag::ToolCallStart {
1058 tool_call_id: id.clone(),
1059 tool_call_name: name.clone(),
1060 parent_message_id: self.last_assistant_message_id.clone(),
1061 base: ag::BaseEvent::default(),
1062 }));
1063 out.push(ag::Event::ToolCallArgs(ag::ToolCallArgs {
1064 tool_call_id: id.clone(),
1065 delta: arguments.clone(),
1066 base: ag::BaseEvent::default(),
1067 }));
1068 out.push(ag::Event::ToolCallEnd(ag::ToolCallEnd {
1069 tool_call_id: id.clone(),
1070 base: ag::BaseEvent::default(),
1071 }));
1072 }
1073 AgentEvent::ToolResult { id, output, .. } => {
1074 let message_id = self
1078 .last_assistant_message_id
1079 .clone()
1080 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1081 out.push(ag::Event::ToolCallResult(ag::ToolCallResult {
1082 tool_call_id: id.clone(),
1083 message_id,
1084 content: output.clone(),
1085 role: Some("tool".into()),
1086 base: ag::BaseEvent::default(),
1087 }));
1088 }
1089 AgentEvent::TurnFinished { .. } => {
1090 if let Some(open) = self.open_message_id.take() {
1093 out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1094 message_id: open,
1095 base: ag::BaseEvent::default(),
1096 }));
1097 }
1098 }
1101 _ => {}
1107 }
1108 out
1109 }
1110}
1111
1112#[cfg(test)]
1117fn agui_events_for(ev: &AgentEvent) -> Vec<agui_protocol::Event> {
1118 AguiConverter::new().convert(ev)
1119}
1120
1121pub(super) async fn agui_run(
1124 State(state): State<Arc<AppState>>,
1125 Json(body): Json<serde_json::Value>,
1126) -> Result<
1127 Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>,
1128 (StatusCode, Json<ErrorResponse>),
1129> {
1130 use agui_protocol as ag;
1131
1132 let input: ag::RunAgentInput = serde_json::from_value(body).map_err(|e| {
1136 (
1137 StatusCode::BAD_REQUEST,
1138 Json(ErrorResponse {
1139 status: "error".into(),
1140 error: format!("invalid AG-UI RunAgentInput: {e}"),
1141 }),
1142 )
1143 })?;
1144
1145 let goal = input
1148 .messages
1149 .iter()
1150 .rev()
1151 .find(|m| m.role == "user")
1152 .and_then(|m| m.content.clone())
1153 .or_else(|| input.context.first().map(|c| c.value.clone()))
1154 .filter(|s| !s.trim().is_empty())
1155 .ok_or_else(|| {
1156 (
1157 StatusCode::BAD_REQUEST,
1158 Json(ErrorResponse {
1159 status: "error".into(),
1160 error: "RunAgentInput must contain at least one user \
1161 message or a non-empty context item"
1162 .into(),
1163 }),
1164 )
1165 })?;
1166
1167 let mut runtime = AgentRuntimeBuilder::new()
1168 .llm(state.provider.clone())
1169 .tools(state.tool_registry.clone())
1170 .system_prompt(state.config.system_prompt.clone())
1171 .max_steps(state.config.max_steps)
1172 .build()
1173 .map_err(|e| {
1174 (
1175 StatusCode::INTERNAL_SERVER_ERROR,
1176 Json(ErrorResponse {
1177 status: "error".into(),
1178 error: format!("failed to build runtime: {e}"),
1179 }),
1180 )
1181 })?;
1182
1183 if let Ok(repo) = crate::ShadowRepo::open(&state.config.workspace) {
1189 let session_id = sanitize_thread_id_for_session(&input.thread_id);
1190 if let Ok(session_dir) = crate::user_sessions_dir(&state.config.workspace) {
1191 let log_dir = session_dir.join(format!("agui-{session_id}"));
1192 let _ = std::fs::create_dir_all(&log_dir);
1193 let log_path = log_dir.join("checkpoints.jsonl");
1194 let touched = runtime.kernel().tools().touched_files();
1195 if let Err(e) =
1196 runtime.enable_checkpoints(Arc::new(repo), session_id, log_path, touched)
1197 {
1198 tracing::warn!("agui: enable_checkpoints failed, continuing without: {e}");
1199 }
1200 }
1201 } else {
1202 tracing::debug!("agui: shadow git unavailable, no per-turn checkpoints");
1203 }
1204
1205 let (sink, mut event_rx) = ChannelSink::new();
1206 runtime.set_event_sink(Arc::new(sink));
1207
1208 let (sse_tx, sse_rx) = tokio::sync::mpsc::unbounded_channel::<ag::Event>();
1210 let thread_id = input.thread_id.clone();
1211 let run_id = input.run_id.clone();
1212
1213 let _ = sse_tx.send(ag::Event::RunStarted(ag::RunStarted {
1216 thread_id: thread_id.clone(),
1217 run_id: run_id.clone(),
1218 base: ag::BaseEvent::default(),
1219 }));
1220
1221 let conv_tx = sse_tx.clone();
1226 let converter_handle = tokio::spawn(async move {
1227 let mut conv = AguiConverter::new();
1228 while let Some(agent_event) = event_rx.recv().await {
1229 for ev in conv.convert(&agent_event) {
1230 if conv_tx.send(ev).is_err() {
1231 return;
1232 }
1233 }
1234 }
1235 });
1236
1237 let metrics = state.metrics.clone();
1245 let drv_thread = thread_id.clone();
1246 let drv_run = run_id.clone();
1247 tokio::spawn(async move {
1248 let outcome = runtime.run(&goal).await;
1249 runtime.set_event_sink(Arc::new(NullSink));
1252
1253 let (checkpoint_id, finished_turn): (Option<String>, Option<usize>) = match &outcome {
1255 Ok(o) => (
1256 o.checkpoint_id.as_ref().map(|c| c.0.clone()),
1257 runtime.turn_index().checked_sub(1),
1258 ),
1259 Err(_) => (None, None),
1260 };
1261
1262 match outcome {
1263 Ok(o) => {
1264 metrics.agent_runs_total.fetch_add(1, Ordering::Relaxed);
1265 metrics.agent_runs_success.fetch_add(1, Ordering::Relaxed);
1266 metrics
1267 .agent_steps_total
1268 .fetch_add(o.steps as u64, Ordering::Relaxed);
1269 metrics
1270 .tokens_prompt_total
1271 .fetch_add(o.total_usage.prompt_tokens as u64, Ordering::Relaxed);
1272 metrics
1273 .tokens_completion_total
1274 .fetch_add(o.total_usage.completion_tokens as u64, Ordering::Relaxed);
1275 }
1276 Err(_) => {
1277 metrics.agent_runs_total.fetch_add(1, Ordering::Relaxed);
1278 metrics.agent_runs_failed.fetch_add(1, Ordering::Relaxed);
1279 }
1280 }
1281
1282 let _ = converter_handle.await;
1286
1287 if let (Some(cp), Some(turn)) = (checkpoint_id, finished_turn) {
1288 let _ = sse_tx.send(ag::Event::Custom(ag::Custom {
1289 name: "agui-tui/checkpoint_post".into(),
1290 value: serde_json::json!({
1291 "turn": turn,
1292 "postId": cp,
1293 }),
1294 base: ag::BaseEvent::default(),
1295 }));
1296 }
1297
1298 let _ = sse_tx.send(ag::Event::RunFinished(ag::RunFinished {
1299 thread_id: drv_thread,
1300 run_id: drv_run,
1301 result: None,
1302 base: ag::BaseEvent::default(),
1303 }));
1304 });
1305
1306 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(sse_rx).map(|ev| {
1307 let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".into());
1308 Ok::<_, Infallible>(Event::default().data(data))
1309 });
1310
1311 Ok(Sse::new(stream))
1312}
1313
1314fn sanitize_thread_id_for_session(thread: &str) -> String {
1319 let mut out: String = thread
1320 .chars()
1321 .map(|c| {
1322 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
1323 c
1324 } else {
1325 '-'
1326 }
1327 })
1328 .collect();
1329 while out.starts_with('.') {
1331 out.replace_range(..1, "-");
1332 }
1333 while out.contains("..") {
1335 out = out.replace("..", "-.");
1336 }
1337 if out.is_empty() {
1338 out.push_str("default");
1339 }
1340 out
1341}
1342
1343pub(super) async fn metrics_handler(State(state): State<Arc<AppState>>) -> String {
1345 let metrics = &state.metrics;
1346 let requests_total = metrics.requests_total.load(Ordering::Relaxed);
1347 let requests_active = metrics.requests_active.load(Ordering::Relaxed);
1348 let agent_runs_total = metrics.agent_runs_total.load(Ordering::Relaxed);
1349 let agent_runs_success = metrics.agent_runs_success.load(Ordering::Relaxed);
1350 let agent_runs_failed = metrics.agent_runs_failed.load(Ordering::Relaxed);
1351 let tokens_prompt_total = metrics.tokens_prompt_total.load(Ordering::Relaxed);
1352 let tokens_completion_total = metrics.tokens_completion_total.load(Ordering::Relaxed);
1353 let agent_steps_total = metrics.agent_steps_total.load(Ordering::Relaxed);
1354
1355 format!(
1356 "# HELP recursive_requests_total Total HTTP requests\n\
1357 # TYPE recursive_requests_total counter\n\
1358 recursive_requests_total {requests_total}\n\
1359 # HELP recursive_requests_active Currently active HTTP requests\n\
1360 # TYPE recursive_requests_active gauge\n\
1361 recursive_requests_active {requests_active}\n\
1362 # HELP recursive_agent_runs_total Total agent runs\n\
1363 # TYPE recursive_agent_runs_total counter\n\
1364 recursive_agent_runs_total {agent_runs_total}\n\
1365 # HELP recursive_agent_runs_success Successful agent runs\n\
1366 # TYPE recursive_agent_runs_success counter\n\
1367 recursive_agent_runs_success {agent_runs_success}\n\
1368 # HELP recursive_agent_runs_failed Failed agent runs\n\
1369 # TYPE recursive_agent_runs_failed counter\n\
1370 recursive_agent_runs_failed {agent_runs_failed}\n\
1371 # HELP recursive_tokens_prompt_total Total prompt tokens consumed\n\
1372 # TYPE recursive_tokens_prompt_total counter\n\
1373 recursive_tokens_prompt_total {tokens_prompt_total}\n\
1374 # HELP recursive_tokens_completion_total Total completion tokens generated\n\
1375 # TYPE recursive_tokens_completion_total counter\n\
1376 recursive_tokens_completion_total {tokens_completion_total}\n\
1377 # HELP recursive_agent_steps_total Total agent steps executed\n\
1378 # TYPE recursive_agent_steps_total counter\n\
1379 recursive_agent_steps_total {agent_steps_total}\n"
1380 )
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385 use super::*;
1386 use crate::event::AgentEvent;
1387 use crate::http::SseEvent;
1388
1389 #[test]
1390 fn agui_events_for_assistant_text_emits_start_content_end() {
1391 use agui_protocol as ag;
1392 let ev = AgentEvent::AssistantText {
1393 text: "hi".into(),
1394 step: 0,
1395 };
1396 let out = agui_events_for(&ev);
1397 assert_eq!(out.len(), 3, "got {out:?}");
1398 assert!(matches!(out[0], ag::Event::TextMessageStart(_)));
1399 assert!(matches!(out[1], ag::Event::TextMessageContent(_)));
1400 assert!(matches!(out[2], ag::Event::TextMessageEnd(_)));
1401 }
1402
1403 #[test]
1409 fn tool_progress_emitted_after_tool_result() {
1410 use std::collections::HashMap;
1411 use std::time::Instant;
1412
1413 let mut tool_start_times: HashMap<String, Instant> = HashMap::new();
1414 let mut emitted: Vec<SseEvent> = Vec::new();
1415
1416 let call_event = AgentEvent::ToolCall {
1418 name: "run_shell".to_string(),
1419 id: "tc-1".to_string(),
1420 arguments: "{}".to_string(),
1421 step: 0,
1422 };
1423 if let AgentEvent::ToolCall { id, .. } = &call_event {
1424 tool_start_times.insert(id.clone(), Instant::now());
1425 }
1426 if let Some(ev) = map_agent_event(&call_event) {
1427 emitted.push(ev);
1428 }
1429
1430 let result_event = AgentEvent::ToolResult {
1432 id: "tc-1".to_string(),
1433 name: "run_shell".to_string(),
1434 output: "ok".to_string(),
1435 step: 0,
1436 };
1437 if let Some(ev) = map_agent_event(&result_event) {
1438 emitted.push(ev);
1439 }
1440 if let AgentEvent::ToolResult { id, name, .. } = &result_event {
1441 let elapsed_ms = tool_start_times
1442 .remove(id)
1443 .map(|start| start.elapsed().as_millis() as u64)
1444 .unwrap_or(0);
1445 emitted.push(SseEvent::ToolProgress {
1446 tool_use_id: id.clone(),
1447 tool_name: name.clone(),
1448 elapsed_ms,
1449 });
1450 }
1451
1452 assert_eq!(emitted.len(), 3, "expected 3 events");
1454 assert!(matches!(emitted[0], SseEvent::ToolCall { .. }));
1455 assert!(matches!(emitted[1], SseEvent::ToolResult { .. }));
1456 let SseEvent::ToolProgress {
1457 tool_use_id,
1458 tool_name,
1459 elapsed_ms,
1460 } = &emitted[2]
1461 else {
1462 panic!("third event should be ToolProgress");
1463 };
1464 assert_eq!(tool_use_id, "tc-1");
1465 assert_eq!(tool_name, "run_shell");
1466 let _ = elapsed_ms; }
1468
1469 #[test]
1472 fn tool_progress_elapsed_is_zero_for_unmatched_result() {
1473 use std::collections::HashMap;
1474 use std::time::Instant;
1475
1476 let mut tool_start_times: HashMap<String, Instant> = HashMap::new();
1477
1478 let result_event = AgentEvent::ToolResult {
1479 id: "tc-orphan".to_string(),
1480 name: "read_file".to_string(),
1481 output: "data".to_string(),
1482 step: 0,
1483 };
1484 let elapsed_ms = if let AgentEvent::ToolResult { id, .. } = &result_event {
1485 tool_start_times
1486 .remove(id)
1487 .map(|start| start.elapsed().as_millis() as u64)
1488 .unwrap_or(0)
1489 } else {
1490 unreachable!()
1491 };
1492 assert_eq!(elapsed_ms, 0);
1494 }
1495}