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}
47
48impl Block {
49    /// Build a [`Block::Image`] from raw image bytes, base64-encoding them.
50    /// `media_type` is a MIME type like `"image/png"`.
51    pub fn image_bytes(media_type: impl Into<String>, bytes: &[u8]) -> Self {
52        Block::Image {
53            media_type: media_type.into(),
54            base64: base64_encode(bytes),
55        }
56    }
57}
58
59/// Minimal standard-base64 encoder (RFC 4648, with padding). Kept dependency-free
60/// so `harness-core` stays lean — image payloads are its only base64 user.
61fn base64_encode(input: &[u8]) -> String {
62    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
63    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
64    for chunk in input.chunks(3) {
65        let b0 = chunk[0] as u32;
66        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
67        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
68        let n = (b0 << 16) | (b1 << 8) | b2;
69        out.push(T[(n >> 18) as usize & 63] as char);
70        out.push(T[(n >> 12) as usize & 63] as char);
71        out.push(if chunk.len() > 1 {
72            T[(n >> 6) as usize & 63] as char
73        } else {
74            '='
75        });
76        out.push(if chunk.len() > 2 {
77            T[n as usize & 63] as char
78        } else {
79            '='
80        });
81    }
82    out
83}
84
85#[cfg(test)]
86mod base64_tests {
87    use super::base64_encode;
88    #[test]
89    fn matches_known_vectors() {
90        assert_eq!(base64_encode(b""), "");
91        assert_eq!(base64_encode(b"f"), "Zg==");
92        assert_eq!(base64_encode(b"fo"), "Zm8=");
93        assert_eq!(base64_encode(b"foo"), "Zm9v");
94        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
95        assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
96        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
97    }
98}
99
100/// A single conversation turn (assistant or user).
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Turn {
103    pub role: TurnRole,
104    pub blocks: Vec<Block>,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "lowercase")]
109#[non_exhaustive]
110pub enum TurnRole {
111    User,
112    Assistant,
113    System,
114    Tool,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Task {
119    pub description: String,
120    pub source: Option<String>, // slack url, github issue, etc.
121    pub deadline: Option<i64>,
122}
123
124#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
125pub struct Policy {
126    pub max_iters: u32,
127    pub max_input_tokens: u32,
128    pub max_output_tokens: u32,
129    pub self_correct_rounds: u32,
130}
131
132impl Default for Policy {
133    fn default() -> Self {
134        Self {
135            max_iters: 50,
136            max_input_tokens: 150_000,
137            max_output_tokens: 8_000,
138            self_correct_rounds: 3,
139        }
140    }
141}
142
143/// Constrain the model's terminal (non-tool-call) reply shape. Default = Free.
144///
145/// Each model adapter translates this to the provider's native format on the
146/// wire:
147/// - OpenAI / DeepSeek: `response_format: {type: "json_object"}` for
148///   `JsonObject`; `{type: "json_schema", json_schema: {name, schema, strict}}`
149///   for `JsonSchema`. Providers that only support `json_object` (DeepSeek as
150///   of Dec 2025) degrade gracefully by injecting the schema into the system
151///   prompt instead.
152/// - Gemini: `generationConfig.responseMimeType = "application/json"` plus
153///   `generationConfig.responseSchema = <schema>` for `JsonSchema`.
154/// - Anthropic: no native field — adapters synthesise a "structured_output"
155///   tool with the schema, force `tool_choice` to it, and surface the tool's
156///   args as the assistant text on response.
157///
158/// `JsonSchema.schema` is a `serde_json::Value` so callers can build it
159/// however they like — hand-rolled, via `schemars::schema_for!(T)`, or pulled
160/// from a `harness_loop::AgentLoop::run_typed<T>()` derivation.
161#[derive(Debug, Clone, Serialize, Deserialize, Default)]
162#[serde(tag = "type", rename_all = "snake_case")]
163#[non_exhaustive]
164pub enum ResponseFormat {
165    /// Free-form text. The framework adds nothing to the request body.
166    #[default]
167    Free,
168    /// "Reply with valid JSON of any shape." Useful when the caller will run
169    /// its own validation and doesn't want to commit to a schema yet.
170    JsonObject,
171    /// "Reply with JSON matching this schema." Adapters may need to sanitise
172    /// dialect-specific keys before emitting (Gemini rejects `$ref`, OpenAI
173    /// strict mode demands `additionalProperties: false` everywhere, …).
174    JsonSchema {
175        /// Short identifier — providers that require one (OpenAI) use it as
176        /// the `json_schema.name` field.
177        name: String,
178        /// JSON Schema, as a `serde_json::Value`.
179        schema: serde_json::Value,
180    },
181}
182
183/// The model-visible state of an in-progress agent run.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct Context {
186    pub system: Vec<Block>,
187    pub guides: Vec<Block>,
188    pub history: Vec<Turn>,
189    pub task: Task,
190    pub policy: Policy,
191    pub metadata: BTreeMap<String, serde_json::Value>,
192    /// Tools the agent may call this turn. Model adapters translate these to
193    /// the provider's tool-calling format (OpenAI `tools`, Anthropic `tools`, …).
194    pub tools: Vec<crate::ToolSchema>,
195    /// Constraint on the model's terminal reply. Defaults to `Free` —
196    /// providers receive no extra request fields. See [`ResponseFormat`].
197    #[serde(default, skip_serializing_if = "response_format_is_default")]
198    pub response_format: ResponseFormat,
199}
200
201fn response_format_is_default(f: &ResponseFormat) -> bool {
202    matches!(f, ResponseFormat::Free)
203}
204
205impl Context {
206    pub fn new(task: Task) -> Self {
207        Self {
208            system: Vec::new(),
209            guides: Vec::new(),
210            history: Vec::new(),
211            task,
212            policy: Policy::default(),
213            metadata: BTreeMap::new(),
214            tools: Vec::new(),
215            response_format: ResponseFormat::Free,
216        }
217    }
218
219    /// Append a model turn to the history. Captures reasoning content so it
220    /// can be echoed back on subsequent calls (required by DeepSeek thinking
221    /// mode and Anthropic thinking blocks).
222    pub fn push_model_output(&mut self, out: &ModelOutput) {
223        let mut blocks = Vec::new();
224        if let Some(r) = &out.reasoning
225            && !r.is_empty()
226        {
227            blocks.push(Block::Reasoning(r.clone()));
228        }
229        if let Some(t) = &out.text
230            && !t.is_empty()
231        {
232            blocks.push(Block::Text(t.clone()));
233        }
234        for c in &out.tool_calls {
235            blocks.push(Block::ToolCall {
236                call_id: c.id.clone(),
237                name: c.name.clone(),
238                args: c.args.clone(),
239            });
240        }
241        self.history.push(Turn {
242            role: TurnRole::Assistant,
243            blocks,
244        });
245    }
246
247    /// Append feedback signals as a tool-role turn.
248    pub fn push_feedback(&mut self, signals: Vec<Signal>) {
249        self.history.push(Turn {
250            role: TurnRole::Tool,
251            blocks: vec![Block::Feedback(signals)],
252        });
253    }
254}
255
256/// One action the agent has asked to take, paired with the originating tool call.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct Action {
259    pub tool: String,
260    pub call_id: String,
261    pub args: serde_json::Value,
262}