aonyx_api/agent.rs
1//! The injected agent-turn runner, the streamed frame type, and the
2//! read-only metadata DTOs (tools / skills / config).
3//!
4//! The API owns session + memory persistence directly; everything that lives
5//! in `aonyx-agent` (the loop, the tool registry, the loaded skills, the
6//! config) is reached through this trait so `aonyx-api` never depends on
7//! `aonyx-agent` (no dependency cycle) and stays unit-testable with a stub.
8
9use aonyx_core::{Message, Result, Role};
10use async_trait::async_trait;
11use serde::Serialize;
12use tokio::sync::mpsc::Sender;
13
14/// A single streamed event of a turn, serialized to the client as a JSON
15/// frame (`{"type": "...", ...}`). Mirrors the agent loop's internal
16/// `TurnEvent`; the binary maps its events onto these.
17#[derive(Debug, Clone, Serialize)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum StreamFrame {
20 /// Incremental assistant text.
21 Delta {
22 /// The chunk of assistant text.
23 text: String,
24 },
25 /// A tool call is starting.
26 ToolStart {
27 /// Tool name.
28 name: String,
29 /// JSON arguments.
30 args: serde_json::Value,
31 /// Safety class (`safe` / `caution` / `destructive`).
32 class: String,
33 },
34 /// A tool call finished.
35 ToolEnd {
36 /// Tool name.
37 name: String,
38 /// Whether the call succeeded.
39 ok: bool,
40 /// One-line outcome summary.
41 summary: String,
42 },
43 /// A tool call was rejected by the approval gate.
44 ToolRejected {
45 /// Tool name.
46 name: String,
47 /// Safety class that triggered the rejection.
48 class: String,
49 },
50 /// A new loop iteration began.
51 Iteration {
52 /// 1-based iteration number.
53 n: u32,
54 },
55 /// The turn completed. Emitted by the HTTP layer after it persists the
56 /// result — not by the agent.
57 Done {
58 /// Final assistant reply.
59 reply: String,
60 /// The session's new turn count.
61 turns: u32,
62 },
63 /// The turn failed.
64 Error {
65 /// Human-readable error.
66 message: String,
67 },
68}
69
70/// Metadata for one registered tool (`GET /v1/tools`).
71#[derive(Debug, Clone, Serialize)]
72pub struct ToolInfo {
73 /// Tool name (matches `ToolHandler::name`).
74 pub name: String,
75 /// Human-readable description.
76 pub description: String,
77 /// Safety class (`safe` / `caution` / `destructive`).
78 pub class: String,
79 /// JSON Schema for the tool's arguments.
80 pub schema: serde_json::Value,
81}
82
83/// Metadata for one loaded skill (`GET /v1/skills`).
84#[derive(Debug, Clone, Serialize)]
85pub struct SkillInfo {
86 /// Skill id (the `SKILL.md` slug).
87 pub id: String,
88 /// Short description.
89 pub description: String,
90 /// Trigger labels (keywords / patterns / `always`).
91 pub triggers: Vec<String>,
92}
93
94/// Non-secret server configuration (`GET /v1/config`). Never carries keys.
95#[derive(Debug, Clone, Default, Serialize)]
96pub struct ConfigInfo {
97 /// Active provider id.
98 pub provider: String,
99 /// Active default model id.
100 pub model: String,
101 /// Loop iteration cap.
102 pub max_iterations: usize,
103 /// Whether skill auto-generation is on.
104 pub skill_autogen: bool,
105}
106
107/// One agent turn over a full message history — blocking or streaming — plus
108/// read-only metadata accessors the binary fills from its live components.
109#[async_trait]
110pub trait ApiAgent: Send + Sync + 'static {
111 /// Run one turn over `history`, returning the complete message log after
112 /// the turn — the assistant reply, plus any tool messages the loop
113 /// appended along the way.
114 async fn run_turn(&self, history: Vec<Message>) -> Result<Vec<Message>>;
115
116 /// Streaming variant: emit [`StreamFrame`]s (deltas, tool activity) on
117 /// `tx` as the loop runs, returning the full post-turn log. The default
118 /// runs the blocking turn and emits the reply as a single `Delta`; the
119 /// binary overrides it to stream tokens + tool events live.
120 ///
121 /// Implementations must NOT emit `Done`/`Error` — the HTTP layer sends
122 /// those after it persists the result.
123 async fn run_turn_streaming(
124 &self,
125 history: Vec<Message>,
126 tx: Sender<StreamFrame>,
127 ) -> Result<Vec<Message>> {
128 let log = self.run_turn(history).await?;
129 let reply = last_assistant_text(&log);
130 let _ = tx.send(StreamFrame::Delta { text: reply }).await;
131 Ok(log)
132 }
133
134 /// The registered tools. Default: none (overridden by the binary).
135 fn tools(&self) -> Vec<ToolInfo> {
136 Vec::new()
137 }
138
139 /// The loaded skills. Default: none (overridden by the binary).
140 fn skills(&self) -> Vec<SkillInfo> {
141 Vec::new()
142 }
143
144 /// The non-secret config snapshot. Default: empty (overridden by the
145 /// binary).
146 fn config(&self) -> ConfigInfo {
147 ConfigInfo::default()
148 }
149}
150
151/// The last non-empty assistant message in a log, or a placeholder when the
152/// turn produced none.
153pub(crate) fn last_assistant_text(messages: &[Message]) -> String {
154 messages
155 .iter()
156 .rev()
157 .find(|m| matches!(m.role, Role::Assistant) && !m.content.trim().is_empty())
158 .map(|m| m.content.clone())
159 .unwrap_or_else(|| "(no reply)".to_string())
160}