doubleo7/src/history.rs
Austin Schaefer f2c10783db
Some checks failed
CI / test (push) Failing after 6s
CI / test (pull_request) Failing after 7s
Extract swear_cleanup to its own repo, flatten deep_research to root
deep_research is the only project this repo is meant to showcase, so the
Cargo workspace wrapping it and an unrelated side project no longer earns
its keep:

- swear_cleanup moved to a new standalone local repo (~/dev/swear_cleanup,
  not pushed anywhere) via `git subtree split`, with its pre-workspace-
  split history (when it lived at src/swear_cleanup/ in a single shared
  crate) spliced onto its post-split history rather than starting from a
  single flattened snapshot. FINDINGS.md, which was sitting at this repo's
  root but was actually swear_cleanup's own build log, went with it.
- deep_research/{src,Cargo.toml,README.md,docs} moved to the repo root;
  the [workspace] table collapsed into a plain [package] manifest with
  dependency versions inlined from the old [workspace.dependencies].
- Cargo.toml keeps an explicit empty [workspace] table (not just omitted)
  so that checking this repo out as a nested git worktree — this
  project's own normal workflow — can't accidentally inherit a stale
  ancestor directory's workspace manifest, which is exactly what broke
  the build while testing this change from a worktree.
- .forgejo/workflows/deep_research-ci.yml -> ci.yml, dropping the now-
  meaningless -p deep_research scoping and path filters (redundant when
  it's the only thing in the repo).
- README.md and docs/case-study.md updated for the flattened commands
  (cargo run/test with no -p flag); their relative links to each other
  and to src/ were already correct since both moved together.

Verified: cargo build/test/clippy/fmt all clean from the new repo root.
2026-08-18 13:43:26 +02:00

292 lines
9.6 KiB
Rust

use rig::completion::message::{ToolResultContent, UserContent};
use rig::completion::{AssistantContent, Message};
const MAX_TOOL_RESULT_CHARS: usize = 2000;
/// Best-effort reconstruction of research material from a chat history left
/// behind when the researcher hit its turn budget mid-investigation: whatever
/// prose the model wrote between tool calls, plus the raw text of every tool
/// result (search snippets, fetched pages), truncated so one huge page can't
/// crowd out everything else that was found.
pub(crate) fn partial_findings_from_history(chat_history: &[Message]) -> String {
let sections: Vec<String> = chat_history
.iter()
.flat_map(|message| {
extract_assistant_text(message)
.into_iter()
.chain(tool_result_text(message, MAX_TOOL_RESULT_CHARS))
})
.collect();
match sections.is_empty() {
true => "The researcher exhausted its turn budget before gathering any usable evidence."
.to_string(),
false => sections.join("\n"),
}
}
/// Plain-text blocks from an assistant message, if any.
fn extract_assistant_text(message: &Message) -> Vec<String> {
let Message::Assistant { content, .. } = message else {
return Vec::new();
};
content
.iter()
.filter_map(|item: &AssistantContent| match item {
AssistantContent::Text(text) => Some(text.text().to_string()),
_ => None,
})
.collect()
}
/// Plain-text tool results attached to a user message (that's where rig
/// places them), each truncated to `max_chars` so one huge fetched page
/// can't crowd out everything else that was found.
fn tool_result_text(message: &Message, max_chars: usize) -> Vec<String> {
let Message::User { content } = message else {
return Vec::new();
};
content
.iter()
.filter_map(|item: &UserContent| match item {
UserContent::ToolResult(tool_result) => Some(tool_result),
_ => None,
})
.flat_map(|tool_result| tool_result.content.iter())
.filter_map(
|tool_result_content: &ToolResultContent| match tool_result_content {
ToolResultContent::Text(text) => Some(truncate(text.text(), max_chars)),
_ => None,
},
)
.collect()
}
fn truncate(text: &str, max_chars: usize) -> String {
let mut result = text.to_string();
if result.len() > max_chars {
result.truncate(max_chars);
result.push_str(" ...[truncated]");
}
result
}
/// Chronological transcript of a partial research run, annotated with tool
/// calls (so a fetch's URL or a search's query stays attached to its
/// result) rather than just the bare result text `partial_findings_from_history`
/// collects — the summarizer agent needs that context to attribute facts to
/// the right source.
pub(crate) fn annotated_transcript_from_history(chat_history: &[Message]) -> String {
chat_history
.iter()
.flat_map(transcript_lines)
.collect::<Vec<String>>()
.join("\n")
}
fn transcript_lines(message: &Message) -> Vec<String> {
match message {
Message::Assistant { content, .. } => content
.iter()
.filter_map(|item| match item {
AssistantContent::Text(text) => Some(format!("Note: {}", text.text())),
AssistantContent::ToolCall(call) => Some(format!(
"Called {}({})",
call.function.name, call.function.arguments
)),
_ => None,
})
.collect(),
Message::User { content } => content
.iter()
.filter_map(|item| match item {
UserContent::ToolResult(result) => Some(result),
_ => None,
})
.flat_map(|result| result.content.iter())
.filter_map(|part| match part {
ToolResultContent::Text(text) => Some(format!(
"Result: {}",
truncate(text.text(), MAX_TOOL_RESULT_CHARS)
)),
_ => None,
})
.collect(),
Message::System { .. } => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_leaves_short_text_untouched() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_leaves_exact_length_text_untouched() {
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn truncate_cuts_long_text_and_marks_it() {
assert_eq!(truncate("hello world", 5), "hello ...[truncated]");
}
#[test]
fn assistant_text_extracts_text_blocks() {
let message = Message::assistant("found it");
assert_eq!(
extract_assistant_text(&message),
vec!["found it".to_string()]
);
}
#[test]
fn assistant_text_ignores_tool_calls() {
let message = Message::Assistant {
id: None,
content: rig::OneOrMany::one(AssistantContent::tool_call(
"call-1",
"search_web",
serde_json::json!({ "query": "test" }),
)),
};
assert!(extract_assistant_text(&message).is_empty());
}
#[test]
fn assistant_text_ignores_non_assistant_messages() {
assert!(extract_assistant_text(&Message::user("hi")).is_empty());
assert!(extract_assistant_text(&Message::system("be careful")).is_empty());
}
#[test]
fn tool_result_text_extracts_and_truncates() {
let short = Message::tool_result("call-1", "short result");
assert_eq!(
tool_result_text(&short, 100),
vec!["short result".to_string()]
);
let long = Message::tool_result("call-2", "0123456789");
assert_eq!(
tool_result_text(&long, 5),
vec!["01234 ...[truncated]".to_string()]
);
}
#[test]
fn tool_result_text_ignores_non_tool_result_content() {
assert!(tool_result_text(&Message::user("plain text, no tool result"), 100).is_empty());
assert!(tool_result_text(&Message::assistant("also ignored"), 100).is_empty());
}
#[test]
fn partial_findings_from_history_falls_back_when_nothing_usable() {
let history = vec![
Message::system("preamble"),
Message::Assistant {
id: None,
content: rig::OneOrMany::one(AssistantContent::tool_call(
"call-1",
"search_web",
serde_json::json!({ "query": "test" }),
)),
},
];
assert_eq!(
partial_findings_from_history(&history),
"The researcher exhausted its turn budget before gathering any usable evidence."
);
}
#[test]
fn partial_findings_from_history_collects_assistant_text_and_tool_results_in_order() {
let history = vec![
Message::assistant("Checking sources..."),
Message::tool_result("call-1", "Result A [1]"),
Message::assistant("Cross-checking..."),
Message::tool_result("call-2", "Result B [2]"),
];
assert_eq!(
partial_findings_from_history(&history),
"Checking sources...\nResult A [1]\nCross-checking...\nResult B [2]"
);
}
#[test]
fn transcript_lines_prefixes_assistant_notes() {
let message = Message::assistant("still checking this claim");
assert_eq!(
transcript_lines(&message),
vec!["Note: still checking this claim".to_string()]
);
}
#[test]
fn transcript_lines_renders_tool_calls_with_their_arguments() {
let message = Message::Assistant {
id: None,
content: rig::OneOrMany::one(AssistantContent::tool_call(
"call-1",
"search_web",
serde_json::json!({ "query": "uruguay senior engineers" }),
)),
};
assert_eq!(
transcript_lines(&message),
vec!["Called search_web({\"query\":\"uruguay senior engineers\"})".to_string()]
);
}
#[test]
fn transcript_lines_prefixes_and_truncates_tool_results() {
let short = Message::tool_result("call-1", "found via search [1]");
assert_eq!(
transcript_lines(&short),
vec!["Result: found via search [1]".to_string()]
);
}
#[test]
fn transcript_lines_ignores_system_messages() {
assert!(transcript_lines(&Message::system("be thorough")).is_empty());
}
#[test]
fn annotated_transcript_from_history_joins_calls_results_and_notes_in_order() {
let history = vec![
Message::Assistant {
id: None,
content: rig::OneOrMany::one(AssistantContent::tool_call(
"call-1",
"search_web",
serde_json::json!({ "query": "test" }),
)),
},
Message::tool_result("call-1", "1. Example\n https://example.com\n snippet"),
Message::assistant("that source looks solid"),
];
assert_eq!(
annotated_transcript_from_history(&history),
"Called search_web({\"query\":\"test\"})\n\
Result: 1. Example\n https://example.com\n snippet\n\
Note: that source looks solid"
);
}
#[test]
fn annotated_transcript_from_history_is_empty_with_no_usable_content() {
assert_eq!(
annotated_transcript_from_history(&[Message::system("preamble")]),
""
);
}
}