blob: ac577f626fa2f23fc3a3e7620cd98c371c5ec441 [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.
*/
//! Imported conversation is historical evidence, never local execution or usage.
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Source {
/// Adapter-owned identity, not a Host provider allowlist.
pub adapter: String,
pub session_id: String,
}
/// No provider options, tool dispatches, credentials or executable bindings can
/// enter this vocabulary. Source timestamps are claims, distinct from import time.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Record {
pub source_message_id: String,
pub source_turn_id: String,
pub timestamp: Option<u64>,
pub content: Content,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum Content {
User {
text: String,
},
Assistant {
text: String,
model: Option<String>,
thinking: Option<String>,
},
/// Foreign tool activity remains an observation, not a replayable tool call.
ToolCall {
/// Adapter-normalized occurrence key, unique among ToolCall records in
/// the complete imported Session. Normalize reused foreign IDs first.
call_id: String,
name: String,
input: Option<serde_json::Value>,
},
ToolResult {
/// Refers to the normalized call occurrence when present; unmatched
/// result keys are allowed.
call_id: String,
output: serde_json::Value,
is_error: bool,
},
Note {
text: String,
},
}
impl Source {
pub fn validate(&self) -> Result<(), &'static str> {
for value in [&self.adapter, &self.session_id] {
if value.is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err("invalid imported source identity");
}
}
Ok(())
}
}
impl Record {
pub fn validate(&self) -> Result<(), &'static str> {
for value in [&self.source_message_id, &self.source_turn_id] {
if value.is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err("invalid imported message identity");
}
}
if self.timestamp.is_some_and(|ts| ts > 9_007_199_254_740_991) {
return Err("invalid imported timestamp");
}
if let Content::ToolCall { call_id, .. } | Content::ToolResult { call_id, .. } =
&self.content
&& (call_id.is_empty() || call_id.len() > 1024 || call_id.chars().any(char::is_control))
{
return Err("invalid imported tool identity");
}
Ok(())
}
pub fn is_conversation(&self) -> bool {
match &self.content {
Content::User { text } | Content::Assistant { text, .. } => !text.trim().is_empty(),
Content::ToolCall { .. } | Content::ToolResult { .. } | Content::Note { .. } => false,
}
}
}
/// An adapter normalizes foreign call keys within the source Session. Copies
/// retain the original canonical Session identity, so their links stay stable.
pub fn tool_call_id(session_id: &str, call_id: &str) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
for part in [session_id, call_id] {
hash.update((part.len() as u64).to_be_bytes());
hash.update(part.as_bytes());
}
format!("import-tool:{}", hex::encode(hash.finalize()))
}