PicoBot/src/bus/message.rs
xiaoxixi 5501c539fc feat: remove agent run groups, add WebUI agent definition management
- 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
2026-08-13 14:03:01 +08:00

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
}