blob: f4d3346f3fc0b1edc60955a033a96f319f91931b [file]
//! Decision contexts: what each model call of a session was sent, and what came back,
//! as the egress saw it. The paper's replay uses recorded decision contexts, not
//! transcripts, and this is why: a transcript is the harness's own account, which an
//! agent with a shell could shape and a harness may leave incomplete, while the egress
//! is in the path of every metered call and records what actually left and returned.
//! A context is kept beside the session's domain's entries, at the session's label when
//! the call was made, never indexed or searched, readable by the owner alone, and swept
//! on the transcripts' clock. It holds every entry body the session had read, so erasing
//! an entry erases the contexts that carry it.
use crate::{Error, Memory};
use dm_core::Label;
use dm_store::Area;
use serde::{Deserialize, Serialize};
use std::sync::atomic::Ordering;
use time::{Duration, OffsetDateTime};
/// A response kept up to this much; a request body over this is kept as a stub.
pub const MAX_BYTES: usize = 16 << 20;
/// One model call, as the egress saw it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Context {
/// Its place in the session, from one.
pub n: usize,
/// When.
pub at: String,
/// The destination the call went to.
pub destination: String,
/// The API path at that destination.
pub path: String,
/// The session's label when the call was made.
pub label: Label,
/// The request body, exactly as it left; `null` when it was too large to keep.
pub request: serde_json::Value,
/// The response as it came back, raw, up to [`MAX_BYTES`].
pub response: String,
/// The tools the model asked for in it, by name.
pub tools: Vec<String>,
/// Who observed it: the egress, which every model call passes through.
#[serde(default = "by_egress")]
pub observed_by: String,
}
fn by_egress() -> String {
crate::transcripts::OBSERVED_BY_EGRESS.to_string()
}
/// The tools a model asked for in a response, in the shapes the providers speak:
/// Anthropic's `tool_use` blocks, OpenAI's chat `function` calls, the Responses API's
/// `function_call` items, Gemini's `functionCall` parts; a whole JSON answer or a
/// stream of `data:` events, each read as JSON and walked, so the order of fields in an
/// object does not matter.
#[must_use]
pub fn tools_in(raw: &str) -> Vec<String> {
let mut out = Vec::new();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
walk(&v, &mut out);
} else {
for line in raw.lines() {
let line = line.trim_start_matches("data:").trim();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
walk(&v, &mut out);
}
}
}
out
}
/// The text a model answered with, from a whole answer or a stream of events, in the
/// shapes the providers speak: the Responses API's `output_text` (and its deltas),
/// chat completions' `content` (and its deltas), Anthropic's text blocks and
/// `text_delta`s. Tool-call arguments are not text.
#[must_use]
pub fn text_in(raw: &str) -> String {
let mut out = String::new();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
text_of(&v, &mut out);
} else {
for line in raw.lines() {
let line = line.trim_start_matches("data:").trim();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
text_of(&v, &mut out);
}
}
}
out
}
fn text_of(v: &serde_json::Value, out: &mut String) {
use serde_json::Value;
let s = |p: &str| v.pointer(p).and_then(Value::as_str);
match v.get("type").and_then(Value::as_str) {
// the Responses API, streamed
Some("response.output_text.delta") => out.push_str(s("/delta").unwrap_or_default()),
// Anthropic, streamed
Some("content_block_delta") if s("/delta/type") == Some("text_delta") => {
out.push_str(s("/delta/text").unwrap_or_default());
}
_ => {
// chat completions, streamed or whole
if let Some(choices) = v.get("choices").and_then(Value::as_array) {
for c in choices {
let t = c
.pointer("/delta/content")
.or_else(|| c.pointer("/message/content"));
out.push_str(t.and_then(Value::as_str).unwrap_or_default());
}
}
// whole answers: Responses `output`, Messages `content`
for key in ["output", "content"] {
for item in v.get(key).and_then(Value::as_array).into_iter().flatten() {
match item.get("type").and_then(Value::as_str) {
Some("text" | "output_text") => {
out.push_str(
item.get("text").and_then(Value::as_str).unwrap_or_default(),
);
}
Some("message") => text_of(item, out),
_ => {}
}
}
}
}
}
}
fn push(out: &mut Vec<String>, name: String) {
if !out.contains(&name) {
out.push(name);
}
}
fn walk(v: &serde_json::Value, out: &mut Vec<String>) {
use serde_json::Value;
match v {
Value::Object(o) => {
let name = o.get("name").and_then(Value::as_str);
if let (Some("tool_use" | "function_call"), Some(n)) =
(o.get("type").and_then(Value::as_str), name)
{
push(
out,
match o.get("namespace").and_then(Value::as_str) {
Some(ns) => format!("{ns}__{n}"),
None => n.to_string(),
},
);
}
for (k, inner) in o {
// a chat completion's call, and a Gemini part's
if k == "function" || k == "functionCall" {
if let Some(n) = inner.get("name").and_then(Value::as_str) {
push(out, n.to_string());
}
}
walk(inner, out);
}
}
Value::Array(a) => {
for x in a {
walk(x, out);
}
}
_ => {}
}
}
fn name_of(n: usize) -> String {
format!("{n:06}.json")
}
impl Memory {
/// Keep one model call's context, under this session in its domain: the domain of
/// the task it holds, else the only granted one; a session with neither keeps none,
/// and nor does a re-run.
///
/// # Errors
///
/// A store error.
pub async fn keep_context(
&self,
destination: &str,
path: &str,
request: &[u8],
response: &[u8],
) -> Result<(), Error> {
if !self.keeps_contexts() {
return Ok(());
}
let Some(domain) = self.recording_domain().await else {
return Ok(());
};
let n = self.contexts_kept.fetch_add(1, Ordering::Relaxed) + 1;
let request = if request.len() > MAX_BYTES {
serde_json::Value::Null
} else {
serde_json::from_slice(request).unwrap_or(serde_json::Value::Null)
};
let response =
String::from_utf8_lossy(&response[..response.len().min(MAX_BYTES)]).into_owned();
let c = Context {
n,
at: OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
destination: destination.to_string(),
path: path.to_string(),
label: self.label(),
request,
tools: tools_in(&response),
response,
observed_by: by_egress(),
};
let bytes = serde_json::to_vec(&c).map_err(|e| Error::Transcript(e.to_string()))?;
self.store
.put_object(
&domain,
Area::Contexts,
&[&self.session_id, &name_of(n)],
bytes,
)
.await?;
Ok(())
}
/// A session's decision contexts, in order. The owner's alone.
///
/// # Errors
///
/// [`Error::Denied`] for anyone but the owner, [`Error::NotFound`] for a session
/// with none, or a store error.
pub async fn contexts(&self, session: &str) -> Result<Vec<Context>, Error> {
if self.author != self.grants.owner {
return Err(Error::Denied(format!(
"decision contexts are the owner's: {}",
self.grants.owner
)));
}
for domain in self.domains() {
let names = self
.store
.objects(&domain, Area::Contexts, &[session], false)
.await?;
if names.is_empty() {
continue;
}
let mut out = Vec::new();
for n in names {
if let Some(text) = self
.store
.get_object(&domain, Area::Contexts, &[session, &n])
.await?
{
out.push(
serde_json::from_str(&text)
.map_err(|e| Error::Transcript(e.to_string()))?,
);
}
}
return Ok(out);
}
Err(Error::NotFound(session.to_string()))
}
/// The sessions that kept contexts, by domain.
///
/// # Errors
///
/// A store error.
pub async fn context_sessions(&self) -> Result<Vec<(String, String)>, Error> {
let mut out = Vec::new();
for domain in self.domains() {
for s in self
.store
.objects(&domain, Area::Contexts, &[], true)
.await?
{
out.push((domain.clone(), s));
}
}
Ok(out)
}
/// Drop the contexts of every session whose last call is older than its domain's
/// transcript clock. Returns the sessions swept.
///
/// # Errors
///
/// A store error.
pub async fn sweep_contexts(&self, now: OffsetDateTime) -> Result<Vec<String>, Error> {
let mut swept = Vec::new();
for (domain, session) in self.context_sessions().await? {
let days = self
.grants
.domains
.get(&domain)
.map_or(dm_policy::DEFAULT_TRANSCRIPT_RETENTION_DAYS, |g| {
g.transcript_retention_days()
});
let names = self
.store
.objects(&domain, Area::Contexts, &[&session], false)
.await?;
let Some(last) = names.last() else { continue };
let Some(text) = self
.store
.get_object(&domain, Area::Contexts, &[&session, last])
.await?
else {
continue;
};
let at = serde_json::from_str::<Context>(&text).ok().and_then(|c| {
OffsetDateTime::parse(&c.at, &time::format_description::well_known::Rfc3339).ok()
});
if at.is_some_and(|at| now >= at + Duration::days(i64::from(days))) {
for n in &names {
self.store
.remove_object(&domain, Area::Contexts, &[&session, n])
.await?;
}
swept.push(session);
}
}
Ok(swept)
}
/// Erase every kept context that carries `body`: an erased entry's text must not
/// outlive it in what a model was sent. Matched on a stretch of the body long enough
/// to be its own; returns the contexts removed, as `<session>/<n>`.
///
/// # Errors
///
/// A store error.
pub async fn erase_from_contexts(&self, body: &str) -> Result<Vec<String>, Error> {
let probe: String = body.trim().chars().take(160).collect();
if probe.chars().count() < 24 {
return Ok(Vec::new());
}
// as it appears inside a JSON string in a request body
let escaped = serde_json::to_string(&probe).unwrap_or_default();
let escaped = escaped.trim_matches('"').to_string();
let mut removed = Vec::new();
for (domain, session) in self.context_sessions().await? {
for n in self
.store
.objects(&domain, Area::Contexts, &[&session], false)
.await?
{
let Some(text) = self
.store
.get_object(&domain, Area::Contexts, &[&session, &n])
.await?
else {
continue;
};
if text.contains(&escaped) || text.contains(&probe) {
self.store
.remove_object(&domain, Area::Contexts, &[&session, &n])
.await?;
removed.push(format!("{session}/{n}"));
}
}
}
Ok(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tools_are_read_from_each_providers_shape() {
assert_eq!(
tools_in(
r#"{"content":[{"type":"tool_use","id":"t1","name":"mcp__memory__get","input":{}}]}"#
),
vec!["mcp__memory__get"]
);
assert_eq!(
tools_in(
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"search","arguments":""}}]}}]}"#
),
vec!["search"]
);
assert_eq!(
tools_in(
r#"{"output":[{"type":"function_call","call_id":"c","name":"write","arguments":"{}"}]}"#
),
vec!["write"]
);
assert_eq!(
tools_in(
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"t0","args":{}}}]}}]}"#
),
vec!["t0"]
);
assert!(tools_in(r#"{"choices":[{"message":{"content":"DR-19 is open."}}]}"#).is_empty());
// the Responses API's stream: the name before the type, and a namespace
let streamed = "data: {\"type\":\"response.output_item.added\",\"item\":{\"arguments\":\"\",\"name\":\"search\",\"type\":\"function_call\",\"namespace\":\"mcp__memory\"}}\n\ndata: {\"type\":\"response.completed\"}\n";
assert_eq!(tools_in(streamed), vec!["mcp__memory__search"]);
}
#[test]
fn the_answers_text_is_read_from_streams_and_wholes() {
let responses = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"DR-19 is \"}\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{}\"}\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"open.\"}\n";
assert_eq!(text_in(responses), "DR-19 is open.");
let chat = "data: {\"choices\":[{\"delta\":{\"content\":\"Still \"}}]}\ndata: {\"choices\":[{\"delta\":{\"content\":\"proposed.\"}}]}\n";
assert_eq!(text_in(chat), "Still proposed.");
assert_eq!(
text_in(r#"{"content":[{"type":"text","text":"Open."}]}"#),
"Open."
);
assert_eq!(
text_in(
r#"{"output":[{"type":"message","content":[{"type":"output_text","text":"Open."}]}]}"#
),
"Open."
);
}
}