oxicode_agent/state.rs
1/// Agent state management
2use crate::types::{StopReason, ToolResult};
3use oxicode_ai::{ContentBlock, Message, TextContent};
4use parking_lot::RwLock;
5use serde::{Deserialize, Serialize};
6use std::sync::Arc;
7
8/// Agent execution state
9///
10/// Tracks the full lifecycle of an agent conversation including messages,
11/// token usage, tool results, and iteration progress.
12///
13/// Derives `Serialize`/`Deserialize` for session persistence and
14/// cross-process state transfer (e.g. oxios supervisor serialization).
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct AgentState {
17 /// Conversation message history (user, assistant, and tool-result messages).
18 pub messages: Vec<Message>,
19 /// Current agent loop iteration (incremented after each assistant turn).
20 pub iteration: usize,
21 /// The reason the last turn stopped, if any.
22 pub stop_reason: Option<StopReason>,
23 /// Accumulated results from tool executions in the current conversation.
24 pub tool_results: Vec<ToolResult>,
25 /// Cumulative token count (input + output) across all turns.
26 pub total_tokens: usize,
27 /// Cumulative prompt / input tokens across all turns.
28 pub input_tokens: usize,
29 /// Cumulative completion / output tokens across all turns.
30 pub output_tokens: usize,
31 /// **Most-recent** reported input-token count from a single LLM response.
32 ///
33 /// Unlike [`Self::input_tokens`] (which is cumulative across all turns),
34 /// this is overwritten on every `ProviderEvent::Done` with the count
35 /// that turn actually sent to the model. It is the **ground-truth** signal
36 /// used by compaction when available — see [`Self::current_token_source`].
37 ///
38 /// `None` until the first `Done` event has been observed.
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub last_input_tokens: Option<usize>,
41 /// Heuristic token estimate (`bytes/4`) that was current when
42 /// `last_input_tokens` was last set. Used to detect the bytes/4 drift
43 /// described in #28 and surface it as a warning.
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub last_estimate_at_report: Option<usize>,
46 /// Divergence factor (reported / estimate) at the time of the last
47 /// `Done`. Surfaced in logs so the operator can see how badly the
48 /// `bytes/4` heuristic is undercounting on token-dense workloads.
49 /// `None` until first divergence observation.
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub last_estimate_divergence: Option<f64>,
52}
53
54/// Source of the value driving compaction / context-size decisions.
55///
56/// `Real` is the provider-reported input-token count from the most recent
57/// `Done` event — ground truth. `Heuristic` is the legacy
58/// `serialized_json.len() / 4` estimate. `None` means no messages have
59/// been sent yet (the loop has not observed any size at all).
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum TokenSource {
62 /// No size observation available yet (cold start).
63 None,
64 /// `bytes/4` estimate; reliable only for token-sparse prose.
65 Heuristic(usize),
66 /// Provider-reported input token count from the most recent `Done`.
67 Real(usize),
68}
69
70impl AgentState {
71 /// Create a new, default-initialized agent state.
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 /// Add a user message
77 pub fn add_user_message(&mut self, content: String) {
78 self.messages
79 .push(Message::User(oxicode_ai::UserMessage::new(content)));
80 }
81
82 /// Add an assistant message
83 pub fn add_assistant_message(&mut self, content: String) {
84 let mut assistant = oxicode_ai::AssistantMessage::new(
85 oxicode_ai::Api::AnthropicMessages,
86 "agent",
87 "agent-model",
88 );
89 assistant.content = vec![ContentBlock::Text(TextContent::new(content))];
90 self.messages.push(Message::Assistant(assistant));
91 }
92
93 /// Add a tool result message to both the message history and the tool results list.
94 pub fn add_tool_result(&mut self, tool_call_id: String, content: String) {
95 let content_for_result = content.clone();
96 let tool_result_msg = oxicode_ai::ToolResultMessage::new(
97 tool_call_id.clone(),
98 "tool",
99 vec![ContentBlock::Text(TextContent::new(content))],
100 );
101 self.messages
102 .push(oxicode_ai::Message::ToolResult(tool_result_msg));
103 self.tool_results
104 .push(ToolResult::success(tool_call_id, content_for_result));
105 }
106
107 /// Increment the iteration counter after an assistant turn completes.
108 pub fn increment_iteration(&mut self) {
109 self.iteration += 1;
110 }
111
112 /// Record the reason the last turn stopped.
113 pub fn set_stop_reason(&mut self, reason: StopReason) {
114 self.stop_reason = Some(reason);
115 }
116
117 /// Accumulate token usage from a completed LLM call.
118 ///
119 /// `input` is **the input-token count for the just-completed turn** —
120 /// NOT a per-turn delta. The provider reports a fresh per-turn
121 /// `usage.input_tokens` on every `Done`; we accumulate it into
122 /// [`Self::input_tokens`] for lifetime accounting and **also** cache
123 /// it as the most-recent observation via [`Self::record_provider_turn`].
124 pub fn record_usage(&mut self, input: usize, output: usize) {
125 self.input_tokens += input;
126 self.output_tokens += output;
127 self.total_tokens += input + output;
128 }
129
130 /// Record the most recent provider-reported input-token count and the
131 /// heuristic estimate that was current at the time of the report, so
132 /// the loop can use the real count for compaction decisions and
133 /// surface drift (issue #28 gap 2).
134 ///
135 /// `input_tokens` is the value from `ProviderEvent::Done.message.usage.input`.
136 /// `estimate_at_report` is the bytes/4 estimate taken at the same moment
137 /// (i.e. the value the legacy path *would* have used for compaction).
138 pub fn record_provider_turn(&mut self, input_tokens: usize, estimate_at_report: usize) {
139 self.last_input_tokens = Some(input_tokens);
140 self.last_estimate_at_report = Some(estimate_at_report);
141 // Divergence = reported / estimate. A value > 1.0 means the
142 // heuristic under-counted; the documented failure in #28 saw
143 // ~3.5×. Guard against the zero-estimate case to avoid Inf/NaN.
144 self.last_estimate_divergence = if estimate_at_report > 0 {
145 Some(input_tokens as f64 / estimate_at_report as f64)
146 } else if input_tokens > 0 {
147 // An estimate of 0 against a non-zero report is the worst-case
148 // divergence: the heuristic was essentially blind to the
149 // context. Surface this as a high multiplier.
150 Some(f64::INFINITY)
151 } else {
152 Some(1.0)
153 };
154 }
155
156 /// Current best estimate of context size, tagged with its source.
157 ///
158 /// - `Real(n)` if the last completed turn reported `usage.input_tokens`.
159 /// - `Heuristic(n)` only before the first `Done` is observed (cold start).
160 /// - `None` if there are no messages yet.
161 ///
162 /// Callers (notably `maybe_compact`) should **prefer** `Real` and **only**
163 /// fall back to `Heuristic` on cold start. See issue #28.
164 pub fn current_token_source(&self) -> TokenSource {
165 if let Some(real) = self.last_input_tokens {
166 TokenSource::Real(real)
167 } else if !self.messages.is_empty() {
168 TokenSource::Heuristic(self.estimate_tokens())
169 } else {
170 TokenSource::None
171 }
172 }
173
174 /// Clear all state, resetting for a new conversation.
175 pub fn clear(&mut self) {
176 self.messages.clear();
177 self.iteration = 0;
178 self.stop_reason = None;
179 self.tool_results.clear();
180 self.total_tokens = 0;
181 self.input_tokens = 0;
182 self.output_tokens = 0;
183 self.last_input_tokens = None;
184 self.last_estimate_at_report = None;
185 self.last_estimate_divergence = None;
186 }
187
188 /// Replace the entire message history (used after context compaction).
189 pub fn replace_messages(&mut self, messages: Vec<Message>) {
190 self.messages = messages;
191 }
192
193 /// Rough token-count estimate based on the serialized message JSON length.
194 pub fn estimate_tokens(&self) -> usize {
195 let json = serde_json::to_string(&self.messages).unwrap_or_default();
196 json.len() / 4 // Rough approximation
197 }
198
199 /// Returns `true` if the agent has signaled a stop reason.
200 pub fn is_complete(&self) -> bool {
201 self.stop_reason.is_some()
202 }
203}
204
205/// Thread-safe agent state wrapper.
206#[derive(Default, Clone)]
207pub struct SharedState {
208 state: Arc<RwLock<AgentState>>,
209}
210
211impl SharedState {
212 /// Create a new SharedState with default (empty) agent state.
213 pub fn new() -> Self {
214 Self::default()
215 }
216
217 /// Obtain a snapshot of the current agent state.
218 pub fn get_state(&self) -> AgentState {
219 self.state.read().clone()
220 }
221
222 /// Mutably update the agent state under a write lock.
223 pub fn update<F>(&self, f: F)
224 where
225 F: FnOnce(&mut AgentState),
226 {
227 let mut state = self.state.write();
228 f(&mut state);
229 }
230
231 /// Reset the state for a new conversation (delegates to [`AgentState::clear`]).
232 pub fn reset(&self) {
233 let mut state = self.state.write();
234 state.clear();
235 }
236}