//! Minimal Anthropic Messages API client with a hand-rolled agentic loop. //! //! Structured output is obtained by forcing a tool choice: we register an //! "emit_result" tool whose input schema is the caller's desired output //! type (derived via `schemars`), then require the model to call it. use std::sync::Arc; use anyhow::{bail, Context, Result}; use serde::de::DeserializeOwned; use serde_json::Value; use super::messages::{ContentBlock, Message, RequestBody, ResponseBody, ToolChoice, ToolSpec}; use super::tool::Tool; const API_URL: &str = "https://api.anthropic.com/v1/messages"; const API_VERSION: &str = "2023-06-01"; const DEFAULT_MAX_TOKENS: u32 = 2048; const MAX_AGENT_TURNS: usize = 6; pub struct AnthropicClient { http: reqwest::Client, api_key: String, model: String, } impl AnthropicClient { pub fn new(api_key: impl Into, model: impl Into) -> Self { Self { http: reqwest::Client::new(), api_key: api_key.into(), model: model.into(), } } /// A single structured-output call with no tools beyond the forced /// output tool. Used by agents that don't need to call external tools /// (e.g. the policy-compliance agent). pub async fn structured_call( &self, system: &str, user_message: &str, output_schema: Value, ) -> Result { self.run_agentic(system, user_message, &[], "emit_result", output_schema) .await } /// Runs a tool-calling loop: the model may call any of `tools` any /// number of times, and must eventually call `output_tool_name` (whose /// schema is `output_schema`) to finish. Bounded by `MAX_AGENT_TURNS` so /// a confused model can't loop forever. pub async fn run_agentic( &self, system: &str, user_message: &str, tools: &[Arc], output_tool_name: &str, output_schema: Value, ) -> Result { let mut tool_specs: Vec = tools .iter() .map(|t| ToolSpec { name: t.name().to_string(), description: t.description().to_string(), input_schema: t.input_schema(), }) .collect(); tool_specs.push(ToolSpec { name: output_tool_name.to_string(), description: "Emit the final structured result for this task.".to_string(), input_schema: output_schema, }); let mut messages = vec![Message::user_text(user_message)]; for _ in 0..MAX_AGENT_TURNS { let tool_choice = if tools.is_empty() { ToolChoice::Tool { name: output_tool_name.to_string(), } } else { ToolChoice::Auto }; let response = self .send(system, &messages, &tool_specs, tool_choice) .await?; let mut tool_results = Vec::new(); let mut output_value = None; for block in &response.content { let ContentBlock::ToolUse { id, name, input } = block else { continue; }; if name == output_tool_name { output_value = Some(input.clone()); continue; } let Some(tool) = tools.iter().find(|t| t.name() == name) else { tool_results.push(ContentBlock::ToolResult { tool_use_id: id.clone(), content: format!("error: unknown tool '{name}'"), }); continue; }; let result = match tool.execute(input.clone()).await { Ok(v) => v.to_string(), Err(e) => format!("error: {e}"), }; tool_results.push(ContentBlock::ToolResult { tool_use_id: id.clone(), content: result, }); } messages.push(Message { role: "assistant".into(), content: response.content, }); if let Some(value) = output_value { return serde_json::from_value(value) .context("deserializing model's structured output"); } if tool_results.is_empty() { bail!( "model produced no tool call and no structured output (stop_reason: {:?})", response.stop_reason ); } messages.push(Message { role: "user".into(), content: tool_results, }); } bail!("exceeded {MAX_AGENT_TURNS} agent turns without a structured result") } async fn send( &self, system: &str, messages: &[Message], tools: &[ToolSpec], tool_choice: ToolChoice, ) -> Result { let body = RequestBody { model: &self.model, max_tokens: DEFAULT_MAX_TOKENS, system, messages, tools, tool_choice, }; let response = self .http .post(API_URL) .header("x-api-key", &self.api_key) .header("anthropic-version", API_VERSION) .json(&body) .send() .await .context("sending request to Anthropic API")?; let status = response.status(); let text = response.text().await.context("reading Anthropic response body")?; if !status.is_success() { bail!("Anthropic API error ({status}): {text}"); } serde_json::from_str(&text).context("parsing Anthropic response body") } }