Skip to main content

termesh_core/
agent.rs

1//! Agent data types shared across the service boundary.
2//!
3//! These live in `core` rather than in `agent` for the same reason [`crate::fs`] does:
4//! [`crate::AppMessage`] has to carry them from the agent worker thread to the single
5//! state owner (ARCHITECTURE.md §7.1). The `AgentService` trait and its implementations
6//! stay in `agent`, which re-exports everything here so call sites see one module.
7//!
8//! Note what is *not* here: no ACP wire type. These are our own vocabulary, translated at
9//! the transport boundary, which is ADR-0003's mitigation for protocol churn.
10
11use std::path::PathBuf;
12
13use crate::{
14    AgentTerminalOperation, AgentTerminalRequestId, AgentTerminalResponse, PermissionRequestId,
15    ProposalId, ReadRequestId, SessionId, TerminalId, TerminalSpec,
16};
17
18/// The protocol's four permission responses.
19///
20/// Four, not three: the Phase-00 stub's `AllowOnce | AllowSession | Deny` could not
21/// express `RejectAlways`, which ACP requires us to round-trip.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PermissionDecision {
24    AllowOnce,
25    AllowAlways,
26    RejectOnce,
27    RejectAlways,
28}
29
30impl PermissionDecision {
31    pub fn allows(self) -> bool {
32        matches!(self, PermissionDecision::AllowOnce | PermissionDecision::AllowAlways)
33    }
34
35    /// Whether this answer should be recorded as a standing policy for the workspace.
36    pub fn is_remembered(self) -> bool {
37        matches!(self, PermissionDecision::AllowAlways | PermissionDecision::RejectAlways)
38    }
39}
40
41/// What the agent told us it can do, read from the `initialize` result
42/// (ADR-0014 §4). Absent means absent — an agent that says nothing about a capability is
43/// assumed **not** to support it, never assumed to. Recorded and reported this phase;
44/// Phase 11 gates behaviour on it.
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub struct AgentCapabilities {
47    /// Whether the agent supports `session/load` — resuming a session by id rather than
48    /// only ever starting a fresh one. No agent this client has spoken to advertises it
49    /// today, and this client has no `session/load` request to send even if one did
50    /// (ADR-0014 §4): recorded so the boundary is a measured fact, not an assumption.
51    pub load_session: bool,
52    pub prompt_capabilities: PromptCapabilities,
53}
54
55/// The content kinds a `session/prompt` turn may include, per the agent's own handshake.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub struct PromptCapabilities {
58    pub image: bool,
59    pub audio: bool,
60    pub embedded_context: bool,
61}
62
63/// Why a turn ended.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum StopReason {
66    EndTurn,
67    Cancelled,
68    Refusal,
69    MaxTokens,
70}
71
72/// Work sent *to* the agent.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum AgentRequest {
75    NewSession {
76        cwd: PathBuf,
77    },
78    /// Move the session to one of the modes the agent offered (ADR-0015).
79    ///
80    /// Only ever sent because a human asked for it. The agent's default stands until
81    /// then, including when that default forbids the edit the agent was just asked to
82    /// make — a client that widens its own permissions on refusal is not asking.
83    SetMode {
84        session: SessionId,
85        mode: String,
86    },
87    /// A user turn. `context` is the workspace snapshot rendered as text and prepended —
88    /// small and current, because everything bulky is *pulled* on demand instead
89    /// (ADR-0007 §4).
90    Prompt {
91        session: SessionId,
92        text: String,
93        context: String,
94    },
95    /// Our answer to [`AgentEvent::ReadFileRequested`], served from the live buffer.
96    /// `None` means we could not read it.
97    ///
98    /// Carries the `request` it answers rather than only the path, so two reads of the
99    /// same file in one turn cannot be confused for each other.
100    FileContents {
101        session: SessionId,
102        request: ReadRequestId,
103        path: PathBuf,
104        contents: Option<String>,
105    },
106    Permission {
107        request: PermissionRequestId,
108        decision: PermissionDecision,
109    },
110    /// Cancel a permission prompt because its owning turn/session is no longer live.
111    PermissionCancelled {
112        request: PermissionRequestId,
113    },
114    TerminalResponse {
115        request: AgentTerminalRequestId,
116        response: AgentTerminalResponse,
117    },
118    Cancel {
119        session: SessionId,
120    },
121    Shutdown,
122}
123
124/// One entry from an agent's `availableModes`.
125///
126/// `description` is the agent's own wording for what the mode permits, which is the only
127/// trustworthy account of it: `auto` and `full-access` mean whatever that agent decided,
128/// and the client must not infer permissions from the name.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct SessionMode {
131    pub id: String,
132    pub name: String,
133    pub description: Option<String>,
134}
135
136/// What comes back *from* the agent.
137///
138/// Exhaustive on purpose, like `FsEvent`: adding a variant should break every loop that
139/// has not decided what to do about it.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum AgentEvent {
142    /// The handshake completed. Emitted once, before any session, so it carries no
143    /// `SessionId` — capabilities belong to the agent connection, not to a session.
144    Ready {
145        capabilities: AgentCapabilities,
146    },
147    SessionStarted {
148        session: SessionId,
149    },
150    /// What the agent will let this session do, and which of those it started in
151    /// (ADR-0015). Absent for agents that do not offer modes, which is most of them.
152    ModesAvailable {
153        session: SessionId,
154        current: String,
155        available: Vec<SessionMode>,
156    },
157    /// The agent reports the session is now in `mode`. The agent's account is the truth,
158    /// so this is what updates the client — not the response to our own request.
159    ModeChanged {
160        session: SessionId,
161        mode: String,
162    },
163    /// Streamed assistant text.
164    MessageChunk {
165        session: SessionId,
166        text: String,
167    },
168    /// Streamed reasoning, rendered dimmer than the answer.
169    ThoughtChunk {
170        session: SessionId,
171        text: String,
172    },
173    /// The agent asked us for a file. We answer from the buffer if it is open.
174    ReadFileRequested {
175        session: SessionId,
176        request: ReadRequestId,
177        path: PathBuf,
178    },
179    /// A proposed edit, as whole-file before/after text — the shape ACP actually uses.
180    /// `old_text` is `None` when the agent is creating a new file.
181    ProposedEdit {
182        session: SessionId,
183        proposal: ProposalId,
184        path: PathBuf,
185        old_text: Option<String>,
186        new_text: String,
187    },
188    /// A tool call awaiting approval. `command` is an argv array — we never interpolate
189    /// agent output into a shell string (ARCHITECTURE.md §9.4, §11).
190    PermissionRequested {
191        session: SessionId,
192        request: PermissionRequestId,
193        summary: String,
194        command: Vec<String>,
195        /// Present only when raw ACP input supplied an exact structured command.
196        terminal_spec: Option<TerminalSpec>,
197    },
198    TerminalRequest {
199        session: SessionId,
200        request: AgentTerminalRequestId,
201        operation: AgentTerminalOperation,
202    },
203    TerminalAttached {
204        session: SessionId,
205        terminal: TerminalId,
206    },
207    TurnEnded {
208        session: SessionId,
209        reason: StopReason,
210    },
211    Failed {
212        session: SessionId,
213        message: String,
214    },
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn permission_answers_split_into_allow_and_remember() {
223        assert!(PermissionDecision::AllowOnce.allows());
224        assert!(PermissionDecision::AllowAlways.allows());
225        assert!(!PermissionDecision::RejectOnce.allows());
226        assert!(!PermissionDecision::RejectAlways.allows());
227
228        assert!(!PermissionDecision::AllowOnce.is_remembered());
229        assert!(PermissionDecision::AllowAlways.is_remembered());
230        assert!(
231            PermissionDecision::RejectAlways.is_remembered(),
232            "the variant the three-way stub could not express"
233        );
234    }
235}