- drop agent_run_groups table and group_id/scope_kind/scope_id columns (schema v8) - remove group_id from AgentExecutionContext and recovery group counters - flatten TasksPage background tab into a per-run list - add WebUI Agents page with definition CRUD and inline provider/model - bump version to 1.11.0
549 lines
18 KiB
Rust
549 lines
18 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
use crate::providers::ToolCall;
|
|
|
|
/// Provider-private state required to faithfully replay an assistant message.
|
|
///
|
|
/// This is durable conversation data, but it is never presentation data. UI and
|
|
/// channel projections must not serialize it to end users.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ProviderReasoningState {
|
|
pub provider: String,
|
|
pub payload: serde_json::Value,
|
|
}
|
|
|
|
impl ProviderReasoningState {
|
|
/// Decode persisted provider state without making conversation history
|
|
/// unreadable when an old or damaged payload is encountered.
|
|
pub fn from_json_lossy(value: &str) -> Option<Self> {
|
|
serde_json::from_str(value).ok()
|
|
}
|
|
}
|
|
|
|
/// Describes whether a persisted message represents a complete model result.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum CompletionStatus {
|
|
#[default]
|
|
Completed,
|
|
Cancelled,
|
|
Interrupted,
|
|
}
|
|
|
|
impl CompletionStatus {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Completed => "completed",
|
|
Self::Cancelled => "cancelled",
|
|
Self::Interrupted => "interrupted",
|
|
}
|
|
}
|
|
|
|
pub fn from_storage(value: &str) -> Self {
|
|
match value {
|
|
"cancelled" => Self::Cancelled,
|
|
"interrupted" => Self::Interrupted,
|
|
_ => Self::Completed,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// ContentBlock - Multimodal content representation (OpenAI-style)
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum ContentBlock {
|
|
#[serde(rename = "text")]
|
|
Text { text: String },
|
|
#[serde(rename = "image_url")]
|
|
ImageUrl { image_url: ImageUrlBlock },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ImageUrlBlock {
|
|
pub url: String,
|
|
}
|
|
|
|
impl ContentBlock {
|
|
pub fn text(content: impl Into<String>) -> Self {
|
|
Self::Text {
|
|
text: content.into(),
|
|
}
|
|
}
|
|
|
|
pub fn image_url(url: impl Into<String>) -> Self {
|
|
Self::ImageUrl {
|
|
image_url: ImageUrlBlock { url: url.into() },
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MediaRef - Media reference in ChatMessage (carries type info)
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MediaRef {
|
|
pub path: String,
|
|
pub media_type: String,
|
|
}
|
|
|
|
// ============================================================================
|
|
// MediaItem - Media metadata for messages
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MediaItem {
|
|
pub path: String, // Local file path
|
|
pub media_type: String, // "image", "audio", "file", "video"
|
|
pub mime_type: Option<String>,
|
|
pub original_key: Option<String>, // Feishu file_key for download
|
|
}
|
|
|
|
impl MediaItem {
|
|
pub fn new(path: impl Into<String>, media_type: impl Into<String>) -> Self {
|
|
Self {
|
|
path: path.into(),
|
|
media_type: media_type.into(),
|
|
mime_type: None,
|
|
original_key: None,
|
|
}
|
|
}
|
|
|
|
pub fn to_media_ref(&self) -> MediaRef {
|
|
MediaRef {
|
|
path: self.path.clone(),
|
|
media_type: self.media_type.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn from_media_ref(media_ref: &MediaRef) -> Self {
|
|
Self::new(media_ref.path.clone(), media_ref.media_type.clone())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// ChatMessage - Used by AgentLoop for LLM conversation history
|
|
// ============================================================================
|
|
|
|
/// Whether a message may be surfaced to clients. Hidden messages exist only
|
|
/// for model replay (internal triggers) and must never appear in history,
|
|
/// projections or delivery.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ClientVisibility {
|
|
#[default]
|
|
Visible,
|
|
Hidden,
|
|
}
|
|
|
|
impl ClientVisibility {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Visible => "visible",
|
|
Self::Hidden => "hidden",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where a message Turn originated. Persisted alongside the message so
|
|
/// clients can render agent-driven continuation output without treating it as
|
|
/// a user bubble.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TurnOrigin {
|
|
#[default]
|
|
User,
|
|
AgentContinuation,
|
|
Scheduled,
|
|
}
|
|
|
|
impl TurnOrigin {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::User => "user",
|
|
Self::AgentContinuation => "agent_continuation",
|
|
Self::Scheduled => "scheduled",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChatMessage {
|
|
pub id: String,
|
|
pub role: String,
|
|
pub content: String,
|
|
pub reasoning_content: Option<String>,
|
|
/// Opaque state used only when replaying history to the same provider.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub provider_state: Option<ProviderReasoningState>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub turn_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub iteration: Option<u32>,
|
|
#[serde(default)]
|
|
pub completion_status: CompletionStatus,
|
|
#[serde(default)]
|
|
pub client_visibility: ClientVisibility,
|
|
#[serde(default)]
|
|
pub turn_origin: TurnOrigin,
|
|
pub media_refs: Vec<MediaRef>,
|
|
pub timestamp: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tool_call_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tool_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tool_calls: Option<Vec<ToolCall>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source: Option<MessageSource>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum SourceKind {
|
|
#[serde(rename = "user_input")]
|
|
UserInput,
|
|
#[serde(rename = "system_notification")]
|
|
SystemNotification,
|
|
#[serde(rename = "cross_channel")]
|
|
CrossChannel,
|
|
#[serde(rename = "external_trigger")]
|
|
ExternalTrigger,
|
|
/// A durable signal emitted by a background Agent via `emit_signal`.
|
|
#[serde(rename = "agent_signal")]
|
|
AgentSignal,
|
|
/// A durable background run completion outcome.
|
|
#[serde(rename = "agent_result")]
|
|
AgentCompletion,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MessageSource {
|
|
pub kind: SourceKind,
|
|
pub from_channel: Option<String>,
|
|
pub from_session: Option<String>,
|
|
pub from_user_id: Option<String>,
|
|
pub system_name: Option<String>,
|
|
pub task_id: Option<String>,
|
|
/// Durable Agent run identity for `agent_signal`/`agent_result` sources.
|
|
#[serde(default)]
|
|
pub from_run_id: Option<String>,
|
|
/// Agent definition id for `agent_signal`/`agent_result` sources.
|
|
#[serde(default)]
|
|
pub from_agent_id: Option<String>,
|
|
}
|
|
|
|
impl ChatMessage {
|
|
pub fn user(content: impl Into<String>) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
role: "user".to_string(),
|
|
content: content.into(),
|
|
reasoning_content: None,
|
|
provider_state: None,
|
|
turn_id: None,
|
|
iteration: None,
|
|
completion_status: CompletionStatus::Completed,
|
|
media_refs: Vec::new(),
|
|
timestamp: current_timestamp(),
|
|
tool_call_id: None,
|
|
tool_name: None,
|
|
tool_calls: None,
|
|
source: None,
|
|
client_visibility: ClientVisibility::Visible,
|
|
turn_origin: TurnOrigin::User,
|
|
}
|
|
}
|
|
|
|
pub fn user_with_media(content: impl Into<String>, media_refs: Vec<MediaRef>) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
role: "user".to_string(),
|
|
content: content.into(),
|
|
reasoning_content: None,
|
|
provider_state: None,
|
|
turn_id: None,
|
|
iteration: None,
|
|
completion_status: CompletionStatus::Completed,
|
|
media_refs,
|
|
timestamp: current_timestamp(),
|
|
tool_call_id: None,
|
|
tool_name: None,
|
|
tool_calls: None,
|
|
source: None,
|
|
client_visibility: ClientVisibility::Visible,
|
|
turn_origin: TurnOrigin::User,
|
|
}
|
|
}
|
|
|
|
pub fn assistant(content: impl Into<String>) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
role: "assistant".to_string(),
|
|
content: content.into(),
|
|
reasoning_content: None,
|
|
provider_state: None,
|
|
turn_id: None,
|
|
iteration: None,
|
|
completion_status: CompletionStatus::Completed,
|
|
media_refs: Vec::new(),
|
|
timestamp: current_timestamp(),
|
|
tool_call_id: None,
|
|
tool_name: None,
|
|
tool_calls: None,
|
|
source: None,
|
|
client_visibility: ClientVisibility::Visible,
|
|
turn_origin: TurnOrigin::User,
|
|
}
|
|
}
|
|
|
|
pub fn assistant_with_tool_calls(
|
|
content: impl Into<String>,
|
|
tool_calls: Vec<ToolCall>,
|
|
) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
role: "assistant".to_string(),
|
|
content: content.into(),
|
|
reasoning_content: None,
|
|
provider_state: None,
|
|
turn_id: None,
|
|
iteration: None,
|
|
completion_status: CompletionStatus::Completed,
|
|
media_refs: Vec::new(),
|
|
timestamp: current_timestamp(),
|
|
tool_call_id: None,
|
|
tool_name: None,
|
|
tool_calls: Some(tool_calls),
|
|
source: None,
|
|
client_visibility: ClientVisibility::Visible,
|
|
turn_origin: TurnOrigin::User,
|
|
}
|
|
}
|
|
|
|
pub fn assistant_with_source(content: impl Into<String>, source: MessageSource) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
role: "assistant".to_string(),
|
|
content: content.into(),
|
|
reasoning_content: None,
|
|
provider_state: None,
|
|
turn_id: None,
|
|
iteration: None,
|
|
completion_status: CompletionStatus::Completed,
|
|
media_refs: Vec::new(),
|
|
timestamp: current_timestamp(),
|
|
tool_call_id: None,
|
|
tool_name: None,
|
|
tool_calls: None,
|
|
source: Some(source),
|
|
client_visibility: ClientVisibility::Visible,
|
|
turn_origin: TurnOrigin::User,
|
|
}
|
|
}
|
|
|
|
pub fn system(content: impl Into<String>) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
role: "system".to_string(),
|
|
content: content.into(),
|
|
reasoning_content: None,
|
|
provider_state: None,
|
|
turn_id: None,
|
|
iteration: None,
|
|
completion_status: CompletionStatus::Completed,
|
|
media_refs: Vec::new(),
|
|
timestamp: current_timestamp(),
|
|
tool_call_id: None,
|
|
tool_name: None,
|
|
tool_calls: None,
|
|
source: None,
|
|
client_visibility: ClientVisibility::Visible,
|
|
turn_origin: TurnOrigin::User,
|
|
}
|
|
}
|
|
|
|
pub fn tool(
|
|
tool_call_id: impl Into<String>,
|
|
tool_name: impl Into<String>,
|
|
content: impl Into<String>,
|
|
) -> Self {
|
|
Self::tool_with_media(tool_call_id, tool_name, content, Vec::new())
|
|
}
|
|
|
|
pub fn tool_with_media(
|
|
tool_call_id: impl Into<String>,
|
|
tool_name: impl Into<String>,
|
|
content: impl Into<String>,
|
|
media_refs: Vec<MediaRef>,
|
|
) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
role: "tool".to_string(),
|
|
content: content.into(),
|
|
reasoning_content: None,
|
|
provider_state: None,
|
|
turn_id: None,
|
|
iteration: None,
|
|
completion_status: CompletionStatus::Completed,
|
|
media_refs,
|
|
timestamp: current_timestamp(),
|
|
tool_call_id: Some(tool_call_id.into()),
|
|
tool_name: Some(tool_name.into()),
|
|
tool_calls: None,
|
|
source: None,
|
|
client_visibility: ClientVisibility::Visible,
|
|
turn_origin: TurnOrigin::User,
|
|
}
|
|
}
|
|
|
|
pub fn user_with_source(content: impl Into<String>, source: MessageSource) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
role: "user".to_string(),
|
|
content: content.into(),
|
|
reasoning_content: None,
|
|
provider_state: None,
|
|
turn_id: None,
|
|
iteration: None,
|
|
completion_status: CompletionStatus::Completed,
|
|
media_refs: Vec::new(),
|
|
timestamp: current_timestamp(),
|
|
tool_call_id: None,
|
|
tool_name: None,
|
|
tool_calls: None,
|
|
source: Some(source),
|
|
client_visibility: ClientVisibility::Visible,
|
|
turn_origin: TurnOrigin::User,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod conversation_message_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn damaged_provider_state_is_ignored() {
|
|
assert!(ProviderReasoningState::from_json_lossy("not-json").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_completion_status_is_backward_compatible() {
|
|
assert_eq!(
|
|
CompletionStatus::from_storage("future-status"),
|
|
CompletionStatus::Completed
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// InboundMessage - Message from Channel to Bus (user input)
|
|
// ============================================================================
|
|
|
|
/// Opaque channel-owned context that may be carried to the corresponding reply.
|
|
/// Core routing understands `reply_to`; all other platform data remains
|
|
/// private. `durable_private` holds only values the channel declares safe to
|
|
/// reuse across turns (thread/root identity); one-shot message/reaction ids
|
|
/// belong in `private`.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ChannelContext {
|
|
pub reply_to: Option<String>,
|
|
pub private: HashMap<String, String>,
|
|
pub durable_private: HashMap<String, String>,
|
|
}
|
|
|
|
/// Public, durable projection of a newly committed conversation message.
|
|
/// Provider replay state and source identities are deliberately excluded.
|
|
#[derive(Debug, Clone)]
|
|
pub struct CommittedMessage {
|
|
pub id: String,
|
|
pub seq: i64,
|
|
pub role: String,
|
|
pub content: String,
|
|
pub reasoning_content: Option<String>,
|
|
pub completion_status: CompletionStatus,
|
|
pub media_refs: Vec<MediaRef>,
|
|
pub created_at: i64,
|
|
pub tool_call_id: Option<String>,
|
|
pub tool_name: Option<String>,
|
|
pub tool_calls: Option<Vec<ToolCall>>,
|
|
pub turn_origin: TurnOrigin,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CommittedTurnDelta {
|
|
pub session_id: String,
|
|
/// Highest durable message sequence included in this commit.
|
|
pub history_revision: i64,
|
|
pub messages: Vec<CommittedMessage>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct InboundMessage {
|
|
pub channel: String,
|
|
pub sender_id: String,
|
|
pub chat_id: String,
|
|
/// Client-provided id for optimistic UI reconciliation. Channel-owned
|
|
/// inputs that do not expose a client id leave this unset; the session
|
|
/// layer may generate a durable id when it accepts the message.
|
|
pub client_message_id: Option<String>,
|
|
pub content: String,
|
|
pub received_at: i64,
|
|
pub media: Vec<MediaItem>,
|
|
pub channel_context: ChannelContext,
|
|
}
|
|
|
|
// ============================================================================
|
|
// OutboundMessage - Message from Agent to Channel (bot response)
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OutboundMessage {
|
|
pub channel: String,
|
|
pub chat_id: String,
|
|
pub content: String,
|
|
pub reply_to: Option<String>,
|
|
pub media: Vec<MediaItem>,
|
|
pub metadata: HashMap<String, String>,
|
|
pub(crate) delivery: Option<tokio::sync::watch::Sender<Option<Result<(), String>>>>,
|
|
}
|
|
|
|
impl OutboundMessage {
|
|
pub(crate) fn complete_delivery(&self, result: Result<(), String>) {
|
|
if let Some(delivery) = &self.delivery {
|
|
delivery.send_replace(Some(result));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// ControlMessage - Message for control channel (session management)
|
|
// Uses SessionCommand from session module
|
|
// ============================================================================
|
|
|
|
use crate::channels::base::ChannelError;
|
|
use crate::session::{SessionCommand, SessionEvent};
|
|
use tokio::sync::mpsc;
|
|
|
|
/// Control message containing a session operation and reply channel
|
|
#[derive(Debug, Clone)]
|
|
pub struct ControlMessage {
|
|
pub op: SessionCommand,
|
|
pub reply_tx: mpsc::Sender<Result<SessionEvent, ChannelError>>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
pub(crate) fn current_timestamp() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as i64
|
|
}
|