radkit 0.0.5

Rust AI Agent Development Kit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
//! Skill handler abstraction for agent capabilities.
//!
//! This module provides the core skill handler trait and related types for implementing
//! agent capabilities. Skills are the building blocks of agent functionality, handling
//! requests, managing state, and producing results.
//!
//! # Overview
//!
//! - [`SkillHandler`]: Core trait for implementing skill logic
//! - [`OnRequestResult`]: Result returned when a skill handles a request
//! - [`OnInputResult`]: Result returned when a skill receives user input
//! - [`SkillSlot`]: Type-erased slot for requesting additional input
//! - [`Artifact`]: Output artifacts produced by skill execution
//!
//! # Examples
//!
//! ```ignore
//! use radkit::agent::{SkillHandler, OnRequestResult, Artifact};
//! use radkit::runtime::context::{State, ProgressSender};
//! use radkit::runtime::AgentRuntime;
//! use radkit::errors::AgentResult;
//!
//! struct WeatherSkill;
//!
//! #[async_trait::async_trait]
//! impl SkillHandler for WeatherSkill {
//!     async fn on_request(
//!         &self,
//!         state: &mut State,
//!         progress: &ProgressSender,
//!         runtime: &dyn AgentRuntime,
//!         content: Content,
//!     ) -> AgentResult<OnRequestResult> {
//!         // Access current user
//!         let user = runtime.current_user();
//!
//!         // Stream progress updates
//!         progress.send_update("Fetching weather...").await?;
//!
//!         let artifact = Artifact::from_json("forecast.json", &weather_data)?;
//!         Ok(OnRequestResult::Completed {
//!             message: Some(Content::from_text("Weather retrieved")),
//!             artifacts: vec![artifact],
//!         })
//!     }
//! }
//! ```

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};

/// Artifact produced by a skill execution.
///
/// Represents a tangible output generated by an agent, containing one or more parts.
/// Framework automatically converts to A2A Artifact with proper Part types.
///
/// # Examples
///
/// ```ignore
/// // Create JSON artifact
/// let artifact = Artifact::from_json("result.json", &data)?;
///
/// // Create text artifact
/// let artifact = Artifact::from_text("output.txt", "Hello, world!");
///
/// // Create file artifact
/// let artifact = Artifact::from_file("image.png", "image/png", image_bytes);
/// ```
#[derive(Debug, Clone)]
pub struct Artifact {
    /// Artifact identifier/name
    name: String,
    /// Artifact content
    content: Content,
}

impl Artifact {
    /// Create an artifact from JSON data.
    ///
    /// Automatically converts to A2A Artifact with `DataPart`.
    ///
    /// # Arguments
    ///
    /// * `name` - Artifact name/identifier
    /// * `data` - Serializable data
    ///
    /// # Errors
    ///
    /// Returns error if data cannot be serialized.
    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)?;

        // Encode JSON as base64 for A2A protocol
        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)),
        })
    }

    /// Create an artifact from text content.
    ///
    /// Automatically converts to A2A Artifact with `TextPart`.
    ///
    /// # Arguments
    ///
    /// * `name` - Artifact name/identifier
    /// * `text` - Text content
    #[must_use]
    pub fn from_text(name: &str, text: &str) -> Self {
        Self {
            name: name.to_string(),
            content: Content::from_text(text),
        }
    }

    /// Create an artifact from file data.
    ///
    /// Automatically converts to A2A Artifact with `FilePart`.
    ///
    /// # Arguments
    ///
    /// * `name` - Artifact name/identifier
    /// * `mime_type` - MIME type of the file
    /// * `data` - File bytes
    #[must_use]
    pub fn from_file(name: &str, mime_type: &str, data: &[u8]) -> Self {
        use crate::models::content_part::{ContentPart, Data, DataSource};

        // Encode file bytes as base64 for A2A protocol
        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)),
        }
    }

    /// Get the artifact name.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Get the artifact content.
    #[must_use]
    pub const fn content(&self) -> &Content {
        &self.content
    }
}

/// Wrapper for skill input slot values.
///
/// Used to track different input-required states in multi-turn conversations.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SkillSlot {
    value: serde_json::Value,
}

impl SkillSlot {
    /// Create a new skill slot from a serializable value.
    ///
    /// # Arguments
    ///
    /// * `value` - Slot value (typically an enum variant)
    ///
    /// # Panics
    ///
    /// Panics if the value cannot be serialized to JSON. This should only occur
    /// if the type's `Serialize` implementation is broken or returns an error.
    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 }
    }

    /// Deserialize the slot value into the requested type.
    ///
    /// # Errors
    ///
    /// Returns an error if the value cannot be deserialized into type T.
    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 }
    }
}

/// Metadata describing a skill's public surface.
///
/// For Rust skills annotated with `#[skill]`, this is generated at compile
/// time by the macro. For `AgentSkills` loaded from a `SKILL.md` file, this
/// is populated from the frontmatter at startup.
#[derive(Debug, Clone, Serialize)]
pub struct SkillMetadata {
    /// Unique identifier for the skill. Used for routing and task association.
    pub id: String,
    /// Human-readable display name.
    pub name: String,
    /// Description of what the skill does and when to use it.
    /// Shown to the negotiator LLM for routing decisions.
    pub description: String,
    /// Optional keyword tags.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    /// Example prompts that trigger this skill.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub examples: Vec<String>,
    /// Supported input MIME types.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub input_modes: Vec<String>,
    /// Supported output MIME types.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub output_modes: Vec<String>,
    /// Full instruction body from `SKILL.md` (`AgentSkills` only).
    /// `None` for programmatic Rust skills.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub instructions: Option<String>,
    /// License information (`AgentSkills` only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub license: Option<String>,
    /// Compatibility notes (`AgentSkills` only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub compatibility: Option<String>,
    /// Pre-approved tool names from `allowed-tools` frontmatter (`AgentSkills` only).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub allowed_tools: Vec<String>,
}

impl SkillMetadata {
    /// Create metadata for a programmatic Rust skill.
    ///
    /// Used by the `#[skill]` macro and in tests.
    #[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(),
        }
    }
}

/// Trait implemented by skills that expose metadata to the agent builder.
pub trait RegisteredSkill: SkillHandler + Sized {
    /// Returns shared metadata for this skill type.
    ///
    /// Called once per `with_skill()` call at agent builder time.
    fn metadata() -> std::sync::Arc<SkillMetadata>;
}

/// Result of skill handler's `on_request` method.
///
/// Controls the A2A task lifecycle. Maps directly to A2A task states.
#[derive(Debug)]
pub enum OnRequestResult {
    /// Task needs user input.
    ///
    /// Maps to A2A `TaskState::InputRequired` (terminal, final=true).
    InputRequired {
        /// Message to send to user
        message: Content,
        /// Slot to track which input is being requested
        slot: SkillSlot,
    },

    /// Task completed successfully.
    ///
    /// Maps to A2A `TaskState::Completed` (terminal, final=true).
    /// Artifacts here are marked as final.
    Completed {
        /// Optional completion message
        message: Option<Content>,
        /// Final artifacts produced
        artifacts: Vec<Artifact>,
    },

    /// Task failed due to an error.
    ///
    /// Maps to A2A `TaskState::Failed` (terminal, final=true).
    Failed {
        /// Error message
        error: Content,
    },

    /// Task was rejected and not started.
    ///
    /// Maps to A2A `TaskState::Rejected` (terminal, final=true).
    Rejected {
        /// Rejection reason
        reason: Content,
    },
}

/// Result of skill handler's `on_input_received` method.
///
/// Controls continuation after receiving user input.
#[derive(Debug)]
pub enum OnInputResult {
    /// Still need more input.
    ///
    /// Can ask for input again if the user's response was invalid.
    InputRequired {
        /// Message to send to user
        message: Content,
        /// New slot for the next input
        slot: SkillSlot,
    },

    /// Task completed after receiving input.
    ///
    /// Maps to A2A `TaskState::Completed` (terminal, final=true).
    Completed {
        /// Optional completion message
        message: Option<Content>,
        /// Final artifacts produced
        artifacts: Vec<Artifact>,
    },

    /// Failed to process the input.
    ///
    /// Maps to A2A `TaskState::Failed` (terminal, final=true).
    Failed {
        /// Error message
        error: Content,
    },
}

/// Trait for implementing skill handlers.
///
/// Skills are the building blocks of A2A agents. Each skill handles a specific capability
/// and is annotated with the `#[skill]` macro for A2A metadata.
///
/// # Examples
///
/// ```ignore
/// use radkit::errors::AgentResult;
///
/// #[skill(
///     id = "my_skill",
///     name = "My Skill",
///     description = "Does something useful",
///     tags = ["utility"],
///     examples = ["Do something"],
///     input_modes = ["text/plain"],
///     output_modes = ["application/json"]
/// )]
/// pub struct MySkill;
///
/// #[async_trait]
/// impl SkillHandler for MySkill {
///     async fn on_request(
///         &self,
///         state: &mut State,
///         progress: &ProgressSender,
///         runtime: &dyn AgentRuntime,
///         content: Content,
///     ) -> AgentResult<OnRequestResult> {
///         // Task-scoped state (for multi-turn within this skill)
///         state.task().save("partial", &data)?;
///
///         // Session-scoped state (shared across skills)
///         state.session().save("user_data", &user_data)?;
///
///         // Streaming updates
///         progress.send_update("Processing...").await?;
///
///         // Get current user
///         let user = runtime.current_user();
///
///         Ok(OnRequestResult::Completed {
///             message: Some(Content::from_text("Done")),
///             artifacts: vec![],
///         })
///     }
/// }
/// ```
#[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 {
    /// Handle initial skill invocation.
    ///
    /// This is the primary entry point for a new task. Contains the "happy path" logic.
    /// If it succeeds, return `Completed`. If it needs more information, save partial
    /// work and return `InputRequired`.
    ///
    /// # Arguments
    ///
    /// * `state` - Mutable state container with task and session scopes
    /// * `progress` - Sender for streaming updates to the client
    /// * `runtime` - Runtime services (use `runtime.current_user()` for auth)
    /// * `content` - Input content from the user
    ///
    /// # Errors
    ///
    /// Returns error if skill execution fails in an unexpected way.
    async fn on_request(
        &self,
        state: &mut State,
        progress: &ProgressSender,
        runtime: &dyn AgentRuntime,
        content: Content,
    ) -> Result<OnRequestResult, AgentError>;

    /// Handle continued interaction after input request.
    ///
    /// Only called if `on_request` or a previous `on_input_received` returned `InputRequired`.
    /// Load partial state, process the new input, and continue the logic.
    ///
    /// Default implementation rejects all input.
    ///
    /// # Arguments
    ///
    /// * `state` - Mutable state container with saved data
    /// * `progress` - Sender for streaming updates to the client
    /// * `runtime` - Runtime services (use `runtime.current_user()` for auth)
    /// * `content` - New input content from the user
    ///
    /// # Errors
    ///
    /// Returns error if input processing fails in an unexpected way.
    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(),
        })
    }
}

/// Structured output contract used by [`LlmSkillHandler`] to drive
/// LLM-backed `AgentSkills`.
///
/// The LLM must respond with one of these three variants when executing
/// an `AgentSkill`. `tryparse` handles fuzzy matching so minor formatting
/// deviations (different casing, extra whitespace) are tolerated.
///
/// # Examples
///
/// ```ignore
/// use radkit::agent::WorkStatus;
///
/// // Skill finished
/// let done = WorkStatus::Complete {
///     message: "Here is your summary: ...".to_string(),
/// };
///
/// // Skill needs more info
/// let ask = WorkStatus::NeedsInput {
///     message: "Which language should I translate to?".to_string(),
/// };
/// ```
#[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 {
    /// The skill completed successfully. Return `message` to the user.
    Complete {
        /// The final response to present to the user.
        message: String,
    },
    /// The skill needs additional input before it can continue.
    NeedsInput {
        /// The question or prompt to present to the user.
        message: String,
    },
    /// The skill encountered an unrecoverable error.
    Failed {
        /// Human-readable explanation of what went wrong.
        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()));
    }

    // ── WorkStatus tests ──────────────────────────────────────────────────

    #[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 { .. }));
    }
}