Skip to main content

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}