use std::io::Write as _;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use clap::{Args, Subcommand, ValueEnum};
use serde_json::{json, Map, Value};
use agent_block_core::bridge::config::knl_path;
use agent_block_core::knl::event::{
kind_of, seq_of, FIELD_BEAT, FIELD_DATA, FIELD_EPOCH_MS, FIELD_KIND, FIELD_META, FIELD_SEQ,
};
use agent_block_core::knl::{EventStore, Logs, SqliteEventStore};
const PAGE: usize = 512;
#[derive(Debug, Args)]
pub struct KnlArgs {
#[command(subcommand)]
pub command: KnlCommand,
}
#[derive(Debug, Subcommand)]
pub enum KnlCommand {
Export(ExportArgs),
}
#[derive(Debug, Args)]
pub struct ExportArgs {
#[arg(long, value_name = "ID")]
pub session: String,
#[arg(long, value_name = "PATH")]
pub store: Option<PathBuf>,
#[arg(long = "as", value_name = "FORM")]
pub form: Form,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Form {
Events,
Messages,
}
pub async fn run(args: KnlArgs, project: &Path) -> anyhow::Result<()> {
match args.command {
KnlCommand::Export(export) => self::export(export, project).await,
}
}
async fn export(args: ExportArgs, project: &Path) -> anyhow::Result<()> {
let path = match args.store {
Some(path) => path,
None => knl_path(project).map_err(|reason| {
anyhow::anyhow!("the project's kernel database could not be located: {reason}")
})?,
};
if !path.exists() {
anyhow::bail!("no kernel database at '{}'", path.display());
}
let logs = Logs::new();
let printed = read_and_print(&path, &args.session, args.form, &logs).await;
for failure in logs.shutdown().await {
tracing::warn!(error = %failure, "knl export: a log did not close cleanly");
}
printed
}
async fn read_and_print(path: &Path, session: &str, form: Form, logs: &Logs) -> anyhow::Result<()> {
let store = SqliteEventStore::open(path, session, logs)
.await
.with_context(|| format!("opening the kernel database at '{}'", path.display()))?;
let events = read_whole(&store)
.await
.with_context(|| format!("reading the session '{session}'"))?;
if events.is_empty() {
anyhow::bail!(
"no session '{session}' in '{}' (a session with no events is a session that was \
never opened)",
path.display()
);
}
let records = match form {
Form::Events => events,
Form::Messages => messages_of(&events),
};
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock());
for record in &records {
let line = serde_json::to_string(record).context("rendering a record as JSON")?;
writeln!(out, "{line}").context("writing to stdout")?;
}
out.flush().context("writing to stdout")
}
async fn read_whole(store: &SqliteEventStore) -> agent_block_core::knl::KnlResult<Vec<Value>> {
let mut from = 0u64;
let mut all: Vec<Value> = Vec::new();
loop {
let page = store.read(from, PAGE).await?;
let Some(last) = page.last() else {
break;
};
let next = seq_of(last).saturating_add(1);
let was_full = page.len() >= PAGE;
all.extend(page);
if !was_full || next <= from {
break;
}
from = next;
}
Ok(all)
}
fn messages_of(events: &[Value]) -> Vec<Value> {
events.iter().filter_map(message_of).collect()
}
fn result_text(result: Option<Value>) -> Value {
match result {
Some(Value::String(text)) => Value::String(text),
Some(other) => Value::String(other.to_string()),
None => Value::String(Value::Null.to_string()),
}
}
fn message_of(event: &Value) -> Option<Value> {
let data = event.get(FIELD_DATA);
let field = |name: &str| data.and_then(|data| data.get(name)).cloned();
let content = || field("content").unwrap_or(Value::Null);
let kind = kind_of(event);
let mut record = match kind {
"msg_user" => json!({ "role": "user", "content": content() }),
"llm_response" => {
let mut record = json!({ "role": "assistant", "content": content() });
for name in ["usage", "stop_reason"] {
if let (Some(value), Some(object)) = (field(name), record.as_object_mut()) {
object.insert(name.to_string(), value);
}
}
record
}
"tool_call" => {
let mut block = Map::new();
block.insert("type".to_string(), json!("tool_use"));
block.insert("id".to_string(), field("call_id").unwrap_or(Value::Null));
block.insert("name".to_string(), field("name").unwrap_or(Value::Null));
block.insert("input".to_string(), field("args").unwrap_or(Value::Null));
json!({ "role": "assistant", "content": Value::Object(block) })
}
"tool_result" => {
let mut block = Map::new();
block.insert("type".to_string(), json!("tool_result"));
block.insert(
"tool_use_id".to_string(),
field("call_id").unwrap_or(Value::Null),
);
block.insert("content".to_string(), result_text(field("result")));
if field("ok") == Some(Value::Bool(false)) {
block.insert("is_error".to_string(), json!(true));
}
json!({ "role": "user", "content": Value::Object(block) })
}
_ => return None,
};
let object = record.as_object_mut()?;
if let Some(beat) = event
.get(FIELD_META)
.and_then(|meta| meta.get(FIELD_BEAT))
.cloned()
{
object.insert(FIELD_BEAT.to_string(), beat);
}
object.insert(FIELD_SEQ.to_string(), json!(seq_of(event)));
if let Some(epoch_ms) = event.get(FIELD_EPOCH_MS).cloned() {
object.insert(FIELD_EPOCH_MS.to_string(), epoch_ms);
}
object.insert(FIELD_KIND.to_string(), json!(kind));
Some(record)
}
#[cfg(test)]
mod tests {
use super::messages_of;
use serde_json::{json, Value};
fn stored(seq: u64, kind: &str, beat: Option<&str>, data: Value) -> Value {
json!({
"kind": kind,
"seq": seq,
"epoch_ms": 1_700_000_000_000u64 + seq,
"meta": match beat {
Some(beat) => json!({ "beat": beat }),
None => json!({}),
},
"data": data,
})
}
#[test]
fn the_four_kinds_fold_to_the_messages_they_are() {
let events = [
stored(3, "msg_user", None, json!({ "content": "hi" })),
stored(
4,
"llm_response",
Some("b1"),
json!({
"content": [{ "type": "text", "text": "on it" }],
"usage": { "input_tokens": 7, "output_tokens": 2 },
"stop_reason": "tool_use",
}),
),
stored(
5,
"tool_call",
Some("b1"),
json!({ "call_id": "c-1", "name": "sh", "args": { "cmd": "ls" } }),
),
stored(
6,
"tool_result",
Some("b1"),
json!({ "call_id": "c-1", "ok": true, "result": { "out": "a\nb" } }),
),
];
let messages = messages_of(&events);
assert_eq!(
messages,
[
json!({
"role": "user", "content": "hi",
"seq": 3, "epoch_ms": 1_700_000_000_003u64, "kind": "msg_user",
}),
json!({
"role": "assistant",
"content": [{ "type": "text", "text": "on it" }],
"usage": { "input_tokens": 7, "output_tokens": 2 },
"stop_reason": "tool_use",
"beat": "b1", "seq": 4, "epoch_ms": 1_700_000_000_004u64,
"kind": "llm_response",
}),
json!({
"role": "assistant",
"content": {
"type": "tool_use", "id": "c-1", "name": "sh",
"input": { "cmd": "ls" },
},
"beat": "b1", "seq": 5, "epoch_ms": 1_700_000_000_005u64,
"kind": "tool_call",
}),
json!({
"role": "user",
"content": {
"type": "tool_result", "tool_use_id": "c-1",
"content": "{\"out\":\"a\\nb\"}",
},
"beat": "b1", "seq": 6, "epoch_ms": 1_700_000_000_006u64,
"kind": "tool_result",
}),
]
);
}
#[test]
fn a_failed_tool_result_is_marked_and_a_good_one_is_not() {
let events = [
stored(
1,
"tool_result",
None,
json!({ "call_id": "c-1", "ok": false, "result": "boom" }),
),
stored(
2,
"tool_result",
None,
json!({ "call_id": "c-2", "ok": true, "result": "fine" }),
),
];
let messages = messages_of(&events);
assert_eq!(messages[0]["content"]["is_error"], json!(true));
assert_eq!(
messages[1]["content"].get("is_error"),
None,
"a call that went well carries no mark: {}",
messages[1]
);
}
#[test]
fn a_tool_results_content_is_uncut() {
let long = "x".repeat(50_000);
let events = [stored(
1,
"tool_result",
None,
json!({ "call_id": "c-1", "ok": true, "result": long.clone() }),
)];
let messages = messages_of(&events);
assert_eq!(messages[0]["content"]["content"], json!(long));
}
#[test]
fn the_kinds_that_are_not_a_conversation_are_skipped() {
let events = [
stored(1, "session_opened", None, json!({ "owner": "u" })),
stored(2, "budget_granted", None, json!({ "amount": 8 })),
stored(3, "llm_request", None, json!({ "request": {} })),
stored(4, "llm_call_failed", None, json!({ "error": "nope" })),
stored(5, "note", None, json!({ "text": "mine" })),
stored(6, "msg_user", None, json!({ "content": "hi" })),
stored(7, "session_closed", None, json!({ "reason": "done" })),
];
let messages = messages_of(&events);
assert_eq!(messages.len(), 1, "{messages:?}");
assert_eq!(messages[0]["kind"], json!("msg_user"));
}
#[test]
fn an_event_with_no_data_still_folds() {
let events = [json!({ "kind": "msg_user", "seq": 1, "meta": {} })];
let messages = messages_of(&events);
assert_eq!(
messages,
[json!({ "role": "user", "content": Value::Null, "seq": 1, "kind": "msg_user" })]
);
}
}