Skip to main content

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}