mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
reflections using tools
This commit is contained in:
@@ -1670,6 +1670,9 @@
|
||||
"ReflectionsConfigToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"storage_tools_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"usage_hint_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -1601,6 +1601,7 @@ impl Session {
|
||||
context_window,
|
||||
sidecar_path.as_path(),
|
||||
per_turn_config.reflections.usage_hint_text.as_deref(),
|
||||
per_turn_config.reflections.storage_tools_enabled,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
@@ -1627,7 +1628,11 @@ impl Session {
|
||||
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_reflections(reflections_enabled, reflections_usage_hint_text)
|
||||
.with_reflections(
|
||||
reflections_enabled,
|
||||
reflections_usage_hint_text,
|
||||
per_turn_config.reflections.storage_tools_enabled,
|
||||
)
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&per_turn_config.agent_roles,
|
||||
));
|
||||
@@ -2440,7 +2445,10 @@ impl Session {
|
||||
}
|
||||
|
||||
let remaining_tokens = auto_compact_limit.saturating_sub(total_usage_tokens).max(0);
|
||||
let reminder = crate::reflections::near_limit_reminder(Some(remaining_tokens));
|
||||
let reminder = crate::reflections::near_limit_reminder(
|
||||
Some(remaining_tokens),
|
||||
turn_context.config.reflections.storage_tools_enabled,
|
||||
);
|
||||
self.record_conversation_items(turn_context, std::slice::from_ref(&reminder))
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -6316,6 +6316,7 @@ async fn reflections_config_from_strategy_and_feature_table() -> std::io::Result
|
||||
[features.reflections]
|
||||
usage_hint_enabled = false
|
||||
usage_hint_text = "Custom recovery guidance."
|
||||
storage_tools_enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
@@ -6334,6 +6335,30 @@ usage_hint_text = "Custom recovery guidance."
|
||||
config.reflections.usage_hint_text.as_deref(),
|
||||
Some("Custom recovery guidance.")
|
||||
);
|
||||
assert!(!config.reflections.storage_tools_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflections_config_defaults_enable_storage_tools() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"compaction_strategy = "reflections"
|
||||
|
||||
[features.reflections]
|
||||
usage_hint_enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.reflections.storage_tools_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -6349,6 +6374,7 @@ compaction_strategy = "reflections"
|
||||
[features.reflections]
|
||||
usage_hint_enabled = true
|
||||
usage_hint_text = "base hint"
|
||||
storage_tools_enabled = true
|
||||
|
||||
[profiles.default_compaction]
|
||||
compaction_strategy = "default"
|
||||
@@ -6356,6 +6382,7 @@ compaction_strategy = "default"
|
||||
[profiles.default_compaction.features.reflections]
|
||||
usage_hint_enabled = false
|
||||
usage_hint_text = "profile hint"
|
||||
storage_tools_enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
@@ -6374,6 +6401,7 @@ usage_hint_text = "profile hint"
|
||||
config.reflections.usage_hint_text.as_deref(),
|
||||
Some("profile hint")
|
||||
);
|
||||
assert!(!config.reflections.storage_tools_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -626,6 +626,7 @@ impl Default for MultiAgentV2Config {
|
||||
pub struct ReflectionsConfig {
|
||||
pub usage_hint_enabled: bool,
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub storage_tools_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for ReflectionsConfig {
|
||||
@@ -633,6 +634,7 @@ impl Default for ReflectionsConfig {
|
||||
Self {
|
||||
usage_hint_enabled: true,
|
||||
usage_hint_text: None,
|
||||
storage_tools_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1415,10 +1417,15 @@ fn resolve_reflections_config(
|
||||
.or_else(|| base.and_then(|config| config.usage_hint_text.as_ref()))
|
||||
.cloned()
|
||||
.or(default.usage_hint_text);
|
||||
let storage_tools_enabled = profile
|
||||
.and_then(|config| config.storage_tools_enabled)
|
||||
.or_else(|| base.and_then(|config| config.storage_tools_enabled))
|
||||
.unwrap_or(default.storage_tools_enabled);
|
||||
|
||||
ReflectionsConfig {
|
||||
usage_hint_enabled,
|
||||
usage_hint_text,
|
||||
storage_tools_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_tools::REFLECTIONS_LIST_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub(crate) struct LogEntry {
|
||||
pub(crate) entry_id: String,
|
||||
pub(crate) kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) role: Option<String>,
|
||||
pub(crate) content: String,
|
||||
pub(crate) metadata: Value,
|
||||
}
|
||||
|
||||
pub(crate) fn log_entries_from_items(window: &str, items: &[RolloutItem]) -> Vec<LogEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut call_tools = std::collections::HashMap::new();
|
||||
for item in items {
|
||||
let entry = match item {
|
||||
RolloutItem::EventMsg(event) => log_entry_from_event(event),
|
||||
RolloutItem::ResponseItem(response_item) => {
|
||||
log_entry_from_response_item(response_item, &mut call_tools)
|
||||
}
|
||||
RolloutItem::SessionMeta(_)
|
||||
| RolloutItem::Compacted(_)
|
||||
| RolloutItem::TurnContext(_) => None,
|
||||
};
|
||||
if let Some(mut entry) = entry {
|
||||
let entry_index = entries.len() + 1;
|
||||
entry.entry_id = entry_id(window, entry_index);
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn log_entry_from_event(event: &EventMsg) -> Option<LogEntry> {
|
||||
match event {
|
||||
EventMsg::UserMessage(event) => {
|
||||
let mut text = event.message.clone();
|
||||
if let Some(images) = event.images.as_ref().filter(|images| !images.is_empty()) {
|
||||
push_blank_line_if_needed(&mut text);
|
||||
text.push_str("images:\n");
|
||||
for image in images {
|
||||
text.push_str(&format!("- {image}\n"));
|
||||
}
|
||||
}
|
||||
if !event.local_images.is_empty() {
|
||||
push_blank_line_if_needed(&mut text);
|
||||
text.push_str("local_images:\n");
|
||||
for image in &event.local_images {
|
||||
text.push_str(&format!("- {}\n", image.display()));
|
||||
}
|
||||
}
|
||||
Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "user_message".to_string(),
|
||||
role: Some("user".to_string()),
|
||||
content: text,
|
||||
metadata: json!({}),
|
||||
})
|
||||
}
|
||||
EventMsg::AgentMessage(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "assistant_message".to_string(),
|
||||
role: Some("assistant".to_string()),
|
||||
content: event.message.clone(),
|
||||
metadata: json!({
|
||||
"phase": event.phase.as_ref().map(|phase| format!("{phase:?}").to_lowercase()),
|
||||
}),
|
||||
}),
|
||||
EventMsg::McpToolCallBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"arguments": event.invocation.arguments,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({
|
||||
"tool_name": format!("mcp.{}.{}", event.invocation.server, event.invocation.tool),
|
||||
}),
|
||||
}),
|
||||
EventMsg::McpToolCallEnd(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"arguments": event.invocation.arguments,
|
||||
"success": event.is_success(),
|
||||
"result": event.result,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({
|
||||
"tool_name": format!("mcp.{}.{}", event.invocation.server, event.invocation.tool),
|
||||
}),
|
||||
}),
|
||||
EventMsg::WebSearchBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(event).unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "web_search" }),
|
||||
}),
|
||||
EventMsg::WebSearchEnd(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"query": event.query,
|
||||
"action": event.action,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "web_search" }),
|
||||
}),
|
||||
EventMsg::ImageGenerationBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(event).unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "image_generation" }),
|
||||
}),
|
||||
EventMsg::ImageGenerationEnd(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"status": event.status,
|
||||
"revised_prompt": event.revised_prompt,
|
||||
"result": event.result,
|
||||
"saved_path": event.saved_path.as_ref().map(|path| path.display().to_string()),
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "image_generation" }),
|
||||
}),
|
||||
EventMsg::ExecCommandBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: format!(
|
||||
"call_id: {}\ncwd: {}\ncommand: {}\n",
|
||||
event.call_id,
|
||||
event.cwd.display(),
|
||||
serde_json::to_string(&event.command).unwrap_or_else(|_| "[]".to_string())
|
||||
),
|
||||
metadata: json!({ "tool_name": "exec_command" }),
|
||||
}),
|
||||
EventMsg::ExecCommandEnd(event) => {
|
||||
let mut content = format!(
|
||||
"call_id: {}\nstatus: {:?}\nexit_code: {}\ncwd: {}\ncommand: {}\n",
|
||||
event.call_id,
|
||||
event.status,
|
||||
event.exit_code,
|
||||
event.cwd.display(),
|
||||
serde_json::to_string(&event.command).unwrap_or_else(|_| "[]".to_string())
|
||||
);
|
||||
let output = exec_output(event);
|
||||
if !output.is_empty() {
|
||||
content.push_str("\noutput:\n");
|
||||
content.push_str(&output);
|
||||
}
|
||||
Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content,
|
||||
metadata: json!({ "tool_name": "exec_command" }),
|
||||
})
|
||||
}
|
||||
EventMsg::ViewImageToolCall(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"path": event.path.display().to_string(),
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "view_image" }),
|
||||
}),
|
||||
EventMsg::DynamicToolCallRequest(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: dynamic_tool_call_content(&event.tool, &event.call_id, &event.arguments),
|
||||
metadata: json!({ "tool_name": event.tool }),
|
||||
}),
|
||||
EventMsg::DynamicToolCallResponse(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: dynamic_tool_response_content(
|
||||
&event.tool,
|
||||
&event.call_id,
|
||||
event.success,
|
||||
event.error.as_deref(),
|
||||
&event.content_items,
|
||||
&event.arguments,
|
||||
),
|
||||
metadata: json!({ "tool_name": event.tool }),
|
||||
}),
|
||||
EventMsg::PatchApplyBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"auto_approved": event.auto_approved,
|
||||
"changes": event.changes,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "apply_patch" }),
|
||||
}),
|
||||
EventMsg::PatchApplyEnd(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"success": event.success,
|
||||
"status": event.status,
|
||||
"stdout": event.stdout,
|
||||
"stderr": event.stderr,
|
||||
"changes": event.changes,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "apply_patch" }),
|
||||
}),
|
||||
EventMsg::Error(_)
|
||||
| EventMsg::Warning(_)
|
||||
| EventMsg::RealtimeConversationStarted(_)
|
||||
| EventMsg::RealtimeConversationRealtime(_)
|
||||
| EventMsg::RealtimeConversationClosed(_)
|
||||
| EventMsg::RealtimeConversationSdp(_)
|
||||
| EventMsg::ModelReroute(_)
|
||||
| EventMsg::ContextCompacted(_)
|
||||
| EventMsg::ThreadRolledBack(_)
|
||||
| EventMsg::TurnStarted(_)
|
||||
| EventMsg::TurnComplete(_)
|
||||
| EventMsg::TokenCount(_)
|
||||
| EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoning(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::AgentReasoningRawContent(_)
|
||||
| EventMsg::AgentReasoningRawContentDelta(_)
|
||||
| EventMsg::AgentReasoningSectionBreak(_)
|
||||
| EventMsg::SessionConfigured(_)
|
||||
| EventMsg::ThreadNameUpdated(_)
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::RequestPermissions(_)
|
||||
| EventMsg::RequestUserInput(_)
|
||||
| EventMsg::ElicitationRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::GuardianAssessment(_)
|
||||
| EventMsg::DeprecationNotice(_)
|
||||
| EventMsg::BackgroundEvent(_)
|
||||
| EventMsg::UndoStarted(_)
|
||||
| EventMsg::UndoCompleted(_)
|
||||
| EventMsg::StreamError(_)
|
||||
| EventMsg::TurnDiff(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::RealtimeConversationListVoicesResponse(_)
|
||||
| EventMsg::SkillsUpdateAvailable
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::TurnAborted(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::ItemStarted(_)
|
||||
| EventMsg::ItemCompleted(_)
|
||||
| EventMsg::HookStarted(_)
|
||||
| EventMsg::HookCompleted(_)
|
||||
| EventMsg::AgentMessageContentDelta(_)
|
||||
| EventMsg::PlanDelta(_)
|
||||
| EventMsg::ReasoningContentDelta(_)
|
||||
| EventMsg::ReasoningRawContentDelta(_)
|
||||
| EventMsg::CollabAgentSpawnBegin(_)
|
||||
| EventMsg::CollabAgentSpawnEnd(_)
|
||||
| EventMsg::CollabAgentInteractionBegin(_)
|
||||
| EventMsg::CollabAgentInteractionEnd(_)
|
||||
| EventMsg::CollabWaitingBegin(_)
|
||||
| EventMsg::CollabWaitingEnd(_)
|
||||
| EventMsg::CollabCloseBegin(_)
|
||||
| EventMsg::CollabCloseEnd(_)
|
||||
| EventMsg::CollabResumeBegin(_)
|
||||
| EventMsg::CollabResumeEnd(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_entry_from_response_item(
|
||||
item: &ResponseItem,
|
||||
call_tools: &mut std::collections::HashMap<String, String>,
|
||||
) -> Option<LogEntry> {
|
||||
match item {
|
||||
ResponseItem::FunctionCall {
|
||||
name,
|
||||
arguments,
|
||||
call_id,
|
||||
namespace,
|
||||
..
|
||||
} if is_reflections_storage_tool(name) => {
|
||||
call_tools.insert(call_id.clone(), name.clone());
|
||||
let arguments_json = serde_json::from_str(arguments).unwrap_or(Value::Null);
|
||||
Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: reflections_storage_tool_call_summary(name, call_id, &arguments_json),
|
||||
metadata: json!({
|
||||
"tool_name": name,
|
||||
"namespace": namespace,
|
||||
}),
|
||||
})
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { call_id, output }
|
||||
if call_tools
|
||||
.get(call_id)
|
||||
.is_some_and(|tool_name| is_reflections_storage_tool(tool_name)) =>
|
||||
{
|
||||
let tool_name = call_tools.get(call_id).cloned().unwrap_or_default();
|
||||
Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: reflections_storage_tool_output_summary(&tool_name, call_id, output),
|
||||
metadata: json!({
|
||||
"tool_name": tool_name,
|
||||
"success": output.success,
|
||||
}),
|
||||
})
|
||||
}
|
||||
ResponseItem::Message { .. }
|
||||
| ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_blank_line_if_needed(text: &mut String) {
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn exec_output(event: &codex_protocol::protocol::ExecCommandEndEvent) -> String {
|
||||
if !event.aggregated_output.is_empty() {
|
||||
return event.aggregated_output.clone();
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
if !event.stdout.is_empty() {
|
||||
output.push_str("stdout:\n");
|
||||
output.push_str(&event.stdout);
|
||||
if !event.stdout.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
if !event.stderr.is_empty() {
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str("stderr:\n");
|
||||
output.push_str(&event.stderr);
|
||||
if !event.stderr.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub(super) fn dynamic_tool_output_items_to_text(
|
||||
items: &[DynamicToolCallOutputContentItem],
|
||||
) -> String {
|
||||
let mut pieces = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
DynamicToolCallOutputContentItem::InputText { text } => pieces.push(text.clone()),
|
||||
DynamicToolCallOutputContentItem::InputImage { .. } => {
|
||||
pieces.push("[image omitted from Reflections transcript]".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
pieces.join("\n")
|
||||
}
|
||||
|
||||
fn dynamic_tool_call_content(tool: &str, call_id: &str, arguments: &Value) -> String {
|
||||
let value = if is_reflections_storage_tool(tool) {
|
||||
reflections_storage_tool_call_metadata(tool, call_id, arguments)
|
||||
} else {
|
||||
json!({
|
||||
"call_id": call_id,
|
||||
"arguments": arguments,
|
||||
})
|
||||
};
|
||||
serde_json::to_string_pretty(&value).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn dynamic_tool_response_content(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
content_items: &[DynamicToolCallOutputContentItem],
|
||||
arguments: &Value,
|
||||
) -> String {
|
||||
if is_reflections_storage_tool(tool) {
|
||||
return serde_json::to_string_pretty(&reflections_storage_tool_response_metadata(
|
||||
tool,
|
||||
call_id,
|
||||
success,
|
||||
error,
|
||||
content_items,
|
||||
arguments,
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
format!(
|
||||
"call_id: {call_id}\nsuccess: {success}\nerror: {}\n\n{}",
|
||||
error.unwrap_or(""),
|
||||
dynamic_tool_output_items_to_text(content_items)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn is_reflections_storage_tool(tool: &str) -> bool {
|
||||
matches!(
|
||||
tool,
|
||||
REFLECTIONS_LIST_TOOL_NAME
|
||||
| REFLECTIONS_READ_TOOL_NAME
|
||||
| REFLECTIONS_SEARCH_TOOL_NAME
|
||||
| REFLECTIONS_WRITE_NOTE_TOOL_NAME
|
||||
)
|
||||
}
|
||||
|
||||
fn reflections_storage_tool_call_summary(tool: &str, call_id: &str, arguments: &Value) -> String {
|
||||
serde_json::to_string_pretty(&reflections_storage_tool_call_metadata(
|
||||
tool, call_id, arguments,
|
||||
))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn reflections_storage_tool_output_summary(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
output: &FunctionCallOutputPayload,
|
||||
) -> String {
|
||||
let text = output.body.to_text().unwrap_or_default();
|
||||
let parsed = serde_json::from_str::<Value>(&text).unwrap_or(Value::Null);
|
||||
serde_json::to_string_pretty(&reflections_storage_output_value_metadata(
|
||||
tool,
|
||||
call_id,
|
||||
output.success.unwrap_or(true),
|
||||
None,
|
||||
&parsed,
|
||||
))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn reflections_storage_tool_call_metadata(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
arguments: &Value,
|
||||
) -> Value {
|
||||
match tool {
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": arguments.get("note_id"),
|
||||
"operation": arguments.get("operation"),
|
||||
"content_chars": arguments
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::chars)
|
||||
.map(Iterator::count)
|
||||
.unwrap_or(0),
|
||||
}),
|
||||
REFLECTIONS_READ_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"kind": arguments.get("kind"),
|
||||
"id": arguments.get("id"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_LIST_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"collection": arguments.get("collection"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_SEARCH_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"scope": arguments.get("scope"),
|
||||
"query": arguments.get("query"),
|
||||
"log_id": arguments.get("log_id"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
_ => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"arguments": arguments,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reflections_storage_tool_response_metadata(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
content_items: &[DynamicToolCallOutputContentItem],
|
||||
arguments: &Value,
|
||||
) -> Value {
|
||||
let text = dynamic_tool_output_items_to_text(content_items);
|
||||
let parsed = serde_json::from_str::<Value>(&text).unwrap_or(Value::Null);
|
||||
let mut metadata =
|
||||
reflections_storage_output_value_metadata(tool, call_id, success, error, &parsed);
|
||||
if metadata.get("kind").is_none()
|
||||
&& let Some(kind) = arguments.get("kind")
|
||||
{
|
||||
metadata["kind"] = kind.clone();
|
||||
}
|
||||
if metadata.get("id").is_none()
|
||||
&& let Some(id) = arguments.get("id")
|
||||
{
|
||||
metadata["id"] = id.clone();
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
fn reflections_storage_output_value_metadata(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
value: &Value,
|
||||
) -> Value {
|
||||
match tool {
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": value.get("id"),
|
||||
"operation": value.get("operation"),
|
||||
"content_chars": value.get("content_chars"),
|
||||
"total_content_chars": value.get("total_content_chars"),
|
||||
"line_count": value.get("line_count"),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_READ_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"kind": value.get("kind"),
|
||||
"id": value.get("id"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"returned_entries": value
|
||||
.get("entries")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
"content_chars": value.get("content_chars"),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_LIST_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"collection": value.get("collection"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"returned_items": value
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_SEARCH_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"scope": value.get("scope"),
|
||||
"query": value.get("query"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"returned_results": value
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
_ => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_id(window: &str, index: usize) -> String {
|
||||
format!("{window}:msg-{index:06}")
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod log_entries;
|
||||
mod prompt;
|
||||
mod storage;
|
||||
mod storage_tools;
|
||||
mod transcript;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -32,6 +34,13 @@ pub(crate) use prompt::near_limit_reminder_threshold;
|
||||
pub(crate) use prompt::usage_hint;
|
||||
pub(crate) use storage::ensure_sidecar_dirs;
|
||||
pub(crate) use storage::sidecar_path_for_rollout;
|
||||
pub(crate) use storage_tools::StorageToolError;
|
||||
pub(crate) use storage_tools::list_logs;
|
||||
pub(crate) use storage_tools::list_notes;
|
||||
pub(crate) use storage_tools::read_log;
|
||||
pub(crate) use storage_tools::read_note;
|
||||
pub(crate) use storage_tools::search;
|
||||
pub(crate) use storage_tools::write_note;
|
||||
|
||||
pub(crate) async fn run_reflections_compact_task(
|
||||
sess: Arc<Session>,
|
||||
@@ -130,6 +139,7 @@ async fn run_reflections_compact_task_inner_impl(
|
||||
turn_context.model_context_window(),
|
||||
&window.logs_path,
|
||||
&window.notes_path,
|
||||
turn_context.config.reflections.storage_tools_enabled,
|
||||
);
|
||||
let mut new_history = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -191,6 +201,9 @@ pub(crate) async fn write_current_window(
|
||||
};
|
||||
let sidecar_path = storage::sidecar_path_for_rollout(&rollout_path);
|
||||
let (rollout_items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path).await?;
|
||||
let item_range = transcript::item_range_since_last_compaction(&rollout_items);
|
||||
let rollout_start_line = item_range.start.saturating_add(1);
|
||||
let rollout_end_line = item_range.end;
|
||||
let events = transcript::events_since_last_compaction(&rollout_items);
|
||||
let transcript = transcript::render(transcript::TranscriptInput {
|
||||
events: &events,
|
||||
@@ -204,6 +217,8 @@ pub(crate) async fn write_current_window(
|
||||
&rollout_path,
|
||||
trigger,
|
||||
turn_context.model_context_window(),
|
||||
rollout_start_line,
|
||||
rollout_end_line,
|
||||
transcript,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -12,11 +12,23 @@ pub(crate) fn usage_hint(
|
||||
context_window_size: Option<i64>,
|
||||
sidecar_path: &Path,
|
||||
custom_text: Option<&str>,
|
||||
storage_tools_enabled: bool,
|
||||
) -> String {
|
||||
if let Some(custom_text) = custom_text {
|
||||
return custom_text.to_string();
|
||||
}
|
||||
|
||||
if storage_tools_enabled {
|
||||
return format!(
|
||||
"{context_window_text}\n\n\
|
||||
Reflections is enabled. Codex automatically records visible messages and tool events from each context window.\n\n\
|
||||
Use `reflections_write_note` for durable recovery notes. Use `reflections_list`, `reflections_read`, and `reflections_search` to inspect previous context-window logs and notes by stable IDs.\n\n\
|
||||
You may want to keep concise notes about your progress incrementally so you can more easily resume after the context window resets. Having things in context is useful, but when details may be needed later, prefer storing references to specific messages, files, commands, findings, or important decisions in notes rather than repeatedly reading complete log windows.\n\n\
|
||||
Future context windows will not automatically include the full previous context. If the current task, current user request, important tool result, or relevant instruction detail may matter later, record a concise note and reference the relevant log ID and entry ID.",
|
||||
context_window_text = context_window_text(context_window_size),
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"{context_window_text}\n\n\
|
||||
Reflections is enabled. Codex automatically records visible messages and tool events from each context window under:\n\n\
|
||||
@@ -32,16 +44,24 @@ Future context windows will not automatically include the full previous transcri
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn near_limit_reminder(remaining_tokens: Option<i64>) -> ResponseItem {
|
||||
pub(crate) fn near_limit_reminder(
|
||||
remaining_tokens: Option<i64>,
|
||||
storage_tools_enabled: bool,
|
||||
) -> ResponseItem {
|
||||
let opening = match remaining_tokens {
|
||||
Some(remaining_tokens) => format!(
|
||||
"{NEAR_LIMIT_REMINDER_PREFIX} ({remaining_tokens} tokens remain before your context will be reset)."
|
||||
),
|
||||
None => format!("{NEAR_LIMIT_REMINDER_PREFIX} and will be reset soon."),
|
||||
};
|
||||
let note_location = if storage_tools_enabled {
|
||||
"with `reflections_write_note`"
|
||||
} else {
|
||||
"under the Reflections notes directory"
|
||||
};
|
||||
let text = format!(
|
||||
"{opening}\n\n\
|
||||
You may want to pause task work and write concise recovery notes under the Reflections notes directory before continuing. Include the current task, progress made, important files, commands, findings, decisions, and next steps. If you have not finished the user's task, you are advised NOT to send a final answer and to use this time to write or clean up notes so you can resume work in the next context window after the context reset.\n\n\
|
||||
You may want to pause task work and write concise recovery notes {note_location} before continuing. Include the current task, progress made, important files, commands, findings, decisions, and next steps. If you have not finished the user's task, you are advised NOT to send a final answer and to use this time to write or clean up notes so you can resume work in the next context window after the context reset.\n\n\
|
||||
After saving notes, you may call `reflections_new_context_window` to start a fresh context window. If you continue without calling it, your context will automatically reset once the compaction limit is reached."
|
||||
);
|
||||
|
||||
@@ -77,7 +97,19 @@ pub(crate) fn post_compaction_handoff(
|
||||
context_window_size: Option<i64>,
|
||||
logs_path: &Path,
|
||||
notes_path: &Path,
|
||||
storage_tools_enabled: bool,
|
||||
) -> String {
|
||||
if storage_tools_enabled {
|
||||
return format!(
|
||||
"{context_window_text}\n\n\
|
||||
Reflections is enabled. Codex automatically recorded visible messages and tool events from previous context windows.\n\n\
|
||||
Use `reflections_list` to list notes and previous log windows. Use `reflections_read` to read an explicit note or log window by ID. Use `reflections_search` to search notes and previous log windows. Use `reflections_write_note` to update durable recovery notes.\n\n\
|
||||
Your context window was reset, and you are continuing in a fresh context window. The current task may only be available in Reflections logs from previous context windows. There may be no new user message in this context window, so do not assume one exists.\n\n\
|
||||
To recover context, first inspect notes with `reflections_list` and `reflections_read`. If notes are empty, missing, or do not clearly identify the current task and status, inspect or search the full conversation context logs for the previous context windows with `reflections_list`, `reflections_read`, and `reflections_search`. The logs are organized by context window with explicit IDs like `cw00000`. Prefer recovering only the details you need rather than rereading every log window.",
|
||||
context_window_text = context_window_text(context_window_size),
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"{context_window_text}\n\n\
|
||||
Reflections is enabled. Codex automatically recorded visible messages and tool events from previous context windows here:\n\n\
|
||||
@@ -114,19 +146,42 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn usage_hint_mentions_sidecar_paths() {
|
||||
let hint = usage_hint(Some(98304), Path::new("/tmp/rollout.reflections"), None);
|
||||
let hint = usage_hint(
|
||||
Some(98304),
|
||||
Path::new("/tmp/rollout.reflections"),
|
||||
None,
|
||||
/*storage_tools_enabled*/ false,
|
||||
);
|
||||
|
||||
assert!(hint.contains("Your context window size is 98304 tokens."));
|
||||
assert!(hint.contains("/tmp/rollout.reflections/logs/cwNNNNN/transcript.md"));
|
||||
assert!(hint.contains("/tmp/rollout.reflections/notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_hint_mentions_storage_tools_when_enabled() {
|
||||
let hint = usage_hint(
|
||||
Some(98304),
|
||||
Path::new("/tmp/rollout.reflections"),
|
||||
None,
|
||||
/*storage_tools_enabled*/ true,
|
||||
);
|
||||
|
||||
assert!(hint.contains("Your context window size is 98304 tokens."));
|
||||
assert!(hint.contains("reflections_write_note"));
|
||||
assert!(hint.contains("reflections_list"));
|
||||
assert!(hint.contains("reflections_read"));
|
||||
assert!(hint.contains("reflections_search"));
|
||||
assert!(!hint.contains("/tmp/rollout.reflections"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_mentions_logs_and_notes() {
|
||||
let handoff = post_compaction_handoff(
|
||||
None,
|
||||
Path::new("/tmp/rollout.reflections/logs"),
|
||||
Path::new("/tmp/rollout.reflections/notes"),
|
||||
/*storage_tools_enabled*/ false,
|
||||
);
|
||||
|
||||
assert!(handoff.contains("Your context window size is not available for this model."));
|
||||
@@ -137,9 +192,25 @@ mod tests {
|
||||
assert!(handoff.contains("/tmp/rollout.reflections/notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_mentions_storage_tools_when_enabled() {
|
||||
let handoff = post_compaction_handoff(
|
||||
None,
|
||||
Path::new("/tmp/rollout.reflections/logs"),
|
||||
Path::new("/tmp/rollout.reflections/notes"),
|
||||
/*storage_tools_enabled*/ true,
|
||||
);
|
||||
|
||||
assert!(handoff.contains("reflections_list"));
|
||||
assert!(handoff.contains("reflections_read"));
|
||||
assert!(handoff.contains("reflections_search"));
|
||||
assert!(handoff.contains("reflections_write_note"));
|
||||
assert!(!handoff.contains("/tmp/rollout.reflections"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_is_developer_message() {
|
||||
let reminder = near_limit_reminder(Some(4058));
|
||||
let reminder = near_limit_reminder(Some(4058), /*storage_tools_enabled*/ true);
|
||||
let ResponseItem::Message { role, content, .. } = &reminder else {
|
||||
panic!("near-limit reminder should be a message");
|
||||
};
|
||||
@@ -151,13 +222,14 @@ mod tests {
|
||||
"Your context window is nearly exhausted (4058 tokens remain before your context will be reset)."
|
||||
));
|
||||
assert!(text.contains("advised NOT to send a final answer"));
|
||||
assert!(text.contains("reflections_write_note"));
|
||||
assert!(text.contains("reflections_new_context_window"));
|
||||
assert!(is_near_limit_reminder(&reminder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_supports_unknown_remaining_tokens() {
|
||||
let reminder = near_limit_reminder(None);
|
||||
let reminder = near_limit_reminder(None, /*storage_tools_enabled*/ false);
|
||||
let ResponseItem::Message { content, .. } = &reminder else {
|
||||
panic!("near-limit reminder should be a message");
|
||||
};
|
||||
|
||||
@@ -22,21 +22,25 @@ pub(crate) struct WrittenWindow {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ReflectionsState {
|
||||
pub(super) struct ReflectionsState {
|
||||
schema: String,
|
||||
next_window_index: u64,
|
||||
latest_window: Option<String>,
|
||||
rollout_path: PathBuf,
|
||||
windows: Vec<WindowState>,
|
||||
pub(super) windows: Vec<WindowState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WindowState {
|
||||
window: String,
|
||||
trigger: String,
|
||||
created_at: String,
|
||||
pub(super) struct WindowState {
|
||||
pub(super) window: String,
|
||||
pub(super) trigger: String,
|
||||
pub(super) created_at: String,
|
||||
transcript_path: PathBuf,
|
||||
context_window_size: Option<i64>,
|
||||
pub(super) context_window_size: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) rollout_start_line: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) rollout_end_line: Option<usize>,
|
||||
}
|
||||
|
||||
pub(crate) fn sidecar_path_for_rollout(rollout_path: &Path) -> PathBuf {
|
||||
@@ -54,6 +58,8 @@ pub(crate) async fn write_window(
|
||||
rollout_path: &Path,
|
||||
trigger: CompactionTrigger,
|
||||
context_window_size: Option<i64>,
|
||||
rollout_start_line: usize,
|
||||
rollout_end_line: usize,
|
||||
transcript: String,
|
||||
) -> std::io::Result<WrittenWindow> {
|
||||
ensure_sidecar_dirs(sidecar_path).await?;
|
||||
@@ -77,6 +83,8 @@ pub(crate) async fn write_window(
|
||||
created_at,
|
||||
transcript_path: PathBuf::from("logs").join(&window).join(TRANSCRIPT_FILE),
|
||||
context_window_size,
|
||||
rollout_start_line: Some(rollout_start_line),
|
||||
rollout_end_line: Some(rollout_end_line),
|
||||
});
|
||||
write_state(sidecar_path, &state).await?;
|
||||
|
||||
@@ -88,7 +96,10 @@ pub(crate) async fn write_window(
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_state(sidecar_path: &Path, rollout_path: &Path) -> std::io::Result<ReflectionsState> {
|
||||
pub(super) async fn read_state(
|
||||
sidecar_path: &Path,
|
||||
rollout_path: &Path,
|
||||
) -> std::io::Result<ReflectionsState> {
|
||||
let state_path = sidecar_path.join("state.json");
|
||||
match tokio::fs::read_to_string(&state_path).await {
|
||||
Ok(contents) => {
|
||||
@@ -135,7 +146,7 @@ async fn write_state(sidecar_path: &Path, state: &ReflectionsState) -> std::io::
|
||||
write_atomic(&sidecar_path.join("state.json"), &state_json).await
|
||||
}
|
||||
|
||||
async fn write_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
pub(super) async fn write_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||
file.write_all(contents).await?;
|
||||
@@ -156,6 +167,7 @@ mod tests {
|
||||
use super::sidecar_path_for_rollout;
|
||||
use super::write_window;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_window_allocates_transcript_and_state() -> std::io::Result<()> {
|
||||
@@ -168,6 +180,8 @@ mod tests {
|
||||
&rollout,
|
||||
CompactionTrigger::Manual,
|
||||
Some(98304),
|
||||
1,
|
||||
1,
|
||||
"first".to_string(),
|
||||
)
|
||||
.await?;
|
||||
@@ -176,6 +190,8 @@ mod tests {
|
||||
&rollout,
|
||||
CompactionTrigger::Auto,
|
||||
Some(98304),
|
||||
2,
|
||||
2,
|
||||
"second".to_string(),
|
||||
)
|
||||
.await?;
|
||||
@@ -190,6 +206,8 @@ mod tests {
|
||||
assert!(sidecar.join("notes").is_dir());
|
||||
let state = tokio::fs::read_to_string(sidecar.join("state.json")).await?;
|
||||
assert!(state.contains("\"latest_window\": \"cw00001\""));
|
||||
assert!(state.contains("\"rollout_start_line\": 1"));
|
||||
assert!(state.contains("\"rollout_end_line\": 2"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
use super::storage::WindowState;
|
||||
use super::storage::ensure_sidecar_dirs;
|
||||
use super::storage::read_state;
|
||||
use super::storage::write_atomic;
|
||||
use super::transcript;
|
||||
use crate::RolloutRecorder;
|
||||
|
||||
const NOTE_VERSIONS_FILE: &str = "note_versions.json";
|
||||
const WINDOW_ID_WIDTH: usize = 5;
|
||||
pub(crate) const MAX_NOTE_CHARS: usize = 65_536;
|
||||
const DEFAULT_LIST_WINDOW: usize = 50;
|
||||
const MAX_LIST_ITEMS: usize = 200;
|
||||
const DEFAULT_LOG_READ_WINDOW: usize = 50;
|
||||
const MAX_LOG_READ_ENTRIES: usize = 200;
|
||||
const DEFAULT_NOTE_READ_WINDOW: usize = 1000;
|
||||
const MAX_NOTE_READ_LINES: usize = 1000;
|
||||
const DEFAULT_SEARCH_WINDOW: usize = 20;
|
||||
const MAX_SEARCH_RESULTS: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct RangeInfo {
|
||||
pub(crate) start: usize,
|
||||
pub(crate) stop: usize,
|
||||
pub(crate) total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct ListResponse {
|
||||
pub(crate) collection: String,
|
||||
pub(crate) items: Vec<Value>,
|
||||
pub(crate) range: RangeInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct ReadLogResponse {
|
||||
pub(crate) kind: &'static str,
|
||||
pub(crate) id: String,
|
||||
pub(crate) entries: Vec<transcript::LogEntry>,
|
||||
pub(crate) range: RangeInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct ReadNoteResponse {
|
||||
pub(crate) kind: &'static str,
|
||||
pub(crate) id: String,
|
||||
pub(crate) content: String,
|
||||
pub(crate) content_chars: usize,
|
||||
pub(crate) range: RangeInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct SearchResponse {
|
||||
pub(crate) scope: String,
|
||||
pub(crate) query: String,
|
||||
pub(crate) results: Vec<Value>,
|
||||
pub(crate) range: RangeInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct WriteNoteResponse {
|
||||
pub(crate) kind: &'static str,
|
||||
pub(crate) id: String,
|
||||
pub(crate) operation: String,
|
||||
pub(crate) content_chars: usize,
|
||||
pub(crate) total_content_chars: usize,
|
||||
pub(crate) line_count: usize,
|
||||
pub(crate) version: u64,
|
||||
pub(crate) ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum StorageToolError {
|
||||
Io(std::io::Error),
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StorageToolError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(err) => write!(f, "Reflections storage failed: {err}"),
|
||||
Self::Invalid(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for StorageToolError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
type StorageToolResult<T> = Result<T, StorageToolError>;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Selection {
|
||||
start: usize,
|
||||
stop: usize,
|
||||
}
|
||||
|
||||
pub(crate) async fn list_logs(
|
||||
sidecar_path: &Path,
|
||||
rollout_path: &Path,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
) -> StorageToolResult<ListResponse> {
|
||||
let selection = selection(start, stop, DEFAULT_LIST_WINDOW, MAX_LIST_ITEMS)?;
|
||||
let state = read_state(sidecar_path, rollout_path).await?;
|
||||
let rollout_items = load_rollout_items(rollout_path).await?;
|
||||
let mut items = Vec::new();
|
||||
for window in state.windows.iter().rev() {
|
||||
let entries = entries_for_window(window, &state.windows, &rollout_items)?;
|
||||
items.push(json!({
|
||||
"kind": "log",
|
||||
"id": window.window,
|
||||
"trigger": window.trigger,
|
||||
"created_at": window.created_at,
|
||||
"context_window_size": window.context_window_size,
|
||||
"entry_count": entries.len(),
|
||||
}));
|
||||
}
|
||||
|
||||
let total = items.len();
|
||||
let items = slice_items(items, selection);
|
||||
Ok(ListResponse {
|
||||
collection: "logs".to_string(),
|
||||
items,
|
||||
range: range_info(selection, total),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn list_notes(
|
||||
sidecar_path: &Path,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
) -> StorageToolResult<ListResponse> {
|
||||
let selection = selection(start, stop, DEFAULT_LIST_WINDOW, MAX_LIST_ITEMS)?;
|
||||
ensure_sidecar_dirs(sidecar_path).await?;
|
||||
let notes_path = sidecar_path.join("notes");
|
||||
let mut entries = tokio::fs::read_dir(notes_path).await?;
|
||||
let mut items = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let note_id = entry.file_name().to_string_lossy().to_string();
|
||||
if note_id.starts_with('.') || !valid_note_id(¬e_id) {
|
||||
continue;
|
||||
}
|
||||
let metadata = entry.metadata().await?;
|
||||
let content = tokio::fs::read_to_string(entry.path())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
items.push((
|
||||
metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
note_id.clone(),
|
||||
json!({
|
||||
"kind": "note",
|
||||
"id": note_id,
|
||||
"updated_at": system_time_rfc3339(metadata.modified().ok()),
|
||||
"content_chars": content.chars().count(),
|
||||
"line_count": line_count(&content),
|
||||
}),
|
||||
));
|
||||
}
|
||||
items.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|(_, _, value)| value)
|
||||
.collect::<Vec<_>>();
|
||||
let total = items.len();
|
||||
let items = slice_items(items, selection);
|
||||
Ok(ListResponse {
|
||||
collection: "notes".to_string(),
|
||||
items,
|
||||
range: range_info(selection, total),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn read_log(
|
||||
sidecar_path: &Path,
|
||||
rollout_path: &Path,
|
||||
id: &str,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
) -> StorageToolResult<ReadLogResponse> {
|
||||
reject_latest(id)?;
|
||||
validate_window_id(id)?;
|
||||
let selection = selection(start, stop, DEFAULT_LOG_READ_WINDOW, MAX_LOG_READ_ENTRIES)?;
|
||||
let state = read_state(sidecar_path, rollout_path).await?;
|
||||
let window = state
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.window == id)
|
||||
.ok_or_else(|| {
|
||||
StorageToolError::Invalid(format!("Reflections log `{id}` was not found"))
|
||||
})?;
|
||||
let rollout_items = load_rollout_items(rollout_path).await?;
|
||||
let entries = entries_for_window(window, &state.windows, &rollout_items)?;
|
||||
let total = entries.len();
|
||||
let entries = slice_items(entries, selection);
|
||||
Ok(ReadLogResponse {
|
||||
kind: "log",
|
||||
id: id.to_string(),
|
||||
entries,
|
||||
range: range_info(selection, total),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn read_note(
|
||||
sidecar_path: &Path,
|
||||
id: &str,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
) -> StorageToolResult<ReadNoteResponse> {
|
||||
reject_latest(id)?;
|
||||
validate_note_id(id)?;
|
||||
let selection = selection(start, stop, DEFAULT_NOTE_READ_WINDOW, MAX_NOTE_READ_LINES)?;
|
||||
let path = note_path(sidecar_path, id);
|
||||
let content = tokio::fs::read_to_string(&path).await.map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
StorageToolError::Invalid(format!("Reflections note `{id}` was not found"))
|
||||
} else {
|
||||
StorageToolError::Io(err)
|
||||
}
|
||||
})?;
|
||||
let lines = content.lines().collect::<Vec<_>>();
|
||||
let total = lines.len();
|
||||
let selected_content = if selection.start > total {
|
||||
String::new()
|
||||
} else {
|
||||
let end = selection.stop.min(total);
|
||||
let mut selected = lines[selection.start - 1..end].join("\n");
|
||||
if !selected.is_empty() && content.ends_with('\n') {
|
||||
selected.push('\n');
|
||||
}
|
||||
selected
|
||||
};
|
||||
Ok(ReadNoteResponse {
|
||||
kind: "note",
|
||||
id: id.to_string(),
|
||||
content: selected_content.clone(),
|
||||
content_chars: selected_content.chars().count(),
|
||||
range: range_info(selection, total),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn search(
|
||||
sidecar_path: &Path,
|
||||
rollout_path: &Path,
|
||||
scope: &str,
|
||||
query: &str,
|
||||
log_id: Option<&str>,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
) -> StorageToolResult<SearchResponse> {
|
||||
if query.is_empty() {
|
||||
return Err(StorageToolError::Invalid(
|
||||
"`query` is required and must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if log_id.is_some() && scope != "logs" {
|
||||
return Err(StorageToolError::Invalid(
|
||||
"`log_id` is only valid when `scope` is `logs`".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(log_id) = log_id {
|
||||
reject_latest(log_id)?;
|
||||
validate_window_id(log_id)?;
|
||||
}
|
||||
let selection = selection(start, stop, DEFAULT_SEARCH_WINDOW, MAX_SEARCH_RESULTS)?;
|
||||
let needle = query.to_ascii_lowercase();
|
||||
let mut results = Vec::new();
|
||||
|
||||
if matches!(scope, "all" | "logs") {
|
||||
let state = read_state(sidecar_path, rollout_path).await?;
|
||||
let rollout_items = load_rollout_items(rollout_path).await?;
|
||||
for window in &state.windows {
|
||||
if log_id.is_some_and(|id| id != window.window) {
|
||||
continue;
|
||||
}
|
||||
let entries = entries_for_window(window, &state.windows, &rollout_items)?;
|
||||
for (entry_index, entry) in entries.iter().enumerate() {
|
||||
if entry.content.to_ascii_lowercase().contains(&needle) {
|
||||
let entry_number = entry_index + 1;
|
||||
results.push(json!({
|
||||
"kind": "log",
|
||||
"log_id": window.window,
|
||||
"entry_id": entry.entry_id,
|
||||
"snippet": snippet(&entry.content, query),
|
||||
"read": {
|
||||
"kind": "log",
|
||||
"id": window.window,
|
||||
"start": entry_number,
|
||||
"stop": entry_number,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(scope, "all" | "notes") {
|
||||
ensure_sidecar_dirs(sidecar_path).await?;
|
||||
let mut entries = tokio::fs::read_dir(sidecar_path.join("notes")).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let note_id = entry.file_name().to_string_lossy().to_string();
|
||||
if note_id.starts_with('.') || !valid_note_id(¬e_id) {
|
||||
continue;
|
||||
}
|
||||
let content = tokio::fs::read_to_string(entry.path())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for (line_index, line) in content.lines().enumerate() {
|
||||
if line.to_ascii_lowercase().contains(&needle) {
|
||||
let line_number = line_index + 1;
|
||||
results.push(json!({
|
||||
"kind": "note",
|
||||
"note_id": note_id,
|
||||
"line": line_number,
|
||||
"snippet": snippet(line, query),
|
||||
"read": {
|
||||
"kind": "note",
|
||||
"id": note_id,
|
||||
"start": line_number,
|
||||
"stop": line_number,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = results.len();
|
||||
let results = slice_items(results, selection);
|
||||
Ok(SearchResponse {
|
||||
scope: scope.to_string(),
|
||||
query: query.to_string(),
|
||||
results,
|
||||
range: range_info(selection, total),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn write_note(
|
||||
sidecar_path: &Path,
|
||||
id: &str,
|
||||
operation: &str,
|
||||
content: &str,
|
||||
) -> StorageToolResult<WriteNoteResponse> {
|
||||
validate_note_id(id)?;
|
||||
if content.chars().count() > MAX_NOTE_CHARS {
|
||||
return Err(StorageToolError::Invalid(format!(
|
||||
"Reflections note writes are limited to {MAX_NOTE_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
ensure_sidecar_dirs(sidecar_path).await?;
|
||||
let path = note_path(sidecar_path, id);
|
||||
let existing = tokio::fs::read_to_string(&path).await;
|
||||
let existing = match existing {
|
||||
Ok(existing) => Some(existing),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(err) => return Err(StorageToolError::Io(err)),
|
||||
};
|
||||
let new_content = match operation {
|
||||
"create_only" => {
|
||||
if existing.is_some() {
|
||||
return Err(StorageToolError::Invalid(format!(
|
||||
"Reflections note `{id}` already exists"
|
||||
)));
|
||||
}
|
||||
content.to_string()
|
||||
}
|
||||
"append" => {
|
||||
let mut combined = existing.unwrap_or_default();
|
||||
combined.push_str(content);
|
||||
if combined.chars().count() > MAX_NOTE_CHARS {
|
||||
return Err(StorageToolError::Invalid(format!(
|
||||
"Appending would make Reflections note `{id}` exceed {MAX_NOTE_CHARS} characters; replace the note with a shorter version instead"
|
||||
)));
|
||||
}
|
||||
combined
|
||||
}
|
||||
"replace" => content.to_string(),
|
||||
_ => {
|
||||
return Err(StorageToolError::Invalid(format!(
|
||||
"unsupported Reflections note operation `{operation}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if new_content.chars().count() > MAX_NOTE_CHARS {
|
||||
return Err(StorageToolError::Invalid(format!(
|
||||
"Reflections note `{id}` would exceed {MAX_NOTE_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
write_atomic(&path, new_content.as_bytes()).await?;
|
||||
let version = increment_note_version(sidecar_path, id).await?;
|
||||
Ok(WriteNoteResponse {
|
||||
kind: "note",
|
||||
id: id.to_string(),
|
||||
operation: operation.to_string(),
|
||||
content_chars: content.chars().count(),
|
||||
total_content_chars: new_content.chars().count(),
|
||||
line_count: line_count(&new_content),
|
||||
version,
|
||||
ok: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_rollout_items(
|
||||
rollout_path: &Path,
|
||||
) -> StorageToolResult<Vec<codex_protocol::protocol::RolloutItem>> {
|
||||
let (items, _, _) = RolloutRecorder::load_rollout_items(rollout_path).await?;
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn entries_for_window(
|
||||
window: &WindowState,
|
||||
windows: &[WindowState],
|
||||
rollout_items: &[codex_protocol::protocol::RolloutItem],
|
||||
) -> StorageToolResult<Vec<transcript::LogEntry>> {
|
||||
let range = rollout_range_for_window(window, windows, rollout_items)?;
|
||||
Ok(transcript::log_entries_from_items(
|
||||
&window.window,
|
||||
&rollout_items[range],
|
||||
))
|
||||
}
|
||||
|
||||
fn rollout_range_for_window(
|
||||
window: &WindowState,
|
||||
windows: &[WindowState],
|
||||
rollout_items: &[codex_protocol::protocol::RolloutItem],
|
||||
) -> StorageToolResult<std::ops::Range<usize>> {
|
||||
if let (Some(start_line), Some(end_line)) = (window.rollout_start_line, window.rollout_end_line)
|
||||
{
|
||||
if start_line == 0 || end_line < start_line {
|
||||
return Ok(0..0);
|
||||
}
|
||||
let start = start_line.saturating_sub(1).min(rollout_items.len());
|
||||
let end = end_line.min(rollout_items.len());
|
||||
return Ok(start..end);
|
||||
}
|
||||
|
||||
let window_index = windows
|
||||
.iter()
|
||||
.position(|candidate| candidate.window == window.window)
|
||||
.ok_or_else(|| {
|
||||
StorageToolError::Invalid(format!(
|
||||
"Reflections log `{}` is missing from state",
|
||||
window.window
|
||||
))
|
||||
})?;
|
||||
Ok(rollout_range_for_legacy_window(window_index, rollout_items))
|
||||
}
|
||||
|
||||
fn rollout_range_for_legacy_window(
|
||||
window_index: usize,
|
||||
rollout_items: &[codex_protocol::protocol::RolloutItem],
|
||||
) -> std::ops::Range<usize> {
|
||||
let compacted_indices = rollout_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, item)| {
|
||||
matches!(item, codex_protocol::protocol::RolloutItem::Compacted(_)).then_some(index)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let start = if window_index == 0 {
|
||||
0
|
||||
} else {
|
||||
compacted_indices
|
||||
.get(window_index - 1)
|
||||
.map_or(rollout_items.len(), |index| index + 1)
|
||||
};
|
||||
let end = compacted_indices
|
||||
.get(window_index)
|
||||
.copied()
|
||||
.unwrap_or(rollout_items.len());
|
||||
start.min(rollout_items.len())..end.min(rollout_items.len())
|
||||
}
|
||||
|
||||
fn selection(
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
default_window: usize,
|
||||
max_window: usize,
|
||||
) -> StorageToolResult<Selection> {
|
||||
let start = start.unwrap_or(1);
|
||||
if start == 0 {
|
||||
return Err(StorageToolError::Invalid(
|
||||
"`start` must be 1 or greater".to_string(),
|
||||
));
|
||||
}
|
||||
let requested_stop = stop.unwrap_or_else(|| start.saturating_add(default_window - 1));
|
||||
if requested_stop < start {
|
||||
return Err(StorageToolError::Invalid(
|
||||
"`stop` must be greater than or equal to `start`".to_string(),
|
||||
));
|
||||
}
|
||||
let max_stop = start.saturating_add(max_window - 1);
|
||||
Ok(Selection {
|
||||
start,
|
||||
stop: requested_stop.min(max_stop),
|
||||
})
|
||||
}
|
||||
|
||||
fn slice_items<T>(items: Vec<T>, selection: Selection) -> Vec<T> {
|
||||
if selection.start > items.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let start_index = selection.start - 1;
|
||||
let end_index = selection.stop.min(items.len());
|
||||
items
|
||||
.into_iter()
|
||||
.skip(start_index)
|
||||
.take(end_index.saturating_sub(start_index))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn range_info(selection: Selection, total: usize) -> RangeInfo {
|
||||
RangeInfo {
|
||||
start: selection.start,
|
||||
stop: selection.stop,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_latest(id: &str) -> StorageToolResult<()> {
|
||||
if id == "latest" {
|
||||
return Err(StorageToolError::Invalid(
|
||||
"`latest` is not supported; list logs or notes and choose an explicit ID".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_window_id(id: &str) -> StorageToolResult<()> {
|
||||
if id.len() == WINDOW_ID_WIDTH + 2
|
||||
&& id.starts_with("cw")
|
||||
&& id[2..].chars().all(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StorageToolError::Invalid(format!(
|
||||
"Reflections log ID `{id}` is invalid; expected cwNNNNN"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_note_id(id: &str) -> StorageToolResult<()> {
|
||||
if valid_note_id(id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StorageToolError::Invalid(format!(
|
||||
"Reflections note ID `{id}` is invalid; use ^[A-Za-z0-9][A-Za-z0-9_.-]{{0,127}}$ with no slashes, `..`, or absolute paths"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_note_id(id: &str) -> bool {
|
||||
let mut chars = id.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if !first.is_ascii_alphanumeric() || id.len() > 128 || id.contains("..") {
|
||||
return false;
|
||||
}
|
||||
chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
|
||||
}
|
||||
|
||||
fn note_path(sidecar_path: &Path, id: &str) -> PathBuf {
|
||||
sidecar_path.join("notes").join(id)
|
||||
}
|
||||
|
||||
fn line_count(content: &str) -> usize {
|
||||
content.lines().count()
|
||||
}
|
||||
|
||||
fn system_time_rfc3339(time: Option<SystemTime>) -> Option<String> {
|
||||
time.map(|time| {
|
||||
let datetime: DateTime<Utc> = time.into();
|
||||
datetime.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
})
|
||||
}
|
||||
|
||||
async fn increment_note_version(sidecar_path: &Path, id: &str) -> StorageToolResult<u64> {
|
||||
let path = sidecar_path.join(NOTE_VERSIONS_FILE);
|
||||
let mut versions = match tokio::fs::read_to_string(&path).await {
|
||||
Ok(contents) => serde_json::from_str::<std::collections::BTreeMap<String, u64>>(&contents)
|
||||
.unwrap_or_default(),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => std::collections::BTreeMap::new(),
|
||||
Err(err) => return Err(StorageToolError::Io(err)),
|
||||
};
|
||||
let version = versions.get(id).copied().unwrap_or(0).saturating_add(1);
|
||||
versions.insert(id.to_string(), version);
|
||||
let bytes = serde_json::to_vec_pretty(&versions).map_err(|err| {
|
||||
StorageToolError::Invalid(format!("failed to serialize note versions: {err}"))
|
||||
})?;
|
||||
write_atomic(&path, &bytes).await?;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn snippet(content: &str, query: &str) -> String {
|
||||
let lower = content.to_ascii_lowercase();
|
||||
let needle = query.to_ascii_lowercase();
|
||||
let mut match_start = lower.find(&needle).unwrap_or(0).min(content.len());
|
||||
while !content.is_char_boundary(match_start) {
|
||||
match_start = match_start.saturating_sub(1);
|
||||
}
|
||||
let start = content[..match_start]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.nth(60)
|
||||
.map_or(0, |(index, _)| index);
|
||||
let mut end = match_start;
|
||||
let mut chars = content[match_start..].char_indices();
|
||||
for _ in 0..180 {
|
||||
match chars.next() {
|
||||
Some((offset, ch)) => end = match_start + offset + ch.len_utf8(),
|
||||
None => {
|
||||
end = content.len();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut snippet = String::new();
|
||||
if start > 0 {
|
||||
snippet.push_str("...");
|
||||
}
|
||||
snippet.push_str(content[start..end].trim());
|
||||
if end < content.len() {
|
||||
snippet.push_str("...");
|
||||
}
|
||||
snippet
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::MAX_NOTE_CHARS;
|
||||
use super::list_logs;
|
||||
use super::list_notes;
|
||||
use super::read_log;
|
||||
use super::read_note;
|
||||
use super::search;
|
||||
use super::write_note;
|
||||
use crate::reflections::storage::sidecar_path_for_rollout;
|
||||
use crate::reflections::storage::write_window;
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_note_validates_ids_and_size() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let rollout = temp.path().join("rollout-2026-04-16T00-00-00-thread.jsonl");
|
||||
let sidecar = sidecar_path_for_rollout(&rollout);
|
||||
|
||||
let invalid = write_note(&sidecar, "../bad", "append", "content").await;
|
||||
assert!(invalid.is_err());
|
||||
|
||||
let too_large = "x".repeat(MAX_NOTE_CHARS + 1);
|
||||
let too_large = write_note(&sidecar, "handoff", "append", &too_large).await;
|
||||
assert!(too_large.is_err());
|
||||
|
||||
let first = write_note(&sidecar, "handoff", "create_only", "Current task\n")
|
||||
.await
|
||||
.expect("create note");
|
||||
assert_eq!(first.version, 1);
|
||||
assert_eq!(first.total_content_chars, "Current task\n".chars().count());
|
||||
|
||||
let append = write_note(&sidecar, "handoff", "append", "Next step\n")
|
||||
.await
|
||||
.expect("append note");
|
||||
assert_eq!(append.version, 2);
|
||||
assert_eq!(append.line_count, 2);
|
||||
|
||||
let note = read_note(&sidecar, "handoff", Some(2), Some(2))
|
||||
.await
|
||||
.expect("read note");
|
||||
assert_eq!(note.content, "Next step\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_fails_when_note_would_exceed_limit() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let rollout = temp.path().join("rollout-2026-04-16T00-00-00-thread.jsonl");
|
||||
let sidecar = sidecar_path_for_rollout(&rollout);
|
||||
|
||||
write_note(
|
||||
&sidecar,
|
||||
"handoff",
|
||||
"create_only",
|
||||
&"x".repeat(MAX_NOTE_CHARS - 1),
|
||||
)
|
||||
.await
|
||||
.expect("create near-limit note");
|
||||
let result = write_note(&sidecar, "handoff", "append", "xx").await;
|
||||
assert!(result.is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_read_and_search_use_rollout_jsonl_not_markdown() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let rollout = temp.path().join("rollout-2026-04-16T00-00-00-thread.jsonl");
|
||||
let sidecar = sidecar_path_for_rollout(&rollout);
|
||||
write_rollout(
|
||||
&rollout,
|
||||
&[
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "Need parser tests".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "I will inspect parser tests next.".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
})),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
write_window(
|
||||
&sidecar,
|
||||
&rollout,
|
||||
CompactionTrigger::Auto,
|
||||
Some(9500),
|
||||
1,
|
||||
2,
|
||||
"markdown-only parser marker should not be parsed".to_string(),
|
||||
)
|
||||
.await?;
|
||||
write_note(
|
||||
&sidecar,
|
||||
"handoff",
|
||||
"replace",
|
||||
"Current task: parser tests\n",
|
||||
)
|
||||
.await
|
||||
.expect("write note");
|
||||
|
||||
let logs = list_logs(&sidecar, &rollout, Some(1), Some(50))
|
||||
.await
|
||||
.expect("list logs");
|
||||
assert_eq!(logs.items[0]["id"], "cw00000");
|
||||
assert_eq!(logs.items[0]["entry_count"], 2);
|
||||
|
||||
let notes = list_notes(&sidecar, Some(1), Some(50))
|
||||
.await
|
||||
.expect("list notes");
|
||||
assert_eq!(notes.items[0]["id"], "handoff");
|
||||
|
||||
let read = read_log(&sidecar, &rollout, "cw00000", Some(1), Some(50))
|
||||
.await
|
||||
.expect("read log");
|
||||
assert_eq!(read.entries[0].entry_id, "cw00000:msg-000001");
|
||||
assert_eq!(read.entries[0].kind, "user_message");
|
||||
assert_eq!(read.entries[0].role.as_deref(), Some("user"));
|
||||
assert!(read.entries[0].content.contains("Need parser tests"));
|
||||
|
||||
let latest = read_log(&sidecar, &rollout, "latest", Some(1), Some(1)).await;
|
||||
assert!(latest.is_err());
|
||||
|
||||
let search_result = search(
|
||||
&sidecar,
|
||||
&rollout,
|
||||
"logs",
|
||||
"parser tests",
|
||||
Some("cw00000"),
|
||||
Some(1),
|
||||
Some(20),
|
||||
)
|
||||
.await
|
||||
.expect("search logs");
|
||||
assert_eq!(search_result.results[0]["log_id"], "cw00000");
|
||||
assert_eq!(search_result.results[0]["entry_id"], "cw00000:msg-000001");
|
||||
assert_eq!(search_result.results[0]["read"]["start"], 1);
|
||||
|
||||
let markdown_only = search(
|
||||
&sidecar,
|
||||
&rollout,
|
||||
"logs",
|
||||
"markdown-only",
|
||||
Some("cw00000"),
|
||||
Some(1),
|
||||
Some(20),
|
||||
)
|
||||
.await
|
||||
.expect("search logs");
|
||||
assert!(markdown_only.results.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflections_storage_tool_entries_are_metadata_only() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let rollout = temp.path().join("rollout-2026-04-16T00-00-00-thread.jsonl");
|
||||
let sidecar = sidecar_path_for_rollout(&rollout);
|
||||
write_rollout(
|
||||
&rollout,
|
||||
&[
|
||||
RolloutItem::ResponseItem(ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "reflections_write_note".to_string(),
|
||||
namespace: None,
|
||||
arguments: serde_json::json!({
|
||||
"note_id": "handoff",
|
||||
"operation": "append",
|
||||
"content": "SECRET NOTE BODY"
|
||||
})
|
||||
.to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
}),
|
||||
RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(
|
||||
serde_json::json!({
|
||||
"kind": "note",
|
||||
"id": "handoff",
|
||||
"operation": "append",
|
||||
"content_chars": 16,
|
||||
"total_content_chars": 16,
|
||||
"line_count": 1,
|
||||
"version": 1,
|
||||
"ok": true
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
}),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
write_window(
|
||||
&sidecar,
|
||||
&rollout,
|
||||
CompactionTrigger::Manual,
|
||||
Some(9500),
|
||||
1,
|
||||
2,
|
||||
String::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let read = read_log(&sidecar, &rollout, "cw00000", Some(1), Some(50))
|
||||
.await
|
||||
.expect("read log");
|
||||
assert_eq!(read.entries.len(), 2);
|
||||
assert!(read.entries[0].content.contains("\"content_chars\": 16"));
|
||||
assert!(!read.entries[0].content.contains("SECRET NOTE BODY"));
|
||||
assert!(!read.entries[1].content.contains("SECRET NOTE BODY"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_rollout(path: &std::path::Path, items: &[RolloutItem]) -> std::io::Result<()> {
|
||||
let mut contents = String::new();
|
||||
for item in items {
|
||||
let line = RolloutLine {
|
||||
timestamp: "2026-04-16T00:00:00.000Z".to_string(),
|
||||
item: item.clone(),
|
||||
};
|
||||
contents.push_str(&serde_json::to_string(&line)?);
|
||||
contents.push('\n');
|
||||
}
|
||||
tokio::fs::write(path, contents).await
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) use super::log_entries::LogEntry;
|
||||
use super::log_entries::dynamic_tool_output_items_to_text;
|
||||
use super::log_entries::exec_output;
|
||||
use super::log_entries::is_reflections_storage_tool;
|
||||
pub(crate) use super::log_entries::log_entries_from_items;
|
||||
use super::log_entries::push_blank_line_if_needed;
|
||||
use super::log_entries::reflections_storage_tool_call_metadata;
|
||||
use super::log_entries::reflections_storage_tool_response_metadata;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use serde::Serialize;
|
||||
@@ -15,12 +23,9 @@ pub(crate) struct TranscriptInput<'a> {
|
||||
}
|
||||
|
||||
pub(crate) fn events_since_last_compaction(items: &[RolloutItem]) -> Vec<EventMsg> {
|
||||
let start_index = items
|
||||
.iter()
|
||||
.rposition(|item| matches!(item, RolloutItem::Compacted(_)))
|
||||
.map_or(0, |index| index + 1);
|
||||
let range = item_range_since_last_compaction(items);
|
||||
|
||||
items[start_index..]
|
||||
items[range]
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
RolloutItem::EventMsg(event) => Some(event.clone()),
|
||||
@@ -32,6 +37,14 @@ pub(crate) fn events_since_last_compaction(items: &[RolloutItem]) -> Vec<EventMs
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn item_range_since_last_compaction(items: &[RolloutItem]) -> Range<usize> {
|
||||
let start_index = items
|
||||
.iter()
|
||||
.rposition(|item| matches!(item, RolloutItem::Compacted(_)))
|
||||
.map_or(0, |index| index + 1);
|
||||
start_index..items.len()
|
||||
}
|
||||
|
||||
pub(crate) fn render(input: TranscriptInput<'_>) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("# Reflections Log\n\n");
|
||||
@@ -205,6 +218,19 @@ fn push_event(out: &mut String, index: usize, event: &EventMsg) -> bool {
|
||||
true
|
||||
}
|
||||
EventMsg::DynamicToolCallRequest(event) => {
|
||||
if is_reflections_storage_tool(&event.tool) {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
&format!("tool_call {}", event.tool),
|
||||
&reflections_storage_tool_call_metadata(
|
||||
&event.tool,
|
||||
&event.call_id,
|
||||
&event.arguments,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
@@ -217,6 +243,22 @@ fn push_event(out: &mut String, index: usize, event: &EventMsg) -> bool {
|
||||
true
|
||||
}
|
||||
EventMsg::DynamicToolCallResponse(event) => {
|
||||
if is_reflections_storage_tool(&event.tool) {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
&format!("tool_result {}", event.tool),
|
||||
&reflections_storage_tool_response_metadata(
|
||||
&event.tool,
|
||||
&event.call_id,
|
||||
event.success,
|
||||
event.error.as_deref(),
|
||||
&event.content_items,
|
||||
&event.arguments,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
let text = format!(
|
||||
"call_id: {}\nsuccess: {}\nerror: {}\n\n{}",
|
||||
event.call_id,
|
||||
@@ -351,54 +393,6 @@ fn push_json<T: Serialize>(out: &mut String, index: usize, heading: &str, value:
|
||||
push_fenced(out, index, heading, "json", &text);
|
||||
}
|
||||
|
||||
fn push_blank_line_if_needed(text: &mut String) {
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
fn exec_output(event: &codex_protocol::protocol::ExecCommandEndEvent) -> String {
|
||||
if !event.aggregated_output.is_empty() {
|
||||
return event.aggregated_output.clone();
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
if !event.stdout.is_empty() {
|
||||
output.push_str("stdout:\n");
|
||||
output.push_str(&event.stdout);
|
||||
if !event.stdout.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
if !event.stderr.is_empty() {
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str("stderr:\n");
|
||||
output.push_str(&event.stderr);
|
||||
if !event.stderr.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn dynamic_tool_output_items_to_text(items: &[DynamicToolCallOutputContentItem]) -> String {
|
||||
let mut pieces = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
DynamicToolCallOutputContentItem::InputText { text } => pieces.push(text.clone()),
|
||||
DynamicToolCallOutputContentItem::InputImage { .. } => {
|
||||
pieces.push("[image omitted from Reflections transcript]".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
pieces.join("\n")
|
||||
}
|
||||
|
||||
fn fence_for(text: &str) -> String {
|
||||
let longest_run = longest_backtick_run(text);
|
||||
"`".repeat(longest_run.max(2) + 1)
|
||||
|
||||
@@ -44,7 +44,11 @@ pub use mcp::McpHandler;
|
||||
pub use mcp_resource::McpResourceHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use reflections::ReflectionsGetContextRemainingHandler;
|
||||
pub use reflections::ReflectionsListHandler;
|
||||
pub use reflections::ReflectionsNewContextWindowHandler;
|
||||
pub use reflections::ReflectionsReadHandler;
|
||||
pub use reflections::ReflectionsSearchHandler;
|
||||
pub use reflections::ReflectionsWriteNoteHandler;
|
||||
pub use request_permissions::RequestPermissionsHandler;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
pub use shell::ShellCommandHandler;
|
||||
|
||||
@@ -2,11 +2,50 @@ use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_tools::REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_LIST_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListArgs {
|
||||
collection: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReadArgs {
|
||||
kind: String,
|
||||
id: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchArgs {
|
||||
scope: String,
|
||||
query: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
log_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WriteNoteArgs {
|
||||
note_id: String,
|
||||
operation: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
pub struct ReflectionsNewContextWindowHandler;
|
||||
|
||||
@@ -69,3 +108,191 @@ impl ToolHandler for ReflectionsGetContextRemainingHandler {
|
||||
Ok(FunctionToolOutput::from_text(content, Some(true)))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsListHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsListHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: ListArgs = parse_function_args(&invocation, REFLECTIONS_LIST_TOOL_NAME)?;
|
||||
let (sidecar_path, rollout_path) = reflections_paths(&invocation).await?;
|
||||
let output = match args.collection.as_str() {
|
||||
"logs" => {
|
||||
crate::reflections::list_logs(&sidecar_path, &rollout_path, args.start, args.stop)
|
||||
.await
|
||||
}
|
||||
"notes" => crate::reflections::list_notes(&sidecar_path, args.start, args.stop).await,
|
||||
other => Err(crate::reflections::StorageToolError::Invalid(format!(
|
||||
"unsupported Reflections collection `{other}`"
|
||||
))),
|
||||
}
|
||||
.map_err(storage_error)?;
|
||||
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsReadHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsReadHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: ReadArgs = parse_function_args(&invocation, REFLECTIONS_READ_TOOL_NAME)?;
|
||||
let (sidecar_path, rollout_path) = reflections_paths(&invocation).await?;
|
||||
match args.kind.as_str() {
|
||||
"log" => {
|
||||
let output = crate::reflections::read_log(
|
||||
&sidecar_path,
|
||||
&rollout_path,
|
||||
&args.id,
|
||||
args.start,
|
||||
args.stop,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
"note" => {
|
||||
let output =
|
||||
crate::reflections::read_note(&sidecar_path, &args.id, args.start, args.stop)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
other => Err(FunctionCallError::RespondToModel(format!(
|
||||
"unsupported Reflections read kind `{other}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsSearchHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsSearchHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: SearchArgs = parse_function_args(&invocation, REFLECTIONS_SEARCH_TOOL_NAME)?;
|
||||
if !matches!(args.scope.as_str(), "all" | "logs" | "notes") {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"unsupported Reflections search scope `{}`",
|
||||
args.scope
|
||||
)));
|
||||
}
|
||||
let (sidecar_path, rollout_path) = reflections_paths(&invocation).await?;
|
||||
let output = crate::reflections::search(
|
||||
&sidecar_path,
|
||||
&rollout_path,
|
||||
&args.scope,
|
||||
&args.query,
|
||||
args.log_id.as_deref(),
|
||||
args.start,
|
||||
args.stop,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsWriteNoteHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsWriteNoteHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: WriteNoteArgs =
|
||||
parse_function_args(&invocation, REFLECTIONS_WRITE_NOTE_TOOL_NAME)?;
|
||||
let (sidecar_path, _) = reflections_paths(&invocation).await?;
|
||||
let output = crate::reflections::write_note(
|
||||
&sidecar_path,
|
||||
&args.note_id,
|
||||
&args.operation,
|
||||
&args.content,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_function_args<T>(
|
||||
invocation: &ToolInvocation,
|
||||
tool_name: &str,
|
||||
) -> Result<T, FunctionCallError>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let ToolPayload::Function { arguments } = &invocation.payload else {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{tool_name} handler received unsupported payload"
|
||||
)));
|
||||
};
|
||||
parse_arguments(arguments)
|
||||
}
|
||||
|
||||
async fn reflections_paths(
|
||||
invocation: &ToolInvocation,
|
||||
) -> Result<(PathBuf, PathBuf), FunctionCallError> {
|
||||
invocation
|
||||
.session
|
||||
.try_ensure_rollout_materialized()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"Reflections requires a persisted rollout path: {err}"
|
||||
))
|
||||
})?;
|
||||
invocation.session.flush_rollout().await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to flush Reflections rollout before reading storage: {err}"
|
||||
))
|
||||
})?;
|
||||
let rollout_path = invocation.session.current_rollout_path().await.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"Reflections storage requires a persisted rollout path and is unavailable for ephemeral sessions"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let sidecar_path = crate::reflections::sidecar_path_for_rollout(&rollout_path);
|
||||
Ok((sidecar_path, rollout_path))
|
||||
}
|
||||
|
||||
fn storage_error(err: crate::reflections::StorageToolError) -> FunctionCallError {
|
||||
FunctionCallError::RespondToModel(err.to_string())
|
||||
}
|
||||
|
||||
fn serialize_output<T>(output: T) -> Result<FunctionToolOutput, FunctionCallError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
serde_json::to_string(&output)
|
||||
.map(|content| FunctionToolOutput::from_text(content, Some(true)))
|
||||
.map_err(|err| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"failed to serialize Reflections tool output: {err}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,7 +73,11 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::McpResourceHandler;
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::ReflectionsGetContextRemainingHandler;
|
||||
use crate::tools::handlers::ReflectionsListHandler;
|
||||
use crate::tools::handlers::ReflectionsNewContextWindowHandler;
|
||||
use crate::tools::handlers::ReflectionsReadHandler;
|
||||
use crate::tools::handlers::ReflectionsSearchHandler;
|
||||
use crate::tools::handlers::ReflectionsWriteNoteHandler;
|
||||
use crate::tools::handlers::RequestPermissionsHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
@@ -214,10 +218,22 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
Arc::new(ReflectionsGetContextRemainingHandler),
|
||||
);
|
||||
}
|
||||
ToolHandlerKind::ReflectionsList => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsListHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsNewContextWindow => {
|
||||
builder
|
||||
.register_handler(handler.name, Arc::new(ReflectionsNewContextWindowHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsRead => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsReadHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsSearch => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsSearchHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsWriteNote => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsWriteNoteHandler));
|
||||
}
|
||||
ToolHandlerKind::RequestPermissions => {
|
||||
builder.register_handler(handler.name, request_permissions_handler.clone());
|
||||
}
|
||||
|
||||
@@ -486,7 +486,11 @@ async fn reflections_manual_compact_writes_sidecar_without_summarizer_request()
|
||||
)
|
||||
});
|
||||
assert!(rollout.contains("Reflections is enabled"));
|
||||
assert!(rollout.contains(&format!("{}/logs", sidecar_path.display())));
|
||||
assert!(rollout.contains("reflections_list"));
|
||||
assert!(rollout.contains("reflections_read"));
|
||||
assert!(rollout.contains("reflections_search"));
|
||||
assert!(rollout.contains("reflections_write_note"));
|
||||
assert!(!rollout.contains(&format!("{}/logs", sidecar_path.display())));
|
||||
assert!(!rollout.contains("logs/cw00000/transcript.md"));
|
||||
}
|
||||
|
||||
@@ -581,6 +585,10 @@ async fn reflections_near_limit_reminder_is_recorded_once_and_dropped_after_comp
|
||||
body_contains_text(&request_bodies[1], "advised NOT to send a final answer"),
|
||||
"near-limit reminder should include the stronger note-writing guidance"
|
||||
);
|
||||
assert!(
|
||||
body_contains_text(&request_bodies[1], "reflections_write_note"),
|
||||
"near-limit reminder should point to the storage note tool"
|
||||
);
|
||||
assert!(
|
||||
request_bodies[1].contains(&format!("unsupported call: {DUMMY_FUNCTION_NAME}")),
|
||||
"tool output should still be sent with the reminder"
|
||||
|
||||
@@ -29,4 +29,6 @@ pub struct ReflectionsConfigToml {
|
||||
pub usage_hint_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_hint_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub storage_tools_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -366,6 +366,7 @@ fn reflections_feature_config_deserializes_table_without_feature_toggle() {
|
||||
[reflections]
|
||||
usage_hint_enabled = false
|
||||
usage_hint_text = "Custom recovery guidance."
|
||||
storage_tools_enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("features table should deserialize");
|
||||
@@ -376,6 +377,7 @@ usage_hint_text = "Custom recovery guidance."
|
||||
Some(crate::ReflectionsConfigToml {
|
||||
usage_hint_enabled: Some(false),
|
||||
usage_hint_text: Some("Custom recovery guidance.".to_string()),
|
||||
storage_tools_enabled: Some(false),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,9 +76,17 @@ pub use mcp_tool::mcp_call_tool_result_output_schema;
|
||||
pub use mcp_tool::parse_mcp_tool;
|
||||
pub use plan_tool::create_update_plan_tool;
|
||||
pub use reflections_tool::REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_LIST_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_READ_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
pub use reflections_tool::create_reflections_get_context_remaining_tool;
|
||||
pub use reflections_tool::create_reflections_list_tool;
|
||||
pub use reflections_tool::create_reflections_new_context_window_tool;
|
||||
pub use reflections_tool::create_reflections_read_tool;
|
||||
pub use reflections_tool::create_reflections_search_tool;
|
||||
pub use reflections_tool::create_reflections_write_note_tool;
|
||||
pub use request_user_input_tool::REQUEST_USER_INPUT_TOOL_NAME;
|
||||
pub use request_user_input_tool::create_request_user_input_tool;
|
||||
pub use request_user_input_tool::normalize_request_user_input_args;
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::ToolSpec;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME: &str = "reflections_new_context_window";
|
||||
pub const REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME: &str = "reflections_get_context_remaining";
|
||||
pub const REFLECTIONS_LIST_TOOL_NAME: &str = "reflections_list";
|
||||
pub const REFLECTIONS_READ_TOOL_NAME: &str = "reflections_read";
|
||||
pub const REFLECTIONS_SEARCH_TOOL_NAME: &str = "reflections_search";
|
||||
pub const REFLECTIONS_WRITE_NOTE_TOOL_NAME: &str = "reflections_write_note";
|
||||
|
||||
pub fn create_reflections_new_context_window_tool(usage_hint: Option<&str>) -> ToolSpec {
|
||||
let mut description = "Starts a fresh context window for the same task. Use this after you have saved concise recovery notes under the Reflections notes directory when the current context is large or the next steps should continue from durable logs.".to_string();
|
||||
pub fn create_reflections_new_context_window_tool(
|
||||
usage_hint: Option<&str>,
|
||||
storage_tools_enabled: bool,
|
||||
) -> ToolSpec {
|
||||
let recovery_notes_location = if storage_tools_enabled {
|
||||
"with `reflections_write_note`"
|
||||
} else {
|
||||
"under the Reflections notes directory"
|
||||
};
|
||||
let mut description = format!(
|
||||
"Starts a fresh context window for the same task. Use this after you have saved concise recovery notes {recovery_notes_location} when the current context is large or the next steps should continue from durable logs."
|
||||
);
|
||||
if let Some(usage_hint) = usage_hint {
|
||||
description.push_str("\n\n");
|
||||
description.push_str(usage_hint);
|
||||
@@ -35,6 +50,174 @@ pub fn create_reflections_get_context_remaining_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_parameters() -> JsonSchema {
|
||||
JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into()))
|
||||
pub fn create_reflections_list_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_LIST_TOOL_NAME.to_string(),
|
||||
description: "Lists Reflections logs or notes using backend-neutral IDs. Use this to discover explicit log window IDs such as `cw00003` or durable note IDs before reading them.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"collection".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("logs"), json!("notes")],
|
||||
Some("Which Reflections collection to list.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start position. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop position. Defaults to 50 and returns at most 200 items.".to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["collection"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_read_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_READ_TOOL_NAME.to_string(),
|
||||
description: "Reads a Reflections log window or note by explicit ID. Log IDs are `cwNNNNN`; note IDs are simple slugs. The `latest` alias is not supported.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"kind".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("log"), json!("note")],
|
||||
Some("Whether to read a log window or note.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Explicit log ID such as `cw00003`, or note slug such as `handoff`."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start entry or line. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop entry or line. Defaults to 50 for logs and 1000 for notes."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["kind", "id"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_search_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_SEARCH_TOOL_NAME.to_string(),
|
||||
description: "Searches Reflections notes and/or explicit log windows. Search results include a one-call `read` locator for follow-up with `reflections_read`.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"scope".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("all"), json!("logs"), json!("notes")],
|
||||
Some("Where to search.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"query".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Case-insensitive literal text to search for.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start result position. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop result position. Defaults to 20 and returns at most 100 results."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"log_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional explicit log ID such as `cw00003`; only valid when searching logs."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["scope", "query"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_write_note_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_WRITE_NOTE_TOOL_NAME.to_string(),
|
||||
description: "Creates, appends to, or replaces a durable Reflections note. Note IDs are simple slugs, not file paths. A single note can contain at most 65,536 characters after the write.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"note_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Note slug matching ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ with no `..`."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"operation".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("create_only"), json!("append"), json!("replace")],
|
||||
Some("How to write the note.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"content".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"UTF-8 note content. The individual write and final note are each limited to 65,536 characters."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["note_id", "operation", "content"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_parameters() -> JsonSchema {
|
||||
object(BTreeMap::new(), Vec::new())
|
||||
}
|
||||
|
||||
fn object(properties: BTreeMap<String, JsonSchema>, required: Vec<&str>) -> JsonSchema {
|
||||
JsonSchema::object(
|
||||
properties,
|
||||
Some(required.into_iter().map(str::to_string).collect()),
|
||||
Some(false.into()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ pub struct ToolsConfig {
|
||||
pub multi_agent_v2: bool,
|
||||
pub reflections: bool,
|
||||
pub reflections_usage_hint_text: Option<String>,
|
||||
pub reflections_storage_tools: bool,
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub spawn_agent_usage_hint: bool,
|
||||
pub spawn_agent_usage_hint_text: Option<String>,
|
||||
@@ -229,6 +230,7 @@ impl ToolsConfig {
|
||||
multi_agent_v2: include_multi_agent_v2,
|
||||
reflections: false,
|
||||
reflections_usage_hint_text: None,
|
||||
reflections_storage_tools: true,
|
||||
hide_spawn_agent_metadata: false,
|
||||
spawn_agent_usage_hint: true,
|
||||
spawn_agent_usage_hint_text: None,
|
||||
@@ -262,9 +264,11 @@ impl ToolsConfig {
|
||||
mut self,
|
||||
reflections: bool,
|
||||
reflections_usage_hint_text: Option<String>,
|
||||
reflections_storage_tools: bool,
|
||||
) -> Self {
|
||||
self.reflections = reflections;
|
||||
self.reflections_usage_hint_text = reflections_usage_hint_text;
|
||||
self.reflections_storage_tools = reflections_storage_tools;
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use crate::CommandToolOptions;
|
||||
use crate::REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME;
|
||||
use crate::REFLECTIONS_LIST_TOOL_NAME;
|
||||
use crate::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
use crate::REFLECTIONS_READ_TOOL_NAME;
|
||||
use crate::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
use crate::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
use crate::REQUEST_USER_INPUT_TOOL_NAME;
|
||||
use crate::ShellToolOptions;
|
||||
use crate::SpawnAgentToolOptions;
|
||||
@@ -36,7 +40,11 @@ use crate::create_list_mcp_resources_tool;
|
||||
use crate::create_local_shell_tool;
|
||||
use crate::create_read_mcp_resource_tool;
|
||||
use crate::create_reflections_get_context_remaining_tool;
|
||||
use crate::create_reflections_list_tool;
|
||||
use crate::create_reflections_new_context_window_tool;
|
||||
use crate::create_reflections_read_tool;
|
||||
use crate::create_reflections_search_tool;
|
||||
use crate::create_reflections_write_note_tool;
|
||||
use crate::create_report_agent_job_result_tool;
|
||||
use crate::create_request_permissions_tool;
|
||||
use crate::create_request_user_input_tool;
|
||||
@@ -255,6 +263,7 @@ pub fn build_tool_registry_plan(
|
||||
plan.push_spec(
|
||||
create_reflections_new_context_window_tool(
|
||||
config.reflections_usage_hint_text.as_deref(),
|
||||
config.reflections_storage_tools,
|
||||
),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
@@ -272,6 +281,39 @@ pub fn build_tool_registry_plan(
|
||||
REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsGetContextRemaining,
|
||||
);
|
||||
|
||||
if config.reflections_storage_tools {
|
||||
plan.push_spec(
|
||||
create_reflections_list_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_read_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_search_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_write_note_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.register_handler(REFLECTIONS_LIST_TOOL_NAME, ToolHandlerKind::ReflectionsList);
|
||||
plan.register_handler(REFLECTIONS_READ_TOOL_NAME, ToolHandlerKind::ReflectionsRead);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_SEARCH_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsSearch,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsWriteNote,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if config.search_tool
|
||||
|
||||
@@ -656,9 +656,16 @@ fn reflections_tools_require_config_flag() {
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_NOTE_TOOL_NAME);
|
||||
|
||||
let reflections_tools_config =
|
||||
default_tools_config.with_reflections(true, Some("Custom Reflections hint.".to_string()));
|
||||
let reflections_tools_config = default_tools_config.clone().with_reflections(
|
||||
true,
|
||||
Some("Custom Reflections hint.".to_string()),
|
||||
/*reflections_storage_tools*/ true,
|
||||
);
|
||||
let (tools, handlers) = build_specs(
|
||||
&reflections_tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
@@ -670,6 +677,10 @@ fn reflections_tools_require_config_flag() {
|
||||
&[
|
||||
REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME,
|
||||
REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME,
|
||||
REFLECTIONS_LIST_TOOL_NAME,
|
||||
REFLECTIONS_READ_TOOL_NAME,
|
||||
REFLECTIONS_SEARCH_TOOL_NAME,
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME,
|
||||
],
|
||||
);
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
@@ -680,12 +691,63 @@ fn reflections_tools_require_config_flag() {
|
||||
name: ToolName::plain(REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsGetContextRemaining,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_LIST_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsList,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_READ_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsRead,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_SEARCH_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsSearch,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_WRITE_NOTE_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsWriteNote,
|
||||
}));
|
||||
|
||||
let new_context_tool = find_tool(&tools, REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME);
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &new_context_tool.spec else {
|
||||
panic!("{REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME} should be a function tool");
|
||||
};
|
||||
assert!(description.contains("Custom Reflections hint."));
|
||||
|
||||
let path_based_reflections_tools_config = default_tools_config.with_reflections(
|
||||
true,
|
||||
Some("Custom Reflections hint.".to_string()),
|
||||
/*reflections_storage_tools*/ false,
|
||||
);
|
||||
let (tools, handlers) = build_specs(
|
||||
&path_based_reflections_tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_contains_tool_names(
|
||||
&tools,
|
||||
&[
|
||||
REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME,
|
||||
REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME,
|
||||
],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_NOTE_TOOL_NAME);
|
||||
assert!(!handlers.iter().any(|handler| matches!(
|
||||
handler.kind,
|
||||
ToolHandlerKind::ReflectionsList
|
||||
| ToolHandlerKind::ReflectionsRead
|
||||
| ToolHandlerKind::ReflectionsSearch
|
||||
| ToolHandlerKind::ReflectionsWriteNote
|
||||
)));
|
||||
let new_context_tool = find_tool(&tools, REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME);
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &new_context_tool.spec else {
|
||||
panic!("{REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME} should be a function tool");
|
||||
};
|
||||
assert!(description.contains("under the Reflections notes directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,7 +27,11 @@ pub enum ToolHandlerKind {
|
||||
McpResource,
|
||||
Plan,
|
||||
ReflectionsGetContextRemaining,
|
||||
ReflectionsList,
|
||||
ReflectionsNewContextWindow,
|
||||
ReflectionsRead,
|
||||
ReflectionsSearch,
|
||||
ReflectionsWriteNote,
|
||||
RequestPermissions,
|
||||
RequestUserInput,
|
||||
ResumeAgentV1,
|
||||
|
||||
Reference in New Issue
Block a user