* feat: add multi-turn conversation history and tool execution Major enhancement to the agent loop: **Multi-turn conversation:** - Add `ChatMessage` type with system/user/assistant constructors - Add `chat_with_history` method to Provider trait (default impl delegates to `chat_with_system` for backward compatibility) - Implement native `chat_with_history` on OpenRouter, Compatible, Reliable, and Router providers to send full message history - Interactive mode now maintains persistent history across turns **Tool execution:** - Agent loop now parses `<tool_call>` XML tags from LLM responses - Executes tools from the registry and feeds results back as `<tool_result>` messages - Agentic loop continues until LLM produces final text (no tool calls) - MAX_TOOL_ITERATIONS (10) safety limit prevents runaway loops - System prompt includes structured tool-use protocol with JSON schemas **Types:** - `ChatMessage`, `ChatResponse`, `ToolCall`, `ToolResultMessage`, `ConversationMessage` — full conversation modeling types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review comments on multi-turn + tool execution - Add history sliding window (MAX_HISTORY_MESSAGES=50) to prevent unbounded conversation history growth in interactive mode - Add 404→Responses API fallback in compatible.rs chat_with_history, matching chat_with_system behavior - Use super::api_error() for error sanitization in compatible.rs instead of raw error body (prevents secret leakage) - Add missing operational logs in reliable.rs chat_with_history: recovery, non-retryable, fallback switch warnings - Add trim_history tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address second round of review comments - Sanitize raw error text in compatible.rs chat_with_system using sanitize_api_error (prevents leaking secrets in error messages) - Add chat_with_history to MockProvider in reliable.rs tests so the retry/fallback path is exercised end-to-end - Add chat_with_history_retries_then_recovers and chat_with_history_falls_back tests - Log warning on malformed <tool_call> JSON instead of silent drop - Flush stdout after print! in agent_turn so output appears before tool execution on line-buffered terminals - Make interactive mode resilient to transient errors (continue loop instead of terminating session) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
191 lines
5.4 KiB
Rust
191 lines
5.4 KiB
Rust
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// A single message in a conversation.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChatMessage {
|
|
pub role: String,
|
|
pub content: String,
|
|
}
|
|
|
|
impl ChatMessage {
|
|
pub fn system(content: impl Into<String>) -> Self {
|
|
Self {
|
|
role: "system".into(),
|
|
content: content.into(),
|
|
}
|
|
}
|
|
|
|
pub fn user(content: impl Into<String>) -> Self {
|
|
Self {
|
|
role: "user".into(),
|
|
content: content.into(),
|
|
}
|
|
}
|
|
|
|
pub fn assistant(content: impl Into<String>) -> Self {
|
|
Self {
|
|
role: "assistant".into(),
|
|
content: content.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A tool call requested by the LLM.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolCall {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub arguments: String,
|
|
}
|
|
|
|
/// An LLM response that may contain text, tool calls, or both.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ChatResponse {
|
|
/// Text content of the response (may be empty if only tool calls).
|
|
pub text: Option<String>,
|
|
/// Tool calls requested by the LLM.
|
|
pub tool_calls: Vec<ToolCall>,
|
|
}
|
|
|
|
impl ChatResponse {
|
|
/// True when the LLM wants to invoke at least one tool.
|
|
pub fn has_tool_calls(&self) -> bool {
|
|
!self.tool_calls.is_empty()
|
|
}
|
|
|
|
/// Convenience: return text content or empty string.
|
|
pub fn text_or_empty(&self) -> &str {
|
|
self.text.as_deref().unwrap_or("")
|
|
}
|
|
}
|
|
|
|
/// A tool result to feed back to the LLM.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolResultMessage {
|
|
pub tool_call_id: String,
|
|
pub content: String,
|
|
}
|
|
|
|
/// A message in a multi-turn conversation, including tool interactions.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum ConversationMessage {
|
|
/// Regular chat message (system, user, assistant).
|
|
Chat(ChatMessage),
|
|
/// Tool calls from the assistant (stored for history fidelity).
|
|
AssistantToolCalls {
|
|
text: Option<String>,
|
|
tool_calls: Vec<ToolCall>,
|
|
},
|
|
/// Result of a tool execution, fed back to the LLM.
|
|
ToolResult(ToolResultMessage),
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait Provider: Send + Sync {
|
|
async fn chat(&self, message: &str, model: &str, temperature: f64) -> anyhow::Result<String> {
|
|
self.chat_with_system(None, message, model, temperature)
|
|
.await
|
|
}
|
|
|
|
async fn chat_with_system(
|
|
&self,
|
|
system_prompt: Option<&str>,
|
|
message: &str,
|
|
model: &str,
|
|
temperature: f64,
|
|
) -> anyhow::Result<String>;
|
|
|
|
/// Multi-turn conversation. Default implementation extracts the last user
|
|
/// message and delegates to `chat_with_system`.
|
|
async fn chat_with_history(
|
|
&self,
|
|
messages: &[ChatMessage],
|
|
model: &str,
|
|
temperature: f64,
|
|
) -> anyhow::Result<String> {
|
|
let system = messages
|
|
.iter()
|
|
.find(|m| m.role == "system")
|
|
.map(|m| m.content.as_str());
|
|
let last_user = messages
|
|
.iter()
|
|
.rfind(|m| m.role == "user")
|
|
.map(|m| m.content.as_str())
|
|
.unwrap_or("");
|
|
self.chat_with_system(system, last_user, model, temperature)
|
|
.await
|
|
}
|
|
|
|
/// Warm up the HTTP connection pool (TLS handshake, DNS, HTTP/2 setup).
|
|
/// Default implementation is a no-op; providers with HTTP clients should override.
|
|
async fn warmup(&self) -> anyhow::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn chat_message_constructors() {
|
|
let sys = ChatMessage::system("Be helpful");
|
|
assert_eq!(sys.role, "system");
|
|
assert_eq!(sys.content, "Be helpful");
|
|
|
|
let user = ChatMessage::user("Hello");
|
|
assert_eq!(user.role, "user");
|
|
|
|
let asst = ChatMessage::assistant("Hi there");
|
|
assert_eq!(asst.role, "assistant");
|
|
}
|
|
|
|
#[test]
|
|
fn chat_response_helpers() {
|
|
let empty = ChatResponse {
|
|
text: None,
|
|
tool_calls: vec![],
|
|
};
|
|
assert!(!empty.has_tool_calls());
|
|
assert_eq!(empty.text_or_empty(), "");
|
|
|
|
let with_tools = ChatResponse {
|
|
text: Some("Let me check".into()),
|
|
tool_calls: vec![ToolCall {
|
|
id: "1".into(),
|
|
name: "shell".into(),
|
|
arguments: "{}".into(),
|
|
}],
|
|
};
|
|
assert!(with_tools.has_tool_calls());
|
|
assert_eq!(with_tools.text_or_empty(), "Let me check");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_call_serialization() {
|
|
let tc = ToolCall {
|
|
id: "call_123".into(),
|
|
name: "file_read".into(),
|
|
arguments: r#"{"path":"test.txt"}"#.into(),
|
|
};
|
|
let json = serde_json::to_string(&tc).unwrap();
|
|
assert!(json.contains("call_123"));
|
|
assert!(json.contains("file_read"));
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_message_variants() {
|
|
let chat = ConversationMessage::Chat(ChatMessage::user("hi"));
|
|
let json = serde_json::to_string(&chat).unwrap();
|
|
assert!(json.contains("\"type\":\"Chat\""));
|
|
|
|
let tool_result = ConversationMessage::ToolResult(ToolResultMessage {
|
|
tool_call_id: "1".into(),
|
|
content: "done".into(),
|
|
});
|
|
let json = serde_json::to_string(&tool_result).unwrap();
|
|
assert!(json.contains("\"type\":\"ToolResult\""));
|
|
}
|
|
}
|