harn_vm/orchestration/training_example/
mod.rs1mod project;
36mod source;
37mod validate;
38
39#[cfg(test)]
40mod tests;
41
42use std::path::{Path, PathBuf};
43
44use serde::{Deserialize, Serialize};
45use serde_json::Value as JsonValue;
46
47pub use crate::llm::tools::function_schema_from_catalog_row;
48pub use project::{TOOL_CALL_RECEIPT_VERSION, TOOL_RESULT_RECEIPT_VERSION};
49pub use validate::{validate_training_example_pairing, TrainingPairingError};
50
51pub const TRAINING_EXAMPLE_SCHEMA_VERSION: &str = "harn.agent_training_example.v1";
54
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58pub struct TrainingExampleError {
59 pub kind: String,
60 pub message: String,
61 #[serde(skip_serializing_if = "Option::is_none", default)]
63 pub event_index: Option<usize>,
64}
65
66impl TrainingExampleError {
67 pub(crate) fn new(kind: &str, message: impl Into<String>) -> Self {
68 Self {
69 kind: kind.to_string(),
70 message: message.into(),
71 event_index: None,
72 }
73 }
74
75 pub(crate) fn at(kind: &str, event_index: usize, message: impl Into<String>) -> Self {
76 Self {
77 kind: kind.to_string(),
78 message: message.into(),
79 event_index: Some(event_index),
80 }
81 }
82}
83
84impl std::fmt::Display for TrainingExampleError {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self.event_index {
87 Some(index) => write!(f, "{} (event {index}): {}", self.kind, self.message),
88 None => write!(f, "{}: {}", self.kind, self.message),
89 }
90 }
91}
92
93impl std::error::Error for TrainingExampleError {}
94
95#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
102pub struct TrainingMessage {
103 pub role: String,
104 pub content: String,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub tool_calls: Vec<TrainingToolCall>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub tool_call_id: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub name: Option<String>,
111}
112
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub struct TrainingToolCall {
115 pub id: String,
116 #[serde(rename = "type")]
117 pub call_type: String,
118 pub function: TrainingToolFunction,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122pub struct TrainingToolFunction {
123 pub name: String,
124 pub arguments: JsonValue,
125}
126
127#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
128pub struct TrainingUsage {
129 pub provider_calls: usize,
130 pub input_tokens: u64,
131 pub output_tokens: u64,
132}
133
134#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct TrainingSource {
137 pub descriptor_schema_version: String,
138 pub transcript_path: String,
139 pub transcript_sha256: String,
140 pub transcript_byte_len: u64,
141 pub event_count: usize,
142 pub first_event_index: usize,
143 pub last_event_index: usize,
144 pub first_event_id: String,
145 pub last_event_id: String,
146}
147
148#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct TrainingProvenance {
150 pub run_id: String,
151 pub session_id: String,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub stage_id: Option<String>,
154 pub provider: String,
155 pub model: String,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub route_policy: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub declared_tool_format: Option<String>,
164 pub effective_tool_format: String,
166 pub tool_catalog_hash: String,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub tool_catalog_content_hash: Option<String>,
170 pub terminal_status: String,
171 pub usage: TrainingUsage,
172 pub source: TrainingSource,
173}
174
175#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
177pub struct AgentTrainingExample {
178 pub schema_version: String,
179 pub messages: Vec<TrainingMessage>,
180 pub tools: Vec<JsonValue>,
183 pub provenance: TrainingProvenance,
184}
185
186#[derive(Clone, Debug, Default)]
190pub struct TrainingExampleRequest {
191 pub run_record_path: PathBuf,
192 pub run_id: Option<String>,
193 pub session_id: Option<String>,
194}
195
196impl TrainingExampleRequest {
197 pub fn new(run_record_path: impl Into<PathBuf>) -> Self {
198 Self {
199 run_record_path: run_record_path.into(),
200 run_id: None,
201 session_id: None,
202 }
203 }
204}
205
206pub fn project_agent_training_example(
213 request: &TrainingExampleRequest,
214) -> Result<AgentTrainingExample, TrainingExampleError> {
215 let run_record_path = request.run_record_path.as_path();
216 let run = load_run(run_record_path)?;
217 if let Some(expected) = request.run_id.as_deref() {
218 if run.id != expected {
219 return Err(TrainingExampleError::new(
220 "run_id_mismatch",
221 format!(
222 "{} holds run {}, not the requested {expected}",
223 run_record_path.display(),
224 run.id
225 ),
226 ));
227 }
228 }
229 let transcript_path = super::verified_llm_transcript_pointer_path(&run, run_record_path)
230 .map_err(|error| TrainingExampleError::new(error.kind, error.message))?;
231 let descriptor = run
232 .observability
233 .as_ref()
234 .and_then(|observability| {
235 observability
236 .transcript_pointers
237 .iter()
238 .find(|pointer| pointer.kind == "llm_jsonl")
239 })
240 .and_then(|pointer| pointer.descriptor.clone())
241 .ok_or_else(|| {
242 TrainingExampleError::new("missing_descriptor", "run has no llm_jsonl descriptor")
243 })?;
244 if !descriptor.complete {
245 return Err(TrainingExampleError::new(
246 "incomplete_source",
247 format!(
248 "{} has no terminal record; the run did not finalize",
249 transcript_path.display()
250 ),
251 ));
252 }
253 let events = source::load_events(&transcript_path)?;
254 project::project(&run, &descriptor, &transcript_path, &events, request)
255}
256
257fn load_run(path: &Path) -> Result<super::RunRecord, TrainingExampleError> {
266 let content = std::fs::read_to_string(path).map_err(|error| {
267 TrainingExampleError::new(
268 "run_record_unreadable",
269 format!("failed to read {}: {error}", path.display()),
270 )
271 })?;
272 serde_json::from_str(&content).map_err(|error| {
273 TrainingExampleError::new(
274 "run_record_unreadable",
275 format!("failed to parse {}: {error}", path.display()),
276 )
277 })
278}