use async_trait::async_trait;
use saya_types::{ClaimId, ClaimStatus};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRequest {
pub prompt: String,
pub profile_names: Vec<String>,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default)]
pub history: Vec<ChatMessage>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_blocks: Vec<ContextBlock>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextBlock {
pub label: String,
pub body: String,
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChatMessage {
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl ChatMessage {
pub fn text(role: &str, content: impl Into<String>) -> Self {
Self {
role: role.into(),
content: content.into(),
tool_calls: Vec::new(),
tool_call_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolMetadata {
pub name: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<ChatMessage>,
pub tools: Vec<ToolDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
pub message: ChatMessage,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum KnowledgeOutcome {
Off,
Skipped,
Ran { store_unavailable: bool },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SuppliedClaimDto {
pub claim_id: ClaimId,
pub kind: String,
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<String>,
pub status: ClaimStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SuppliedContractDto {
pub profile: String,
pub object: String,
pub schema_state: String,
pub claims: Vec<SuppliedClaimDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProposedClaimDto {
pub claim_id: ClaimId,
pub profile: String,
pub object: String,
pub kind: String,
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<String>,
pub status: ClaimStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OverrideFindingDto {
pub claim_id: ClaimId,
pub kind: String,
pub claimed_value: String,
pub observed_columns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum AgentEvent {
AssistantText {
text: String,
},
ToolRequested {
name: String,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
arguments: serde_json::Value,
},
ToolCompleted {
name: String,
summary: String,
},
ToolDenied {
name: String,
reason: String,
},
KnowledgeSupplied {
outcome: KnowledgeOutcome,
contracts: Vec<SuppliedContractDto>,
dropped_by_bounds: usize,
},
KnowledgeProposed {
claim: ProposedClaimDto,
},
KnowledgeOverridden {
findings: Vec<OverrideFindingDto>,
},
KnowledgeLearningSkipped {
reason: LearningSkipReason,
},
Complete,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LearningSkipReason {
TimedOut,
Failed,
}
impl AgentEvent {
pub fn assistant_text(text: impl Into<String>) -> Self {
Self::AssistantText { text: text.into() }
}
pub fn tool_requested(name: impl Into<String>, arguments: serde_json::Value) -> Self {
Self::ToolRequested {
name: name.into(),
arguments,
}
}
pub fn knowledge_supplied(
outcome: KnowledgeOutcome,
contracts: Vec<SuppliedContractDto>,
dropped_by_bounds: usize,
) -> Self {
Self::KnowledgeSupplied {
outcome,
contracts,
dropped_by_bounds,
}
}
pub fn knowledge_proposed(claim: ProposedClaimDto) -> Self {
Self::KnowledgeProposed { claim }
}
pub fn knowledge_overridden(findings: Vec<OverrideFindingDto>) -> Self {
Self::KnowledgeOverridden { findings }
}
pub fn knowledge_learning_skipped(reason: LearningSkipReason) -> Self {
Self::KnowledgeLearningSkipped { reason }
}
pub fn complete() -> Self {
Self::Complete
}
}
#[async_trait]
pub trait ApprovalDecider: Send + Sync {
async fn approve(&self, tool: &ToolDefinition, arguments: &serde_json::Value) -> bool;
}
pub struct AllowReadOnlyApproval;
#[async_trait]
impl ApprovalDecider for AllowReadOnlyApproval {
async fn approve(&self, _: &ToolDefinition, _: &serde_json::Value) -> bool {
true
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProviderError {
#[error("provider request failed: {0}")]
Request(String),
#[error("provider returned an invalid response")]
InvalidResponse,
#[error("provider is not configured: {0}")]
Configuration(String),
#[error("provider stream was cancelled")]
Cancelled,
}
impl ProviderError {
pub fn configuration(message: impl Into<String>) -> Self {
Self::Configuration(message.into())
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum ToolError {
#[error("data sharing is disabled for this cloud provider")]
DataSharingDisabled,
#[error("invalid query arguments")]
InvalidQueryArguments,
#[error("unsupported read-only tool")]
UnsupportedTool,
#[error("invalid tool arguments: expected an object")]
ArgumentsNotObject,
#[error("invalid tool arguments: unsupported property")]
UnsupportedProperty,
#[error("invalid tool arguments: connection must be a string")]
ConnectionNotString,
#[error("invalid tool arguments: sql must be a string")]
SqlNotString,
#[error("no database profile is selected")]
NoConnectionSelected,
#[error("unknown connection \"{target}\"; available connections: {available}")]
UnknownConnection { target: String, available: String },
#[error("read-only query failed")]
QueryFailed,
#[error("read-only query failed: {0}")]
QueryFailedDetail(String),
#[error("read-only query timed out")]
QueryTimedOut,
#[error("query result unavailable")]
QueryResultUnavailable,
#[error("schema discovery failed: {0}")]
SchemaDiscoveryFailed(String),
#[error("{0}")]
Chart(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LocalStateEffect {
#[default]
None,
Read,
WriteCandidate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolEffect {
pub database_data: bool,
pub external_side_effect: bool,
pub requires_approval: bool,
#[serde(default)]
pub local_state: LocalStateEffect,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub read_only: bool,
pub parameters: serde_json::Value,
pub effect: ToolEffect,
}
#[async_trait]
pub trait ToolExecutor: Send + Sync {
async fn execute(
&self,
name: &str,
arguments: serde_json::Value,
) -> Result<serde_json::Value, ToolError>;
}
#[cfg(test)]
mod tests {
use super::LocalStateEffect;
#[test]
fn tool_effect_without_local_state_key_defaults_to_none() {
let json = r#"{
"database_data": false,
"external_side_effect": false,
"requires_approval": false
}"#;
let effect: super::ToolEffect = serde_json::from_str(json).expect("old form deserializes");
assert_eq!(effect.local_state, LocalStateEffect::None);
}
#[test]
fn local_state_effect_round_trips_through_snake_case() {
for (variant, expected) in [
(LocalStateEffect::None, "none"),
(LocalStateEffect::Read, "read"),
(LocalStateEffect::WriteCandidate, "write_candidate"),
] {
let text = serde_json::to_string(&variant).expect("serializes");
assert_eq!(text, format!("\"{expected}\""), "{variant:?}");
let back: LocalStateEffect = serde_json::from_str(&text).expect("deserializes back");
assert_eq!(back, variant, "{variant:?}");
}
}
#[test]
fn knowledge_overridden_serializes_with_type_tag_and_findings() {
use super::{AgentEvent, OverrideFindingDto};
use saya_types::ClaimId;
let event = AgentEvent::knowledge_overridden(vec![OverrideFindingDto {
claim_id: ClaimId::parse("c-rental-time").unwrap(),
kind: "default_time_column".into(),
claimed_value: "return_date".into(),
observed_columns: vec!["rental_date".into()],
}]);
let json = serde_json::to_string(&event).expect("serializes");
assert!(json.contains(r#""type":"knowledge_overridden""#), "{json}");
assert!(
json.contains("return_date"),
"carries the claimed value: {json}"
);
assert!(
json.contains("rental_date"),
"carries the observed column: {json}"
);
let fake_identity =
"sha256:9f2a8c7b1e4d0a6f3c5b8e2d7a9f1c4b6e8a0d2f4c6b8e0a2d4f6c8b0e2d4f6";
assert!(!json.contains(fake_identity), "identity leaked: {json}");
}
#[test]
fn knowledge_learning_skipped_serializes_with_type_tag_and_reason() {
use super::{AgentEvent, LearningSkipReason};
for (reason, token) in [
(LearningSkipReason::TimedOut, "timed_out"),
(LearningSkipReason::Failed, "failed"),
] {
let event = AgentEvent::knowledge_learning_skipped(reason);
let json = serde_json::to_string(&event).expect("serializes");
assert!(
json.contains(r#""type":"knowledge_learning_skipped""#),
"type tag for {reason:?}: {json}"
);
assert!(
json.contains(&format!(r#""reason":"{token}""#)),
"reason token for {reason:?}: {json}"
);
let back: AgentEvent = serde_json::from_str(&json).expect("deserializes back");
assert_eq!(back, event, "round-trips for {reason:?}");
}
}
}