Skip to main content

harness_core/
model.rs

1use crate::{Context, error::ModelError};
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4
5/// Information about a configured model — uniform across providers.
6///
7/// `handle` is the user-chosen logical identifier (used in logs, metrics,
8/// and `harness.toml` selectors); `model` is the wire-protocol model id
9/// sent to the provider.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ModelInfo {
12    pub handle: String,
13    pub provider: String,
14    pub model: String,
15    pub context_window: u32,
16    pub input_cost_usd_per_million_tokens: Option<f64>,
17    pub output_cost_usd_per_million_tokens: Option<f64>,
18    pub supports_tool_use: bool,
19    pub supports_streaming: bool,
20    /// Whether the provider will answer from live web results using its own
21    /// built-in search (Gemini's `google_search`, and friends). Distinct from
22    /// `supports_tool_use`: this is search the provider runs server-side, not a
23    /// tool we hand it.
24    #[serde(default)]
25    pub supports_web_grounding: bool,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ModelOutput {
30    pub text: Option<String>,
31    pub tool_calls: Vec<ToolCall>,
32    pub usage: Usage,
33    pub stop_reason: StopReason,
34    /// Provider-specific reasoning trace (DeepSeek `reasoning_content`,
35    /// Anthropic `thinking` blocks). Pushed back to the API verbatim on
36    /// subsequent calls; required by providers that gate on it.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub reasoning: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ToolCall {
43    pub id: String,
44    pub name: String,
45    pub args: serde_json::Value,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct Usage {
50    pub input_tokens: u32,
51    pub output_tokens: u32,
52    pub cached_input_tokens: u32,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57#[non_exhaustive]
58pub enum StopReason {
59    EndTurn,
60    ToolUse,
61    MaxTokens,
62    StopSequence,
63    Other,
64}
65
66/// Streaming delta — incremental output from the model.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[non_exhaustive]
69pub enum ModelDelta {
70    Text(String),
71    ToolCallStart {
72        id: String,
73        name: String,
74    },
75    ToolCallArgs {
76        id: String,
77        partial_json: String,
78    },
79    ToolCallEnd {
80        id: String,
81    },
82    Usage(Usage),
83    Stop(StopReason),
84    /// Provider-specific reasoning trace that must round-trip on the next
85    /// request. DeepSeek thinking content, Anthropic thinking blocks, and
86    /// Gemini raw `parts` (with thoughtSignatures) all flow through this —
87    /// the AgentLoop folds them into the final `ModelOutput.reasoning`
88    /// without surfacing them to user-visible token streams.
89    Reasoning(String),
90}
91
92#[async_trait]
93pub trait Model: Send + Sync + 'static {
94    async fn complete(&self, ctx: &Context) -> Result<ModelOutput, ModelError>;
95
96    /// Answer `query` from live web results, using the provider's OWN search.
97    ///
98    /// Returns `None` when the provider has no such thing, which is the default
99    /// — callers fall back to a search index. The answer comes back as prose
100    /// with its sources, not as a result list, because that is what these
101    /// providers return.
102    ///
103    /// Why it is a separate call rather than a tool the loop can pick: the
104    /// providers that offer built-in search generally refuse to accept it in
105    /// the same request as function declarations. Gemini answers *"Please
106    /// enable tool_config.include_server_side_tool_invocations to use Built-in
107    /// tools with Function calling"*, and OpenAI-compatible gateways in front
108    /// of it usually drop that switch. So grounding has to happen in its own
109    /// tool-free request, and hiding that here keeps every caller from
110    /// rediscovering it.
111    async fn search_web(&self, query: &str) -> Option<Result<String, ModelError>> {
112        let _ = query;
113        None
114    }
115
116    /// Streaming is optional; default implementation falls back to `complete`.
117    async fn stream(
118        &self,
119        ctx: &Context,
120    ) -> Result<futures::stream::BoxStream<'static, Result<ModelDelta, ModelError>>, ModelError>
121    {
122        let out = self.complete(ctx).await?;
123        let deltas: Vec<Result<ModelDelta, ModelError>> = out
124            .text
125            .into_iter()
126            .map(|t| Ok(ModelDelta::Text(t)))
127            .chain(std::iter::once(Ok(ModelDelta::Stop(out.stop_reason))))
128            .collect();
129        Ok(Box::pin(futures::stream::iter(deltas)))
130    }
131
132    fn info(&self) -> ModelInfo;
133}
134
135/// A concrete newtype wrapping a boxed model, so an `Arc<dyn Model>` can be used
136/// where a concrete `M: Model` is required (e.g. `Subagent::new` / `AgentLoop<M>`).
137///
138/// We deliberately do NOT `impl Model for Arc<dyn Model>` directly. Doing so
139/// changes `.stream()` method resolution on EVERY `Arc<dyn Model>` value in the
140/// program (from a deref to `dyn Model` into the Arc impl's `async fn stream`
141/// RPITIT), and proving that boxed streaming future is `Send` inside a `Send`
142/// context (e.g. an axum handler driving the streaming loop) overflows the
143/// auto-trait solver (E0275). Wrapping in this concrete newtype gives callers
144/// `DynModel: Model` without touching resolution for bare `Arc<dyn Model>`.
145pub struct DynModel(pub std::sync::Arc<dyn Model>);
146
147#[async_trait]
148impl Model for DynModel {
149    async fn complete(&self, ctx: &Context) -> Result<ModelOutput, ModelError> {
150        self.0.complete(ctx).await
151    }
152    async fn stream(
153        &self,
154        ctx: &Context,
155    ) -> Result<futures::stream::BoxStream<'static, Result<ModelDelta, ModelError>>, ModelError>
156    {
157        self.0.stream(ctx).await
158    }
159    fn info(&self) -> ModelInfo {
160        self.0.info()
161    }
162}
163
164#[cfg(test)]
165mod arc_model_tests {
166    use super::*;
167    use std::sync::Arc;
168
169    struct Dummy;
170
171    #[async_trait]
172    impl Model for Dummy {
173        async fn complete(&self, _ctx: &Context) -> Result<ModelOutput, ModelError> {
174            Ok(ModelOutput {
175                text: Some("ok".into()),
176                tool_calls: vec![],
177                usage: Usage::default(),
178                stop_reason: StopReason::EndTurn,
179                reasoning: None,
180            })
181        }
182        fn info(&self) -> ModelInfo {
183            ModelInfo {
184                handle: "dummy".into(),
185                provider: "test".into(),
186                model: "dummy".into(),
187                context_window: 8192,
188                input_cost_usd_per_million_tokens: None,
189                output_cost_usd_per_million_tokens: None,
190                supports_tool_use: false,
191                supports_streaming: false,
192                supports_web_grounding: false,
193            }
194        }
195    }
196
197    fn assert_is_model<M: Model>(_m: &M) {}
198
199    #[tokio::test]
200    async fn dyn_model_wrapper_is_a_model() {
201        let m: Arc<dyn Model> = Arc::new(Dummy);
202        let wrapped = DynModel(m);
203        assert_is_model(&wrapped); // compiles only if DynModel: Model
204        let out = wrapped
205            .complete(&Context::new(crate::Task {
206                description: "x".into(),
207                source: None,
208                deadline: None,
209            }))
210            .await
211            .unwrap();
212        assert_eq!(out.text.as_deref(), Some("ok"));
213    }
214}