harness/event.rs
1use std::path::PathBuf;
2use std::time::Duration;
3
4use serde_json::Value;
5use tokio_util::sync::CancellationToken;
6
7use crate::model::{ChatMessage, UserAttachment};
8
9/// Token / cache usage reported by the model client at the end of a turn.
10///
11/// Kept independent of any wire format (no `grpc::*` import) so the harness
12/// crate stays a pure domain library. The `core::native_adapter` layer
13/// converts this to the proto `SessionUsage` shape on the way out.
14#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
15pub struct HarnessUsage {
16 /// Total input tokens spent this turn (main steps + compaction
17 /// summarize calls combined). Same field the wire `SessionUsage`
18 /// surfaces today — keeps HR's existing dashboards correct.
19 pub input_tokens: u64,
20 /// Total output tokens this turn (main + compaction).
21 pub output_tokens: u64,
22 /// Cache-read tokens (Anthropic). 0 = unknown / not applicable.
23 pub cache_read_input_tokens: u64,
24 /// Cache-create tokens (Anthropic). 0 = unknown / not applicable.
25 pub cache_creation_input_tokens: u64,
26 /// Subset of `input_tokens` spent specifically on compaction
27 /// `summarize` calls. 0 = no compaction ran this turn (or it ran
28 /// but the provider didn't report usage). Surfaced in
29 /// tracing for now; not yet wired to proto `SessionUsage` —
30 /// when HR wants the breakdown on the wire, add fields to the
31 /// proto + map in `native_adapter::harness_usage_to_proto`.
32 pub compaction_input_tokens: u64,
33 /// Subset of `output_tokens` spent on compaction.
34 pub compaction_output_tokens: u64,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38pub enum HarnessInternalEvent {
39 AssistantTextChunk {
40 msg_id: String,
41 delta: String,
42 },
43 AssistantThinkingChunk {
44 msg_id: String,
45 delta: String,
46 },
47 ToolCall {
48 id: String,
49 name: String,
50 input: Value,
51 },
52 ToolResult {
53 id: String,
54 output: Result<Value, String>,
55 },
56 /// Compaction fired between steps and the running history was
57 /// folded. Counts let HR estimate how aggressive compaction is at
58 /// this point in the turn (and whether to raise alerts). Token
59 /// counts come from the harness's own estimator — see
60 /// `compaction::estimate_messages_tokens` — and are approximate.
61 ///
62 /// Native_adapter currently does NOT project this onto an
63 /// `AdapterEvent` (no proto field reserved for it yet). It surfaces
64 /// only through structured tracing on the RD side; tests can still
65 /// assert on it in the harness's mpsc channel.
66 CompactionApplied {
67 original_message_count: usize,
68 compacted_message_count: usize,
69 original_tokens: u64,
70 compacted_tokens: u64,
71 },
72 TurnEnd {
73 stop_reason: String,
74 usage: Option<HarnessUsage>,
75 /// Ids of tool invocations that had NOT resolved when the turn
76 /// ended. Non-empty only on `stop_reason == "interrupt"` and only
77 /// when the post-cancel grace window expired before every tool
78 /// future finished; a normal (or fully-drained) turn end always
79 /// carries `[]`.
80 ///
81 /// Consumers (e.g. RD's turn-terminal fence) must treat a
82 /// non-empty list as "sandbox-side writers may still be running":
83 /// the loop dropped those futures here, so anything the runtime
84 /// does on drop (or fails to do) is the only cleanup that ran.
85 /// Empty list ⇒ every tool future resolved before the event was
86 /// published and no writer can outlive the turn via this path.
87 ///
88 /// This is **internal-only** state, like `final_messages`: it
89 /// never lands on the wire.
90 pending_tools: Vec<String>,
91 /// Final messages history at turn end (post-compaction, includes
92 /// the assistant's reply and any tool round-trips). RD captures
93 /// this snapshot to seed the next dispatch's `prior_messages`
94 /// so multi-turn conversations in the same RD process don't
95 /// re-prompt from scratch.
96 ///
97 /// This is **internal-only** state: it never lands on the wire.
98 /// `native_adapter::HarnessEventAdapter::ingest` reads it via a
99 /// side-channel (history_handle) and drops it before projecting
100 /// to `AdapterEvent::TurnEnd`. Tests can still assert on the
101 /// raw `HarnessInternalEvent` directly.
102 final_messages: Vec<ChatMessage>,
103 },
104}
105
106#[derive(Debug, Clone)]
107pub struct NativeTurnInput {
108 /// The user-facing prompt that triggered this turn. Lands as the first
109 /// `ChatMessage::User.content` the model sees.
110 pub prompt_text: String,
111 /// Pre-composed system prompt (e.g. `spec_snapshot.system_prompt` +
112 /// `driver.append_system_prompt`). `None` ⇒ no system message sent.
113 pub system_prompt: Option<String>,
114 /// Non-text attachments lifted from the inbound `UserMessagePayload`
115 /// content blocks. Empty Vec ⇒ pure-text prompt. Lands on the first
116 /// `ChatMessage::User.attachments` so each provider's projection
117 /// can render them as image / file content blocks.
118 pub attachments: Vec<UserAttachment>,
119 /// Optional cancellation handle. When fired, the harness loop
120 /// short-circuits at the next stream-chunk await (or before the
121 /// next step starts) and emits `TurnEnd { stop_reason: "interrupt" }`.
122 /// `None` ⇒ harness cannot be cancelled mid-flight (fine for tests
123 /// and for fire-and-forget turns); production wires this through
124 /// from RD's `active_native_cancel`.
125 pub cancel_token: Option<CancellationToken>,
126 /// Prior `messages` history for **in-memory** mode (`context_path = None`).
127 /// Ignored when `context_path` is `Some` — harness loads history from
128 /// the JSONL file instead.
129 ///
130 /// Empty Vec + `context_path = None` = fresh in-memory conversation.
131 pub prior_messages: Vec<ChatMessage>,
132
133 /// Absolute path to harness's context JSONL.
134 ///
135 /// * `Some(path)` — **persistent mode**: harness loads prior messages
136 /// from this file at turn start (creating it on first use), appends
137 /// new messages incrementally, and rewrites it on compaction.
138 /// `prior_messages` is ignored. `TurnEnd.final_messages` is empty.
139 ///
140 /// * `None` — **in-memory mode**: `prior_messages` is used as the seed;
141 /// harness never touches the filesystem. `TurnEnd.final_messages`
142 /// carries the full history snapshot for the caller to persist.
143 /// Suitable for runtime-driver (manages history in RAM) and tests.
144 pub context_path: Option<PathBuf>,
145 /// How long the loop waits for in-flight tool futures to resolve after
146 /// the cancel token fires before publishing `TurnEnd{interrupt}`.
147 ///
148 /// `None` falls back to the harness-level grace configured via
149 /// [`crate::agent_loop::AgentLoopHarness::with_turn_end_grace`] (which
150 /// itself defaults to [`crate::agent_loop::DEFAULT_TURN_END_GRACE`],
151 /// 1s — the historical hard-coded value). Hosts whose tool runtimes
152 /// need longer to shut down remote work (e.g. sandbox exec with a
153 /// TERM-grace + SIGKILL tail) should raise this so `TurnEnd` is only
154 /// published once writers actually stopped.
155 ///
156 /// Regardless of the window's outcome, the event carries
157 /// `TurnEnd.pending_tools`: the ids still unresolved when it fired.
158 pub turn_end_grace: Option<Duration>,
159}
160
161impl PartialEq for NativeTurnInput {
162 fn eq(&self, other: &Self) -> bool {
163 // CancellationToken doesn't implement PartialEq (it's a runtime
164 // handle, not a value). Two NativeTurnInputs are considered
165 // equal iff the *value-typed* fields match; the cancel handle
166 // is opaque ambient state.
167 self.prompt_text == other.prompt_text
168 && self.system_prompt == other.system_prompt
169 && self.attachments == other.attachments
170 && self.prior_messages == other.prior_messages
171 && self.context_path == other.context_path
172 && self.turn_end_grace == other.turn_end_grace
173 }
174}
175
176/// Categorised native-path failure. Each variant maps 1:1 to an
177/// `acpx::NativeFaultCategory` in `core::native_adapter::native_error_to_invoker`,
178/// which is in turn projected to a `RuntimeError` by `core::error::
179/// native_fault_to_runtime_error`. Keeping the buckets named here lets the
180/// harness crate produce structured errors without taking on any grpc /
181/// proto dependency.
182#[derive(Debug, thiserror::Error)]
183pub enum NativeHarnessError {
184 #[error("native harness failed: {0}")]
185 Failed(String),
186
187 #[error("native harness event encode failed: {0}")]
188 Encode(String),
189
190 #[error("native harness channel closed")]
191 ChannelClosed,
192
193 /// LLM provider returned 429.
194 #[error("model rate limit: {0}")]
195 ModelRateLimit(String),
196
197 /// LLM provider returned 401 / 403.
198 #[error("model auth error: {0}")]
199 ModelAuth(String),
200
201 /// Prompt + completion exceeded the model's context window.
202 #[error("model context overflow: {0}")]
203 ModelContextOverflow(String),
204
205 /// Transport-level failure (DNS / TCP / TLS / truncated body).
206 #[error("model network error: {0}")]
207 ModelNetwork(String),
208
209 /// HTTP 400 that is a config error (wrong model name, invalid params).
210 /// Not retryable — the caller must fix their configuration.
211 #[error("model bad request: {0}")]
212 ModelBadRequest(String),
213
214 /// HTTP 5xx or transient server error. Retryable.
215 #[error("model server error: {0}")]
216 ModelServerError(String),
217
218 /// Any other model-side failure not captured above.
219 #[error("model other error: {0}")]
220 ModelOther(String),
221
222 /// Sandbox / tool runtime hard error (process spawn refused, envd
223 /// unreachable). NOT a `ToolFailure` — those are domain failures the
224 /// model can observe and recover from; this is RD-side infrastructure
225 /// breaking under the tool.
226 #[error("tool runtime error: {0}")]
227 ToolRuntime(String),
228}