Skip to main content

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}