1mod error_codes;
21mod fmt;
22mod time;
23
24pub use error_codes::ErrorCode;
25
26use serde::Serialize;
27use serde_json::{Map, Value};
28use std::fs::{File, OpenOptions};
29use std::io::Write;
30use std::path::{Path, PathBuf};
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::Mutex;
33use uuid::Uuid;
34
35const COMPACT_MAP: &[(&str, &str)] = &[
37 ("ts","t"),("level","l"),("module","n"),("msg","m"),("pid","p"),("rid","r"),("seq","q"),
38 ("error_code","e"),("dur","d"),("tool","o"),("cmd","c"),("exit","x"),("op","w"),("path","h"),
39 ("ctx","z"),("tid","i"),("lines","s"),("funcs","f"),("lang","g"),("choice","k"),("alts","a"),
40 ("reason","u"),("stdout","v"),("stderr","b"),("ok","y"),("size","j"),
41];
42
43fn compact_entry(entry: Map<String, Value>) -> Map<String, Value> {
44 entry.into_iter().map(|(k, v)| {
45 let k = COMPACT_MAP.iter().find(|(full, _)| *full == k).map(|(_, c)| (*c).to_string()).unwrap_or(k);
46 (k, v)
47 }).collect()
48}
49
50pub struct AgentLogger {
55 program: String,
56 command: String,
57 file_path: PathBuf,
58 rid: String,
59 pid: String,
60 seq: AtomicU64,
61 file: Mutex<File>,
62 compact: bool,
63}
64
65impl AgentLogger {
66 pub fn new(
73 program: &str,
74 command: Option<&str>,
75 log_dir: impl AsRef<Path>,
76 rid: Option<&str>,
77 ) -> std::io::Result<Self> {
78 Self::new_compact(program, command, log_dir, rid, false)
79 }
80
81 pub fn new_compact(
83 program: &str,
84 command: Option<&str>,
85 log_dir: impl AsRef<Path>,
86 rid: Option<&str>,
87 compact: bool,
88 ) -> std::io::Result<Self> {
89 let pid = std::process::id().to_string();
90 let rid = rid.map(|s| s.to_string()).unwrap_or_else(|| {
91 Uuid::new_v4().simple().to_string()[..8].to_string()
92 });
93
94 let safe_program = sanitize(program);
95 let command_str = command.map(|c| c.to_string()).unwrap_or_else(|| format!("pid{}", pid));
96 let safe_command = sanitize(&command_str);
97
98 let log_dir = log_dir.as_ref().to_path_buf();
99 std::fs::create_dir_all(&log_dir)?;
100
101 let stamp = time::filename_stamp();
102 let filename = format!("{}_{}_{}.jsonl", safe_program, safe_command, stamp);
103 let file_path = log_dir.join(&filename);
104
105 let mut file = OpenOptions::new()
106 .create(true)
107 .append(true)
108 .open(&file_path)?;
109
110 let mut header = Map::new();
112 header.insert("ts".into(), Value::String(time::now_iso()));
113 header.insert("level".into(), Value::String("__GLOBAL_CTX__".into()));
114 header.insert("msg".into(), Value::String("Global context".into()));
115 header.insert("module".into(), Value::String("__system__".into()));
116 header.insert("rid".into(), Value::String(rid.clone()));
117 header.insert("pid".into(), Value::String(pid.clone()));
118 header.insert("seq".into(), Value::Number(0u64.into()));
119 header.insert("program".into(), Value::String(safe_program.clone()));
120 header.insert("command".into(), Value::String(safe_command.clone()));
121 let header = if compact { compact_entry(header) } else { header };
122 write_value(&mut file, &Value::Object(header))?;
123 file.write_all(b"\n")?;
124
125 Ok(Self {
126 program: safe_program,
127 command: safe_command,
128 file_path,
129 rid,
130 pid,
131 seq: AtomicU64::new(0),
132 file: Mutex::new(file),
133 compact,
134 })
135 }
136
137 pub fn file_path(&self) -> &Path {
139 &self.file_path
140 }
141 pub fn rid(&self) -> &str {
142 &self.rid
143 }
144 pub fn program(&self) -> &str {
145 &self.program
146 }
147 pub fn command(&self) -> &str {
148 &self.command
149 }
150
151 fn write_entry(&self, mut entry: Map<String, Value>) -> std::io::Result<()> {
154 let seq = self.seq.fetch_add(1, Ordering::SeqCst) + 1;
155 entry.insert("ts".into(), Value::String(time::now_iso()));
156 entry.insert("pid".into(), Value::String(self.pid.clone()));
157 entry.insert("rid".into(), Value::String(self.rid.clone()));
158 entry.insert("seq".into(), Value::Number(seq.into()));
159 if self.compact {
160 entry = compact_entry(entry);
161 }
162 let mut f = self.file.lock().expect("poisoned lock");
163 write_value(&mut *f, &Value::Object(entry))?;
164 f.write_all(b"\n")?;
165 Ok(())
166 }
167
168 pub fn save_traceback(&self, exc_type: &str, exc_msg: &str, traceback: &str) -> std::io::Result<String> {
172 let tid = format!("tb_{}", &Uuid::new_v4().simple().to_string()[..8]);
173 self.save_traceback_text(&tid, exc_type, exc_msg, traceback)?;
174 Ok(tid)
175 }
176
177 pub fn save_traceback_text(&self, tid: &str, exc_type: &str, exc_msg: &str, traceback: &str) -> std::io::Result<()> {
178 let tb_path = self.file_path.with_extension("tracebacks");
179 let mut rec = Map::new();
180 rec.insert("tid".into(), Value::String(tid.into()));
181 rec.insert("exception_type".into(), Value::String(exc_type.into()));
182 rec.insert("exception_msg".into(), Value::String(exc_msg.into()));
183 rec.insert("traceback".into(), Value::String(traceback.into()));
184 let mut f = OpenOptions::new().create(true).append(true).open(tb_path)?;
185 write_value(&mut f, &Value::Object(rec))?;
186 f.write_all(b"\n")?;
187 Ok(())
188 }
189
190 pub fn info(&self, msg: &str, module: Option<&str>) -> std::io::Result<()> {
194 self.info_full(msg, module, None, None, None, None)
195 }
196 #[allow(clippy::too_many_arguments)]
197 pub fn info_full(
198 &self,
199 msg: &str,
200 module: Option<&str>,
201 dur: Option<u64>,
202 error_code: Option<&str>,
203 tid: Option<&str>,
204 ctx: Option<Value>,
205 ) -> std::io::Result<()> {
206 let mut e = Map::new();
207 e.insert("level".into(), Value::String("INFO".into()));
208 e.insert("msg".into(), Value::String(truncate_msg(msg)));
209 basic_fields(&mut e, module, dur, error_code, tid, ctx);
210 self.write_entry(e)
211 }
212
213 pub fn warn(&self, msg: &str, module: Option<&str>) -> std::io::Result<()> {
214 self.warn_full(msg, module, None, None, None, None)
215 }
216 #[allow(clippy::too_many_arguments)]
217 pub fn warn_full(
218 &self,
219 msg: &str,
220 module: Option<&str>,
221 dur: Option<u64>,
222 error_code: Option<&str>,
223 tid: Option<&str>,
224 ctx: Option<Value>,
225 ) -> std::io::Result<()> {
226 let mut e = Map::new();
227 e.insert("level".into(), Value::String("WARN".into()));
228 e.insert("msg".into(), Value::String(truncate_msg(msg)));
229 basic_fields(&mut e, module, dur, error_code, tid, ctx);
230 self.write_entry(e)
231 }
232
233 pub fn error(
234 &self,
235 msg: &str,
236 module: Option<&str>,
237 error_code: ErrorCode,
238 tid: Option<&str>,
239 ) -> std::io::Result<()> {
240 self.error_full(msg, module, error_code.as_str(), tid, None, None)
241 }
242 #[allow(clippy::too_many_arguments)]
243 pub fn error_full(
244 &self,
245 msg: &str,
246 module: Option<&str>,
247 error_code: &str,
248 tid: Option<&str>,
249 dur: Option<u64>,
250 ctx: Option<Value>,
251 ) -> std::io::Result<()> {
252 let mut e = Map::new();
253 e.insert("level".into(), Value::String("ERROR".into()));
254 e.insert("msg".into(), Value::String(truncate_msg(msg)));
255 if let Some(m) = module {
256 e.insert("module".into(), Value::String(m.into()));
257 }
258 e.insert("error_code".into(), Value::String(error_code.into()));
259 if let Some(t) = tid {
260 e.insert("tid".into(), Value::String(t.into()));
261 }
262 if let Some(d) = dur {
263 e.insert("dur".into(), Value::Number(d.into()));
264 }
265 if let Some(c) = ctx {
266 e.insert("ctx".into(), c);
267 }
268 self.write_entry(e)
269 }
270
271 #[allow(clippy::too_many_arguments)]
277 pub fn tool_call(
278 &self,
279 tool: &str,
280 cmd: &str,
281 exit: i64,
282 dur: u64,
283 error_code: Option<&str>,
284 tid: Option<&str>,
285 stdout: Option<&str>,
286 stderr: Option<&str>,
287 ctx: Option<Value>,
288 ) -> std::io::Result<()> {
289 let mut e = Map::new();
290 let ok = exit == 0;
291 e.insert(
292 "level".into(),
293 Value::String("TOOL".into()),
294 );
295 e.insert(
296 "msg".into(),
297 Value::String(format!("Tool {} {}", tool, if ok { "succeeded" } else { "failed" })),
298 );
299 e.insert("tool".into(), Value::String(tool.into()));
300 e.insert("cmd".into(), Value::String(cmd.into()));
301 e.insert("exit".into(), Value::Number(exit.into()));
302 e.insert("dur".into(), Value::Number(dur.into()));
303 if let Some(ec) = error_code {
304 e.insert("error_code".into(), Value::String(ec.into()));
305 }
306 if let Some(t) = tid {
307 e.insert("tid".into(), Value::String(t.into()));
308 }
309 if let Some(o) = stdout {
310 e.insert("stdout".into(), Value::String(truncate64k(o)));
311 }
312 if let Some(s) = stderr {
313 e.insert("stderr".into(), Value::String(truncate64k(s)));
314 }
315 if let Some(c) = ctx {
316 e.insert("ctx".into(), c);
317 }
318 e.insert(
319 "module".into(),
320 Value::String("unknown".into()),
321 );
322 self.write_entry(e)
323 }
324
325 #[allow(clippy::too_many_arguments)]
326 pub fn file_op(
327 &self,
328 op: &str,
329 path: &str,
330 ok: bool,
331 size: Option<u64>,
332 error_code: Option<&str>,
333 tid: Option<&str>,
334 dur: Option<u64>,
335 ctx: Option<Value>,
336 ) -> std::io::Result<()> {
337 let mut e = Map::new();
338 e.insert("level".into(), Value::String("FILE_OP".into()));
339 e.insert(
340 "msg".into(),
341 Value::String(format!("File {} {}: {}", op, if ok { "succeeded" } else { "failed" }, path)),
342 );
343 e.insert("op".into(), Value::String(op.into()));
344 e.insert("path".into(), Value::String(path.into()));
345 e.insert("ok".into(), Value::Bool(ok));
346 if let Some(s) = size {
347 e.insert("size".into(), Value::Number(s.into()));
348 }
349 if let Some(ec) = error_code {
350 e.insert("error_code".into(), Value::String(ec.into()));
351 }
352 if let Some(t) = tid {
353 e.insert("tid".into(), Value::String(t.into()));
354 }
355 if let Some(d) = dur {
356 e.insert("dur".into(), Value::Number(d.into()));
357 }
358 if let Some(c) = ctx {
359 e.insert("ctx".into(), c);
360 }
361 e.insert("module".into(), Value::String("unknown".into()));
362 self.write_entry(e)
363 }
364
365 #[allow(clippy::too_many_arguments)]
366 pub fn decision(
367 &self,
368 choice: &str,
369 alts: Option<&[&str]>,
370 reason: Option<&str>,
371 confidence: Option<f64>,
372 module: Option<&str>,
373 ctx: Option<Value>,
374 ) -> std::io::Result<()> {
375 let mut e = Map::new();
376 e.insert("level".into(), Value::String("DECISION".into()));
377 e.insert("msg".into(), Value::String(format!("Decision: {}", choice)));
378 e.insert("choice".into(), Value::String(choice.into()));
379 if let Some(a) = alts {
380 e.insert(
381 "alts".into(),
382 Value::Array(a.iter().map(|s| Value::String((*s).into())).collect()),
383 );
384 }
385 if let Some(r) = reason {
386 e.insert("reason".into(), Value::String(r.into()));
387 }
388 if let Some(c) = confidence {
389 e.insert("confidence".into(), serde_json::Number::from_f64(c).map(Value::Number).unwrap_or(Value::Null));
390 }
391 if let Some(m) = module {
392 e.insert("module".into(), Value::String(m.into()));
393 } else {
394 e.insert("module".into(), Value::String("unknown".into()));
395 }
396 if let Some(c) = ctx {
397 e.insert("ctx".into(), c);
398 }
399 self.write_entry(e)
400 }
401
402 #[allow(clippy::too_many_arguments)]
403 pub fn code_gen(
404 &self,
405 lang: &str,
406 path: &str,
407 lines: Option<u64>,
408 funcs: Option<&[&str]>,
409 imports: Option<&[&str]>,
410 module: Option<&str>,
411 ctx: Option<Value>,
412 ) -> std::io::Result<()> {
413 let mut e = Map::new();
414 e.insert("level".into(), Value::String("CODE_GEN".into()));
415 e.insert("msg".into(), Value::String(format!("Generated {} code: {}", lang, path)));
416 e.insert("lang".into(), Value::String(lang.into()));
417 e.insert("path".into(), Value::String(path.into()));
418 if let Some(l) = lines {
419 e.insert("lines".into(), Value::Number(l.into()));
420 }
421 if let Some(f) = funcs {
422 e.insert(
423 "funcs".into(),
424 Value::Array(f.iter().map(|s| Value::String((*s).into())).collect()),
425 );
426 }
427 if let Some(im) = imports {
428 e.insert(
429 "imports".into(),
430 Value::Array(im.iter().map(|s| Value::String((*s).into())).collect()),
431 );
432 }
433 e.insert("module".into(), Value::String(module.unwrap_or("unknown").into()));
434 if let Some(c) = ctx {
435 e.insert("ctx".into(), c);
436 }
437 self.write_entry(e)
438 }
439
440 pub fn context_switch(
441 &self,
442 to_task: &str,
443 from_task: Option<&str>,
444 reason: Option<&str>,
445 module: Option<&str>,
446 ctx: Option<Value>,
447 ) -> std::io::Result<()> {
448 let mut e = Map::new();
449 e.insert("level".into(), Value::String("CONTEXT".into()));
450 e.insert("msg".into(), Value::String(format!("Switching to: {}", to_task)));
451 e.insert("to_task".into(), Value::String(to_task.into()));
452 if let Some(f) = from_task {
453 e.insert("from_task".into(), Value::String(f.into()));
454 }
455 if let Some(r) = reason {
456 e.insert("reason".into(), Value::String(r.into()));
457 }
458 e.insert("module".into(), Value::String(module.unwrap_or("unknown").into()));
459 if let Some(c) = ctx {
460 e.insert("ctx".into(), c);
461 }
462 self.write_entry(e)
463 }
464}
465
466fn write_value(w: &mut impl Write, v: &Value) -> std::io::Result<()> {
469 use serde_json::ser::Serializer;
470 let mut ser = Serializer::with_formatter(w, fmt::PythonFormatter);
471 v.serialize(&mut ser)
472 .map_err(std::io::Error::other)?;
473 Ok(())
474}
475
476#[allow(clippy::too_many_arguments)]
477fn basic_fields(
478 e: &mut Map<String, Value>,
479 module: Option<&str>,
480 dur: Option<u64>,
481 error_code: Option<&str>,
482 tid: Option<&str>,
483 ctx: Option<Value>,
484) {
485 e.insert("module".into(), Value::String(module.unwrap_or("unknown").into()));
486 if let Some(d) = dur {
487 e.insert("dur".into(), Value::Number(d.into()));
488 }
489 if let Some(ec) = error_code {
490 e.insert("error_code".into(), Value::String(ec.into()));
491 }
492 if let Some(t) = tid {
493 e.insert("tid".into(), Value::String(t.into()));
494 }
495 if let Some(c) = ctx {
496 e.insert("ctx".into(), c);
497 }
498}
499
500fn truncate_msg(s: &str) -> String {
501 if s.chars().count() <= 4096 {
502 s.to_string()
503 } else {
504 s.chars().take(4096).collect()
505 }
506}
507fn truncate64k(s: &str) -> String {
508 if s.len() <= 65536 {
509 s.to_string()
510 } else {
511 s[..65536].to_string()
512 }
513}
514
515fn sanitize(s: &str) -> String {
517 s.chars()
518 .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
519 .take(50)
520 .collect()
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 fn tmpdir() -> PathBuf {
528 let d = std::env::temp_dir().join(format!("agentic_rust_test_{}", Uuid::new_v4().simple()));
529 std::fs::create_dir_all(&d).unwrap();
530 d
531 }
532
533 #[test]
534 fn writes_byte_compatible_jsonl() {
535 let d = tmpdir();
536 let lg = AgentLogger::new("rust_probe", Some("demo"), &d, Some("cafebabe")).unwrap();
537 lg.info_full(
538 "Processing started",
539 Some("parser"),
540 Some(12),
541 None,
542 None,
543 Some(serde_json::json!({"file": "data.json", "size": 1024})),
544 )
545 .unwrap();
546 lg.tool_call("bash", "npm install", 0, 1234, None, None, Some("added 50 pkgs"), None, None)
547 .unwrap();
548 lg.error("Build failed", Some("build"), ErrorCode::ExecNonZero, Some("tb_abcd1234"))
549 .unwrap();
550 lg.file_op("write", "/p/f.rs", true, Some(2048), None, None, Some(5), None).unwrap();
551 lg.decision("use_redis", Some(&["redis", "memcached"]), Some("perf"), Some(0.85), Some("arch"), None)
552 .unwrap();
553 lg.code_gen("rust", "src/main.rs", Some(50), Some(&["main", "helper"]), None, None, None)
554 .unwrap();
555 lg.context_switch("test", Some("build"), Some("done"), None, None).unwrap();
556 drop(lg);
557
558 let content = std::fs::read_to_string(lg_file(&d)).unwrap();
559 let lines: Vec<&str> = content.trim_end().split('\n').collect();
560 assert_eq!(lines.len(), 8); for (i, line) in lines.iter().enumerate() {
563 let v: Value = serde_json::from_str(line).unwrap_or_else(|e| panic!("line {i} not JSON: {e}\n{line}"));
564 let obj = v.as_object().unwrap();
565 assert!(obj.get("ts").unwrap().is_string(), "ts must be string");
566 assert!(obj.get("pid").unwrap().is_string(), "pid must be string");
567 assert!(obj.get("seq").unwrap().is_i64(), "seq must be number");
568 assert_eq!(obj.get("rid").unwrap(), &Value::String("cafebabe".into()));
569 }
570
571 let hdr: Value = serde_json::from_str(lines[0]).unwrap();
573 assert_eq!(hdr["level"], "__GLOBAL_CTX__");
574 assert_eq!(hdr["seq"], 0);
575
576 assert!(content.contains("\"level\": \"INFO\""), "missing python-style separator");
578 assert!(content.contains("\"exit\": 0"), "exit must be unquoted number");
579 assert!(!content.contains("\\u"), "must not emit \\uXXXX escapes (ensure_ascii=false)");
580 }
581
582 fn lg_file(d: &Path) -> PathBuf {
583 std::fs::read_dir(d)
584 .unwrap()
585 .filter_map(|e| e.ok())
586 .map(|e| e.path())
587 .find(|p| p.extension().and_then(|s| s.to_str()) == Some("jsonl"))
588 .unwrap()
589 }
590
591 #[test]
592 fn compact_mode_matches_python_keys() {
593 let d = tmpdir();
594 let lg = AgentLogger::new_compact("cp", Some("d"), &d, Some("cafe0000"), true).unwrap();
595 lg.info_full("hi", Some("parser"), Some(12), None, None, Some(serde_json::json!({"f":"d.json"}))).unwrap();
596 drop(lg);
597 let content = std::fs::read_to_string(lg_file(&d)).unwrap();
598 assert!(content.contains("\"l\": \"INFO\""), "level→l: {content}");
599 assert!(content.contains("\"n\": \"parser\""), "module→n");
600 assert!(content.contains("\"d\": 12"), "dur→d");
601 assert!(content.contains("\"q\": 1"), "seq→q");
602 assert!(content.contains("\"z\""), "ctx→z");
604 }
605
606 #[test]
607 fn traceback_sidecar_full_keys() {
608 let d = tmpdir();
609 let lg = AgentLogger::new("tb", Some("d"), &d, None).unwrap();
610 let tid = lg.save_traceback("ValueError", "bad", "Traceback:\n boom").unwrap();
611 assert!(tid.starts_with("tb_"));
612 drop(lg);
613 let tb = std::fs::read_dir(&d).unwrap()
614 .filter_map(|e| e.ok()).map(|e| e.path())
615 .find(|p| p.extension().and_then(|s| s.to_str()) == Some("tracebacks")).unwrap();
616 let line = std::fs::read_to_string(tb).unwrap();
617 assert!(line.contains("\"exception_type\": \"ValueError\""), "{line}");
618 assert!(line.contains("\"tid\": "), "{line}");
619 }
620
621 #[test]
622 fn sanitizes_filename() {
623 let d = tmpdir();
624 let lg = AgentLogger::new("weird/prog name!", Some("a b"), &d, None).unwrap();
625 let name = lg.file_path().file_name().unwrap().to_str().unwrap();
626 assert!(name.starts_with("weird_prog_name_"), "got {name}");
627 }
628
629 #[test]
630 fn msg_truncation() {
631 assert_eq!(truncate_msg("hi"), "hi");
632 let big: String = "a".repeat(5000);
633 assert_eq!(truncate_msg(&big).chars().count(), 4096);
634 }
635}