agentyk-core 0.1.1

The agentyk contract: traits, event protocol, and the turn state machine. Implement against this; build with `agentyk`.
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
//! Chat messages exchanged with LLM drivers and reconstructed from the event
//! log. Mirrors the everruns message vocabulary (`Message`, `Role`,
//! `ToolCall`, `ContentPart`).

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// Who a [`Message`] came from.
///
/// Deliberately exhaustive: a driver that failed to translate a new role
/// would silently drop part of the conversation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    /// Instructions framing the conversation. Most drivers send these
    /// out-of-band rather than as a message — see
    /// [`crate::driver::ChatRequest::system_prompt`].
    System,
    /// Input from whoever is talking to the agent.
    User,
    /// The model's own output: text, tool calls, or both.
    Assistant,
    /// A tool result addressed back to the model.
    Tool,
}

/// Identity of a user message originating outside the embedding application.
///
/// Channel adapters attach this value so several people can speak in one
/// session without coupling the core protocol to Slack, Discord, Teams, or
/// another transport. The model-facing request prefixes the user's text with
/// [`Self::display_label`]; the durable message itself remains unchanged.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExternalActor {
    /// Opaque identifier assigned by the source, such as a Slack user id.
    pub actor_id: String,
    /// Human-readable name, when the source resolved one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub actor_name: Option<String>,
    /// Source namespace, such as `slack` or `discord`.
    pub source: String,
    /// Source-specific attributes useful to the embedding host.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub metadata: Option<HashMap<String, String>>,
}

impl ExternalActor {
    /// Create an actor from its source namespace and opaque source id.
    pub fn new(source: impl Into<String>, actor_id: impl Into<String>) -> Self {
        Self {
            actor_id: actor_id.into(),
            actor_name: None,
            source: source.into(),
            metadata: None,
        }
    }

    /// Attach the name shown to the model and presentation layers.
    pub fn name(mut self, name: impl Into<String>) -> Self {
        self.actor_name = Some(name.into());
        self
    }

    /// Human-readable name when present, otherwise the source id.
    pub fn display_label(&self) -> &str {
        self.actor_name.as_deref().unwrap_or(&self.actor_id)
    }
}

/// A tool invocation requested by the model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
    /// Driver-assigned call id, echoed back in the tool result.
    pub id: String,
    /// Which tool to run — matched against [`crate::tool::ToolDefinition`]
    /// names.
    pub name: String,
    /// Arguments as a JSON object. The model produces these, so treat them as
    /// untrusted: a tool validates its own arguments.
    #[serde(default)]
    pub arguments: serde_json::Value,
}

/// Text content.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextContentPart {
    /// The text itself.
    pub text: String,
}

/// Image content, as a URL or inline base64 data (mirrors everruns'
/// `ImageContentPart`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageContentPart {
    /// Where the image lives, for providers that fetch it themselves.
    /// Mutually exclusive with [`ImageContentPart::base64`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    /// The image inline, base64-encoded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base64: Option<String>,
    /// MIME type of the inline data, e.g. `"image/png"`. Required alongside
    /// `base64`; drivers assume PNG without it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub media_type: Option<String>,
}

impl ImageContentPart {
    /// An image the provider fetches for itself.
    pub fn from_url(url: impl Into<String>) -> Self {
        Self {
            url: Some(url.into()),
            base64: None,
            media_type: None,
        }
    }

    /// An image sent inline, which needs no reachable URL.
    pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
        Self {
            url: None,
            base64: Some(base64.into()),
            media_type: Some(media_type.into()),
        }
    }
}

/// So a turn can be opened with either a string or a whole message — see
/// `Session::run`. Text is by far the common case and should stay a one-liner;
/// an image the model is asked to look at should not need a different method.
impl From<String> for Message {
    fn from(text: String) -> Self {
        Message::user(text)
    }
}

impl From<&str> for Message {
    fn from(text: &str) -> Self {
        Message::user(text)
    }
}

/// A part of message content: text or an image. Tool calls and tool results
/// stay as dedicated [`Message`] fields rather than content-part variants —
/// a deliberate simplification over everruns' unified content-part model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
    /// Text.
    Text(TextContentPart),
    /// An image, by URL or inline.
    Image(ImageContentPart),
}

impl ContentPart {
    /// A text part.
    pub fn text(text: impl Into<String>) -> Self {
        ContentPart::Text(TextContentPart { text: text.into() })
    }

    /// An image part the provider fetches by URL.
    pub fn image_url(url: impl Into<String>) -> Self {
        ContentPart::Image(ImageContentPart::from_url(url))
    }

    /// An image part sent inline.
    pub fn image_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
        ContentPart::Image(ImageContentPart::from_base64(base64, media_type))
    }

    /// The text of a text part, or `None` for anything else.
    pub fn as_text(&self) -> Option<&str> {
        match self {
            ContentPart::Text(part) => Some(&part.text),
            ContentPart::Image(_) => None,
        }
    }
}

/// One message in a conversation.
///
/// This is both what drivers send to a provider and what replaying the event
/// log reconstructs, so anything that must survive a resume lives here rather
/// than beside it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
    /// Who produced it.
    pub role: Role,
    /// Content parts, in order. Text-only messages carry a single `Text`
    /// part (or none, for an empty message) — see [`Message::text`].
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub content: Vec<ContentPart>,
    /// Tool calls requested by an assistant message.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_calls: Vec<ToolCall>,
    /// For `Role::Tool` messages: the call this result answers.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_call_id: Option<String>,
    /// Provider reasoning ("extended thinking") text, when the model emitted
    /// a reasoning block. First-class because it is **not** everruns-specific
    /// (OpenAI reasoning, Anthropic thinking) and it must round-trip back to
    /// the provider on subsequent turns for the exchange to stay valid —
    /// which an out-of-band store can't guarantee (the driver only ever sees
    /// `Message`). Empty for the common no-reasoning case.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub thinking: Option<String>,
    /// Opaque signature the provider requires to accept a replayed thinking
    /// block (e.g. Anthropic's `signature`). Paired with [`Message::thinking`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub thinking_signature: Option<String>,
    /// Generic, serializable extension hatch — the message analogue of
    /// [`crate::event::EventData::Custom`]. Everruns-flavored richness a
    /// adopting host owns (execution `phase`, narration hints, provider
    /// extras) rides here instead of growing typed core fields. `Null` for
    /// the common case.
    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
    pub metadata: serde_json::Value,
    /// External identity of the user who produced this message.
    ///
    /// Kept separate from generic metadata because correct multi-actor model
    /// context depends on it: drivers must be able to distinguish speakers.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub external_actor: Option<ExternalActor>,
}

impl Message {
    fn text_content(text: impl Into<String>) -> Vec<ContentPart> {
        let text = text.into();
        if text.is_empty() {
            Vec::new()
        } else {
            vec![ContentPart::text(text)]
        }
    }

    fn new(role: Role, content: Vec<ContentPart>) -> Self {
        Self {
            role,
            content,
            tool_calls: Vec::new(),
            tool_call_id: None,
            thinking: None,
            thinking_signature: None,
            metadata: serde_json::Value::Null,
            external_actor: None,
        }
    }

    /// A plain-text user message.
    pub fn user(text: impl Into<String>) -> Self {
        Self::new(Role::User, Self::text_content(text))
    }

    /// A user message with arbitrary content parts (text, images).
    pub fn user_multimodal(content: Vec<ContentPart>) -> Self {
        Self::new(Role::User, content)
    }

    /// A plain-text assistant message.
    pub fn assistant(text: impl Into<String>) -> Self {
        Self::new(Role::Assistant, Self::text_content(text))
    }

    /// An assistant message requesting tools, with optional accompanying
    /// text. An empty `text` is normal: models often answer with calls alone.
    pub fn assistant_with_calls(text: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
        Self {
            tool_calls,
            ..Self::new(Role::Assistant, Self::text_content(text))
        }
    }

    /// A tool's result, addressed back to the call it answers. The turn loop
    /// builds these from `tool.completed` events, so a resumed session
    /// reconstructs them identically.
    pub fn tool_result(call_id: impl Into<String>, text: impl Into<String>) -> Self {
        Self {
            tool_call_id: Some(call_id.into()),
            ..Self::new(Role::Tool, Self::text_content(text))
        }
    }

    /// A tool result carrying extra content parts (images) after its text —
    /// see [`crate::tool::ToolOutput::parts`]. Equivalent to
    /// [`Message::tool_result`] when `parts` is empty.
    pub fn tool_result_with_parts(
        call_id: impl Into<String>,
        text: impl Into<String>,
        parts: impl IntoIterator<Item = ContentPart>,
    ) -> Self {
        let mut content = Self::text_content(text);
        content.extend(parts);
        Self {
            tool_call_id: Some(call_id.into()),
            ..Self::new(Role::Tool, content)
        }
    }

    /// Attach provider reasoning to this message — see [`Message::thinking`].
    pub fn with_thinking(mut self, thinking: impl Into<String>, signature: Option<String>) -> Self {
        self.thinking = Some(thinking.into());
        self.thinking_signature = signature;
        self
    }

    /// Attach extension metadata — see [`Message::metadata`].
    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
        self.metadata = metadata;
        self
    }

    /// Attribute a user message to an external channel actor.
    pub fn with_external_actor(mut self, actor: ExternalActor) -> Self {
        self.external_actor = Some(actor);
        self
    }

    /// The concatenation of every text part, in order. Empty if the message
    /// has no text content (e.g. an image-only message, or a tool-call-only
    /// assistant message).
    pub fn text(&self) -> String {
        self.content
            .iter()
            .filter_map(ContentPart::as_text)
            .collect::<Vec<_>>()
            .join("")
    }

    /// True if every content part is text (no images) — the common case,
    /// and the signal drivers use to decide whether they can send a plain
    /// string on the wire instead of a content-part array.
    pub fn is_text_only(&self) -> bool {
        self.content
            .iter()
            .all(|part| matches!(part, ContentPart::Text(_)))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn text_constructors_round_trip_through_text() {
        assert_eq!(Message::user("hi").text(), "hi");
        assert_eq!(Message::assistant("").text(), "");
        assert!(Message::assistant("").content.is_empty());
    }

    #[test]
    fn multimodal_message_mixes_text_and_image() {
        let message = Message::user_multimodal(vec![
            ContentPart::text("what is this?"),
            ContentPart::image_url("https://example.com/cat.png"),
        ]);
        assert_eq!(message.text(), "what is this?");
        assert!(!message.is_text_only());
    }

    #[test]
    fn serde_roundtrip_preserves_content_parts() {
        let message = Message::user_multimodal(vec![
            ContentPart::text("a"),
            ContentPart::image_base64("Zm9v", "image/png"),
        ]);
        let json = serde_json::to_string(&message).unwrap();
        let back: Message = serde_json::from_str(&json).unwrap();
        assert_eq!(message, back);
    }

    #[test]
    fn thinking_and_metadata_round_trip() {
        let message = Message::assistant("done")
            .with_thinking("let me consider…", Some("sig-abc".into()))
            .with_metadata(serde_json::json!({"phase": "final"}));
        let json = serde_json::to_string(&message).unwrap();
        let back: Message = serde_json::from_str(&json).unwrap();
        assert_eq!(message, back);
        assert_eq!(back.thinking.as_deref(), Some("let me consider…"));
        assert_eq!(back.thinking_signature.as_deref(), Some("sig-abc"));
        assert_eq!(back.metadata["phase"], "final");
    }

    #[test]
    fn external_actor_round_trips_and_uses_name_as_label() {
        let message = Message::user("hello")
            .with_external_actor(ExternalActor::new("slack", "U123").name("Alice"));
        let json = serde_json::to_string(&message).unwrap();
        let back: Message = serde_json::from_str(&json).unwrap();

        assert_eq!(back, message);
        assert_eq!(
            back.external_actor.as_ref().unwrap().display_label(),
            "Alice"
        );
    }

    #[test]
    fn plain_messages_omit_the_new_fields_from_json() {
        // Back-compat: a message with no reasoning/metadata serializes exactly
        // as before, and pre-0.1.1 JSON (without these keys) still loads.
        let json = serde_json::to_string(&Message::user("hi")).unwrap();
        assert!(!json.contains("thinking"));
        assert!(!json.contains("metadata"));
        assert!(!json.contains("external_actor"));
        let back: Message =
            serde_json::from_str(r#"{"role":"user","content":[{"type":"text","text":"hi"}]}"#)
                .unwrap();
        assert_eq!(back, Message::user("hi"));
    }
}