feat: add multi-turn conversation history and tool execution
* 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>
This commit is contained in:
parent
92c42dc24d
commit
89b1ec6fa2
7 changed files with 829 additions and 21 deletions
|
|
@ -1,4 +1,86 @@
|
|||
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 {
|
||||
|
|
@ -15,9 +97,95 @@ pub trait Provider: Send + Sync {
|
|||
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\""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue