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