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