feat: step artifacts framework

- Add Artifact type to Step (name, path, artifact_type, description)
- step_done tool accepts optional artifacts parameter
- Save artifacts to step_artifacts DB table
- Display artifacts in frontend PlanSection (tag style)
- Show artifacts in step context for sub-agents and coordinator
- Add LLM client retry with exponential backoff
This commit is contained in:
Fam Zheng
2026-03-09 12:01:29 +00:00
parent 29f026e383
commit fa800b1601
7 changed files with 273 additions and 47 deletions

View File

@@ -93,7 +93,11 @@ pub struct ChatChoice {
impl LlmClient {
pub fn new(config: &LlmConfig) -> Self {
Self {
client: reqwest::Client::new(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.expect("Failed to build HTTP client"),
config: config.clone(),
}
}
@@ -106,34 +110,65 @@ impl LlmClient {
.unwrap_or_default())
}
/// Chat with tool definitions — returns full response for tool-calling loop
/// Chat with tool definitions — returns full response for tool-calling loop.
/// Retries up to 3 times with exponential backoff on transient errors.
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?;
let max_retries = 3u32;
let mut last_err = None;
let tools_vec = tools.to_vec();
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);
for attempt in 0..max_retries {
if attempt > 0 {
let delay = std::time::Duration::from_secs(2u64.pow(attempt));
tracing::warn!("LLM retry #{} after {}s", attempt, delay.as_secs());
tokio::time::sleep(delay).await;
}
tracing::debug!("LLM request to {} model={} messages={} tools={} attempt={}", url, self.config.model, messages.len(), tools_vec.len(), attempt + 1);
let result = self.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.config.api_key))
.json(&ChatRequest {
model: self.config.model.clone(),
messages: messages.clone(),
tools: tools_vec.clone(),
})
.send()
.await;
let http_resp = match result {
Ok(r) => r,
Err(e) => {
tracing::warn!("LLM request error (attempt {}): {}", attempt + 1, e);
last_err = Some(anyhow::anyhow!("{}", e));
continue;
}
};
let status = http_resp.status();
if status.is_server_error() || status.as_u16() == 429 {
let body = http_resp.text().await.unwrap_or_default();
tracing::warn!("LLM API error {} (attempt {}): {}", status, attempt + 1, &body[..body.len().min(200)]);
last_err = Some(anyhow::anyhow!("LLM API error {}: {}", status, body));
continue;
}
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)
})?;
return Ok(resp);
}
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)
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("LLM call failed after {} retries", max_retries)))
}
}