mod project;
mod source;
mod validate;
#[cfg(test)]
mod tests;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
pub use crate::llm::tools::function_schema_from_catalog_row;
pub use project::{TOOL_CALL_RECEIPT_VERSION, TOOL_RESULT_RECEIPT_VERSION};
pub use validate::{validate_training_example_pairing, TrainingPairingError};
pub const TRAINING_EXAMPLE_SCHEMA_VERSION: &str = "harn.agent_training_example.v1";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrainingExampleError {
pub kind: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub event_index: Option<usize>,
}
impl TrainingExampleError {
pub(crate) fn new(kind: &str, message: impl Into<String>) -> Self {
Self {
kind: kind.to_string(),
message: message.into(),
event_index: None,
}
}
pub(crate) fn at(kind: &str, event_index: usize, message: impl Into<String>) -> Self {
Self {
kind: kind.to_string(),
message: message.into(),
event_index: Some(event_index),
}
}
}
impl std::fmt::Display for TrainingExampleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.event_index {
Some(index) => write!(f, "{} (event {index}): {}", self.kind, self.message),
None => write!(f, "{}: {}", self.kind, self.message),
}
}
}
impl std::error::Error for TrainingExampleError {}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrainingMessage {
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<TrainingToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrainingToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: TrainingToolFunction,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrainingToolFunction {
pub name: String,
pub arguments: JsonValue,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrainingUsage {
pub provider_calls: usize,
pub input_tokens: u64,
pub output_tokens: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrainingSource {
pub descriptor_schema_version: String,
pub transcript_path: String,
pub transcript_sha256: String,
pub transcript_byte_len: u64,
pub event_count: usize,
pub first_event_index: usize,
pub last_event_index: usize,
pub first_event_id: String,
pub last_event_id: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrainingProvenance {
pub run_id: String,
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage_id: Option<String>,
pub provider: String,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub declared_tool_format: Option<String>,
pub effective_tool_format: String,
pub tool_catalog_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_catalog_content_hash: Option<String>,
pub terminal_status: String,
pub usage: TrainingUsage,
pub source: TrainingSource,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentTrainingExample {
pub schema_version: String,
pub messages: Vec<TrainingMessage>,
pub tools: Vec<JsonValue>,
pub provenance: TrainingProvenance,
}
#[derive(Clone, Debug, Default)]
pub struct TrainingExampleRequest {
pub run_record_path: PathBuf,
pub run_id: Option<String>,
pub session_id: Option<String>,
}
impl TrainingExampleRequest {
pub fn new(run_record_path: impl Into<PathBuf>) -> Self {
Self {
run_record_path: run_record_path.into(),
run_id: None,
session_id: None,
}
}
}
pub fn project_agent_training_example(
request: &TrainingExampleRequest,
) -> Result<AgentTrainingExample, TrainingExampleError> {
let run_record_path = request.run_record_path.as_path();
let run = load_run(run_record_path)?;
if let Some(expected) = request.run_id.as_deref() {
if run.id != expected {
return Err(TrainingExampleError::new(
"run_id_mismatch",
format!(
"{} holds run {}, not the requested {expected}",
run_record_path.display(),
run.id
),
));
}
}
let transcript_path = super::verified_llm_transcript_pointer_path(&run, run_record_path)
.map_err(|error| TrainingExampleError::new(error.kind, error.message))?;
let descriptor = run
.observability
.as_ref()
.and_then(|observability| {
observability
.transcript_pointers
.iter()
.find(|pointer| pointer.kind == "llm_jsonl")
})
.and_then(|pointer| pointer.descriptor.clone())
.ok_or_else(|| {
TrainingExampleError::new("missing_descriptor", "run has no llm_jsonl descriptor")
})?;
if !descriptor.complete {
return Err(TrainingExampleError::new(
"incomplete_source",
format!(
"{} has no terminal record; the run did not finalize",
transcript_path.display()
),
));
}
let events = source::load_events(&transcript_path)?;
project::project(&run, &descriptor, &transcript_path, &events, request)
}
fn load_run(path: &Path) -> Result<super::RunRecord, TrainingExampleError> {
let content = std::fs::read_to_string(path).map_err(|error| {
TrainingExampleError::new(
"run_record_unreadable",
format!("failed to read {}: {error}", path.display()),
)
})?;
serde_json::from_str(&content).map_err(|error| {
TrainingExampleError::new(
"run_record_unreadable",
format!("failed to parse {}: {error}", path.display()),
)
})
}