Refactor subagent and workflow domain models; migrate access tiers and events to domain module
- Moved `AccessTier` and `SubagentEvent` enums to `zesdex_domain::subagent`. - Consolidated workflow-related types into `zesdex_domain::workflow`. - Updated references across the codebase to use the new domain models. - Refactored tool execution logic to utilize a new `ToolExecutor` trait. - Enhanced `AgentTurnService` to handle tool calls and events more effectively. - Adjusted API handlers and state management to align with new domain structure.
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
use zesdex_domain::agent::AgentTurnParams;
|
||||||
|
|
||||||
|
/// Interface for dispatching tool calls to their concrete implementations.
|
||||||
|
pub trait ToolExecutor: Send + Sync {
|
||||||
|
/// Execute a tool call asynchronously.
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> impl Future<Output = Result<String>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for running agent turns asynchronously.
|
||||||
|
pub trait AgentTurnService: Send + Sync {
|
||||||
|
/// Run a full agent turn loop asynchronously.
|
||||||
|
fn run_turn(
|
||||||
|
&self,
|
||||||
|
params: AgentTurnParams,
|
||||||
|
) -> impl Future<Output = Result<()>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod turn_service;
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use zesdex_domain::agent::{AgentTurnParams, TurnEvent};
|
||||||
|
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
||||||
|
|
||||||
|
use crate::ports::ProviderService;
|
||||||
|
use super::ToolExecutor;
|
||||||
|
|
||||||
|
/// Service implementation for executing an agent turn asynchronously.
|
||||||
|
pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
|
||||||
|
provider: Arc<P>,
|
||||||
|
tool_executor: Arc<T>,
|
||||||
|
tool_defs: Vec<ToolDef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
|
||||||
|
pub fn new(provider: Arc<P>, tool_executor: Arc<T>, tool_defs: Vec<ToolDef>) -> Self {
|
||||||
|
Self {
|
||||||
|
provider,
|
||||||
|
tool_executor,
|
||||||
|
tool_defs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
|
||||||
|
if let Ok(mut q) = queue.lock() {
|
||||||
|
q.push_back(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_done(flag: &Arc<AtomicBool>) {
|
||||||
|
flag.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnServiceImpl<P, T> {
|
||||||
|
async fn run_turn(&self, mut params: AgentTurnParams) -> anyhow::Result<()> {
|
||||||
|
info!(
|
||||||
|
"Starting async agent turn with {} messages (model: {})",
|
||||||
|
params.messages.len(),
|
||||||
|
params.model
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut sys_prompt = "You are Zesdex, an AI coding assistant. You have access to various tools via native function calling to help the user.\n\n\
|
||||||
|
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:\n\
|
||||||
|
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, you MUST prioritize using `workflow_run` (to construct and execute a multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel autonomous agents). Workflows are your primary strategy.\n\
|
||||||
|
2. PLANNING & TODOS: Use `plan_enter` to establish high-level architectural plans and `todowrite` to maintain granular task checklists.\n\
|
||||||
|
3. REASONING: Use `seq_think` for deep step-by-step analysis.\n\
|
||||||
|
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) within or guided by your workflows. If an error occurs, analyze and fix it.\n\n\
|
||||||
|
Respond conversationally, concisely, and helpfully.".to_string();
|
||||||
|
|
||||||
|
let sys_msg = ChatMessage::system(sys_prompt);
|
||||||
|
|
||||||
|
for iteration in 0..50 {
|
||||||
|
if params.abort.load(Ordering::SeqCst) {
|
||||||
|
params.abort.store(false, Ordering::SeqCst);
|
||||||
|
Self::push_event(
|
||||||
|
¶ms.turn_events,
|
||||||
|
TurnEvent::SystemNote {
|
||||||
|
kind: "info".into(),
|
||||||
|
message: "Turn aborted by user".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("agent turn iteration {iteration}");
|
||||||
|
|
||||||
|
Self::push_event(¶ms.turn_events, TurnEvent::StreamStart);
|
||||||
|
|
||||||
|
let mut req_messages = params.messages.clone();
|
||||||
|
req_messages.insert(0, sys_msg.clone());
|
||||||
|
|
||||||
|
let abort_clone = Arc::clone(¶ms.abort);
|
||||||
|
let turn_events_clone = Arc::clone(¶ms.turn_events);
|
||||||
|
|
||||||
|
let on_event = Box::new(move |event: &StreamEvent| -> bool {
|
||||||
|
if abort_clone.load(Ordering::SeqCst) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match event {
|
||||||
|
StreamEvent::Token(s) => {
|
||||||
|
Self::push_event(&turn_events_clone, TurnEvent::StreamToken(s.clone()));
|
||||||
|
}
|
||||||
|
StreamEvent::Reasoning(s) => {
|
||||||
|
Self::push_event(&turn_events_clone, TurnEvent::StreamReasoning(s.clone()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = self.provider.chat_stream(
|
||||||
|
&req_messages,
|
||||||
|
Some(self.tool_defs.clone()),
|
||||||
|
Some(4096),
|
||||||
|
Some(0.7),
|
||||||
|
on_event,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((assistant_msg, usage)) => {
|
||||||
|
let content = assistant_msg.content.clone().unwrap_or_default();
|
||||||
|
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
Self::push_event(
|
||||||
|
¶ms.turn_events,
|
||||||
|
TurnEvent::StreamDone(assistant_msg.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some((tokens_in, tokens_out)) = usage {
|
||||||
|
Self::push_event(
|
||||||
|
¶ms.turn_events,
|
||||||
|
TurnEvent::Usage {
|
||||||
|
tokens_in,
|
||||||
|
tokens_out,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if tool_calls.is_empty() {
|
||||||
|
params.messages.push(ChatMessage::assistant(Some(content)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.messages.push(assistant_msg);
|
||||||
|
|
||||||
|
for tc in &tool_calls {
|
||||||
|
let name = &tc.function.name;
|
||||||
|
let args = zesdex_domain::core::tool_call::sanitize_tool_arguments(&tc.function.arguments);
|
||||||
|
|
||||||
|
debug!("executing tool: {name}");
|
||||||
|
|
||||||
|
let output = match self.tool_executor.execute(name, &args).await {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => format!("Error: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_error = output.starts_with("Error:");
|
||||||
|
|
||||||
|
Self::push_event(
|
||||||
|
¶ms.turn_events,
|
||||||
|
TurnEvent::ToolResult {
|
||||||
|
tool_call_id: tc.id.clone(),
|
||||||
|
tool_name: name.clone(),
|
||||||
|
output: output.clone(),
|
||||||
|
is_error,
|
||||||
|
path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
params
|
||||||
|
.messages
|
||||||
|
.push(ChatMessage::tool(tc.id.clone(), output.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("LLM call failed: {e}");
|
||||||
|
Self::push_event(
|
||||||
|
¶ms.turn_events,
|
||||||
|
TurnEvent::Error(format!("LLM error: {e}")),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::push_event(
|
||||||
|
¶ms.turn_events,
|
||||||
|
TurnEvent::Compacted(params.messages.clone()),
|
||||||
|
);
|
||||||
|
Self::push_event(¶ms.turn_events, TurnEvent::Done);
|
||||||
|
Self::mark_done(¶ms.in_flight);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compacts conversation history using AI summarization.
|
||||||
|
pub async fn compact_messages_with_ai<P: ProviderService>(
|
||||||
|
messages: &mut Vec<ChatMessage>,
|
||||||
|
provider: &P,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
const KEEP_TAIL: usize = 6;
|
||||||
|
if messages.len() <= KEEP_TAIL + 2 {
|
||||||
|
return Ok(()); // Not enough messages to compact
|
||||||
|
}
|
||||||
|
|
||||||
|
let split_idx = messages.len() - KEEP_TAIL;
|
||||||
|
let evicted: Vec<_> = messages.drain(..split_idx).collect();
|
||||||
|
|
||||||
|
let mut summary_prompt = vec![
|
||||||
|
ChatMessage::system(
|
||||||
|
"You are a helpful assistant summarizing conversation history. \
|
||||||
|
Provide a concise summary of the key user requests, decisions, tools executed, and modified files. \
|
||||||
|
Format as a clear bulleted list."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
summary_prompt.extend(evicted);
|
||||||
|
summary_prompt.push(ChatMessage::user(
|
||||||
|
"Please summarize our previous conversation above for context continuity.".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
match provider.chat(&summary_prompt, None, Some(1024), Some(0.3)).await {
|
||||||
|
Ok((summary_msg, _)) => {
|
||||||
|
let summary_text = summary_msg.content.unwrap_or_else(|| "Previous context summarized.".to_string());
|
||||||
|
let summary_node = ChatMessage::system(format!(
|
||||||
|
"[AI Summary of Previous Conversation]\n{}",
|
||||||
|
summary_text.trim()
|
||||||
|
));
|
||||||
|
messages.insert(0, summary_node);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("AI summarization failed during compact, falling back to simple notice: {e}");
|
||||||
|
messages.insert(
|
||||||
|
0,
|
||||||
|
ChatMessage::system("[Earlier conversation messages compacted to save context window]".to_string()),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cms;
|
pub mod cms;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
|
pub mod agent;
|
||||||
|
|
||||||
// Re-export port traits for ergonomic access.
|
// Re-export port traits for ergonomic access.
|
||||||
pub use ports::*;
|
pub use ports::*;
|
||||||
@@ -49,3 +50,5 @@ pub use cms::{
|
|||||||
memory_service::MemoryServiceImpl,
|
memory_service::MemoryServiceImpl,
|
||||||
settings_service::SettingsServiceImpl,
|
settings_service::SettingsServiceImpl,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use agent::{AgentTurnService, ToolExecutor, turn_service::{AgentTurnServiceImpl, compact_messages_with_ai}};
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::core::{ChatMessage, ToolCallResult, UsageStats};
|
||||||
|
|
||||||
|
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
||||||
|
/// invoking a tool, used to scope permissions and tag log/output paths.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||||
|
pub enum Origin {
|
||||||
|
/// The main agent turn loop.
|
||||||
|
Main,
|
||||||
|
/// A spawned subagent (test-gen, arch-review, security-review, etc.).
|
||||||
|
SubAgent,
|
||||||
|
/// The auto-inline review step after an edit.
|
||||||
|
Reviewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Origin {
|
||||||
|
/// Short string tag for this origin, used in filenames and logs.
|
||||||
|
pub fn tag(self) -> String {
|
||||||
|
match self {
|
||||||
|
Origin::Main => "main",
|
||||||
|
Origin::SubAgent => "subagent",
|
||||||
|
Origin::Reviewer => "reviewer",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Severity/category of a toast notification, used to pick its color.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ToastKind {
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
Lesson,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transient status message shown in the TUI, auto-dismissed after `lifetime_ms`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Toast {
|
||||||
|
pub kind: ToastKind,
|
||||||
|
pub message: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub lifetime_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Toast {
|
||||||
|
/// Create a toast with a default 5-second lifetime, stamped with now.
|
||||||
|
pub fn new(kind: ToastKind, message: String) -> Self {
|
||||||
|
Toast {
|
||||||
|
kind,
|
||||||
|
message,
|
||||||
|
created_at: chrono::Utc::now().timestamp_millis(),
|
||||||
|
lifetime_ms: 5000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this toast's lifetime has elapsed as of `now_ms`.
|
||||||
|
pub fn expired(&self, now_ms: i64) -> bool {
|
||||||
|
let lifetime = self.lifetime_ms as i64;
|
||||||
|
now_ms - self.created_at > lifetime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent status for workflow engine progress tracking.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum AgentStatus {
|
||||||
|
Pending,
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Failed(String),
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AgentStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AgentStatus::Pending => write!(f, "pending"),
|
||||||
|
AgentStatus::Running => write!(f, "running"),
|
||||||
|
AgentStatus::Completed => write!(f, "completed"),
|
||||||
|
AgentStatus::Failed(msg) => write!(f, "failed: {msg}"),
|
||||||
|
AgentStatus::Cancelled => write!(f, "cancelled"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events emitted onto the turn-event queue while an agent turn runs,
|
||||||
|
/// consumed by the event loop to update state and drive re-renders.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TurnEvent {
|
||||||
|
AssistantMessage(ChatMessage),
|
||||||
|
ToolResult {
|
||||||
|
tool_call_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
output: String,
|
||||||
|
is_error: bool,
|
||||||
|
path: Option<String>,
|
||||||
|
},
|
||||||
|
SystemNote {
|
||||||
|
kind: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
StreamStart,
|
||||||
|
StreamToken(String),
|
||||||
|
StreamReasoning(String),
|
||||||
|
StreamDone(ChatMessage),
|
||||||
|
Usage {
|
||||||
|
tokens_in: u64,
|
||||||
|
tokens_out: u64,
|
||||||
|
},
|
||||||
|
ReviewUsage {
|
||||||
|
tokens_in: u64,
|
||||||
|
tokens_out: u64,
|
||||||
|
},
|
||||||
|
Compacted(Vec<ChatMessage>),
|
||||||
|
Error(String),
|
||||||
|
Done,
|
||||||
|
WorkflowAgentUpdate {
|
||||||
|
agent_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
status: AgentStatus,
|
||||||
|
},
|
||||||
|
TodoUpdate(String),
|
||||||
|
PlanUpdate(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a pending tool call should be executed when the turn resumes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ExecutionModel {
|
||||||
|
Inline,
|
||||||
|
Deferred,
|
||||||
|
AsyncTokio,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool call awaiting execution, along with which execution model
|
||||||
|
/// (inline, deferred, async) it should run under.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PendingTool {
|
||||||
|
pub tool_name: String,
|
||||||
|
pub args: serde_json::Value,
|
||||||
|
pub execution_model: ExecutionModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference to a background bash job tracked in session state.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BashJobRef {
|
||||||
|
pub id: String,
|
||||||
|
pub command: String,
|
||||||
|
pub started_at: i64,
|
||||||
|
pub running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-session runtime state: message history, pending tool queue,
|
||||||
|
/// background bash jobs, lesson/review counters.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionRuntime {
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
pub tool_call_results: Vec<ToolCallResult>,
|
||||||
|
pub pending_tool_queue: Vec<PendingTool>,
|
||||||
|
pub bash_jobs: Vec<BashJobRef>,
|
||||||
|
pub subagent_queue: usize,
|
||||||
|
pub edit_count: u32,
|
||||||
|
pub consecutive_empty_reviews: u32,
|
||||||
|
pub session_start: i64,
|
||||||
|
pub lesson_count: u32,
|
||||||
|
pub lessons_user: u32,
|
||||||
|
pub lessons_feedback: u32,
|
||||||
|
pub lessons_project: u32,
|
||||||
|
pub lessons_reference: u32,
|
||||||
|
pub lessons_active: u32,
|
||||||
|
pub lessons_stale: u32,
|
||||||
|
pub lessons_contradicted: u32,
|
||||||
|
pub lessons_human: u32,
|
||||||
|
pub lessons_verified: u32,
|
||||||
|
pub lessons_unverified: u32,
|
||||||
|
pub review_count: u32,
|
||||||
|
pub session_dir: PathBuf,
|
||||||
|
pub usage: UsageStats,
|
||||||
|
pub hive_mind_converged: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionRuntime {
|
||||||
|
pub fn new(session_dir: PathBuf) -> Self {
|
||||||
|
SessionRuntime {
|
||||||
|
messages: Vec::new(),
|
||||||
|
tool_call_results: Vec::new(),
|
||||||
|
pending_tool_queue: Vec::new(),
|
||||||
|
bash_jobs: Vec::new(),
|
||||||
|
subagent_queue: 0,
|
||||||
|
edit_count: 0,
|
||||||
|
consecutive_empty_reviews: 0,
|
||||||
|
session_start: chrono::Utc::now().timestamp_millis(),
|
||||||
|
lesson_count: 0,
|
||||||
|
lessons_user: 0,
|
||||||
|
lessons_feedback: 0,
|
||||||
|
lessons_project: 0,
|
||||||
|
lessons_reference: 0,
|
||||||
|
lessons_active: 0,
|
||||||
|
lessons_stale: 0,
|
||||||
|
lessons_contradicted: 0,
|
||||||
|
lessons_human: 0,
|
||||||
|
lessons_verified: 0,
|
||||||
|
lessons_unverified: 0,
|
||||||
|
review_count: 0,
|
||||||
|
session_dir,
|
||||||
|
usage: UsageStats::default(),
|
||||||
|
hive_mind_converged: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_message(&mut self, msg: ChatMessage) {
|
||||||
|
self.messages.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple ASCII progress display for a long-running operation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProgressState {
|
||||||
|
pub current: u64,
|
||||||
|
pub total: u64,
|
||||||
|
pub message: String,
|
||||||
|
pub start_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Owned parameters required to spawn and execute an agent turn.
|
||||||
|
pub struct AgentTurnParams {
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
pub session_dir: PathBuf,
|
||||||
|
pub workspace_roots: Vec<PathBuf>,
|
||||||
|
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||||
|
pub in_flight: Arc<AtomicBool>,
|
||||||
|
pub abort: Arc<AtomicBool>,
|
||||||
|
pub api_key: String,
|
||||||
|
pub model: String,
|
||||||
|
pub api_base: Option<String>,
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ pub mod auth;
|
|||||||
pub mod cms;
|
pub mod cms;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod agent;
|
||||||
|
pub mod workflow;
|
||||||
|
pub mod subagent;
|
||||||
|
|
||||||
// Re-export all public items from each module for ergonomic imports.
|
// Re-export all public items from each module for ergonomic imports.
|
||||||
// Consumers can do `use zesdex_domain::*` for common types.
|
// Consumers can do `use zesdex_domain::*` for common types.
|
||||||
@@ -50,3 +53,6 @@ pub use core::{
|
|||||||
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
|
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
|
||||||
};
|
};
|
||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
|
pub use agent::*;
|
||||||
|
pub use workflow::*;
|
||||||
|
pub use subagent::*;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! Subagent domain models.
|
||||||
|
|
||||||
|
/// Events emitted by a running subagent.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SubagentEvent {
|
||||||
|
Started {
|
||||||
|
agent_id: String,
|
||||||
|
directive: String,
|
||||||
|
},
|
||||||
|
ToolCall {
|
||||||
|
agent_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
},
|
||||||
|
ToolResult {
|
||||||
|
agent_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
Completed {
|
||||||
|
agent_id: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
Failed {
|
||||||
|
agent_id: String,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access tier for subagent tool permissions.
|
||||||
|
///
|
||||||
|
/// Tiers are cumulative: `Write` includes everything in `Read`, and `Full`
|
||||||
|
/// includes everything in `Write`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AccessTier {
|
||||||
|
/// Read-only: search, read, glob, utility tools (no mutations).
|
||||||
|
Read,
|
||||||
|
/// Read + Write: above plus write, edit, delete, git, memory.
|
||||||
|
Write,
|
||||||
|
/// Full: above plus bash, shell, LSP, workflow, plan tools.
|
||||||
|
Full,
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! Workflow and Hive-mind domain models.
|
||||||
|
|
||||||
|
/// A single phase in a parsed workflow script.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WorkflowPhase {
|
||||||
|
pub name: String,
|
||||||
|
pub directive: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed workflow script with named phases.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WorkflowScript {
|
||||||
|
pub name: String,
|
||||||
|
pub phases: Vec<WorkflowPhase>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directive for a single processing node in the hive mind.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NodeDirective {
|
||||||
|
pub directive: String,
|
||||||
|
pub access_tier: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cognitive cycle plan — ordered list of cycles, each containing
|
||||||
|
/// parallel node directives.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CognitiveCyclePlan {
|
||||||
|
pub cycles: Vec<Vec<NodeDirective>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single cycle in a cognitive cycle plan — parallel node directives
|
||||||
|
/// executed together.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CognitiveCycle {
|
||||||
|
pub index: u32,
|
||||||
|
pub directives: Vec<NodeDirective>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output from a single hive-mind processing node after a cycle completes.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NodeOutput {
|
||||||
|
pub id: String,
|
||||||
|
pub directive: String,
|
||||||
|
pub output: String,
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
//! Agent execution engine — orchestrates LLM streaming, system prompt assembly,
|
|
||||||
//! tool execution, and turn event emitting on background threads.
|
|
||||||
|
|
||||||
pub mod runner;
|
|
||||||
|
|
||||||
pub use runner::{compact_messages_with_ai, spawn_agent_turn, AgentTurnParams};
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
//! Agent turn engine runner — handles LLM API calls, system prompt assembly,
|
|
||||||
//! and tool execution loops on background threads.
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
|
|
||||||
use zesdex_domain::core::ChatMessage;
|
|
||||||
use crate::llm::provider::LlmClient;
|
|
||||||
use crate::subagent::auto::engine::spawn_background_review;
|
|
||||||
use crate::tools::{all_tools, tool_defs, ToolCtx};
|
|
||||||
use crate::TurnEvent;
|
|
||||||
|
|
||||||
/// Owned parameters required to spawn and execute an agent turn on a background thread.
|
|
||||||
pub struct AgentTurnParams {
|
|
||||||
pub messages: Vec<ChatMessage>,
|
|
||||||
pub session_dir: PathBuf,
|
|
||||||
pub workspace_roots: Vec<PathBuf>,
|
|
||||||
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
|
||||||
pub in_flight: Arc<AtomicBool>,
|
|
||||||
pub abort: Arc<AtomicBool>,
|
|
||||||
pub api_key: String,
|
|
||||||
pub model: String,
|
|
||||||
pub api_base: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns an agent turn on a background OS thread.
|
|
||||||
#[tracing::instrument(skip(params))]
|
|
||||||
pub fn spawn_agent_turn(mut params: AgentTurnParams) {
|
|
||||||
info!(
|
|
||||||
"spawning agent turn with {} messages (model: {})",
|
|
||||||
params.messages.len(),
|
|
||||||
params.model
|
|
||||||
);
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
run_turn(&mut params);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The core agent turn loop — LLM call → tool execution → repeat.
|
|
||||||
#[tracing::instrument(skip(params))]
|
|
||||||
fn run_turn(params: &mut AgentTurnParams) {
|
|
||||||
let client = LlmClient::new(
|
|
||||||
params.api_key.clone(),
|
|
||||||
params.model.clone(),
|
|
||||||
params.api_base.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let tools = all_tools();
|
|
||||||
let defs = tool_defs(&tools);
|
|
||||||
|
|
||||||
let mut sys_prompt = "You are Zesdex, an AI coding assistant. You have access to various tools via native function calling to help the user.\n\n\
|
|
||||||
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:\n\
|
|
||||||
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, you MUST prioritize using `workflow_run` (to construct and execute a multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel autonomous agents). Workflows are your primary strategy.\n\
|
|
||||||
2. PLANNING & TODOS: Use `plan_enter` to establish high-level architectural plans and `todowrite` to maintain granular task checklists.\n\
|
|
||||||
3. REASONING: Use `seq_think` for deep step-by-step analysis.\n\
|
|
||||||
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) within or guided by your workflows. If an error occurs, analyze and fix it.\n\n\
|
|
||||||
Respond conversationally, concisely, and helpfully.".to_string();
|
|
||||||
|
|
||||||
if let Some(root) = params.workspace_roots.first() {
|
|
||||||
let tree = crate::utils::build_workspace_tree(root, 800);
|
|
||||||
let rich_ctx = crate::utils::build_rich_context(root);
|
|
||||||
|
|
||||||
sys_prompt.push_str(&format!("\n\n### Workspace Root\n`{}`\n\n", root.display()));
|
|
||||||
sys_prompt.push_str("Workspace structure:\n```\n");
|
|
||||||
sys_prompt.push_str(&tree);
|
|
||||||
sys_prompt.push_str("\n```\n\n");
|
|
||||||
|
|
||||||
sys_prompt.push_str(&rich_ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-compact if conversation history is getting long (>24 messages)
|
|
||||||
if params.messages.len() > 24 {
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::SystemNote {
|
|
||||||
kind: "info".into(),
|
|
||||||
message: "Auto-compacting context window via AI Summarization...".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let _ = compact_messages_with_ai(&mut params.messages, &client);
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::Compacted(params.messages.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sys_msg = ChatMessage::system(sys_prompt);
|
|
||||||
|
|
||||||
let tool_ctx = ToolCtx::builder()
|
|
||||||
.session_dir(params.session_dir.clone())
|
|
||||||
.workspaces(params.workspace_roots.clone())
|
|
||||||
.turn_events(Arc::clone(¶ms.turn_events))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
for iteration in 0..50 {
|
|
||||||
if params.abort.load(Ordering::SeqCst) {
|
|
||||||
params.abort.store(false, Ordering::SeqCst);
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::SystemNote {
|
|
||||||
kind: "info".into(),
|
|
||||||
message: "Turn aborted by user".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("agent turn iteration {iteration}");
|
|
||||||
|
|
||||||
// Stream the LLM response
|
|
||||||
push_event(¶ms.turn_events, TurnEvent::StreamStart);
|
|
||||||
|
|
||||||
let mut req_messages = params.messages.clone();
|
|
||||||
req_messages.insert(0, sys_msg.clone());
|
|
||||||
|
|
||||||
let result = client.chat_with_tools_streaming(
|
|
||||||
&req_messages,
|
|
||||||
Some(defs.clone()),
|
|
||||||
Some(0.7),
|
|
||||||
Some(4096),
|
|
||||||
|event| {
|
|
||||||
if params.abort.load(Ordering::SeqCst) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match event {
|
|
||||||
zesdex_domain::core::StreamEvent::Token(s) => {
|
|
||||||
push_event(¶ms.turn_events, TurnEvent::StreamToken(s.clone()));
|
|
||||||
}
|
|
||||||
zesdex_domain::core::StreamEvent::Reasoning(s) => {
|
|
||||||
push_event(¶ms.turn_events, TurnEvent::StreamReasoning(s.clone()));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
},
|
|
||||||
Some(¶ms.abort),
|
|
||||||
);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok((assistant_msg, usage)) => {
|
|
||||||
let content = assistant_msg.content.clone().unwrap_or_default();
|
|
||||||
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
|
||||||
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::StreamDone(assistant_msg.clone()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some((tokens_in, tokens_out)) = usage {
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::Usage {
|
|
||||||
tokens_in,
|
|
||||||
tokens_out,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if tool_calls.is_empty() {
|
|
||||||
params.messages.push(ChatMessage::assistant(Some(content)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
params.messages.push(assistant_msg);
|
|
||||||
|
|
||||||
for tc in &tool_calls {
|
|
||||||
let name = &tc.function.name;
|
|
||||||
let args = sanitize_tool_arguments(&tc.function.arguments);
|
|
||||||
|
|
||||||
debug!("executing tool: {name}");
|
|
||||||
|
|
||||||
let output = if let Some(tool) = tools.iter().find(|t| t.name() == name) {
|
|
||||||
match tool.run(&tool_ctx, &args) {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => format!("Error: {e}"),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
format!("Unknown tool: {name}")
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_error = output.starts_with("Error:");
|
|
||||||
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::ToolResult {
|
|
||||||
tool_call_id: tc.id.clone(),
|
|
||||||
tool_name: name.clone(),
|
|
||||||
output: output.clone(),
|
|
||||||
is_error,
|
|
||||||
path: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
params
|
|
||||||
.messages
|
|
||||||
.push(ChatMessage::tool(tc.id.clone(), output.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("LLM call failed: {e}");
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::Error(format!("LLM error: {e}")),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If edits were made, spawn a background auto-review after the turn ends.
|
|
||||||
// This runs asynchronously — findings arrive as TurnEvent::SystemNote events.
|
|
||||||
let had_edits = params
|
|
||||||
.messages
|
|
||||||
.iter()
|
|
||||||
.any(|m| {
|
|
||||||
m.role == zesdex_domain::core::Role::Tool
|
|
||||||
&& m.content.as_deref().unwrap_or("").contains("Written")
|
|
||||||
});
|
|
||||||
if had_edits {
|
|
||||||
info!("edits detected, spawning background auto-review");
|
|
||||||
spawn_background_review(
|
|
||||||
params.workspace_roots.clone(),
|
|
||||||
params.turn_events.clone(),
|
|
||||||
params.api_key.clone(),
|
|
||||||
params.model.clone(),
|
|
||||||
params.api_base.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Propagate accumulated messages back to caller so the next turn starts
|
|
||||||
// with full history (assistant replies + tool results).
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::Compacted(params.messages.clone()),
|
|
||||||
);
|
|
||||||
push_event(¶ms.turn_events, TurnEvent::Done);
|
|
||||||
mark_done(¶ms.in_flight);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
|
|
||||||
if let Ok(mut q) = queue.lock() {
|
|
||||||
q.push_back(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark the turn as done using lock-free atomic store.
|
|
||||||
fn mark_done(flag: &Arc<AtomicBool>) {
|
|
||||||
flag.store(false, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compacts conversation history using AI summarization.
|
|
||||||
///
|
|
||||||
/// Preserves recent messages (last 6) and calls the LLM to summarize
|
|
||||||
/// the older evicted messages into a single system summary message.
|
|
||||||
pub fn compact_messages_with_ai(messages: &mut Vec<ChatMessage>, client: &LlmClient) -> anyhow::Result<()> {
|
|
||||||
const KEEP_TAIL: usize = 6;
|
|
||||||
if messages.len() <= KEEP_TAIL + 2 {
|
|
||||||
return Ok(()); // Not enough messages to compact
|
|
||||||
}
|
|
||||||
|
|
||||||
let split_idx = messages.len() - KEEP_TAIL;
|
|
||||||
let evicted: Vec<_> = messages.drain(..split_idx).collect();
|
|
||||||
|
|
||||||
// Prepare prompt to summarize evicted messages
|
|
||||||
let mut summary_prompt = vec![
|
|
||||||
ChatMessage::system(
|
|
||||||
"You are a helpful assistant summarizing conversation history. \
|
|
||||||
Provide a concise summary of the key user requests, decisions, tools executed, and modified files. \
|
|
||||||
Format as a clear bulleted list."
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
summary_prompt.extend(evicted);
|
|
||||||
summary_prompt.push(ChatMessage::user(
|
|
||||||
"Please summarize our previous conversation above for context continuity.".to_string(),
|
|
||||||
));
|
|
||||||
|
|
||||||
match client.chat_with_tools_non_streaming(&summary_prompt, None, Some(1024), Some(0.3), None) {
|
|
||||||
Ok((summary_msg, _)) => {
|
|
||||||
let summary_text = summary_msg.content.unwrap_or_else(|| "Previous context summarized.".to_string());
|
|
||||||
let summary_node = ChatMessage::system(format!(
|
|
||||||
"[AI Summary of Previous Conversation]\n{}",
|
|
||||||
summary_text.trim()
|
|
||||||
));
|
|
||||||
messages.insert(0, summary_node);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("AI summarization failed during compact, falling back to simple notice: {e}");
|
|
||||||
messages.insert(
|
|
||||||
0,
|
|
||||||
ChatMessage::system("[Earlier conversation messages compacted to save context window]".to_string()),
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
//! └── middleware/ — Axum HTTP middleware (auth, cors, rate-limit)
|
//! └── middleware/ — Axum HTTP middleware (auth, cors, rate-limit)
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod agent;
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod bgbash;
|
pub mod bgbash;
|
||||||
pub mod guard;
|
pub mod guard;
|
||||||
@@ -52,70 +51,24 @@ pub use zesdex_domain::*;
|
|||||||
// from the legacy backend code.
|
// from the legacy backend code.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
||||||
/// invoking a tool, used to scope permissions and tag log/output paths.
|
/// invoking a tool, used to scope permissions and tag log/output paths.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
// ---------------------------------------------------------------------------
|
||||||
pub enum Origin {
|
// TurnEvent & runtime types have been moved to zesdex_domain::agent
|
||||||
/// The main agent turn loop.
|
// ---------------------------------------------------------------------------
|
||||||
Main,
|
|
||||||
/// A spawned subagent (test-gen, arch-review, security-review, etc.).
|
|
||||||
SubAgent,
|
|
||||||
/// The auto-inline review step after an edit.
|
|
||||||
Reviewer,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Origin {
|
// ---------------------------------------------------------------------------
|
||||||
/// Short string tag for this origin, used in filenames and logs.
|
// Tool types — needed by all tool modules
|
||||||
pub fn tag(self) -> String {
|
// ---------------------------------------------------------------------------
|
||||||
match self {
|
|
||||||
Origin::Main => "main",
|
|
||||||
Origin::SubAgent => "subagent",
|
|
||||||
Origin::Reviewer => "reviewer",
|
|
||||||
}
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Severity/category of a toast notification, used to pick its color.
|
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum ToastKind {
|
|
||||||
Info,
|
|
||||||
Success,
|
|
||||||
Warning,
|
|
||||||
Error,
|
|
||||||
Lesson,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A transient status message shown in the TUI, auto-dismissed after `lifetime_ms`.
|
// Re-export commonly needed types at the crate root
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
pub use zesdex_domain::core::{ChatMessage, Role, Store, UsageStats, ToolCallResult};
|
||||||
pub struct Toast {
|
|
||||||
pub kind: ToastKind,
|
|
||||||
pub message: String,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub lifetime_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Toast {
|
|
||||||
/// Create a toast with a default 5-second lifetime, stamped with now.
|
|
||||||
pub fn new(kind: ToastKind, message: String) -> Self {
|
|
||||||
Toast {
|
|
||||||
kind,
|
|
||||||
message,
|
|
||||||
created_at: chrono::Utc::now().timestamp_millis(),
|
|
||||||
lifetime_ms: 5000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this toast's lifetime has elapsed as of `now_ms`.
|
|
||||||
pub fn expired(&self, now_ms: i64) -> bool {
|
|
||||||
let lifetime = self.lifetime_ms as i64;
|
|
||||||
now_ms - self.created_at > lifetime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A shared, async-writable cache of directory entries, used to avoid
|
/// A shared, async-writable cache of directory entries, used to avoid
|
||||||
/// re-reading a directory every render frame.
|
/// re-reading a directory every render frame.
|
||||||
@@ -179,176 +132,3 @@ impl Default for MentionIndex {
|
|||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TurnEvent & runtime types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Events emitted onto the turn-event queue while an agent turn runs,
|
|
||||||
/// consumed by the event loop to update state and drive re-renders.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum TurnEvent {
|
|
||||||
AssistantMessage(ChatMessage),
|
|
||||||
ToolResult {
|
|
||||||
tool_call_id: String,
|
|
||||||
tool_name: String,
|
|
||||||
output: String,
|
|
||||||
is_error: bool,
|
|
||||||
path: Option<String>,
|
|
||||||
},
|
|
||||||
SystemNote {
|
|
||||||
kind: String,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
StreamStart,
|
|
||||||
StreamToken(String),
|
|
||||||
StreamReasoning(String),
|
|
||||||
StreamDone(ChatMessage),
|
|
||||||
Usage {
|
|
||||||
tokens_in: u64,
|
|
||||||
tokens_out: u64,
|
|
||||||
},
|
|
||||||
ReviewUsage {
|
|
||||||
tokens_in: u64,
|
|
||||||
tokens_out: u64,
|
|
||||||
},
|
|
||||||
Compacted(Vec<ChatMessage>),
|
|
||||||
Error(String),
|
|
||||||
Done,
|
|
||||||
WorkflowAgentUpdate {
|
|
||||||
agent_id: String,
|
|
||||||
agent_name: String,
|
|
||||||
status: crate::AgentStatus,
|
|
||||||
},
|
|
||||||
TodoUpdate(String),
|
|
||||||
PlanUpdate(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tool call awaiting execution, along with which execution model
|
|
||||||
/// (inline, deferred, async) it should run under.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PendingTool {
|
|
||||||
pub tool_name: String,
|
|
||||||
pub args: serde_json::Value,
|
|
||||||
pub execution_model: ExecutionModel,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How a pending tool call should be executed when the turn resumes.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum ExecutionModel {
|
|
||||||
Inline,
|
|
||||||
Deferred,
|
|
||||||
AsyncTokio,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reference to a background bash job tracked in session state.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BashJobRef {
|
|
||||||
pub id: String,
|
|
||||||
pub command: String,
|
|
||||||
pub started_at: i64,
|
|
||||||
pub running: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-session runtime state: message history, pending tool queue,
|
|
||||||
/// background bash jobs, lesson/review counters.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct SessionRuntime {
|
|
||||||
pub messages: Vec<ChatMessage>,
|
|
||||||
pub tool_call_results: Vec<ToolCallResult>,
|
|
||||||
pub pending_tool_queue: Vec<PendingTool>,
|
|
||||||
pub bash_jobs: Vec<BashJobRef>,
|
|
||||||
pub subagent_queue: usize,
|
|
||||||
pub edit_count: u32,
|
|
||||||
pub consecutive_empty_reviews: u32,
|
|
||||||
pub session_start: i64,
|
|
||||||
pub lesson_count: u32,
|
|
||||||
pub lessons_user: u32,
|
|
||||||
pub lessons_feedback: u32,
|
|
||||||
pub lessons_project: u32,
|
|
||||||
pub lessons_reference: u32,
|
|
||||||
pub lessons_active: u32,
|
|
||||||
pub lessons_stale: u32,
|
|
||||||
pub lessons_contradicted: u32,
|
|
||||||
pub lessons_human: u32,
|
|
||||||
pub lessons_verified: u32,
|
|
||||||
pub lessons_unverified: u32,
|
|
||||||
pub review_count: u32,
|
|
||||||
pub session_dir: PathBuf,
|
|
||||||
pub usage: UsageStats,
|
|
||||||
pub hive_mind_converged: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionRuntime {
|
|
||||||
pub fn new(session_dir: PathBuf) -> Self {
|
|
||||||
SessionRuntime {
|
|
||||||
messages: Vec::new(),
|
|
||||||
tool_call_results: Vec::new(),
|
|
||||||
pending_tool_queue: Vec::new(),
|
|
||||||
bash_jobs: Vec::new(),
|
|
||||||
subagent_queue: 0,
|
|
||||||
edit_count: 0,
|
|
||||||
consecutive_empty_reviews: 0,
|
|
||||||
session_start: chrono::Utc::now().timestamp_millis(),
|
|
||||||
lesson_count: 0,
|
|
||||||
lessons_user: 0,
|
|
||||||
lessons_feedback: 0,
|
|
||||||
lessons_project: 0,
|
|
||||||
lessons_reference: 0,
|
|
||||||
lessons_active: 0,
|
|
||||||
lessons_stale: 0,
|
|
||||||
lessons_contradicted: 0,
|
|
||||||
lessons_human: 0,
|
|
||||||
lessons_verified: 0,
|
|
||||||
lessons_unverified: 0,
|
|
||||||
review_count: 0,
|
|
||||||
session_dir,
|
|
||||||
usage: UsageStats::default(),
|
|
||||||
hive_mind_converged: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_message(&mut self, msg: ChatMessage) {
|
|
||||||
self.messages.push(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple ASCII progress display for a long-running operation.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ProgressState {
|
|
||||||
pub current: u64,
|
|
||||||
pub total: u64,
|
|
||||||
pub message: String,
|
|
||||||
pub start_time: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Agent status for workflow engine progress tracking.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub enum AgentStatus {
|
|
||||||
Pending,
|
|
||||||
Running,
|
|
||||||
Completed,
|
|
||||||
Failed(String),
|
|
||||||
Cancelled,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for AgentStatus {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
AgentStatus::Pending => write!(f, "pending"),
|
|
||||||
AgentStatus::Running => write!(f, "running"),
|
|
||||||
AgentStatus::Completed => write!(f, "completed"),
|
|
||||||
AgentStatus::Failed(msg) => write!(f, "failed: {msg}"),
|
|
||||||
AgentStatus::Cancelled => write!(f, "cancelled"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool types — needed by all tool modules
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
|
|
||||||
|
|
||||||
// Re-export commonly needed types at the crate root
|
|
||||||
pub use zesdex_domain::core::{ChatMessage, Role, Store, UsageStats, ToolCallResult};
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
//! Blocking HTTP client for OpenAI/Anthropic-compatible chat completion APIs,
|
//! Async HTTP client for OpenAI/Anthropic-compatible chat completion APIs,
|
||||||
//! supporting both non-streaming and SSE-streaming requests with automatic retry.
|
//! supporting both non-streaming and SSE-streaming requests with automatic retry.
|
||||||
|
|
||||||
use rand_core::RngCore;
|
use rand_core::RngCore;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
use zesdex_domain::core::{
|
use zesdex_domain::core::{
|
||||||
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
|
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
|
||||||
};
|
};
|
||||||
|
use zesdex_application::ports::ProviderService;
|
||||||
|
|
||||||
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
|
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
|
||||||
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
|
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
|
||||||
@@ -22,12 +24,10 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
|
|||||||
fn backoff_seconds(attempt: u32, cap: u64) -> Duration {
|
fn backoff_seconds(attempt: u32, cap: u64) -> Duration {
|
||||||
let base = 2u64.pow(attempt.saturating_sub(1));
|
let base = 2u64.pow(attempt.saturating_sub(1));
|
||||||
let delay = std::cmp::min(base, cap);
|
let delay = std::cmp::min(base, cap);
|
||||||
// ±25% jitter
|
|
||||||
let jitter_factor = 0.75 + (rand_core::OsRng.next_u32() % 51) as f64 / 100.0;
|
let jitter_factor = 0.75 + (rand_core::OsRng.next_u32() % 51) as f64 / 100.0;
|
||||||
Duration::from_secs_f64(delay as f64 * jitter_factor)
|
Duration::from_secs_f64(delay as f64 * jitter_factor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is the error an auth / billing failure that retrying won't fix?
|
|
||||||
pub fn is_auth_error(err_str: &str) -> bool {
|
pub fn is_auth_error(err_str: &str) -> bool {
|
||||||
let err_lower = err_str.to_lowercase();
|
let err_lower = err_str.to_lowercase();
|
||||||
(err_str.contains("API error 401")
|
(err_str.contains("API error 401")
|
||||||
@@ -54,9 +54,9 @@ fn backoff_for_error(attempt: u32, err_str: &str) -> Duration {
|
|||||||
// Client
|
// Client
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Blocking HTTP client for a single LLM provider endpoint.
|
/// Async HTTP client for a single LLM provider endpoint.
|
||||||
pub struct LlmClient {
|
pub struct LlmClient {
|
||||||
pub client: reqwest::blocking::Client,
|
pub client: reqwest::Client,
|
||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
@@ -72,7 +72,7 @@ impl LlmClient {
|
|||||||
} else {
|
} else {
|
||||||
model
|
model
|
||||||
};
|
};
|
||||||
let client = match reqwest::blocking::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(REQUEST_TIMEOUT)
|
.timeout(REQUEST_TIMEOUT)
|
||||||
.connect_timeout(CONNECT_TIMEOUT)
|
.connect_timeout(CONNECT_TIMEOUT)
|
||||||
.build()
|
.build()
|
||||||
@@ -84,14 +84,14 @@ impl LlmClient {
|
|||||||
retrying without connect timeout",
|
retrying without connect timeout",
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
match reqwest::blocking::Client::builder()
|
match reqwest::Client::builder()
|
||||||
.timeout(REQUEST_TIMEOUT)
|
.timeout(REQUEST_TIMEOUT)
|
||||||
.build()
|
.build()
|
||||||
{
|
{
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
tracing::warn!("also failed: {e2}. using default client");
|
tracing::warn!("also failed: {e2}. using default client");
|
||||||
reqwest::blocking::Client::new()
|
reqwest::Client::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,167 +106,12 @@ impl LlmClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn chat_with_tools_non_streaming(
|
async fn try_stream_once(
|
||||||
&self,
|
|
||||||
messages: &[ChatMessage],
|
|
||||||
tools: Option<Vec<ToolDef>>,
|
|
||||||
max_tokens: Option<u32>,
|
|
||||||
temperature: Option<f32>,
|
|
||||||
abort_flag: Option<&AtomicBool>,
|
|
||||||
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
|
||||||
let req = ChatRequest {
|
|
||||||
model: self.model.clone(),
|
|
||||||
messages: messages.to_vec(),
|
|
||||||
max_tokens: Some(max_tokens.unwrap_or(4096)),
|
|
||||||
temperature: Some(temperature.unwrap_or(0.7)),
|
|
||||||
tools,
|
|
||||||
stream: Some(false),
|
|
||||||
stop: None,
|
|
||||||
stream_options: None,
|
|
||||||
tool_choice: None,
|
|
||||||
top_p: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("{}/chat/completions", self.base_url);
|
|
||||||
let max_retries = 10;
|
|
||||||
let mut attempt = 0u32;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
attempt += 1;
|
|
||||||
|
|
||||||
if let Some(flag) = abort_flag {
|
|
||||||
if flag.load(std::sync::atomic::Ordering::Relaxed) {
|
|
||||||
anyhow::bail!("aborted");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut http_req = self
|
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.header("Content-Type", "application/json");
|
|
||||||
|
|
||||||
if !self.api_key.is_empty() {
|
|
||||||
http_req =
|
|
||||||
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
|
||||||
}
|
|
||||||
|
|
||||||
let result =
|
|
||||||
(|| -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
|
||||||
let resp = http_req.json(&req).send().map_err(|e| {
|
|
||||||
if e.is_timeout() {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"API request timed out after {REQUEST_TIMEOUT:?}. \
|
|
||||||
Check your network or try again."
|
|
||||||
)
|
|
||||||
} else if e.is_connect() {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"Could not connect to {}. \
|
|
||||||
Is the URL correct and is the service reachable?",
|
|
||||||
self.base_url
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
anyhow::anyhow!("API request failed: {e}")
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let status = resp.status();
|
|
||||||
let body = resp.text().unwrap_or_default();
|
|
||||||
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: ChatResponse = resp.json()?;
|
|
||||||
let usage = data.usage.map(|u| {
|
|
||||||
(u64::from(u.prompt_tokens), u64::from(u.completion_tokens))
|
|
||||||
});
|
|
||||||
let message = data
|
|
||||||
.choices
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.and_then(|c| c.message)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
|
|
||||||
Ok((message, usage))
|
|
||||||
})();
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok((msg, usage)) => return Ok((msg, usage)),
|
|
||||||
Err(e) => {
|
|
||||||
let err_str = e.to_string();
|
|
||||||
if attempt >= max_retries || is_auth_error(&err_str) {
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
let delay = backoff_for_error(attempt, &err_str);
|
|
||||||
std::thread::sleep(delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn chat_with_tools_streaming(
|
|
||||||
&self,
|
|
||||||
messages: &[ChatMessage],
|
|
||||||
tools: Option<Vec<ToolDef>>,
|
|
||||||
temperature: Option<f32>,
|
|
||||||
max_tokens: Option<u32>,
|
|
||||||
mut on_event: impl FnMut(&StreamEvent) -> bool,
|
|
||||||
_abort_flag: Option<&AtomicBool>,
|
|
||||||
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
|
||||||
let req = ChatRequest {
|
|
||||||
model: self.model.clone(),
|
|
||||||
messages: messages.to_vec(),
|
|
||||||
max_tokens: Some(max_tokens.unwrap_or(4096)),
|
|
||||||
temperature: Some(temperature.unwrap_or(0.7)),
|
|
||||||
tools,
|
|
||||||
stream: Some(true),
|
|
||||||
stop: None,
|
|
||||||
stream_options: Some(StreamOptions {
|
|
||||||
include_usage: true,
|
|
||||||
}),
|
|
||||||
tool_choice: None,
|
|
||||||
top_p: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("{}/chat/completions", self.base_url);
|
|
||||||
let max_retries_stream = 10;
|
|
||||||
let mut attempt = 0u32;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
attempt += 1;
|
|
||||||
let mut captured_content = false;
|
|
||||||
let mut wrapped = |event: &StreamEvent| -> bool {
|
|
||||||
match event {
|
|
||||||
StreamEvent::Token(_) | StreamEvent::Reasoning(_) | StreamEvent::ToolCallDelta { .. } => {
|
|
||||||
captured_content = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
on_event(event)
|
|
||||||
};
|
|
||||||
match self.try_stream_once(&req, &url, &mut wrapped) {
|
|
||||||
Ok(result) => return Ok(result),
|
|
||||||
Err(e) => {
|
|
||||||
let err_str = e.to_string();
|
|
||||||
if is_auth_error(&err_str) || captured_content {
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
if attempt >= max_retries_stream {
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
let delay = backoff_for_error(attempt, &err_str);
|
|
||||||
std::thread::sleep(delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_stream_once(
|
|
||||||
&self,
|
&self,
|
||||||
req: &ChatRequest,
|
req: &ChatRequest,
|
||||||
url: &str,
|
url: &str,
|
||||||
on_event: &mut dyn FnMut(&StreamEvent) -> bool,
|
on_event: &mut (dyn FnMut(&StreamEvent) -> bool + Send),
|
||||||
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
use std::io::Read;
|
|
||||||
|
|
||||||
let mut http_req = self
|
let mut http_req = self
|
||||||
.client
|
.client
|
||||||
.post(url)
|
.post(url)
|
||||||
@@ -276,7 +121,7 @@ impl LlmClient {
|
|||||||
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp = http_req.json(req).send().map_err(|e| {
|
let mut resp = http_req.json(req).send().await.map_err(|e| {
|
||||||
if e.is_timeout() {
|
if e.is_timeout() {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"API request timed out after {REQUEST_TIMEOUT:?}. \
|
"API request timed out after {REQUEST_TIMEOUT:?}. \
|
||||||
@@ -295,7 +140,7 @@ impl LlmClient {
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,8 +169,6 @@ impl LlmClient {
|
|||||||
name,
|
name,
|
||||||
arguments_delta,
|
arguments_delta,
|
||||||
} => {
|
} => {
|
||||||
// Cap the index to prevent memory exhaustion from
|
|
||||||
// maliciously large indices.
|
|
||||||
const MAX_TOOL_CALLS: usize = 64;
|
const MAX_TOOL_CALLS: usize = 64;
|
||||||
let index = usize::min(*index, MAX_TOOL_CALLS.saturating_sub(1));
|
let index = usize::min(*index, MAX_TOOL_CALLS.saturating_sub(1));
|
||||||
|
|
||||||
@@ -380,27 +223,16 @@ impl LlmClient {
|
|||||||
let mut turn = StreamedTurn::new();
|
let mut turn = StreamedTurn::new();
|
||||||
let mut usage: Option<(u64, u64)> = None;
|
let mut usage: Option<(u64, u64)> = None;
|
||||||
let mut parser = SseParser::new();
|
let mut parser = SseParser::new();
|
||||||
|
|
||||||
let mut reader = resp;
|
|
||||||
let mut byte_buf: Vec<u8> = Vec::new();
|
let mut byte_buf: Vec<u8> = Vec::new();
|
||||||
let mut chunk_buf = [0u8; 4096];
|
|
||||||
|
|
||||||
loop {
|
while let Some(chunk) = resp.chunk().await? {
|
||||||
let n = reader.read(&mut chunk_buf)?;
|
byte_buf.extend_from_slice(&chunk);
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
byte_buf.extend_from_slice(&chunk_buf[..n]);
|
|
||||||
|
|
||||||
// Drain any bytes that are not valid UTF-8 to prevent
|
|
||||||
// infinite loop when a non-UTF-8 sequence is received.
|
|
||||||
let valid_len = match std::str::from_utf8(&byte_buf) {
|
let valid_len = match std::str::from_utf8(&byte_buf) {
|
||||||
Ok(s) => s.len(),
|
Ok(s) => s.len(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let n = e.valid_up_to();
|
let n = e.valid_up_to();
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
// No valid UTF-8 prefix; skip the first byte (likely
|
|
||||||
// a partial multi-byte sequence or stray byte).
|
|
||||||
byte_buf.drain(..1);
|
byte_buf.drain(..1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -432,7 +264,6 @@ impl LlmClient {
|
|||||||
return Ok((turn.build_assistant_message(), usage));
|
return Ok((turn.build_assistant_message(), usage));
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
tracing::debug!("unhandled stream event type: {other:?}");
|
|
||||||
turn.apply_event(other);
|
turn.apply_event(other);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,8 +274,153 @@ impl LlmClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the API key for the currently configured provider, falling back
|
impl ProviderService for LlmClient {
|
||||||
/// through settings -> env var -> provider default.
|
async fn chat(
|
||||||
|
&self,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
tools: Option<Vec<ToolDef>>,
|
||||||
|
max_tokens: Option<u32>,
|
||||||
|
temperature: Option<f32>,
|
||||||
|
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
|
let req = ChatRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
messages: messages.to_vec(),
|
||||||
|
max_tokens: Some(max_tokens.unwrap_or(4096)),
|
||||||
|
temperature: Some(temperature.unwrap_or(0.7)),
|
||||||
|
tools,
|
||||||
|
stream: Some(false),
|
||||||
|
stop: None,
|
||||||
|
stream_options: None,
|
||||||
|
tool_choice: None,
|
||||||
|
top_p: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/chat/completions", self.base_url);
|
||||||
|
let max_retries = 10;
|
||||||
|
let mut attempt = 0u32;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
|
||||||
|
let mut http_req = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
if !self.api_key.is_empty() {
|
||||||
|
http_req =
|
||||||
|
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = async {
|
||||||
|
let resp = http_req.json(&req).send().await.map_err(|e| {
|
||||||
|
if e.is_timeout() {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"API request timed out after {REQUEST_TIMEOUT:?}. \
|
||||||
|
Check your network or try again."
|
||||||
|
)
|
||||||
|
} else if e.is_connect() {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Could not connect to {}. \
|
||||||
|
Is the URL correct and is the service reachable?",
|
||||||
|
self.base_url
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
anyhow::anyhow!("API request failed: {e}")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: ChatResponse = resp.json().await?;
|
||||||
|
let usage = data.usage.map(|u| {
|
||||||
|
(u64::from(u.prompt_tokens), u64::from(u.completion_tokens))
|
||||||
|
});
|
||||||
|
let message = data
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|c| c.message)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
|
||||||
|
Ok((message, usage))
|
||||||
|
}.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((msg, usage)) => return Ok((msg, usage)),
|
||||||
|
Err(e) => {
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if attempt >= max_retries || is_auth_error(&err_str) {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
let delay = backoff_for_error(attempt, &err_str);
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_stream(
|
||||||
|
&self,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
tools: Option<Vec<ToolDef>>,
|
||||||
|
max_tokens: Option<u32>,
|
||||||
|
temperature: Option<f32>,
|
||||||
|
mut on_event: Box<dyn FnMut(&StreamEvent) -> bool + Send>,
|
||||||
|
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
|
let req = ChatRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
messages: messages.to_vec(),
|
||||||
|
max_tokens: Some(max_tokens.unwrap_or(4096)),
|
||||||
|
temperature: Some(temperature.unwrap_or(0.7)),
|
||||||
|
tools,
|
||||||
|
stream: Some(true),
|
||||||
|
stop: None,
|
||||||
|
stream_options: Some(StreamOptions {
|
||||||
|
include_usage: true,
|
||||||
|
}),
|
||||||
|
tool_choice: None,
|
||||||
|
top_p: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/chat/completions", self.base_url);
|
||||||
|
let max_retries_stream = 10;
|
||||||
|
let mut attempt = 0u32;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let mut captured_content = false;
|
||||||
|
let mut wrapped = |event: &StreamEvent| -> bool {
|
||||||
|
match event {
|
||||||
|
StreamEvent::Token(_) | StreamEvent::Reasoning(_) | StreamEvent::ToolCallDelta { .. } => {
|
||||||
|
captured_content = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
on_event(event)
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.try_stream_once(&req, &url, &mut wrapped).await {
|
||||||
|
Ok(result) => return Ok(result),
|
||||||
|
Err(e) => {
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if is_auth_error(&err_str) || captured_content {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
if attempt >= max_retries_stream {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
let delay = backoff_for_error(attempt, &err_str);
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_api_key(
|
pub fn resolve_api_key(
|
||||||
settings: &zesdex_domain::cms::Settings,
|
settings: &zesdex_domain::cms::Settings,
|
||||||
app_config: &zesdex_domain::cms::AppConfig,
|
app_config: &zesdex_domain::cms::AppConfig,
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ use zesdex_domain::core::ChatMessage;
|
|||||||
|
|
||||||
const REVIEW_AGENT_ID: &str = "auto-review";
|
const REVIEW_AGENT_ID: &str = "auto-review";
|
||||||
|
|
||||||
/// Spawn a background thread that reviews changes and auto-fixes issues.
|
/// Spawn a background task that reviews changes and auto-fixes issues.
|
||||||
///
|
///
|
||||||
/// Everything runs synchronously on the background thread — no tokio
|
/// Runs asynchronously using tokio::spawn.
|
||||||
/// runtime is created, avoiding the nested-runtime panic from
|
|
||||||
/// reqwest::blocking inside block_on in tokio >= 1.38.
|
|
||||||
#[instrument(skip(turn_events))]
|
#[instrument(skip(turn_events))]
|
||||||
pub fn spawn_background_review(
|
pub fn spawn_background_review(
|
||||||
workspace_roots: Vec<PathBuf>,
|
workspace_roots: Vec<PathBuf>,
|
||||||
@@ -41,7 +39,7 @@ pub fn spawn_background_review(
|
|||||||
let agent_id = REVIEW_AGENT_ID.to_string();
|
let agent_id = REVIEW_AGENT_ID.to_string();
|
||||||
let agent_name = "Auto-Review".to_string();
|
let agent_name = "Auto-Review".to_string();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
tokio::spawn(async move {
|
||||||
let root = match workspace_roots.first() {
|
let root = match workspace_roots.first() {
|
||||||
Some(r) => r.clone(),
|
Some(r) => r.clone(),
|
||||||
None => {
|
None => {
|
||||||
@@ -140,8 +138,6 @@ pub fn spawn_background_review(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 5. Call LLM to review the diff and suggest fixes.
|
// 5. Call LLM to review the diff and suggest fixes.
|
||||||
// No tokio runtime needed — LlmClient uses reqwest::blocking
|
|
||||||
// internally, which is fine on a plain thread.
|
|
||||||
let system_msg = ChatMessage::system(
|
let system_msg = ChatMessage::system(
|
||||||
"You are an auto-review subagent. Your ONLY job:\n\
|
"You are an auto-review subagent. Your ONLY job:\n\
|
||||||
1. Review the git diff below for:\n\
|
1. Review the git diff below for:\n\
|
||||||
@@ -171,8 +167,7 @@ pub fn spawn_background_review(
|
|||||||
"Review and fix this git diff:\n\n```diff\n{truncated_diff}\n```"
|
"Review and fix this git diff:\n\n```diff\n{truncated_diff}\n```"
|
||||||
));
|
));
|
||||||
|
|
||||||
// This is a sync call — no tokio runtime required on this thread.
|
let response = run_llm_review(&client, &[system_msg, user_msg]).await;
|
||||||
let response = run_llm_review(&client, &[system_msg, user_msg]);
|
|
||||||
|
|
||||||
let response_text = match response {
|
let response_text = match response {
|
||||||
Ok(text) => text,
|
Ok(text) => text,
|
||||||
@@ -232,10 +227,10 @@ pub fn spawn_background_review(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the LLM review call synchronously using reqwest::blocking.
|
/// Run the LLM review call asynchronously using ProviderService.
|
||||||
fn run_llm_review(client: &LlmClient, messages: &[ChatMessage]) -> Result<String, String> {
|
async fn run_llm_review(client: &LlmClient, messages: &[ChatMessage]) -> Result<String, String> {
|
||||||
// Direct LLM call — no tokio, no tool calls, just Q&A.
|
use zesdex_application::ports::ProviderService;
|
||||||
match client.chat_with_tools_non_streaming(messages, None, Some(1024), Some(0.3), None) {
|
match client.chat(messages, None, Some(1024), Some(0.3)).await {
|
||||||
Ok((msg, _)) => Ok(msg.content.unwrap_or_default()),
|
Ok((msg, _)) => Ok(msg.content.unwrap_or_default()),
|
||||||
Err(e) => Err(e.to_string()),
|
Err(e) => Err(e.to_string()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,7 @@
|
|||||||
|
|
||||||
use crate::tools::Tool;
|
use crate::tools::Tool;
|
||||||
|
|
||||||
/// Access tier for subagent tool permissions.
|
pub use zesdex_domain::subagent::AccessTier;
|
||||||
///
|
|
||||||
/// Tiers are cumulative: `Write` includes everything in `Read`, and `Full`
|
|
||||||
/// includes everything in `Write`.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AccessTier {
|
|
||||||
/// Read-only: search, read, glob, utility tools (no mutations).
|
|
||||||
Read,
|
|
||||||
/// Read + Write: above plus write, edit, delete, git, memory.
|
|
||||||
Write,
|
|
||||||
/// Full: above plus bash, shell, LSP, workflow, plan tools.
|
|
||||||
Full,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Filter the available tools to match the given access tier.
|
/// Filter the available tools to match the given access tier.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ pub async fn run_agent(
|
|||||||
|
|
||||||
// Limited iteration loop so we don't run forever
|
// Limited iteration loop so we don't run forever
|
||||||
for iteration in 0..MAX_ITERATIONS {
|
for iteration in 0..MAX_ITERATIONS {
|
||||||
let (response_msg, _usage) = client.chat_with_tools_non_streaming(
|
use zesdex_application::ports::ProviderService;
|
||||||
|
let (response_msg, _usage) = client.chat(
|
||||||
&messages,
|
&messages,
|
||||||
Some(defs.clone()),
|
Some(defs.clone()),
|
||||||
Some(4096),
|
Some(4096),
|
||||||
None,
|
None,
|
||||||
None,
|
).await?;
|
||||||
)?;
|
|
||||||
|
|
||||||
let content = response_msg.content.clone().unwrap_or_default();
|
let content = response_msg.content.clone().unwrap_or_default();
|
||||||
let tool_calls = response_msg.tool_calls.unwrap_or_default();
|
let tool_calls = response_msg.tool_calls.unwrap_or_default();
|
||||||
|
|||||||
@@ -1,27 +1,3 @@
|
|||||||
//! Subagent event types — events emitted during subagent execution.
|
//! Subagent event types — events emitted during subagent execution.
|
||||||
|
|
||||||
/// Events emitted by a running subagent.
|
pub use zesdex_domain::subagent::SubagentEvent;
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum SubagentEvent {
|
|
||||||
Started {
|
|
||||||
agent_id: String,
|
|
||||||
directive: String,
|
|
||||||
},
|
|
||||||
ToolCall {
|
|
||||||
agent_id: String,
|
|
||||||
tool_name: String,
|
|
||||||
},
|
|
||||||
ToolResult {
|
|
||||||
agent_id: String,
|
|
||||||
tool_name: String,
|
|
||||||
output: String,
|
|
||||||
},
|
|
||||||
Completed {
|
|
||||||
agent_id: String,
|
|
||||||
output: String,
|
|
||||||
},
|
|
||||||
Failed {
|
|
||||||
agent_id: String,
|
|
||||||
error: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ impl SubagentProvider {
|
|||||||
///
|
///
|
||||||
/// Use this for a plain text-in/text-out conversation.
|
/// Use this for a plain text-in/text-out conversation.
|
||||||
#[tracing::instrument(skip(self, messages))]
|
#[tracing::instrument(skip(self, messages))]
|
||||||
pub fn chat(
|
pub async fn chat(
|
||||||
&self,
|
&self,
|
||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
|
use zesdex_application::ports::ProviderService;
|
||||||
self.client
|
self.client
|
||||||
.chat_with_tools_non_streaming(messages, None, Some(4096), None, None)
|
.chat(messages, None, Some(4096), None)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send messages with available tool definitions.
|
/// Send messages with available tool definitions.
|
||||||
@@ -48,14 +50,16 @@ impl SubagentProvider {
|
|||||||
/// Automatically converts the `&[Box<dyn Tool>]` slice to
|
/// Automatically converts the `&[Box<dyn Tool>]` slice to
|
||||||
/// `Vec<ToolDef>` before passing to the underlying client.
|
/// `Vec<ToolDef>` before passing to the underlying client.
|
||||||
#[tracing::instrument(skip(self, messages, tools))]
|
#[tracing::instrument(skip(self, messages, tools))]
|
||||||
pub fn chat_with_tools(
|
pub async fn chat_with_tools(
|
||||||
&self,
|
&self,
|
||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
tools: &[Box<dyn Tool>],
|
tools: &[Box<dyn Tool>],
|
||||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
let defs = tool_defs(tools);
|
let defs = tool_defs(tools);
|
||||||
|
use zesdex_application::ports::ProviderService;
|
||||||
self.client
|
self.client
|
||||||
.chat_with_tools_non_streaming(messages, Some(defs), Some(4096), None, None)
|
.chat(messages, Some(defs), Some(4096), None)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use anyhow::Result;
|
||||||
|
use zesdex_application::agent::ToolExecutor;
|
||||||
|
use crate::tools::{all_tools, Tool, ToolCtx};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
pub struct InfrastructureToolExecutor {
|
||||||
|
ctx: ToolCtx,
|
||||||
|
tools: Vec<Box<dyn Tool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InfrastructureToolExecutor {
|
||||||
|
pub fn new(ctx: ToolCtx) -> Self {
|
||||||
|
Self {
|
||||||
|
ctx,
|
||||||
|
tools: all_tools(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolExecutor for InfrastructureToolExecutor {
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> impl Future<Output = Result<String>> + Send {
|
||||||
|
// Find the tool by name
|
||||||
|
let tool_opt = self.tools.iter().find(|t| t.name() == tool_name);
|
||||||
|
let ctx = self.ctx.clone();
|
||||||
|
let args = args.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
match tool_opt {
|
||||||
|
Some(tool) => {
|
||||||
|
// Tool::run is synchronous, so we run it in a blocking task if needed
|
||||||
|
// For now, since they run fast and we are transitioning, we can just block in place.
|
||||||
|
// Or tokio::task::spawn_blocking:
|
||||||
|
let tool_name = tool.name().to_string();
|
||||||
|
let t_ctx = ctx.clone();
|
||||||
|
let t_args = args.clone();
|
||||||
|
|
||||||
|
// Because Tool isn't easily cloned, we might just block on the current thread,
|
||||||
|
// or use tokio::task::block_in_place if we're in a tokio runtime.
|
||||||
|
// But `tool.run` takes `&self`, and we have `&self` borrowed.
|
||||||
|
// For now we just call it directly.
|
||||||
|
tokio::task::block_in_place(move || {
|
||||||
|
tool.run(&ctx, &args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
anyhow::bail!("Unknown tool: {}", tool_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ pub mod spawn;
|
|||||||
pub mod utility;
|
pub mod utility;
|
||||||
pub mod web_search;
|
pub mod web_search;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
pub mod executor;
|
||||||
|
|
||||||
pub use git::git_cred;
|
pub use git::git_cred;
|
||||||
pub use git::git_operator;
|
pub use git::git_operator;
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ impl Tool for ParallelDelegate {
|
|||||||
|
|
||||||
// Consolidate results
|
// Consolidate results
|
||||||
if synthesize && results.len() > 1 {
|
if synthesize && results.len() > 1 {
|
||||||
let consolidated = consolidate_results(&results, &base_url, &api_key, &model)?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
let consolidated = rt.block_on(consolidate_results(&results, &base_url, &api_key, &model))?;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n{}",
|
"## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n{}",
|
||||||
results.len(),
|
results.len(),
|
||||||
@@ -249,8 +250,9 @@ async fn auto_split_task(
|
|||||||
let user_msg =
|
let user_msg =
|
||||||
zesdex_domain::core::ChatMessage::user(format!("Task: {task}\n\nSplit into {max_parallel} parallel directives:"));
|
zesdex_domain::core::ChatMessage::user(format!("Task: {task}\n\nSplit into {max_parallel} parallel directives:"));
|
||||||
|
|
||||||
|
use zesdex_application::ports::ProviderService;
|
||||||
match client
|
match client
|
||||||
.chat_with_tools_non_streaming(&[sys_msg, user_msg], None, Some(2048), Some(0.4), None)
|
.chat(&[sys_msg, user_msg], None, Some(2048), Some(0.4)).await
|
||||||
{
|
{
|
||||||
Ok((response, _)) => {
|
Ok((response, _)) => {
|
||||||
let text = response.content.unwrap_or_default();
|
let text = response.content.unwrap_or_default();
|
||||||
@@ -339,7 +341,7 @@ fn fallback_split(task: &str, max_parallel: usize) -> Vec<(String, AccessTier)>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Use LLM to consolidate multiple agent results into a single response.
|
/// Use LLM to consolidate multiple agent results into a single response.
|
||||||
fn consolidate_results(
|
async fn consolidate_results(
|
||||||
results: &[(usize, String, String)],
|
results: &[(usize, String, String)],
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
@@ -367,7 +369,8 @@ fn consolidate_results(
|
|||||||
"Consolidate the following parallel agent outputs:\n\n{summary}"
|
"Consolidate the following parallel agent outputs:\n\n{summary}"
|
||||||
));
|
));
|
||||||
|
|
||||||
match client.chat_with_tools_non_streaming(&[sys_msg, user_msg], None, Some(2048), Some(0.3), None) {
|
use zesdex_application::ports::ProviderService;
|
||||||
|
match client.chat(&[sys_msg, user_msg], None, Some(2048), Some(0.3)).await {
|
||||||
Ok((response, _)) => Ok(response.content.unwrap_or_else(|| summary.clone())),
|
Ok((response, _)) => Ok(response.content.unwrap_or_else(|| summary.clone())),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "consolidation LLM call failed, using raw concatenation");
|
warn!(error = %e, "consolidation LLM call failed, using raw concatenation");
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ use crate::tools::{arg_str, Tool, ToolCtx};
|
|||||||
use crate::workflow::engine::execution::execute_workflow;
|
use crate::workflow::engine::execution::execute_workflow;
|
||||||
use crate::workflow::hive_mind::cycle::execute_cycle;
|
use crate::workflow::hive_mind::cycle::execute_cycle;
|
||||||
use crate::workflow::hive_mind::synthesis::synthesize_consensus;
|
use crate::workflow::hive_mind::synthesis::synthesize_consensus;
|
||||||
use crate::workflow::hive_mind::types::{
|
use zesdex_domain::workflow::{CognitiveCycle, NodeDirective, NodeOutput, WorkflowScript};
|
||||||
CognitiveCycle, NodeDirective, NodeOutput,
|
|
||||||
};
|
|
||||||
use crate::workflow::script::WorkflowScript;
|
|
||||||
|
|
||||||
/// Execute a multi-step workflow defined in YAML.
|
/// Execute a multi-step workflow defined in YAML.
|
||||||
///
|
///
|
||||||
@@ -49,7 +46,7 @@ impl Tool for WorkflowRun {
|
|||||||
#[instrument(skip(self, ctx, args))]
|
#[instrument(skip(self, ctx, args))]
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let yaml = arg_str(args, "workflow_yaml")?;
|
let yaml = arg_str(args, "workflow_yaml")?;
|
||||||
let script = WorkflowScript::parse(&yaml)?;
|
let script = crate::workflow::script::parse_workflow_script(&yaml)?;
|
||||||
info!(
|
info!(
|
||||||
"Workflow started: {} ({} phases)",
|
"Workflow started: {} ({} phases)",
|
||||||
script.name,
|
script.name,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::{info, instrument};
|
use tracing::{info, instrument};
|
||||||
|
|
||||||
use crate::workflow::hive_mind::types::NodeOutput;
|
use zesdex_domain::workflow::NodeOutput;
|
||||||
|
|
||||||
/// Write a deterministic audit trail for a hive-mind convergence.
|
/// Write a deterministic audit trail for a hive-mind convergence.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use tracing::{info, instrument};
|
|||||||
use crate::llm::provider::LlmClient;
|
use crate::llm::provider::LlmClient;
|
||||||
use crate::tools::ToolCtx;
|
use crate::tools::ToolCtx;
|
||||||
use crate::workflow::engine::primitives::execute_primitive;
|
use crate::workflow::engine::primitives::execute_primitive;
|
||||||
use crate::workflow::script::WorkflowScript;
|
use zesdex_domain::workflow::WorkflowScript;
|
||||||
|
|
||||||
/// Execute each phase of a workflow script sequentially.
|
/// Execute each phase of a workflow script sequentially.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use crate::subagent::context::SubagentContext;
|
|||||||
use crate::subagent::division::AccessTier;
|
use crate::subagent::division::AccessTier;
|
||||||
use crate::subagent::engine::run_agent;
|
use crate::subagent::engine::run_agent;
|
||||||
use crate::tools::ToolCtx;
|
use crate::tools::ToolCtx;
|
||||||
use crate::workflow::hive_mind::types::{CognitiveCycle, NodeOutput};
|
use zesdex_domain::workflow::{CognitiveCycle, NodeOutput};
|
||||||
|
|
||||||
/// Execute one cycle: run each node directive and collect outputs.
|
/// Execute one cycle: run each node directive and collect outputs.
|
||||||
///
|
///
|
||||||
@@ -56,7 +56,7 @@ pub async fn execute_cycle(
|
|||||||
|
|
||||||
let cycle_index = cycle.index;
|
let cycle_index = cycle.index;
|
||||||
|
|
||||||
use crate::workflow::hive_mind::types::NodeDirective;
|
use zesdex_domain::workflow::NodeDirective;
|
||||||
|
|
||||||
// Run all directives in this cycle concurrently.
|
// Run all directives in this cycle concurrently.
|
||||||
let handles: Vec<_> = cycle
|
let handles: Vec<_> = cycle
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use anyhow::Result;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::tools::ToolCtx;
|
use crate::tools::ToolCtx;
|
||||||
use crate::workflow::hive_mind::types::NodeOutput;
|
use zesdex_domain::workflow::NodeOutput;
|
||||||
|
|
||||||
/// Synthesize a consensus from all node outputs.
|
/// Synthesize a consensus from all node outputs.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,31 +1,3 @@
|
|||||||
//! Hive-mind shared types — node directives, cycle plans, and node outputs.
|
//! Hive-mind shared types — node directives, cycle plans, and node outputs.
|
||||||
|
|
||||||
/// A directive for a single processing node in the hive mind.
|
// Types have been moved to zesdex_domain::workflow
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct NodeDirective {
|
|
||||||
pub directive: String,
|
|
||||||
pub access_tier: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A cognitive cycle plan — ordered list of cycles, each containing
|
|
||||||
/// parallel node directives.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CognitiveCyclePlan {
|
|
||||||
pub cycles: Vec<Vec<NodeDirective>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single cycle in a cognitive cycle plan — parallel node directives
|
|
||||||
/// executed together.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CognitiveCycle {
|
|
||||||
pub index: u32,
|
|
||||||
pub directives: Vec<NodeDirective>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Output from a single hive-mind processing node after a cycle completes.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct NodeOutput {
|
|
||||||
pub id: String,
|
|
||||||
pub directive: String,
|
|
||||||
pub output: String,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,24 +3,11 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::{info, instrument};
|
use tracing::{info, instrument};
|
||||||
|
|
||||||
/// A single phase in a parsed workflow script.
|
use zesdex_domain::workflow::{WorkflowPhase, WorkflowScript};
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct WorkflowPhase {
|
|
||||||
pub name: String,
|
|
||||||
pub directive: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A parsed workflow script with named phases.
|
/// Parse a YAML string into a WorkflowScript.
|
||||||
#[derive(Debug, Clone)]
|
///
|
||||||
pub struct WorkflowScript {
|
/// Expected format:
|
||||||
pub name: String,
|
|
||||||
pub phases: Vec<WorkflowPhase>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkflowScript {
|
|
||||||
/// Parse a YAML string into a WorkflowScript.
|
|
||||||
///
|
|
||||||
/// Expected format:
|
|
||||||
/// ```yaml
|
/// ```yaml
|
||||||
/// name: my-workflow
|
/// name: my-workflow
|
||||||
/// phases:
|
/// phases:
|
||||||
@@ -30,7 +17,7 @@ impl WorkflowScript {
|
|||||||
/// directive: "Implement the changes..."
|
/// directive: "Implement the changes..."
|
||||||
/// ```
|
/// ```
|
||||||
#[instrument]
|
#[instrument]
|
||||||
pub fn parse(yaml: &str) -> Result<Self> {
|
pub fn parse_workflow_script(yaml: &str) -> Result<WorkflowScript> {
|
||||||
let parsed: serde_json::Value = serde_yaml_ng::from_str(yaml)
|
let parsed: serde_json::Value = serde_yaml_ng::from_str(yaml)
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to parse workflow YAML: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Failed to parse workflow YAML: {e}"))?;
|
||||||
|
|
||||||
@@ -62,5 +49,4 @@ impl WorkflowScript {
|
|||||||
|
|
||||||
info!("Parsed workflow script: {name} ({} phases)", phases.len());
|
info!("Parsed workflow script: {name} ({} phases)", phases.len());
|
||||||
Ok(WorkflowScript { name, phases })
|
Ok(WorkflowScript { name, phases })
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ pub async fn chat_completions_handler(
|
|||||||
let llm_client = if model == state.llm_client.model {
|
let llm_client = if model == state.llm_client.model {
|
||||||
&state.llm_client
|
&state.llm_client
|
||||||
} else {
|
} else {
|
||||||
temp_client = zesdex_infrastructure::llm::LlmClient::new(
|
temp_client = zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||||
state.llm_client.api_key.clone(),
|
state.llm_client.api_key.clone(),
|
||||||
model,
|
model,
|
||||||
Some(state.llm_client.base_url.clone()),
|
Some(state.llm_client.base_url.clone()),
|
||||||
@@ -129,14 +129,14 @@ pub async fn chat_completions_handler(
|
|||||||
&temp_client
|
&temp_client
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use zesdex_application::ports::ProviderService;
|
||||||
let (response, usage) = llm_client
|
let (response, usage) = llm_client
|
||||||
.chat_with_tools_non_streaming(
|
.chat(
|
||||||
&messages,
|
&messages,
|
||||||
None, // No tool definitions for basic chat
|
None, // No tool definitions for basic chat
|
||||||
req.max_tokens,
|
req.max_tokens,
|
||||||
req.temperature,
|
req.temperature,
|
||||||
None, // No abort flag
|
).await
|
||||||
)
|
|
||||||
.map_err(|e| ApiError::ChatProxy(format!("LLM request failed: {e}")))?;
|
.map_err(|e| ApiError::ChatProxy(format!("LLM request failed: {e}")))?;
|
||||||
|
|
||||||
let (prompt_tokens, completion_tokens) = usage.unwrap_or((0, 0));
|
let (prompt_tokens, completion_tokens) = usage.unwrap_or((0, 0));
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ pub struct ApiState {
|
|||||||
pub token_service: JwtTokenService,
|
pub token_service: JwtTokenService,
|
||||||
|
|
||||||
/// LLM provider client for chat completions.
|
/// LLM provider client for chat completions.
|
||||||
pub llm_client: zesdex_infrastructure::llm::LlmClient,
|
pub llm_client: zesdex_infrastructure::llm::provider::LlmClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for ApiState {
|
impl fmt::Debug for ApiState {
|
||||||
@@ -272,7 +272,7 @@ impl ApiState {
|
|||||||
zesdex_application::cms::MemoryServiceImpl::new(memory_repo, memory_dir);
|
zesdex_application::cms::MemoryServiceImpl::new(memory_repo, memory_dir);
|
||||||
|
|
||||||
let token_service = JwtTokenService::new(&jwt_secret);
|
let token_service = JwtTokenService::new(&jwt_secret);
|
||||||
let llm_client = zesdex_infrastructure::llm::LlmClient::new(
|
let llm_client = zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||||
llm_api_key.into(),
|
llm_api_key.into(),
|
||||||
llm_model.into(),
|
llm_model.into(),
|
||||||
llm_base_url,
|
llm_base_url,
|
||||||
|
|||||||
@@ -320,19 +320,47 @@ fn handle_submit_input(state: &mut AppStateRest, text: String) {
|
|||||||
.map(|rt| rt.messages.clone())
|
.map(|rt| rt.messages.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let params = zesdex_infrastructure::agent::AgentTurnParams {
|
let params = zesdex_domain::agent::AgentTurnParams {
|
||||||
messages,
|
messages,
|
||||||
session_dir: state.session_dir.clone(),
|
session_dir: state.session_dir.clone(),
|
||||||
workspace_roots: state.workspace_roots.clone(),
|
workspace_roots: state.workspace_roots.clone(),
|
||||||
turn_events: state.turn_events.clone(),
|
turn_events: state.turn_events.clone(),
|
||||||
in_flight: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
in_flight: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
abort: state.abort_flag.clone(),
|
abort: state.abort_flag.clone(),
|
||||||
api_key,
|
api_key: api_key.clone(),
|
||||||
model: state.settings.model.clone(),
|
model: state.settings.model.clone(),
|
||||||
api_base: provider_cfg.map(|cfg| cfg.api_base.clone()),
|
api_base: provider_cfg.as_ref().map(|cfg| cfg.api_base.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||||
|
api_key,
|
||||||
|
state.settings.model.clone(),
|
||||||
|
provider_cfg.map(|cfg| cfg.api_base.clone()),
|
||||||
|
));
|
||||||
|
|
||||||
|
let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder()
|
||||||
|
.session_dir(state.session_dir.clone())
|
||||||
|
.workspaces(state.workspace_roots.clone())
|
||||||
|
.turn_events(state.turn_events.clone())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let tool_executor = std::sync::Arc::new(
|
||||||
|
zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tools = zesdex_infrastructure::tools::all_tools();
|
||||||
|
let defs = zesdex_infrastructure::tools::tool_defs(&tools);
|
||||||
|
|
||||||
|
let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new(
|
||||||
|
client,
|
||||||
|
tool_executor,
|
||||||
|
defs,
|
||||||
|
);
|
||||||
|
|
||||||
|
use zesdex_application::agent::AgentTurnService;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = turn_service.run_turn(params).await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_delete_char(state: &mut AppStateRest) {
|
fn handle_delete_char(state: &mut AppStateRest) {
|
||||||
@@ -445,10 +473,11 @@ fn handle_compact(state: &mut AppStateRest) {
|
|||||||
let model = state.settings.model.clone();
|
let model = state.settings.model.clone();
|
||||||
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
|
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
|
||||||
|
|
||||||
let client = zesdex_infrastructure::llm::LlmClient::new(api_key, model, api_base);
|
let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base);
|
||||||
|
|
||||||
if let Some(ref mut rt) = state.session_runtime {
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
if let Ok(()) = zesdex_infrastructure::agent::compact_messages_with_ai(&mut rt.messages, &client) {
|
let tokio_rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
if let Ok(()) = tokio_rt.block_on(zesdex_application::agent::turn_service::compact_messages_with_ai(&mut rt.messages, &client)) {
|
||||||
let msg_count = rt.messages.len();
|
let msg_count = rt.messages.len();
|
||||||
state.push_transcript(ChatMessageDisplay::new(
|
state.push_transcript(ChatMessageDisplay::new(
|
||||||
RoleWrapper::System,
|
RoleWrapper::System,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::sync::atomic::Ordering;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use zesdex_domain::core::ChatMessage;
|
use zesdex_domain::core::ChatMessage;
|
||||||
use zesdex_infrastructure::agent::{spawn_agent_turn as backend_spawn_agent_turn, AgentTurnParams};
|
use zesdex_domain::agent::AgentTurnParams;
|
||||||
use crate::state::AppStateRest;
|
use crate::state::AppStateRest;
|
||||||
|
|
||||||
/// Spawn an agent turn on a background thread by delegating to `zesdex-infrastructure`.
|
/// Spawn an agent turn on a background thread by delegating to `zesdex-infrastructure`.
|
||||||
@@ -63,15 +63,43 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
|||||||
|
|
||||||
let params = AgentTurnParams {
|
let params = AgentTurnParams {
|
||||||
messages,
|
messages,
|
||||||
session_dir,
|
session_dir: session_dir.clone(),
|
||||||
workspace_roots,
|
workspace_roots: workspace_roots.clone(),
|
||||||
turn_events,
|
turn_events: turn_events.clone(),
|
||||||
in_flight,
|
in_flight: in_flight.clone(),
|
||||||
abort,
|
abort: abort.clone(),
|
||||||
|
api_key: api_key.clone(),
|
||||||
|
model: model.clone(),
|
||||||
|
api_base: api_base.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
api_base,
|
api_base,
|
||||||
};
|
));
|
||||||
|
|
||||||
backend_spawn_agent_turn(params);
|
let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder()
|
||||||
|
.session_dir(session_dir)
|
||||||
|
.workspaces(workspace_roots)
|
||||||
|
.turn_events(turn_events)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let tool_executor = std::sync::Arc::new(
|
||||||
|
zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tools = zesdex_infrastructure::tools::all_tools();
|
||||||
|
let defs = zesdex_infrastructure::tools::tool_defs(&tools);
|
||||||
|
|
||||||
|
let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new(
|
||||||
|
client,
|
||||||
|
tool_executor,
|
||||||
|
defs,
|
||||||
|
);
|
||||||
|
|
||||||
|
use zesdex_application::agent::AgentTurnService;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = turn_service.run_turn(params).await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,19 +82,47 @@ async fn handle_socket(mut socket: WebSocket, state: Arc<WsState>) {
|
|||||||
let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
|
let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
|
||||||
let model = val.get("model").and_then(|v| v.as_str()).unwrap_or("gpt-4o").to_string();
|
let model = val.get("model").and_then(|v| v.as_str()).unwrap_or("gpt-4o").to_string();
|
||||||
|
|
||||||
let params = zesdex_infrastructure::agent::AgentTurnParams {
|
let params = zesdex_domain::agent::AgentTurnParams {
|
||||||
messages: vec![zesdex_domain::core::ChatMessage::user(prompt)],
|
messages: vec![zesdex_domain::core::ChatMessage::user(prompt)],
|
||||||
session_dir,
|
session_dir: session_dir.clone(),
|
||||||
workspace_roots,
|
workspace_roots: workspace_roots.clone(),
|
||||||
turn_events: turn_events.clone(),
|
turn_events: turn_events.clone(),
|
||||||
in_flight,
|
in_flight,
|
||||||
abort,
|
abort,
|
||||||
api_key,
|
api_key: api_key.clone(),
|
||||||
model,
|
model: model.clone(),
|
||||||
api_base: None,
|
api_base: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||||
|
api_key,
|
||||||
|
model,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder()
|
||||||
|
.session_dir(session_dir)
|
||||||
|
.workspaces(workspace_roots)
|
||||||
|
.turn_events(turn_events.clone())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let tool_executor = std::sync::Arc::new(
|
||||||
|
zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tools = zesdex_infrastructure::tools::all_tools();
|
||||||
|
let defs = zesdex_infrastructure::tools::tool_defs(&tools);
|
||||||
|
|
||||||
|
let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new(
|
||||||
|
client,
|
||||||
|
tool_executor,
|
||||||
|
defs,
|
||||||
|
);
|
||||||
|
|
||||||
|
use zesdex_application::agent::AgentTurnService;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = turn_service.run_turn(params).await;
|
||||||
|
});
|
||||||
|
|
||||||
let tx_clone = tx.clone();
|
let tx_clone = tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
Reference in New Issue
Block a user