harness_core/context.rs
1use crate::{ModelOutput, Signal, b64::base64_encode};
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/// A single conversation turn (assistant or user).
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Turn {
82 pub role: TurnRole,
83 pub blocks: Vec<Block>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88#[non_exhaustive]
89pub enum TurnRole {
90 User,
91 Assistant,
92 System,
93 Tool,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Task {
98 pub description: String,
99 pub source: Option<String>, // slack url, github issue, etc.
100 pub deadline: Option<i64>,
101}
102
103#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
104pub struct Policy {
105 pub max_iters: u32,
106 pub max_input_tokens: u32,
107 pub max_output_tokens: u32,
108 pub self_correct_rounds: u32,
109}
110
111impl Default for Policy {
112 fn default() -> Self {
113 Self {
114 max_iters: 50,
115 max_input_tokens: 150_000,
116 max_output_tokens: 8_000,
117 self_correct_rounds: 3,
118 }
119 }
120}
121
122/// Constrain the model's terminal (non-tool-call) reply shape. Default = Free.
123///
124/// Each model adapter translates this to the provider's native format on the
125/// wire:
126/// - OpenAI / DeepSeek: `response_format: {type: "json_object"}` for
127/// `JsonObject`; `{type: "json_schema", json_schema: {name, schema, strict}}`
128/// for `JsonSchema`. Providers that only support `json_object` (DeepSeek as
129/// of Dec 2025) degrade gracefully by injecting the schema into the system
130/// prompt instead.
131/// - Gemini: `generationConfig.responseMimeType = "application/json"` plus
132/// `generationConfig.responseSchema = <schema>` for `JsonSchema`.
133/// - Anthropic: no native field — adapters synthesise a "structured_output"
134/// tool with the schema, force `tool_choice` to it, and surface the tool's
135/// args as the assistant text on response.
136///
137/// `JsonSchema.schema` is a `serde_json::Value` so callers can build it
138/// however they like — hand-rolled, via `schemars::schema_for!(T)`, or pulled
139/// from a `harness_loop::AgentLoop::run_typed<T>()` derivation.
140#[derive(Debug, Clone, Serialize, Deserialize, Default)]
141#[serde(tag = "type", rename_all = "snake_case")]
142#[non_exhaustive]
143pub enum ResponseFormat {
144 /// Free-form text. The framework adds nothing to the request body.
145 #[default]
146 Free,
147 /// "Reply with valid JSON of any shape." Useful when the caller will run
148 /// its own validation and doesn't want to commit to a schema yet.
149 JsonObject,
150 /// "Reply with JSON matching this schema." Adapters may need to sanitise
151 /// dialect-specific keys before emitting (Gemini rejects `$ref`, OpenAI
152 /// strict mode demands `additionalProperties: false` everywhere, …).
153 JsonSchema {
154 /// Short identifier — providers that require one (OpenAI) use it as
155 /// the `json_schema.name` field.
156 name: String,
157 /// JSON Schema, as a `serde_json::Value`.
158 schema: serde_json::Value,
159 },
160}
161
162/// The model-visible state of an in-progress agent run.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct Context {
165 pub system: Vec<Block>,
166 pub guides: Vec<Block>,
167 pub history: Vec<Turn>,
168 pub task: Task,
169 pub policy: Policy,
170 pub metadata: BTreeMap<String, serde_json::Value>,
171 /// Tools the agent may call this turn. Model adapters translate these to
172 /// the provider's tool-calling format (OpenAI `tools`, Anthropic `tools`, …).
173 pub tools: Vec<crate::ToolSchema>,
174 /// Constraint on the model's terminal reply. Defaults to `Free` —
175 /// providers receive no extra request fields. See [`ResponseFormat`].
176 #[serde(default, skip_serializing_if = "response_format_is_default")]
177 pub response_format: ResponseFormat,
178}
179
180fn response_format_is_default(f: &ResponseFormat) -> bool {
181 matches!(f, ResponseFormat::Free)
182}
183
184impl Context {
185 pub fn new(task: Task) -> Self {
186 Self {
187 system: Vec::new(),
188 guides: Vec::new(),
189 history: Vec::new(),
190 task,
191 policy: Policy::default(),
192 metadata: BTreeMap::new(),
193 tools: Vec::new(),
194 response_format: ResponseFormat::Free,
195 }
196 }
197
198 /// Append a model turn to the history. Captures reasoning content so it
199 /// can be echoed back on subsequent calls (required by DeepSeek thinking
200 /// mode and Anthropic thinking blocks).
201 pub fn push_model_output(&mut self, out: &ModelOutput) {
202 let mut blocks = Vec::new();
203 if let Some(r) = &out.reasoning
204 && !r.is_empty()
205 {
206 blocks.push(Block::Reasoning(r.clone()));
207 }
208 if let Some(t) = &out.text
209 && !t.is_empty()
210 {
211 blocks.push(Block::Text(t.clone()));
212 }
213 for c in &out.tool_calls {
214 blocks.push(Block::ToolCall {
215 call_id: c.id.clone(),
216 name: c.name.clone(),
217 args: c.args.clone(),
218 });
219 }
220 self.history.push(Turn {
221 role: TurnRole::Assistant,
222 blocks,
223 });
224 }
225
226 /// Append feedback signals as a tool-role turn.
227 pub fn push_feedback(&mut self, signals: Vec<Signal>) {
228 self.history.push(Turn {
229 role: TurnRole::Tool,
230 blocks: vec![Block::Feedback(signals)],
231 });
232 }
233}
234
235/// One action the agent has asked to take, paired with the originating tool call.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct Action {
238 pub tool: String,
239 pub call_id: String,
240 pub args: serde_json::Value,
241}