Skip to main content

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}