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}