blob: 1fa3bae03345f140a898f48802d58041d46c3093 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::{
read::{row_position, sql_number, sql_position},
tool_metadata::read as tool_metadata,
};
use crate::{EventLog, StoreError, sequence_number};
use maka_agent_facts::history::RowPosition;
use maka_agent_ports::session::history::HistorySource;
use maka_agent_ports::session::history::SourceField;
use maka_agent_ports::session::history::SourceFormat;
use maka_agent_ports::session::history::SourcePosition;
use maka_agent_ports::session::history::SourceRole;
use maka_agent_ports::session::history::SourceSegment;
use sqlx::{Connection, Row};
fn source_sql() -> String {
format!("WITH original AS (SELECT
CASE ?4
WHEN 'body' THEN CASE WHEN json_extract(payload, '$.type') IN ('user', 'assistant', 'tool_call', 'tool_result', 'model_interruption') OR (json_extract(payload, '$.type') = 'turn_state' AND json_extract(payload, '$.status') = 'failed') THEN {body} END
WHEN 'thinking' THEN CASE WHEN json_extract(payload, '$.type') = 'assistant' THEN json_extract(payload, '$.thinking.text') END
WHEN 'tool_name' THEN CASE WHEN json_extract(payload, '$.type') = 'tool_call' THEN json_extract(payload, '$.toolName') END
WHEN 'failure_message' THEN CASE WHEN json_extract(payload, '$.type') = 'turn_state' AND json_extract(payload, '$.status') = 'failed' THEN json_extract(payload, '$.failureMessage') END
WHEN 'error_class' THEN CASE WHEN json_extract(payload, '$.type') = 'turn_state' AND json_extract(payload, '$.status') = 'failed' THEN json_extract(payload, '$.errorClass') END
WHEN 'arguments' THEN CASE WHEN json_extract(payload, '$.type') = 'tool_call' THEN payload -> '$.args' END
WHEN 'result' THEN CASE WHEN json_extract(payload, '$.type') = 'tool_result' THEN
CASE json_extract(payload, '$.content.kind') WHEN 'text' THEN json_extract(payload, '$.content.text')
WHEN 'json' THEN payload -> '$.content.value' WHEN 'image' THEN payload -> '$.content'
WHEN 'operation' THEN payload -> '$.content.reply.receipt.outcome.value' END END
WHEN 'receipt' THEN CASE WHEN json_extract(payload, '$.type') = 'tool_result' AND json_extract(payload, '$.content.kind') = 'operation' THEN payload -> '$.content.reply' END END AS body,
CASE ?4 WHEN 'arguments' THEN 'json' WHEN 'receipt' THEN 'json' WHEN 'result' THEN
CASE json_extract(payload, '$.content.kind') WHEN 'text' THEN 'text' WHEN 'json' THEN 'json'
WHEN 'image' THEN 'image_descriptor' WHEN 'operation' THEN 'json' END ELSE 'text' END AS format,
json_extract(payload, '$.type') AS role,
CASE WHEN json_extract(payload, '$.type') = 'user' THEN payload -> '$.origin' END AS message_origin,
json_extract(payload, '$.ts') AS timestamp,
json_type(payload, '$.thinking.text') IS 'text' AS thinking,
json_type(payload, '$.failureMessage') IS 'text' AS failure_message,
json_type(payload, '$.errorClass') IS 'text' AS error_class,
json_extract(payload, '$.content.kind') IS 'operation' AS operation
FROM transcript_cache.rows WHERE session_id = ?1 AND source_sequence = ?2 AND item_index = ?3),
source AS (SELECT
CASE WHEN ?5 IS NULL THEN body WHEN format = 'json' THEN
CASE json_type(body, ?5) WHEN 'text' THEN json_extract(body, ?5) ELSE body -> ?5 END END AS body,
CASE WHEN ?5 IS NULL THEN format WHEN format = 'json' THEN
CASE json_type(body, ?5) WHEN 'text' THEN 'text' ELSE 'json' END END AS format,
role, timestamp, thinking, failure_message, error_class, operation, message_origin FROM original)", body = super::history::BODY)
}
impl EventLog {
/// Locate the original call in the complete immutable directory.
pub async fn tool_source_position(
&self,
session: &str,
through: u64,
tool_use_id: &str,
) -> Result<RowPosition, StoreError> {
self.validate_root()?;
crate::sessions::validate_id(session)?;
let fence = through;
let fence_sql = sql_number(fence)?;
let session = session.to_owned();
let tool_use_id = tool_use_id.to_owned();
self.connection
.run(move |connection| {
Box::pin(async move {
let mut tx = connection.begin().await?;
super::directory::check_read(&mut tx, &session, fence).await?;
let (source, item): (i64, i64) = sqlx::query_as(
"SELECT source_sequence,item_index FROM transcript_directory
WHERE session_id = ? AND message_id = ? AND source_sequence <= ?
AND role = 'tool_call'",
)
.bind(&session)
.bind(tool_use_id)
.bind(fence_sql)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| invalid("history source was not found"))?;
tx.commit().await?;
row_position(source, item)
})
})
.await
}
/// Reads one selected immutable canonical field with a bounded UTF-8 body.
#[allow(clippy::too_many_arguments)]
pub async fn history_source(
&self,
session: &str,
through: u64,
locator: RowPosition,
field: SourceField,
path: Option<Vec<SourceSegment>>,
position: SourcePosition,
max_bytes: usize,
) -> Result<HistorySource, StoreError> {
self.validate_root()?;
crate::sessions::validate_id(session)?;
if !(4..=128 * 1024).contains(&max_bytes) {
return Err(invalid("invalid history source limits"));
}
let json_path = json_path(field, path.as_deref())?;
let fence = through;
if locator.source_sequence > fence {
return Err(invalid("history source exceeds its fence"));
}
let (source_sequence_sql, item_sql) = sql_position(locator)?;
let fence_sql = sql_number(fence)?;
let session = session.to_owned();
let selected = match field {
SourceField::Body => "body",
SourceField::Thinking => "thinking",
SourceField::ToolName => "tool_name",
SourceField::FailureMessage => "failure_message",
SourceField::ErrorClass => "error_class",
SourceField::Arguments => "arguments",
SourceField::Result => "result",
SourceField::Receipt => "receipt",
};
self.connection
.run(move |connection| {
super::bodycache::run(connection, move |tx| Box::pin(async move {
super::directory::check_read(tx, &session, fence).await?;
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM session_control WHERE id = ?)",
)
.bind(&session)
.fetch_one(&mut *tx)
.await?;
if !exists {
return Err(invalid("Session no longer exists"));
}
super::bodycache::ensure(tx, &session, locator).await?;
let sql = source_sql();
let header = format!(
"{sql} SELECT format, length(CAST(body AS BLOB)), role, thinking, failure_message, error_class, timestamp, operation, message_origin FROM source"
);
let header = sqlx::query(sqlx::AssertSqlSafe(header.as_str()))
.bind(&session)
.bind(source_sequence_sql).bind(item_sql)
.bind(selected)
.bind(json_path.as_deref())
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| invalid("history source was not found"))?;
let format: Option<String> = header.try_get(0)?;
let total: Option<i64> = header.try_get(1)?;
let role: String = header.try_get(2)?;
let thinking: bool = header.try_get(3)?;
let failure_message: bool = header.try_get(4)?;
let error_class: bool = header.try_get(5)?;
let timestamp: i64 = header.try_get(6)?;
let operation: bool = header.try_get(7)?;
let origin: Option<String> = header.try_get(8)?;
let total = sequence_number(
total.ok_or_else(|| invalid("field is not a history source"))?,
)?;
let format = match format.as_deref() {
Some("text") => SourceFormat::Text,
Some("json") => SourceFormat::Json,
Some("image_descriptor") => SourceFormat::ImageDescriptor,
_ => return Err(invalid("invalid history source format")),
};
let (role, fields) = match role.as_str() {
"user" => (SourceRole::User, vec![SourceField::Body]),
"assistant" => (SourceRole::Assistant, if thinking {
vec![SourceField::Thinking, SourceField::Body]
} else { vec![SourceField::Body] }),
"tool_call" => (SourceRole::ToolCall, vec![SourceField::ToolName, SourceField::Body, SourceField::Arguments]),
"tool_result" => (SourceRole::ToolResult, if operation { vec![SourceField::Body, SourceField::Result, SourceField::Receipt] } else { vec![SourceField::Body, SourceField::Result] }),
"model_interruption" => (SourceRole::Failure, vec![SourceField::Body]),
"turn_state" => (SourceRole::Failure, [
Some(SourceField::Body),
error_class.then_some(SourceField::ErrorClass),
failure_message.then_some(SourceField::FailureMessage),
].into_iter().flatten().collect()),
_ => return Err(invalid("invalid history source role")),
};
if !fields.contains(&field) { return Err(invalid("field is not a history source")); }
let read =
format!("{sql} SELECT substr(CAST(body AS BLOB), ?6, ?7) FROM source");
let offset = match position {
SourcePosition::Start => 0,
SourcePosition::Tail => total,
SourcePosition::Before { offset } | SourcePosition::After { offset } => {
offset
}
};
sql_number(offset)?;
if offset > total {
return Err(invalid("history source offset exceeds field"));
}
let byte: Vec<u8> = sqlx::query_scalar(sqlx::AssertSqlSafe(read.as_str()))
.bind(&session)
.bind(source_sequence_sql).bind(item_sql)
.bind(selected)
.bind(json_path.as_deref())
.bind(sql_number(offset)? + 1)
.bind(1i64)
.fetch_one(&mut *tx)
.await?;
if byte.first().is_some_and(|byte| byte & 0xc0 == 0x80) {
return Err(invalid("history source offset splits UTF-8"));
}
let reverse = matches!(
position,
SourcePosition::Tail | SourcePosition::Before { .. }
);
let (mut start, end) = if reverse {
(offset.saturating_sub(max_bytes as u64), offset)
} else {
(offset, total.min(offset + max_bytes as u64))
};
let bytes: Vec<u8> = sqlx::query_scalar(sqlx::AssertSqlSafe(read.as_str()))
.bind(&session)
.bind(source_sequence_sql).bind(item_sql)
.bind(selected)
.bind(json_path.as_deref())
.bind(sql_number(start)? + 1)
.bind((end - start) as i64)
.fetch_one(&mut *tx)
.await?;
let text = if reverse {
let skipped = bytes
.iter()
.take_while(|byte| **byte & 0xc0 == 0x80)
.count();
start += skipped as u64;
std::str::from_utf8(&bytes[skipped..])
.map_err(|_| invalid("invalid UTF-8 history source"))?
} else {
match std::str::from_utf8(&bytes) {
Ok(text) => text,
Err(error) if error.error_len().is_none() => {
std::str::from_utf8(&bytes[..error.valid_up_to()])
.map_err(|_| invalid("invalid UTF-8 history source"))?
}
Err(_) => return Err(invalid("invalid UTF-8 history source")),
}
};
if text.is_empty() && start < total && !(reverse && offset == 0) {
return Err(invalid("history source made no progress"));
}
let tool = if matches!(role, SourceRole::ToolCall | SourceRole::ToolResult) {
Some(tool_metadata(tx, &session, locator, fence_sql).await?)
} else {
None
};
let model = if role == SourceRole::Assistant {
super::model::resolve(tx, &session, locator, sql_number(through)?).await?.model
} else { None };
let source = HistorySource {
model,
origin: origin.map(|value| serde_json::from_str(&value)).transpose()?,
position: locator,
timestamp: sequence_number(timestamp)?,
role,
fields,
field,
path,
format,
offset: start,
total_bytes: total,
text: text.to_owned(),
tool,
};
Ok(source)
}))
})
.await
}
}
fn invalid(message: &str) -> StoreError {
StoreError::InvalidTransition(message.into())
}
fn json_path(
field: SourceField,
path: Option<&[SourceSegment]>,
) -> Result<Option<String>, StoreError> {
let Some(path) = path else { return Ok(None) };
if !matches!(
field,
SourceField::Arguments | SourceField::Result | SourceField::Receipt
) {
return Err(invalid("invalid structured history source path"));
}
let mut result = String::from("$");
for segment in path {
match segment {
SourceSegment::Key(key) => {
result.push('.');
result.push_str(&serde_json::to_string(key)?);
}
SourceSegment::Index(index) => result.push_str(&format!("[{index}]")),
}
}
Ok(Some(result))
}