Agent loop state machine refactor, unified LLM interface, and UI improvements
- Rewrite agent loop as Planning→Executing(N)→Completed state machine with per-step context isolation to prevent token explosion - Split tools and prompts by phase (planning vs execution) - Add advance_step/save_memo tools for step transitions and cross-step memory - Unify LLM interface: remove duplicate types, single chat_with_tools path - Add UTF-8 safe truncation (truncate_str) to prevent panics on Chinese text - Extract CreateForm component, add auto-scroll to execution log - Add report generation with app access URL, non-blocking title generation - Add timer system, file serving, app proxy, exec module - Update Dockerfile with uv, deployment config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
96
src/llm.rs
96
src/llm.rs
@@ -10,22 +10,73 @@ pub struct LlmClient {
|
||||
struct ChatRequest {
|
||||
model: String,
|
||||
messages: Vec<ChatMessage>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
tools: Vec<Tool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: &str) -> Self {
|
||||
Self { role: "system".into(), content: Some(content.into()), tool_calls: None, tool_call_id: None }
|
||||
}
|
||||
|
||||
pub fn user(content: &str) -> Self {
|
||||
Self { role: "user".into(), content: Some(content.into()), tool_calls: None, tool_call_id: None }
|
||||
}
|
||||
|
||||
pub fn tool_result(tool_call_id: &str, content: &str) -> Self {
|
||||
Self { role: "tool".into(), content: Some(content.into()), tool_calls: None, tool_call_id: Some(tool_call_id.into()) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tool {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatResponse {
|
||||
choices: Vec<Choice>,
|
||||
pub struct ChatResponse {
|
||||
pub choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Choice {
|
||||
message: ChatMessage,
|
||||
pub struct ChatChoice {
|
||||
pub message: ChatMessage,
|
||||
#[allow(dead_code)]
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl LlmClient {
|
||||
@@ -36,21 +87,42 @@ impl LlmClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple chat without tools — returns content string
|
||||
pub async fn chat(&self, messages: Vec<ChatMessage>) -> anyhow::Result<String> {
|
||||
let resp = self.client
|
||||
.post(format!("{}/chat/completions", self.config.base_url))
|
||||
let resp = self.chat_with_tools(messages, &[]).await?;
|
||||
Ok(resp.choices.into_iter().next()
|
||||
.and_then(|c| c.message.content)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Chat with tool definitions — returns full response for tool-calling loop
|
||||
pub async fn chat_with_tools(&self, messages: Vec<ChatMessage>, tools: &[Tool]) -> anyhow::Result<ChatResponse> {
|
||||
let url = format!("{}/chat/completions", self.config.base_url);
|
||||
tracing::debug!("LLM request to {} model={} messages={} tools={}", url, self.config.model, messages.len(), tools.len());
|
||||
let http_resp = self.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.config.api_key))
|
||||
.json(&ChatRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages,
|
||||
tools: tools.to_vec(),
|
||||
})
|
||||
.send()
|
||||
.await?
|
||||
.json::<ChatResponse>()
|
||||
.await?;
|
||||
|
||||
Ok(resp.choices.first()
|
||||
.map(|c| c.message.content.clone())
|
||||
.unwrap_or_default())
|
||||
let status = http_resp.status();
|
||||
if !status.is_success() {
|
||||
let body = http_resp.text().await.unwrap_or_default();
|
||||
tracing::error!("LLM API error {}: {}", status, &body[..body.len().min(500)]);
|
||||
anyhow::bail!("LLM API error {}: {}", status, body);
|
||||
}
|
||||
|
||||
let body = http_resp.text().await?;
|
||||
let resp: ChatResponse = serde_json::from_str(&body).map_err(|e| {
|
||||
tracing::error!("LLM response parse error: {}. Body: {}", e, &body[..body.len().min(500)]);
|
||||
anyhow::anyhow!("Failed to parse LLM response: {}", e)
|
||||
})?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user