feat(fabro-types): promote transcript primitives and extend agent event… (#357)

## Summary

This is the foundational step of the unified agent transcript
implementation: it establishes one canonical set of replay types in
`fabro-types` and threads them into the existing `agent.message`,
`agent.tool.started`, and `agent.tool.completed` event shapes — without
breaking any existing producers or consumers.

## What changed

**New `fabro-types::transcript` module** owns `ContentPart`,
`ImageData`, `AudioData`, `DocumentData`, `ThinkingData`, `ToolCall`,
`ToolResult`, `MessageKind`, `MessageSource`, `PairMessageRef`,
`TranscriptMessage`, and `MessageId`. These were previously defined in
`fabro-llm::types`; they now live at the canonical layer.

**`fabro-llm::types`** drops its local definitions and re-exports from
`fabro-types` so every existing `fabro_llm::types::*` import keeps
compiling without change.

**`AgentMessageProps`** gains an optional `message:
Option<TranscriptMessage>` field; `AgentToolStartedProps` gains
`tool_call`, `turn_id`, and `parent_message_id`;
`AgentToolCompletedProps` gains `tool_result` and `turn_id`. All new
fields use `#[serde(default, skip_serializing_if = "Option::is_none")]`
so existing stored events deserialize cleanly.

**All current event emitters** (`fabro-workflow/event/convert.rs`, demo
fixtures, test helpers) are updated to set the new fields to `None` —
this is a mechanical compatibility update; actual enrichment comes in
later tasks.

### Design decisions worth noting

- `MessageKind` captures LLM role semantics (system / user / reasoning /
agent); `MessageSource` captures audit provenance (steer, pair,
loop_detection, …). They are intentionally kept separate so a steering
message can be `kind=user, source=steer` without collapsing the
distinction.
- `TranscriptMessage` is named with the `Transcript` prefix specifically
to avoid import ambiguity with `fabro_agent::Message` and
`fabro_llm::types::Message`.
- `ProviderAnswer` and `ProviderReasoning` are included as
`MessageSource` variants so committed model outputs carry a first-class
audit label distinct from user-originated inputs.
- The new fields are additive-only; no narrow legacy fields were
removed. Consumer migration is a separate step.


### Fabro Details

<details>
<summary>Ran 9 stages in 47m 27s for $12.79</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 6s | – | 0 |
| preflight_lint | 2m 21s | – | 0 |
| implement | 20m 6s | $8.53 | 0 |
| simplify_opus | 13m 36s | $2.68 | 0 |
| simplify_gpt | 4m 17s | $1.58 | 0 |
| verify | 4m 7s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **47m 27s** | **$12.79** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
    fmt -> exit
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-22 20:40:10 -04:00 committed by GitHub
parent 37e527a115
commit bf7ce485e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 704 additions and 248 deletions

1
Cargo.lock generated
View file

@ -2041,6 +2041,7 @@ dependencies = [
"fabro-redact",
"fabro-static",
"fabro-test",
"fabro-types",
"fabro-util",
"futures",
"http",

View file

@ -37,6 +37,7 @@ fabro-auth = { path = "../fabro-auth" }
fabro-model = { path = "../fabro-model" }
fabro-redact.workspace = true
fabro-static.workspace = true
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
[dev-dependencies]

View file

@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use fabro_util::backoff::BackoffPolicy;
use serde::{Deserialize, Serialize, de};
use serde::{Deserialize, Serialize};
use crate::error::Error;
@ -19,235 +19,15 @@ pub enum Role {
}
// --- 3.5 Content Data Structures ---
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageData {
pub url: Option<String>,
pub data: Option<Vec<u8>>,
pub media_type: Option<String>,
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AudioData {
pub url: Option<String>,
pub data: Option<Vec<u8>>,
pub media_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentData {
pub url: Option<String>,
pub data: Option<Vec<u8>>,
pub media_type: Option<String>,
pub file_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThinkingData {
pub text: String,
pub signature: Option<String>,
pub redacted: bool,
}
// --- 5.4 ToolCall / ToolResult ---
fn default_tool_type() -> String {
"function".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
#[serde(rename = "type", default = "default_tool_type")]
pub tool_type: String,
pub arguments: serde_json::Value,
pub raw_arguments: Option<String>,
/// Opaque provider-specific metadata (e.g. Gemini `thought_signature`).
/// Preserved across round-trips so the provider can include it when
/// sending conversation history back to the API.
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_metadata: Option<serde_json::Value>,
}
impl ToolCall {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
Self {
id: id.into(),
name: name.into(),
tool_type: "function".to_string(),
arguments,
raw_arguments: None,
provider_metadata: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolResult {
pub tool_call_id: String,
pub content: serde_json::Value,
pub is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_data: Option<Vec<u8>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_media_type: Option<String>,
}
impl ToolResult {
pub fn success(id: impl Into<String>, content: serde_json::Value) -> Self {
Self {
tool_call_id: id.into(),
content,
is_error: false,
image_data: None,
image_media_type: None,
}
}
pub fn error(id: impl Into<String>, message: impl Into<String>) -> Self {
Self {
tool_call_id: id.into(),
content: serde_json::Value::String(message.into()),
is_error: true,
image_data: None,
image_media_type: None,
}
}
}
// --- 3.3 ContentPart ---
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContentPart {
Text(String),
Image(ImageData),
Audio(AudioData),
Document(DocumentData),
ToolCall(ToolCall),
ToolResult(ToolResult),
Thinking(ThinkingData),
Other {
kind: String,
data: serde_json::Value,
},
}
impl Serialize for ContentPart {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(2))?;
match self {
Self::Text(v) => {
map.serialize_entry("kind", "text")?;
map.serialize_entry("data", v)?;
}
Self::Image(v) => {
map.serialize_entry("kind", "image")?;
map.serialize_entry("data", v)?;
}
Self::Audio(v) => {
map.serialize_entry("kind", "audio")?;
map.serialize_entry("data", v)?;
}
Self::Document(v) => {
map.serialize_entry("kind", "document")?;
map.serialize_entry("data", v)?;
}
Self::ToolCall(v) => {
map.serialize_entry("kind", "tool_call")?;
map.serialize_entry("data", v)?;
}
Self::ToolResult(v) => {
map.serialize_entry("kind", "tool_result")?;
map.serialize_entry("data", v)?;
}
Self::Thinking(v) => {
let kind = if v.redacted {
"redacted_thinking"
} else {
"thinking"
};
map.serialize_entry("kind", kind)?;
map.serialize_entry("data", v)?;
}
Self::Other { kind, data } => {
map.serialize_entry("kind", kind)?;
map.serialize_entry("data", data)?;
}
}
map.end()
}
}
impl<'de> Deserialize<'de> for ContentPart {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
let kind = value
.get("kind")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| de::Error::missing_field("kind"))?;
let data = value
.get("data")
.cloned()
.unwrap_or(serde_json::Value::Null);
match kind {
"text" => serde_json::from_value(data)
.map(Self::Text)
.map_err(de::Error::custom),
"image" => serde_json::from_value(data)
.map(Self::Image)
.map_err(de::Error::custom),
"audio" => serde_json::from_value(data)
.map(Self::Audio)
.map_err(de::Error::custom),
"document" => serde_json::from_value(data)
.map(Self::Document)
.map_err(de::Error::custom),
"tool_call" => serde_json::from_value(data)
.map(Self::ToolCall)
.map_err(de::Error::custom),
"tool_result" => serde_json::from_value(data)
.map(Self::ToolResult)
.map_err(de::Error::custom),
"thinking" => serde_json::from_value(data)
.map(Self::Thinking)
.map_err(de::Error::custom),
"redacted_thinking" => serde_json::from_value::<ThinkingData>(data)
.map(|mut td| {
td.redacted = true;
Self::Thinking(td)
})
.map_err(de::Error::custom),
other => Ok(Self::Other {
kind: other.to_string(),
data,
}),
}
}
}
impl ContentPart {
/// Kind string for opaque OpenAI reasoning output items.
pub const OPENAI_REASONING: &str = "openai_reasoning";
/// Kind string for opaque OpenAI message output items.
pub const OPENAI_MESSAGE: &str = "openai_message";
pub fn text(text: impl Into<String>) -> Self {
Self::Text(text.into())
}
/// Returns `true` if this is an opaque OpenAI item (reasoning or message)
/// that should be round-tripped verbatim through the API.
pub fn is_opaque_openai(&self) -> bool {
matches!(self, Self::Other { kind, .. } if kind == Self::OPENAI_REASONING || kind == Self::OPENAI_MESSAGE)
}
}
//
// `ContentPart`, `ImageData`, `AudioData`, `DocumentData`, `ThinkingData`,
// `ToolCall`, and `ToolResult` are the canonical provider-neutral replay
// primitives. They live in `fabro-types` so the event stream, API responses,
// and runtime history can share one model. They are re-exported here so
// existing `fabro_llm::types::*` imports keep working.
pub use fabro_types::{
AudioData, ContentPart, DocumentData, ImageData, ThinkingData, ToolCall, ToolResult,
};
// --- 3.1 Message ---

View file

@ -1446,16 +1446,20 @@ mod runs {
billing: BilledTokenCounts::default(),
tool_call_count: 0,
visit: 1,
message: None,
}),
),
make_envelope(
3,
"evt-detect-drift-3",
EventBody::AgentToolStarted(AgentToolStartedProps {
tool_name: "read_file".into(),
tool_call_id: "toolu_01".into(),
arguments: serde_json::json!({ "path": "environments/production/config.toml" }),
visit: 1,
tool_name: "read_file".into(),
tool_call_id: "toolu_01".into(),
arguments: serde_json::json!({ "path": "environments/production/config.toml" }),
visit: 1,
tool_call: None,
turn_id: None,
parent_message_id: None,
}),
),
make_envelope(
@ -1467,16 +1471,21 @@ mod runs {
output: serde_json::json!("[redis]\nhost = \"redis-prod.internal\"\nport = 6379"),
is_error: false,
visit: 1,
tool_result: None,
turn_id: None,
}),
),
make_envelope(
5,
"evt-detect-drift-5",
EventBody::AgentToolStarted(AgentToolStartedProps {
tool_name: "read_file".into(),
tool_call_id: "toolu_02".into(),
arguments: serde_json::json!({ "path": "environments/staging/config.toml" }),
visit: 1,
tool_name: "read_file".into(),
tool_call_id: "toolu_02".into(),
arguments: serde_json::json!({ "path": "environments/staging/config.toml" }),
visit: 1,
tool_call: None,
turn_id: None,
parent_message_id: None,
}),
),
make_envelope(
@ -1488,6 +1497,8 @@ mod runs {
output: serde_json::json!("[redis]\nhost = \"redis-staging.internal\"\nport = 6379"),
is_error: false,
visit: 1,
tool_result: None,
turn_id: None,
}),
),
make_envelope(
@ -1503,6 +1514,7 @@ mod runs {
billing: BilledTokenCounts::default(),
tool_call_count: 0,
visit: 1,
message: None,
}),
),
]

View file

@ -924,6 +924,7 @@ mod tests {
billing: BilledTokenCounts::default(),
tool_call_count: 0,
visit: 1,
message: None,
}),
),
)
@ -954,6 +955,7 @@ mod tests {
billing: BilledTokenCounts::default(),
tool_call_count: 0,
visit: 1,
message: None,
}),
),
)

View file

@ -2828,6 +2828,7 @@ mod tests {
billing,
tool_call_count: 0,
visit: 1,
message: None,
}
}

View file

@ -44,6 +44,7 @@ pub mod status;
pub mod steering;
pub mod timing;
pub mod todo;
pub mod transcript;
pub use artifact::ArtifactUpload;
pub use auth::{IdpIdentity, IdpIdentityError};
@ -135,3 +136,7 @@ pub use status::{
pub use steering::SteeringMessage;
pub use timing::{RunTiming, StageTiming};
pub use todo::{TodoListKind, TodoListProjection, TodoPatch, TodoProjection, TodoStatus};
pub use transcript::{
AudioData, ContentPart, DocumentData, ImageData, MessageId, MessageKind, MessageSource,
PairMessageRef, ThinkingData, ToolCall, ToolResult, TranscriptMessage,
};

View file

@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::BilledTokenCounts;
use crate::{ModelRef, PairId, PairMessageId, PairSystemMessageKind};
use crate::transcript::{ToolCall, ToolResult, TranscriptMessage};
use crate::{MessageId, ModelRef, PairId, PairMessageId, PairSystemMessageKind, TurnId};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentSessionStartedProps {
@ -55,28 +56,56 @@ pub struct AgentInputProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentMessageProps {
// Narrow legacy fields retained for consumer compatibility.
pub text: String,
pub model: ModelRef,
pub billing: BilledTokenCounts,
pub tool_call_count: usize,
pub visit: u32,
/// Canonical replay-authoritative transcript message. Present on events
/// emitted after the unified transcript migration; absent on legacy
/// payloads so older events still deserialize.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<TranscriptMessage>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentToolStartedProps {
pub tool_name: String,
pub tool_call_id: String,
pub arguments: Value,
pub visit: u32,
// Narrow legacy fields retained for consumer compatibility.
pub tool_name: String,
pub tool_call_id: String,
pub arguments: Value,
pub visit: u32,
/// Canonical tool call payload. Carries `tool_type`, `raw_arguments`, and
/// `provider_metadata` (e.g. Gemini `thought_signature`) so tool actions
/// can be replayed against the originating provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call: Option<ToolCall>,
/// Turn that initiated this tool call.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<TurnId>,
/// Agent message id that owns this tool call. Minted before tool
/// execution so tool actions can be linked back to their parent agent
/// response in the transcript.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_message_id: Option<MessageId>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentToolCompletedProps {
// Narrow legacy fields retained for consumer compatibility.
pub tool_name: String,
pub tool_call_id: String,
pub output: Value,
pub is_error: bool,
pub visit: u32,
/// Canonical tool result payload. Carries the structured output, error
/// state, and supported media/artifact fields.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_result: Option<ToolResult>,
/// Turn that owned this tool call.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<TurnId>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -277,3 +306,132 @@ pub struct AgentSkillActivatedProps {
pub source: AgentSkillActivationSource,
pub visit: u32,
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::transcript::{ContentPart, MessageKind, MessageSource, TranscriptMessage};
fn sample_model_ref() -> ModelRef {
ModelRef {
provider: fabro_model::ProviderId::openai(),
model_id: "gpt-5".to_string(),
speed: None,
}
}
#[test]
fn agent_message_props_back_compat_deserializes_without_message_field() {
// Legacy payload from before the transcript migration.
let v = json!({
"text": "hello",
"model": {"provider": "openai", "model_id": "gpt-5"},
"billing": {
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
},
"tool_call_count": 0,
"visit": 1,
});
let props: AgentMessageProps = serde_json::from_value(v).unwrap();
assert_eq!(props.text, "hello");
assert!(props.message.is_none());
}
#[test]
fn agent_message_props_carries_canonical_transcript_message() {
let msg = TranscriptMessage::new(MessageKind::Agent, MessageSource::ProviderAnswer, vec![
ContentPart::text("ok"),
]);
let props = AgentMessageProps {
text: "ok".to_string(),
model: sample_model_ref(),
billing: BilledTokenCounts::default(),
tool_call_count: 0,
visit: 1,
message: Some(msg.clone()),
};
let v = serde_json::to_value(&props).unwrap();
assert_eq!(v["message"]["kind"], "agent");
assert_eq!(v["message"]["source"], "provider_answer");
let back: AgentMessageProps = serde_json::from_value(v).unwrap();
assert_eq!(back, props);
}
#[test]
fn agent_tool_started_props_back_compat_deserializes_without_canonical_fields() {
let v = json!({
"tool_name": "Bash",
"tool_call_id": "call_1",
"arguments": {"cmd": "ls"},
"visit": 1,
});
let props: AgentToolStartedProps = serde_json::from_value(v).unwrap();
assert_eq!(props.tool_name, "Bash");
assert!(props.tool_call.is_none());
assert!(props.turn_id.is_none());
assert!(props.parent_message_id.is_none());
}
#[test]
fn agent_tool_started_props_carries_canonical_tool_call_and_linkage() {
let mut tc = ToolCall::new("call_1", "Bash", json!({"cmd": "ls"}));
tc.provider_metadata = Some(json!({"thought_signature": "sig"}));
let parent = MessageId::new();
let turn = TurnId::new();
let props = AgentToolStartedProps {
tool_name: "Bash".to_string(),
tool_call_id: "call_1".to_string(),
arguments: json!({"cmd": "ls"}),
visit: 1,
tool_call: Some(tc.clone()),
turn_id: Some(turn),
parent_message_id: Some(parent),
};
let v = serde_json::to_value(&props).unwrap();
assert_eq!(
v["tool_call"]["provider_metadata"]["thought_signature"],
"sig"
);
assert_eq!(v["turn_id"], turn.to_string());
assert_eq!(v["parent_message_id"], parent.to_string());
let back: AgentToolStartedProps = serde_json::from_value(v).unwrap();
assert_eq!(back, props);
}
#[test]
fn agent_tool_completed_props_back_compat_deserializes_without_canonical_fields() {
let v = json!({
"tool_name": "Bash",
"tool_call_id": "call_1",
"output": "ok\n",
"is_error": false,
"visit": 1,
});
let props: AgentToolCompletedProps = serde_json::from_value(v).unwrap();
assert!(props.tool_result.is_none());
assert!(props.turn_id.is_none());
}
#[test]
fn agent_tool_completed_props_carries_canonical_tool_result() {
let tr = ToolResult::success("call_1", json!({"stdout": "ok"}));
let turn = TurnId::new();
let props = AgentToolCompletedProps {
tool_name: "Bash".to_string(),
tool_call_id: "call_1".to_string(),
output: json!({"stdout": "ok"}),
is_error: false,
visit: 1,
tool_result: Some(tr.clone()),
turn_id: Some(turn),
};
let v = serde_json::to_value(&props).unwrap();
assert_eq!(v["tool_result"]["content"]["stdout"], "ok");
let back: AgentToolCompletedProps = serde_json::from_value(v).unwrap();
assert_eq!(back, props);
}
}

View file

@ -0,0 +1,490 @@
//! Canonical provider-neutral transcript primitives.
//!
//! These types are the durable replay shapes for agent sessions. They were
//! promoted from `fabro-llm` so the Fabro event stream, API responses, and
//! runtime history can share one canonical Rust model rather than ferrying
//! parallel DTOs between layers. `fabro-llm::types` re-exports these so
//! existing imports keep working.
use chrono::{DateTime, Utc};
use fabro_model::{ModelRef, TokenCounts};
use serde::{Deserialize, Serialize, de};
use strum::{Display, EnumString, IntoStaticStr};
use crate::id::ulid_id;
use crate::pair::{PairId, PairMessageId};
use crate::principal::Principal;
use crate::session::TurnId;
ulid_id!(MessageId);
// --- Content data structures -------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageData {
pub url: Option<String>,
pub data: Option<Vec<u8>>,
pub media_type: Option<String>,
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AudioData {
pub url: Option<String>,
pub data: Option<Vec<u8>>,
pub media_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentData {
pub url: Option<String>,
pub data: Option<Vec<u8>>,
pub media_type: Option<String>,
pub file_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThinkingData {
pub text: String,
pub signature: Option<String>,
pub redacted: bool,
}
// --- Tool call / tool result -------------------------------------------------
fn default_tool_type() -> String {
"function".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
#[serde(rename = "type", default = "default_tool_type")]
pub tool_type: String,
pub arguments: serde_json::Value,
pub raw_arguments: Option<String>,
/// Opaque provider-specific metadata (e.g. Gemini `thought_signature`).
/// Preserved across round-trips so the provider can include it when
/// sending conversation history back to the API.
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_metadata: Option<serde_json::Value>,
}
impl ToolCall {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
Self {
id: id.into(),
name: name.into(),
tool_type: "function".to_string(),
arguments,
raw_arguments: None,
provider_metadata: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolResult {
pub tool_call_id: String,
pub content: serde_json::Value,
pub is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_data: Option<Vec<u8>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_media_type: Option<String>,
}
impl ToolResult {
pub fn success(id: impl Into<String>, content: serde_json::Value) -> Self {
Self {
tool_call_id: id.into(),
content,
is_error: false,
image_data: None,
image_media_type: None,
}
}
pub fn error(id: impl Into<String>, message: impl Into<String>) -> Self {
Self {
tool_call_id: id.into(),
content: serde_json::Value::String(message.into()),
is_error: true,
image_data: None,
image_media_type: None,
}
}
}
// --- ContentPart -------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContentPart {
Text(String),
Image(ImageData),
Audio(AudioData),
Document(DocumentData),
ToolCall(ToolCall),
ToolResult(ToolResult),
Thinking(ThinkingData),
Other {
kind: String,
data: serde_json::Value,
},
}
impl Serialize for ContentPart {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(2))?;
match self {
Self::Text(v) => {
map.serialize_entry("kind", "text")?;
map.serialize_entry("data", v)?;
}
Self::Image(v) => {
map.serialize_entry("kind", "image")?;
map.serialize_entry("data", v)?;
}
Self::Audio(v) => {
map.serialize_entry("kind", "audio")?;
map.serialize_entry("data", v)?;
}
Self::Document(v) => {
map.serialize_entry("kind", "document")?;
map.serialize_entry("data", v)?;
}
Self::ToolCall(v) => {
map.serialize_entry("kind", "tool_call")?;
map.serialize_entry("data", v)?;
}
Self::ToolResult(v) => {
map.serialize_entry("kind", "tool_result")?;
map.serialize_entry("data", v)?;
}
Self::Thinking(v) => {
let kind = if v.redacted {
"redacted_thinking"
} else {
"thinking"
};
map.serialize_entry("kind", kind)?;
map.serialize_entry("data", v)?;
}
Self::Other { kind, data } => {
map.serialize_entry("kind", kind)?;
map.serialize_entry("data", data)?;
}
}
map.end()
}
}
impl<'de> Deserialize<'de> for ContentPart {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
let kind = value
.get("kind")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| de::Error::missing_field("kind"))?;
let data = value
.get("data")
.cloned()
.unwrap_or(serde_json::Value::Null);
match kind {
"text" => serde_json::from_value(data)
.map(Self::Text)
.map_err(de::Error::custom),
"image" => serde_json::from_value(data)
.map(Self::Image)
.map_err(de::Error::custom),
"audio" => serde_json::from_value(data)
.map(Self::Audio)
.map_err(de::Error::custom),
"document" => serde_json::from_value(data)
.map(Self::Document)
.map_err(de::Error::custom),
"tool_call" => serde_json::from_value(data)
.map(Self::ToolCall)
.map_err(de::Error::custom),
"tool_result" => serde_json::from_value(data)
.map(Self::ToolResult)
.map_err(de::Error::custom),
"thinking" => serde_json::from_value(data)
.map(Self::Thinking)
.map_err(de::Error::custom),
"redacted_thinking" => serde_json::from_value::<ThinkingData>(data)
.map(|mut td| {
td.redacted = true;
Self::Thinking(td)
})
.map_err(de::Error::custom),
other => Ok(Self::Other {
kind: other.to_string(),
data,
}),
}
}
}
impl ContentPart {
/// Kind string for opaque OpenAI reasoning output items.
pub const OPENAI_REASONING: &str = "openai_reasoning";
/// Kind string for opaque OpenAI message output items.
pub const OPENAI_MESSAGE: &str = "openai_message";
pub fn text(text: impl Into<String>) -> Self {
Self::Text(text.into())
}
/// Returns `true` if this is an opaque OpenAI item (reasoning or message)
/// that should be round-tripped verbatim through the API.
pub fn is_opaque_openai(&self) -> bool {
matches!(
self,
Self::Other { kind, .. }
if kind == Self::OPENAI_REASONING || kind == Self::OPENAI_MESSAGE
)
}
}
// --- TranscriptMessage ------------------------------------------------------
/// Provider/model-role semantics for a committed transcript message.
///
/// Captured separately from [`MessageSource`] so audit/UI provenance
/// (`steer`, `pair`, …) does not collapse the LLM role that the message
/// replays as.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
Display,
EnumString,
IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum MessageKind {
System,
User,
Reasoning,
Agent,
}
/// Audit/UI provenance for a committed transcript message.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
Display,
EnumString,
IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum MessageSource {
SystemPrompt,
TurnInput,
Followup,
Steer,
Pair,
InjectedSystem,
InjectedUser,
LoopDetection,
/// Reasoning blocks emitted by the model.
ProviderReasoning,
/// Final agent answer emitted by the model.
ProviderAnswer,
}
/// Reference to the originating pair chat message for messages that
/// entered LLM history via the pair channel.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairMessageRef {
pub pair_id: PairId,
pub message_id: PairMessageId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_message_id: Option<String>,
}
/// Canonical durable transcript message.
///
/// Named `TranscriptMessage` rather than `Message` to avoid import ambiguity
/// with `fabro_agent::Message` and `fabro_llm::types::Message`.
///
/// `kind` captures provider/model-role semantics for replay; `source`
/// captures audit/UI provenance. Both are required to faithfully reconstruct
/// an API-mode session from the event stream.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TranscriptMessage {
pub id: MessageId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<TurnId>,
pub kind: MessageKind,
pub source: MessageSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<Principal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pair: Option<PairMessageRef>,
pub content: Vec<ContentPart>,
/// Provider + model identity for the response that produced this
/// message, when applicable. Strongly typed via [`ModelRef`] so
/// provider and model id can never drift apart.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<ModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenCounts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
impl TranscriptMessage {
/// Constructs a new transcript message with the supplied kind, source, and
/// content.
pub fn new(kind: MessageKind, source: MessageSource, content: Vec<ContentPart>) -> Self {
Self {
id: MessageId::new(),
turn_id: None,
kind,
source,
actor: None,
pair: None,
content,
model: None,
response_id: None,
usage: None,
created_at: None,
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn content_part_text_roundtrips() {
let part = ContentPart::text("hello");
let v = serde_json::to_value(&part).unwrap();
assert_eq!(v, json!({"kind": "text", "data": "hello"}));
let back: ContentPart = serde_json::from_value(v).unwrap();
assert_eq!(back, part);
}
#[test]
fn content_part_thinking_preserves_signature_and_redaction() {
let part = ContentPart::Thinking(ThinkingData {
text: "private thought".to_string(),
signature: Some("sig_abc".to_string()),
redacted: true,
});
let v = serde_json::to_value(&part).unwrap();
assert_eq!(v["kind"], "redacted_thinking");
assert_eq!(v["data"]["signature"], "sig_abc");
let back: ContentPart = serde_json::from_value(v).unwrap();
assert_eq!(back, part);
}
#[test]
fn content_part_other_preserves_provider_kind() {
let part = ContentPart::Other {
kind: ContentPart::OPENAI_REASONING.to_string(),
data: json!({"item_id": "rs_1", "encrypted": "x"}),
};
assert!(part.is_opaque_openai());
let v = serde_json::to_value(&part).unwrap();
let back: ContentPart = serde_json::from_value(v).unwrap();
assert_eq!(back, part);
}
#[test]
fn tool_call_preserves_provider_metadata() {
let mut tc = ToolCall::new("call_1", "Bash", json!({"cmd": "ls"}));
tc.provider_metadata = Some(json!({"thought_signature": "sig"}));
tc.raw_arguments = Some("{\"cmd\":\"ls\"}".to_string());
let v = serde_json::to_value(&tc).unwrap();
assert_eq!(v["provider_metadata"]["thought_signature"], "sig");
let back: ToolCall = serde_json::from_value(v).unwrap();
assert_eq!(back, tc);
}
#[test]
fn tool_result_round_trips_with_default_image_fields() {
let tr = ToolResult::success("call_1", json!({"ok": true}));
let v = serde_json::to_value(&tr).unwrap();
// Optional image fields are omitted on serialize.
assert!(v.get("image_data").is_none());
let back: ToolResult = serde_json::from_value(v).unwrap();
assert_eq!(back, tr);
}
#[test]
fn transcript_message_serde_round_trip() {
let msg = TranscriptMessage {
id: MessageId::new(),
turn_id: None,
kind: MessageKind::User,
source: MessageSource::Steer,
actor: None,
pair: None,
content: vec![ContentPart::text("please continue")],
model: None,
response_id: None,
usage: None,
created_at: None,
};
let v = serde_json::to_value(&msg).unwrap();
assert_eq!(v["kind"], "user");
assert_eq!(v["source"], "steer");
let back: TranscriptMessage = serde_json::from_value(v).unwrap();
assert_eq!(back, msg);
}
#[test]
fn transcript_message_drops_optional_fields_on_serialize() {
let msg = TranscriptMessage::new(MessageKind::Agent, MessageSource::ProviderAnswer, vec![
ContentPart::text("done"),
]);
let v = serde_json::to_value(&msg).unwrap();
let obj = v.as_object().unwrap();
// Optional fields should be omitted, not present as nulls.
assert!(!obj.contains_key("turn_id"));
assert!(!obj.contains_key("actor"));
assert!(!obj.contains_key("pair"));
assert!(!obj.contains_key("model"));
assert!(!obj.contains_key("response_id"));
assert!(!obj.contains_key("usage"));
assert!(!obj.contains_key("created_at"));
}
#[test]
fn pair_message_ref_skips_empty_client_id() {
let r = PairMessageRef {
pair_id: PairId::new(),
message_id: PairMessageId::new(),
client_message_id: None,
};
let v = serde_json::to_value(&r).unwrap();
assert!(v.as_object().unwrap().get("client_message_id").is_none());
}
}

View file

@ -593,6 +593,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
billing,
tool_call_count: *tool_call_count,
visit: *visit,
message: None,
})
}
AgentEvent::ToolCallStarted {
@ -600,10 +601,13 @@ fn event_body_from_event(event: &Event) -> EventBody {
tool_call_id,
arguments,
} => EventBody::AgentToolStarted(fabro_types::AgentToolStartedProps {
tool_name: tool_name.clone(),
tool_call_id: tool_call_id.clone(),
arguments: arguments.clone(),
visit: *visit,
tool_name: tool_name.clone(),
tool_call_id: tool_call_id.clone(),
arguments: arguments.clone(),
visit: *visit,
tool_call: None,
turn_id: None,
parent_message_id: None,
}),
AgentEvent::ToolCallCompleted {
tool_name,
@ -616,6 +620,8 @@ fn event_body_from_event(event: &Event) -> EventBody {
output: output.clone(),
is_error: *is_error,
visit: *visit,
tool_result: None,
turn_id: None,
}),
AgentEvent::Error { error } => EventBody::AgentError(fabro_types::AgentErrorProps {
error: serde_json::to_value(error).expect("serializable agent error"),

View file

@ -17,7 +17,7 @@
export interface RunCheckpointSettings {
'exclude_globs': Array<string>;
/**
* When true, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks. Does not affect Fabro `[[run.hooks]]` or metadata-branch snapshots. Defaults to false.
* When true, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks. Does not affect Fabro `[[run.hooks]]` or metadata-branch snapshots. Defaults to false.
* @type {boolean}
* @memberof RunCheckpointSettings
*/