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 /// A user turn. `context` is the workspace snapshot rendered as text and prepended —
79 /// small and current, because everything bulky is *pulled* on demand instead
80 /// (ADR-0007 §4).
81 Prompt {
82 session: SessionId,
83 text: String,
84 context: String,
85 },
86 /// Our answer to [`AgentEvent::ReadFileRequested`], served from the live buffer.
87 /// `None` means we could not read it.
88 ///
89 /// Carries the `request` it answers rather than only the path, so two reads of the
90 /// same file in one turn cannot be confused for each other.
91 FileContents {
92 session: SessionId,
93 request: ReadRequestId,
94 path: PathBuf,
95 contents: Option<String>,
96 },
97 Permission {
98 request: PermissionRequestId,
99 decision: PermissionDecision,
100 },
101 /// Cancel a permission prompt because its owning turn/session is no longer live.
102 PermissionCancelled {
103 request: PermissionRequestId,
104 },
105 TerminalResponse {
106 request: AgentTerminalRequestId,
107 response: AgentTerminalResponse,
108 },
109 Cancel {
110 session: SessionId,
111 },
112 Shutdown,
113}
114
115/// What comes back *from* the agent.
116///
117/// Exhaustive on purpose, like `FsEvent`: adding a variant should break every loop that
118/// has not decided what to do about it.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum AgentEvent {
121 /// The handshake completed. Emitted once, before any session, so it carries no
122 /// `SessionId` — capabilities belong to the agent connection, not to a session.
123 Ready {
124 capabilities: AgentCapabilities,
125 },
126 SessionStarted {
127 session: SessionId,
128 },
129 /// Streamed assistant text.
130 MessageChunk {
131 session: SessionId,
132 text: String,
133 },
134 /// Streamed reasoning, rendered dimmer than the answer.
135 ThoughtChunk {
136 session: SessionId,
137 text: String,
138 },
139 /// The agent asked us for a file. We answer from the buffer if it is open.
140 ReadFileRequested {
141 session: SessionId,
142 request: ReadRequestId,
143 path: PathBuf,
144 },
145 /// A proposed edit, as whole-file before/after text — the shape ACP actually uses.
146 /// `old_text` is `None` when the agent is creating a new file.
147 ProposedEdit {
148 session: SessionId,
149 proposal: ProposalId,
150 path: PathBuf,
151 old_text: Option<String>,
152 new_text: String,
153 },
154 /// A tool call awaiting approval. `command` is an argv array — we never interpolate
155 /// agent output into a shell string (ARCHITECTURE.md §9.4, §11).
156 PermissionRequested {
157 session: SessionId,
158 request: PermissionRequestId,
159 summary: String,
160 command: Vec<String>,
161 /// Present only when raw ACP input supplied an exact structured command.
162 terminal_spec: Option<TerminalSpec>,
163 },
164 TerminalRequest {
165 session: SessionId,
166 request: AgentTerminalRequestId,
167 operation: AgentTerminalOperation,
168 },
169 TerminalAttached {
170 session: SessionId,
171 terminal: TerminalId,
172 },
173 TurnEnded {
174 session: SessionId,
175 reason: StopReason,
176 },
177 Failed {
178 session: SessionId,
179 message: String,
180 },
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn permission_answers_split_into_allow_and_remember() {
189 assert!(PermissionDecision::AllowOnce.allows());
190 assert!(PermissionDecision::AllowAlways.allows());
191 assert!(!PermissionDecision::RejectOnce.allows());
192 assert!(!PermissionDecision::RejectAlways.allows());
193
194 assert!(!PermissionDecision::AllowOnce.is_remembered());
195 assert!(PermissionDecision::AllowAlways.is_remembered());
196 assert!(
197 PermissionDecision::RejectAlways.is_remembered(),
198 "the variant the three-way stub could not express"
199 );
200 }
201}