use crate::compat::{MaybeSend, MaybeSync};
use crate::errors::AgentError;
use crate::models::Content;
use crate::runtime::context::{ProgressSender, State};
use crate::runtime::AgentRuntime;
use base64::Engine;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct Artifact {
name: String,
content: Content,
}
impl Artifact {
pub fn from_json<T: Serialize>(name: &str, data: &T) -> Result<Self, AgentError> {
use crate::models::content_part::{ContentPart, Data, DataSource};
let json_str = serde_json::to_string(data)?;
let base64_data = base64::engine::general_purpose::STANDARD.encode(json_str.as_bytes());
let data_part = Data {
content_type: "application/json".to_string(),
source: DataSource::Base64(base64_data),
name: Some(name.to_string()),
};
Ok(Self {
name: name.to_string(),
content: Content::from(ContentPart::Data(data_part)),
})
}
#[must_use]
pub fn from_text(name: &str, text: &str) -> Self {
Self {
name: name.to_string(),
content: Content::from_text(text),
}
}
#[must_use]
pub fn from_file(name: &str, mime_type: &str, data: &[u8]) -> Self {
use crate::models::content_part::{ContentPart, Data, DataSource};
let base64_data = base64::engine::general_purpose::STANDARD.encode(data);
let data_part = Data {
content_type: mime_type.to_string(),
source: DataSource::Base64(base64_data),
name: Some(name.to_string()),
};
Self {
name: name.to_string(),
content: Content::from(ContentPart::Data(data_part)),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn content(&self) -> &Content {
&self.content
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SkillSlot {
value: serde_json::Value,
}
impl SkillSlot {
pub fn new<T: Serialize>(value: T) -> Self {
let serialized = serde_json::to_value(value).expect("Failed to serialize skill slot value");
Self { value: serialized }
}
pub fn deserialize<T>(&self) -> Result<T, AgentError>
where
T: serde::de::DeserializeOwned,
{
serde_json::from_value(self.value.clone()).map_err(|e| AgentError::SkillSlot(e.to_string()))
}
#[cfg(feature = "runtime")]
pub(crate) fn into_value(self) -> serde_json::Value {
self.value
}
pub(crate) const fn from_value_unchecked(value: serde_json::Value) -> Self {
Self { value }
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SkillMetadata {
pub id: String,
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub input_modes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output_modes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatibility: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_tools: Vec<String>,
}
impl SkillMetadata {
#[must_use]
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
description: impl Into<String>,
tags: &[&str],
examples: &[&str],
input_modes: &[&str],
output_modes: &[&str],
) -> Self {
Self {
id: id.into(),
name: name.into(),
description: description.into(),
tags: tags.iter().map(|s| (*s).to_string()).collect(),
examples: examples.iter().map(|s| (*s).to_string()).collect(),
input_modes: input_modes.iter().map(|s| (*s).to_string()).collect(),
output_modes: output_modes.iter().map(|s| (*s).to_string()).collect(),
instructions: None,
license: None,
compatibility: None,
allowed_tools: Vec::new(),
}
}
}
pub trait RegisteredSkill: SkillHandler + Sized {
fn metadata() -> std::sync::Arc<SkillMetadata>;
}
#[derive(Debug)]
pub enum OnRequestResult {
InputRequired {
message: Content,
slot: SkillSlot,
},
Completed {
message: Option<Content>,
artifacts: Vec<Artifact>,
},
Failed {
error: Content,
},
Rejected {
reason: Content,
},
}
#[derive(Debug)]
pub enum OnInputResult {
InputRequired {
message: Content,
slot: SkillSlot,
},
Completed {
message: Option<Content>,
artifacts: Vec<Artifact>,
},
Failed {
error: Content,
},
}
#[cfg_attr(
all(target_os = "wasi", target_env = "p1"),
async_trait::async_trait(?Send)
)]
#[cfg_attr(
not(all(target_os = "wasi", target_env = "p1")),
async_trait::async_trait
)]
pub trait SkillHandler: MaybeSend + MaybeSync {
async fn on_request(
&self,
state: &mut State,
progress: &ProgressSender,
runtime: &dyn AgentRuntime,
content: Content,
) -> Result<OnRequestResult, AgentError>;
async fn on_input_received(
&self,
_state: &mut State,
_progress: &ProgressSender,
_runtime: &dyn AgentRuntime,
_content: Content,
) -> Result<OnInputResult, AgentError> {
Ok(OnInputResult::Failed {
error: "This skill does not support multi-turn input".into(),
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "macros", derive(crate::macros::LLMOutput))]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum WorkStatus {
Complete {
message: String,
},
NeedsInput {
message: String,
},
Failed {
reason: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::content_part::{ContentPart, DataSource};
use serde::{Deserialize, Serialize};
#[test]
fn artifact_from_json_encodes_payload() {
let payload = serde_json::json!({ "value": 42 });
let artifact = Artifact::from_json("data.json", &payload).expect("artifact");
assert_eq!(artifact.name(), "data.json");
let content = artifact.content();
let parts = content.parts();
assert_eq!(parts.len(), 1);
let data_part = match &parts[0] {
ContentPart::Data(data) => data,
other @ (ContentPart::Text(_)
| ContentPart::ToolCall(_)
| ContentPart::ToolResponse(_)) => panic!("expected data part, found {other:?}"),
};
assert_eq!(data_part.content_type, "application/json");
assert_eq!(data_part.name.as_deref(), Some("data.json"));
let encoded = match &data_part.source {
DataSource::Base64(b64) => b64,
other @ DataSource::Uri(_) => panic!("expected base64 data, found {other:?}"),
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("base64 decoding");
let text = String::from_utf8(decoded).expect("utf8");
assert_eq!(text, payload.to_string());
}
#[test]
fn artifact_from_file_wraps_bytes() {
let artifact = Artifact::from_file("image.png", "image/png", &[1, 2, 3, 4]);
assert_eq!(artifact.name(), "image.png");
let data_part = match &artifact.content().parts()[0] {
ContentPart::Data(data) => data,
other @ (ContentPart::Text(_)
| ContentPart::ToolCall(_)
| ContentPart::ToolResponse(_)) => panic!("expected data part, found {other:?}"),
};
assert_eq!(data_part.content_type, "image/png");
let encoded = match &data_part.source {
DataSource::Base64(b64) => b64,
other @ DataSource::Uri(_) => panic!("expected base64 data, found {other:?}"),
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("base64 decoding");
assert_eq!(decoded, vec![1, 2, 3, 4]);
}
#[test]
fn skill_slot_round_trips() {
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
enum SlotState {
AwaitingEmail,
Confirm(String),
}
let slot = SkillSlot::new(SlotState::Confirm("example".into()));
let decoded: SlotState = slot
.deserialize()
.expect("slot should deserialize successfully");
assert_eq!(decoded, SlotState::Confirm("example".into()));
}
#[test]
fn work_status_complete_serializes_correctly() {
let status = WorkStatus::Complete {
message: "All done!".to_string(),
};
let json = serde_json::to_value(&status).expect("serialize");
assert_eq!(json["status"], "complete");
assert_eq!(json["message"], "All done!");
}
#[test]
fn work_status_needs_input_serializes_correctly() {
let status = WorkStatus::NeedsInput {
message: "Which language?".to_string(),
};
let json = serde_json::to_value(&status).expect("serialize");
assert_eq!(json["status"], "needs_input");
assert_eq!(json["message"], "Which language?");
}
#[test]
fn work_status_failed_serializes_correctly() {
let status = WorkStatus::Failed {
reason: "Something went wrong".to_string(),
};
let json = serde_json::to_value(&status).expect("serialize");
assert_eq!(json["status"], "failed");
assert_eq!(json["reason"], "Something went wrong");
}
#[test]
fn work_status_round_trips_through_serde() {
let original = WorkStatus::Complete {
message: "Done".to_string(),
};
let json = serde_json::to_string(&original).expect("serialize");
let decoded: WorkStatus = serde_json::from_str(&json).expect("deserialize");
match decoded {
WorkStatus::Complete { message } => assert_eq!(message, "Done"),
_ => panic!("unexpected variant"),
}
}
#[test]
fn work_status_deserializes_all_variants() {
let complete: WorkStatus =
serde_json::from_str(r#"{"status":"complete","message":"ok"}"#).expect("complete");
assert!(matches!(complete, WorkStatus::Complete { .. }));
let needs_input: WorkStatus =
serde_json::from_str(r#"{"status":"needs_input","message":"ask"}"#)
.expect("needs_input");
assert!(matches!(needs_input, WorkStatus::NeedsInput { .. }));
let failed: WorkStatus =
serde_json::from_str(r#"{"status":"failed","reason":"oops"}"#).expect("failed");
assert!(matches!(failed, WorkStatus::Failed { .. }));
}
}