harness_core/context.rs
1use crate::{ModelOutput, Signal};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5/// A single block of content within the assembled prompt.
6///
7/// Blocks are grouped so that long-stable prefixes (system + guides) stay
8/// cacheable across turns ("prompt caching" pattern).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[non_exhaustive]
11pub enum Block {
12 /// Plain prompt text.
13 Text(String),
14 /// Reference to a file in the world. The runtime decides whether to
15 /// inline contents or hand the agent a tool call to read it.
16 FileRef {
17 path: String,
18 hash: Option<String>,
19 excerpt: Option<String>,
20 },
21 /// Reference to an activated SKILL.md body.
22 Skill { name: String, body: String },
23 /// A tool call the assistant requested.
24 ToolCall {
25 call_id: String,
26 name: String,
27 args: serde_json::Value,
28 },
29 /// The result of a previous tool call.
30 ToolResult {
31 call_id: String,
32 content: serde_json::Value,
33 },
34 /// Feedback signals from sensors, rendered for the model.
35 Feedback(Vec<Signal>),
36 /// Provider-specific reasoning trace (DeepSeek `reasoning_content`,
37 /// Anthropic `thinking` blocks). Must be echoed back to the provider on
38 /// subsequent calls or the API rejects the request.
39 Reasoning(String),
40 /// An inline image for vision-capable models. `media_type` is a MIME type
41 /// (e.g. `"image/png"`, `"image/jpeg"`); `base64` is the standard-base64
42 /// encoding of the raw image bytes. Each provider adapter renders this into
43 /// its own multimodal wire shape (OpenAI `image_url` data-URI, Anthropic
44 /// `image`/base64 source, Gemini `inline_data`).
45 Image { media_type: String, base64: String },
46 /// Inline audio for models that listen. `media_type` is a MIME type (e.g.
47 /// `"audio/wav"`, `"audio/webm"`); `base64` is the standard-base64 encoding
48 /// of the raw audio bytes. Each provider adapter renders this into its own
49 /// wire shape (OpenAI `input_audio` with a bare format name, Gemini
50 /// `inline_data`).
51 ///
52 /// This is what a transcript cannot carry: intonation, stress, whether two
53 /// words ran together, whether a vowel was long. Speech transcribed to text
54 /// before the model sees it has already lost the part a pronunciation
55 /// judgement rests on.
56 Audio { media_type: String, base64: String },
57}
58
59impl Block {
60 /// Build a [`Block::Image`] from raw image bytes, base64-encoding them.
61 /// `media_type` is a MIME type like `"image/png"`.
62 pub fn image_bytes(media_type: impl Into<String>, bytes: &[u8]) -> Self {
63 Block::Image {
64 media_type: media_type.into(),
65 base64: base64_encode(bytes),
66 }
67 }
68
69 /// Build a [`Block::Audio`] from raw audio bytes, base64-encoding them.
70 /// `media_type` is a MIME type like `"audio/wav"`.
71 pub fn audio_bytes(media_type: impl Into<String>, bytes: &[u8]) -> Self {
72 Block::Audio {
73 media_type: media_type.into(),
74 base64: base64_encode(bytes),
75 }
76 }
77}
78
79/// Minimal standard-base64 encoder (RFC 4648, with padding). Kept dependency-free
80/// so `harness-core` stays lean — image payloads are its only base64 user.
81fn base64_encode(input: &[u8]) -> String {
82 const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
83 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
84 for chunk in input.chunks(3) {
85 let b0 = chunk[0] as u32;
86 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
87 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
88 let n = (b0 << 16) | (b1 << 8) | b2;
89 out.push(T[(n >> 18) as usize & 63] as char);
90 out.push(T[(n >> 12) as usize & 63] as char);
91 out.push(if chunk.len() > 1 {
92 T[(n >> 6) as usize & 63] as char
93 } else {
94 '='
95 });
96 out.push(if chunk.len() > 2 {
97 T[n as usize & 63] as char
98 } else {
99 '='
100 });
101 }
102 out
103}
104
105#[cfg(test)]
106mod base64_tests {
107 use super::base64_encode;
108 #[test]
109 fn matches_known_vectors() {
110 assert_eq!(base64_encode(b""), "");
111 assert_eq!(base64_encode(b"f"), "Zg==");
112 assert_eq!(base64_encode(b"fo"), "Zm8=");
113 assert_eq!(base64_encode(b"foo"), "Zm9v");
114 assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
115 assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
116 assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
117 }
118}
119
120/// A single conversation turn (assistant or user).
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct Turn {
123 pub role: TurnRole,
124 pub blocks: Vec<Block>,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129#[non_exhaustive]
130pub enum TurnRole {
131 User,
132 Assistant,
133 System,
134 Tool,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Task {
139 pub description: String,
140 pub source: Option<String>, // slack url, github issue, etc.
141 pub deadline: Option<i64>,
142}
143
144#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
145pub struct Policy {
146 pub max_iters: u32,
147 pub max_input_tokens: u32,
148 pub max_output_tokens: u32,
149 pub self_correct_rounds: u32,
150}
151
152impl Default for Policy {
153 fn default() -> Self {
154 Self {
155 max_iters: 50,
156 max_input_tokens: 150_000,
157 max_output_tokens: 8_000,
158 self_correct_rounds: 3,
159 }
160 }
161}
162
163/// Constrain the model's terminal (non-tool-call) reply shape. Default = Free.
164///
165/// Each model adapter translates this to the provider's native format on the
166/// wire:
167/// - OpenAI / DeepSeek: `response_format: {type: "json_object"}` for
168/// `JsonObject`; `{type: "json_schema", json_schema: {name, schema, strict}}`
169/// for `JsonSchema`. Providers that only support `json_object` (DeepSeek as
170/// of Dec 2025) degrade gracefully by injecting the schema into the system
171/// prompt instead.
172/// - Gemini: `generationConfig.responseMimeType = "application/json"` plus
173/// `generationConfig.responseSchema = <schema>` for `JsonSchema`.
174/// - Anthropic: no native field — adapters synthesise a "structured_output"
175/// tool with the schema, force `tool_choice` to it, and surface the tool's
176/// args as the assistant text on response.
177///
178/// `JsonSchema.schema` is a `serde_json::Value` so callers can build it
179/// however they like — hand-rolled, via `schemars::schema_for!(T)`, or pulled
180/// from a `harness_loop::AgentLoop::run_typed<T>()` derivation.
181#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182#[serde(tag = "type", rename_all = "snake_case")]
183#[non_exhaustive]
184pub enum ResponseFormat {
185 /// Free-form text. The framework adds nothing to the request body.
186 #[default]
187 Free,
188 /// "Reply with valid JSON of any shape." Useful when the caller will run
189 /// its own validation and doesn't want to commit to a schema yet.
190 JsonObject,
191 /// "Reply with JSON matching this schema." Adapters may need to sanitise
192 /// dialect-specific keys before emitting (Gemini rejects `$ref`, OpenAI
193 /// strict mode demands `additionalProperties: false` everywhere, …).
194 JsonSchema {
195 /// Short identifier — providers that require one (OpenAI) use it as
196 /// the `json_schema.name` field.
197 name: String,
198 /// JSON Schema, as a `serde_json::Value`.
199 schema: serde_json::Value,
200 },
201}
202
203/// The model-visible state of an in-progress agent run.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct Context {
206 pub system: Vec<Block>,
207 pub guides: Vec<Block>,
208 pub history: Vec<Turn>,
209 pub task: Task,
210 pub policy: Policy,
211 pub metadata: BTreeMap<String, serde_json::Value>,
212 /// Tools the agent may call this turn. Model adapters translate these to
213 /// the provider's tool-calling format (OpenAI `tools`, Anthropic `tools`, …).
214 pub tools: Vec<crate::ToolSchema>,
215 /// Constraint on the model's terminal reply. Defaults to `Free` —
216 /// providers receive no extra request fields. See [`ResponseFormat`].
217 #[serde(default, skip_serializing_if = "response_format_is_default")]
218 pub response_format: ResponseFormat,
219}
220
221fn response_format_is_default(f: &ResponseFormat) -> bool {
222 matches!(f, ResponseFormat::Free)
223}
224
225impl Context {
226 pub fn new(task: Task) -> Self {
227 Self {
228 system: Vec::new(),
229 guides: Vec::new(),
230 history: Vec::new(),
231 task,
232 policy: Policy::default(),
233 metadata: BTreeMap::new(),
234 tools: Vec::new(),
235 response_format: ResponseFormat::Free,
236 }
237 }
238
239 /// Append a model turn to the history. Captures reasoning content so it
240 /// can be echoed back on subsequent calls (required by DeepSeek thinking
241 /// mode and Anthropic thinking blocks).
242 pub fn push_model_output(&mut self, out: &ModelOutput) {
243 let mut blocks = Vec::new();
244 if let Some(r) = &out.reasoning
245 && !r.is_empty()
246 {
247 blocks.push(Block::Reasoning(r.clone()));
248 }
249 if let Some(t) = &out.text
250 && !t.is_empty()
251 {
252 blocks.push(Block::Text(t.clone()));
253 }
254 for c in &out.tool_calls {
255 blocks.push(Block::ToolCall {
256 call_id: c.id.clone(),
257 name: c.name.clone(),
258 args: c.args.clone(),
259 });
260 }
261 self.history.push(Turn {
262 role: TurnRole::Assistant,
263 blocks,
264 });
265 }
266
267 /// Append feedback signals as a tool-role turn.
268 pub fn push_feedback(&mut self, signals: Vec<Signal>) {
269 self.history.push(Turn {
270 role: TurnRole::Tool,
271 blocks: vec![Block::Feedback(signals)],
272 });
273 }
274}
275
276/// One action the agent has asked to take, paired with the originating tool call.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Action {
279 pub tool: String,
280 pub call_id: String,
281 pub args: serde_json::Value,
282}