mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
reflections multiagent support
This commit is contained in:
@@ -1670,6 +1670,9 @@
|
||||
"ReflectionsConfigToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shared_notes_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"storage_tools_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -666,6 +666,7 @@ impl Codex {
|
||||
inherited_shell_snapshot,
|
||||
user_shell_override,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
|
||||
// Generate a unique ID for the lifetime of this Codex session.
|
||||
@@ -928,6 +929,7 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
|
||||
pub(crate) turn_skills: TurnSkillsContext,
|
||||
pub(crate) turn_timing_state: Arc<TurnTimingState>,
|
||||
pub(crate) reflections_shared_notes_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
impl TurnContext {
|
||||
pub(crate) fn model_context_window(&self) -> Option<i64> {
|
||||
@@ -1052,6 +1054,7 @@ impl TurnContext {
|
||||
turn_metadata_state: self.turn_metadata_state.clone(),
|
||||
turn_skills: self.turn_skills.clone(),
|
||||
turn_timing_state: Arc::clone(&self.turn_timing_state),
|
||||
reflections_shared_notes_path: self.reflections_shared_notes_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1213,6 +1216,7 @@ pub(crate) struct SessionConfiguration {
|
||||
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
|
||||
user_shell_override: Option<shell::Shell>,
|
||||
reflections_sidecar_path: Option<AbsolutePathBuf>,
|
||||
reflections_shared_notes_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
impl SessionConfiguration {
|
||||
@@ -1336,6 +1340,24 @@ impl SessionConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
fn should_resolve_reflections_shared_notes_path(
|
||||
config: &Config,
|
||||
session_source: &SessionSource,
|
||||
) -> bool {
|
||||
if config.compaction_strategy != CompactionStrategyConfig::Reflections
|
||||
|| !config.reflections.storage_tools_enabled
|
||||
|| !config.reflections.shared_notes_enabled
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
config.features.enabled(Feature::Collab)
|
||||
|| matches!(
|
||||
session_source,
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct SessionSettingsUpdate {
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
@@ -1586,6 +1608,12 @@ impl Session {
|
||||
let reflections_enabled = per_turn_config.compaction_strategy
|
||||
== CompactionStrategyConfig::Reflections
|
||||
&& session_configuration.reflections_sidecar_path.is_some();
|
||||
let shared_notes_available = reflections_enabled
|
||||
&& per_turn_config.reflections.storage_tools_enabled
|
||||
&& per_turn_config.reflections.shared_notes_enabled
|
||||
&& session_configuration
|
||||
.reflections_shared_notes_path
|
||||
.is_some();
|
||||
let reflections_usage_hint_text = if reflections_enabled
|
||||
&& per_turn_config.reflections.usage_hint_enabled
|
||||
{
|
||||
@@ -1602,6 +1630,7 @@ impl Session {
|
||||
sidecar_path.as_path(),
|
||||
per_turn_config.reflections.usage_hint_text.as_deref(),
|
||||
per_turn_config.reflections.storage_tools_enabled,
|
||||
shared_notes_available,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
@@ -1632,6 +1661,7 @@ impl Session {
|
||||
reflections_enabled,
|
||||
reflections_usage_hint_text,
|
||||
per_turn_config.reflections.storage_tools_enabled,
|
||||
shared_notes_available,
|
||||
)
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&per_turn_config.agent_roles,
|
||||
@@ -1691,6 +1721,9 @@ impl Session {
|
||||
turn_metadata_state,
|
||||
turn_skills: TurnSkillsContext::new(skills_outcome),
|
||||
turn_timing_state: Arc::new(TurnTimingState::default()),
|
||||
reflections_shared_notes_path: session_configuration
|
||||
.reflections_shared_notes_path
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1858,6 +1891,21 @@ impl Session {
|
||||
session_configuration.reflections_sidecar_path = Some(sidecar_path);
|
||||
session_configuration.add_reflections_sidecar_to_sandbox_policies();
|
||||
}
|
||||
if should_resolve_reflections_shared_notes_path(
|
||||
config.as_ref(),
|
||||
&session_configuration.session_source,
|
||||
) && let Some(rollout_path) = rollout_path.as_ref()
|
||||
{
|
||||
session_configuration.reflections_shared_notes_path =
|
||||
crate::reflections::resolve_reflections_shared_notes_path(
|
||||
config.codex_home.as_path(),
|
||||
rollout_path.as_path(),
|
||||
conversation_id,
|
||||
&session_configuration.session_source,
|
||||
)
|
||||
.await
|
||||
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok());
|
||||
}
|
||||
|
||||
let mut post_session_configured_events = Vec::<Event>::new();
|
||||
|
||||
@@ -2445,9 +2493,13 @@ impl Session {
|
||||
}
|
||||
|
||||
let remaining_tokens = auto_compact_limit.saturating_sub(total_usage_tokens).max(0);
|
||||
let shared_notes_available = turn_context.reflections_shared_notes_path.is_some()
|
||||
&& turn_context.config.reflections.storage_tools_enabled
|
||||
&& turn_context.config.reflections.shared_notes_enabled;
|
||||
let reminder = crate::reflections::near_limit_reminder(
|
||||
Some(remaining_tokens),
|
||||
turn_context.config.reflections.storage_tools_enabled,
|
||||
shared_notes_available,
|
||||
);
|
||||
self.record_conversation_items(turn_context, std::slice::from_ref(&reminder))
|
||||
.await;
|
||||
@@ -2803,6 +2855,7 @@ impl Session {
|
||||
sidecar_path.as_path().display()
|
||||
);
|
||||
session_configuration.reflections_sidecar_path = None;
|
||||
session_configuration.reflections_shared_notes_path = None;
|
||||
}
|
||||
}
|
||||
{
|
||||
@@ -6204,6 +6257,7 @@ async fn spawn_review_thread(
|
||||
turn_metadata_state,
|
||||
turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()),
|
||||
turn_timing_state: Arc::new(TurnTimingState::default()),
|
||||
reflections_shared_notes_path: parent_turn_context.reflections_shared_notes_path.clone(),
|
||||
};
|
||||
|
||||
// Seed the child task with the review prompt as the initial user message.
|
||||
|
||||
@@ -1931,6 +1931,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
@@ -2034,6 +2035,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
@@ -2316,6 +2318,51 @@ async fn build_test_config(codex_home: &Path) -> Config {
|
||||
.expect("load default test config")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflections_shared_notes_path_gate_allows_thread_spawn_without_collab_tools() {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
let mut config = build_test_config(codex_home.path()).await;
|
||||
config.compaction_strategy = CompactionStrategyConfig::Reflections;
|
||||
config.reflections.storage_tools_enabled = true;
|
||||
config.reflections.shared_notes_enabled = true;
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
|
||||
assert!(!should_resolve_reflections_shared_notes_path(
|
||||
&config,
|
||||
&SessionSource::Cli
|
||||
));
|
||||
|
||||
let _ = config.features.enable(Feature::Collab);
|
||||
assert!(should_resolve_reflections_shared_notes_path(
|
||||
&config,
|
||||
&SessionSource::Cli
|
||||
));
|
||||
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
assert!(should_resolve_reflections_shared_notes_path(
|
||||
&config,
|
||||
&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: ThreadId::new(),
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})
|
||||
));
|
||||
|
||||
config.reflections.storage_tools_enabled = false;
|
||||
assert!(!should_resolve_reflections_shared_notes_path(
|
||||
&config,
|
||||
&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: ThreadId::new(),
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
fn session_telemetry(
|
||||
conversation_id: ThreadId,
|
||||
config: &Config,
|
||||
@@ -2387,6 +2434,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2698,6 +2746,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
|
||||
let (tx_event, _rx_event) = async_channel::unbounded();
|
||||
@@ -2803,6 +2852,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
let per_turn_config = Session::build_per_turn_config(&session_configuration);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
@@ -3652,6 +3702,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
let per_turn_config = Session::build_per_turn_config(&session_configuration);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
|
||||
@@ -6317,6 +6317,7 @@ async fn reflections_config_from_strategy_and_feature_table() -> std::io::Result
|
||||
usage_hint_enabled = false
|
||||
usage_hint_text = "Custom recovery guidance."
|
||||
storage_tools_enabled = false
|
||||
shared_notes_enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
@@ -6336,6 +6337,7 @@ storage_tools_enabled = false
|
||||
Some("Custom recovery guidance.")
|
||||
);
|
||||
assert!(!config.reflections.storage_tools_enabled);
|
||||
assert!(!config.reflections.shared_notes_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -6359,6 +6361,7 @@ usage_hint_enabled = true
|
||||
.await?;
|
||||
|
||||
assert!(config.reflections.storage_tools_enabled);
|
||||
assert!(config.reflections.shared_notes_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -6375,6 +6378,7 @@ compaction_strategy = "reflections"
|
||||
usage_hint_enabled = true
|
||||
usage_hint_text = "base hint"
|
||||
storage_tools_enabled = true
|
||||
shared_notes_enabled = true
|
||||
|
||||
[profiles.default_compaction]
|
||||
compaction_strategy = "default"
|
||||
@@ -6383,6 +6387,7 @@ compaction_strategy = "default"
|
||||
usage_hint_enabled = false
|
||||
usage_hint_text = "profile hint"
|
||||
storage_tools_enabled = false
|
||||
shared_notes_enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
@@ -6402,6 +6407,7 @@ storage_tools_enabled = false
|
||||
Some("profile hint")
|
||||
);
|
||||
assert!(!config.reflections.storage_tools_enabled);
|
||||
assert!(!config.reflections.shared_notes_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -627,6 +627,7 @@ pub struct ReflectionsConfig {
|
||||
pub usage_hint_enabled: bool,
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub storage_tools_enabled: bool,
|
||||
pub shared_notes_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for ReflectionsConfig {
|
||||
@@ -635,6 +636,7 @@ impl Default for ReflectionsConfig {
|
||||
usage_hint_enabled: true,
|
||||
usage_hint_text: None,
|
||||
storage_tools_enabled: true,
|
||||
shared_notes_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1421,11 +1423,16 @@ fn resolve_reflections_config(
|
||||
.and_then(|config| config.storage_tools_enabled)
|
||||
.or_else(|| base.and_then(|config| config.storage_tools_enabled))
|
||||
.unwrap_or(default.storage_tools_enabled);
|
||||
let shared_notes_enabled = profile
|
||||
.and_then(|config| config.shared_notes_enabled)
|
||||
.or_else(|| base.and_then(|config| config.shared_notes_enabled))
|
||||
.unwrap_or(default.shared_notes_enabled);
|
||||
|
||||
ReflectionsConfig {
|
||||
usage_hint_enabled,
|
||||
usage_hint_text,
|
||||
storage_tools_enabled,
|
||||
shared_notes_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ 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_SHARED_NOTES_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_LIST_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
@@ -459,8 +463,12 @@ pub(crate) fn is_reflections_storage_tool(tool: &str) -> bool {
|
||||
matches!(
|
||||
tool,
|
||||
REFLECTIONS_LIST_TOOL_NAME
|
||||
| REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME
|
||||
| REFLECTIONS_READ_TOOL_NAME
|
||||
| REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME
|
||||
| REFLECTIONS_SEARCH_TOOL_NAME
|
||||
| REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME
|
||||
| REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME
|
||||
| REFLECTIONS_WRITE_NOTE_TOOL_NAME
|
||||
)
|
||||
}
|
||||
@@ -495,7 +503,7 @@ pub(super) fn reflections_storage_tool_call_metadata(
|
||||
arguments: &Value,
|
||||
) -> Value {
|
||||
match tool {
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME => json!({
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME | REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": arguments.get("note_id"),
|
||||
@@ -515,6 +523,13 @@ pub(super) fn reflections_storage_tool_call_metadata(
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": arguments.get("note_id"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_LIST_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
@@ -522,6 +537,12 @@ pub(super) fn reflections_storage_tool_call_metadata(
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_SEARCH_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
@@ -531,6 +552,13 @@ pub(super) fn reflections_storage_tool_call_metadata(
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"query": arguments.get("query"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
_ => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
@@ -572,7 +600,7 @@ fn reflections_storage_output_value_metadata(
|
||||
value: &Value,
|
||||
) -> Value {
|
||||
match tool {
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME => json!({
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME | REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": value.get("id"),
|
||||
@@ -598,6 +626,16 @@ fn reflections_storage_output_value_metadata(
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": value.get("id"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"content_chars": value.get("content_chars"),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_LIST_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
@@ -611,6 +649,19 @@ fn reflections_storage_output_value_metadata(
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_LIST_SHARED_NOTES_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,
|
||||
@@ -625,6 +676,20 @@ fn reflections_storage_output_value_metadata(
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_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,
|
||||
|
||||
@@ -33,14 +33,19 @@ pub(crate) use prompt::near_limit_reminder;
|
||||
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::resolve_reflections_shared_notes_path;
|
||||
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::list_shared_notes;
|
||||
pub(crate) use storage_tools::read_log;
|
||||
pub(crate) use storage_tools::read_note;
|
||||
pub(crate) use storage_tools::read_shared_note;
|
||||
pub(crate) use storage_tools::search;
|
||||
pub(crate) use storage_tools::search_shared_notes;
|
||||
pub(crate) use storage_tools::write_note;
|
||||
pub(crate) use storage_tools::write_shared_note;
|
||||
|
||||
pub(crate) async fn run_reflections_compact_task(
|
||||
sess: Arc<Session>,
|
||||
@@ -140,6 +145,9 @@ async fn run_reflections_compact_task_inner_impl(
|
||||
&window.logs_path,
|
||||
&window.notes_path,
|
||||
turn_context.config.reflections.storage_tools_enabled,
|
||||
turn_context.reflections_shared_notes_path.is_some()
|
||||
&& turn_context.config.reflections.storage_tools_enabled
|
||||
&& turn_context.config.reflections.shared_notes_enabled,
|
||||
);
|
||||
let mut new_history = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
|
||||
@@ -13,18 +13,24 @@ pub(crate) fn usage_hint(
|
||||
sidecar_path: &Path,
|
||||
custom_text: Option<&str>,
|
||||
storage_tools_enabled: bool,
|
||||
shared_notes_available: bool,
|
||||
) -> String {
|
||||
if let Some(custom_text) = custom_text {
|
||||
return custom_text.to_string();
|
||||
}
|
||||
|
||||
if storage_tools_enabled {
|
||||
let shared_notes_text = if shared_notes_available {
|
||||
"\n\nShared Reflections notes are available across agents in the same agent tree. Use `reflections_write_shared_note` for coordination state that should be visible to other agents. Use `reflections_write_note` for thread-local recovery notes."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
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.",
|
||||
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.{shared_notes_text}",
|
||||
context_window_text = context_window_text(context_window_size),
|
||||
);
|
||||
}
|
||||
@@ -47,6 +53,7 @@ Future context windows will not automatically include the full previous transcri
|
||||
pub(crate) fn near_limit_reminder(
|
||||
remaining_tokens: Option<i64>,
|
||||
storage_tools_enabled: bool,
|
||||
shared_notes_available: bool,
|
||||
) -> ResponseItem {
|
||||
let opening = match remaining_tokens {
|
||||
Some(remaining_tokens) => format!(
|
||||
@@ -59,10 +66,15 @@ pub(crate) fn near_limit_reminder(
|
||||
} else {
|
||||
"under the Reflections notes directory"
|
||||
};
|
||||
let shared_notes_text = if storage_tools_enabled && shared_notes_available {
|
||||
"\n\nUse thread-local notes for your own recovery state. Use shared notes only for coordination details that other agents should see."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let text = format!(
|
||||
"{opening}\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."
|
||||
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.{shared_notes_text}"
|
||||
);
|
||||
|
||||
ResponseItem::Message {
|
||||
@@ -98,14 +110,20 @@ pub(crate) fn post_compaction_handoff(
|
||||
logs_path: &Path,
|
||||
notes_path: &Path,
|
||||
storage_tools_enabled: bool,
|
||||
shared_notes_available: bool,
|
||||
) -> String {
|
||||
if storage_tools_enabled {
|
||||
let shared_notes_text = if shared_notes_available {
|
||||
"\n\nIf this task involves multiple agents, inspect shared notes with `reflections_list_shared_notes`, `reflections_read_shared_note`, or `reflections_search_shared_notes` for cross-agent coordination state."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
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.",
|
||||
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.{shared_notes_text}",
|
||||
context_window_text = context_window_text(context_window_size),
|
||||
);
|
||||
}
|
||||
@@ -151,6 +169,7 @@ mod tests {
|
||||
Path::new("/tmp/rollout.reflections"),
|
||||
None,
|
||||
/*storage_tools_enabled*/ false,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
|
||||
assert!(hint.contains("Your context window size is 98304 tokens."));
|
||||
@@ -165,6 +184,7 @@ mod tests {
|
||||
Path::new("/tmp/rollout.reflections"),
|
||||
None,
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
|
||||
assert!(hint.contains("Your context window size is 98304 tokens."));
|
||||
@@ -173,6 +193,21 @@ mod tests {
|
||||
assert!(hint.contains("reflections_read"));
|
||||
assert!(hint.contains("reflections_search"));
|
||||
assert!(!hint.contains("/tmp/rollout.reflections"));
|
||||
assert!(!hint.contains("reflections_write_shared_note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_hint_mentions_shared_note_tools_when_available() {
|
||||
let hint = usage_hint(
|
||||
Some(98304),
|
||||
Path::new("/tmp/rollout.reflections"),
|
||||
None,
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ true,
|
||||
);
|
||||
|
||||
assert!(hint.contains("reflections_write_shared_note"));
|
||||
assert!(hint.contains("thread-local recovery notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -182,6 +217,7 @@ mod tests {
|
||||
Path::new("/tmp/rollout.reflections/logs"),
|
||||
Path::new("/tmp/rollout.reflections/notes"),
|
||||
/*storage_tools_enabled*/ false,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
|
||||
assert!(handoff.contains("Your context window size is not available for this model."));
|
||||
@@ -199,6 +235,7 @@ mod tests {
|
||||
Path::new("/tmp/rollout.reflections/logs"),
|
||||
Path::new("/tmp/rollout.reflections/notes"),
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
|
||||
assert!(handoff.contains("reflections_list"));
|
||||
@@ -206,11 +243,30 @@ mod tests {
|
||||
assert!(handoff.contains("reflections_search"));
|
||||
assert!(handoff.contains("reflections_write_note"));
|
||||
assert!(!handoff.contains("/tmp/rollout.reflections"));
|
||||
assert!(!handoff.contains("reflections_list_shared_notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_mentions_shared_note_tools_when_available() {
|
||||
let handoff = post_compaction_handoff(
|
||||
None,
|
||||
Path::new("/tmp/rollout.reflections/logs"),
|
||||
Path::new("/tmp/rollout.reflections/notes"),
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ true,
|
||||
);
|
||||
|
||||
assert!(handoff.contains("reflections_list_shared_notes"));
|
||||
assert!(handoff.contains("cross-agent coordination state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_is_developer_message() {
|
||||
let reminder = near_limit_reminder(Some(4058), /*storage_tools_enabled*/ true);
|
||||
let reminder = near_limit_reminder(
|
||||
Some(4058),
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
let ResponseItem::Message { role, content, .. } = &reminder else {
|
||||
panic!("near-limit reminder should be a message");
|
||||
};
|
||||
@@ -224,12 +280,15 @@ mod tests {
|
||||
assert!(text.contains("advised NOT to send a final answer"));
|
||||
assert!(text.contains("reflections_write_note"));
|
||||
assert!(text.contains("reflections_new_context_window"));
|
||||
assert!(!text.contains("shared notes"));
|
||||
assert!(is_near_limit_reminder(&reminder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_supports_unknown_remaining_tokens() {
|
||||
let reminder = near_limit_reminder(None, /*storage_tools_enabled*/ false);
|
||||
let reminder = near_limit_reminder(
|
||||
None, /*storage_tools_enabled*/ false, /*shared_notes_available*/ false,
|
||||
);
|
||||
let ResponseItem::Message { content, .. } = &reminder else {
|
||||
panic!("near-limit reminder should be a message");
|
||||
};
|
||||
@@ -241,6 +300,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_mentions_shared_notes_when_available() {
|
||||
let reminder = near_limit_reminder(
|
||||
Some(4058),
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ true,
|
||||
);
|
||||
let ResponseItem::Message { content, .. } = &reminder else {
|
||||
panic!("near-limit reminder should be a message");
|
||||
};
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
panic!("near-limit reminder should contain exactly one text item");
|
||||
};
|
||||
assert!(text.contains("Use shared notes only for coordination details"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_threshold_never_goes_negative() {
|
||||
assert_eq!(super::near_limit_reminder_threshold(10_000), 5904);
|
||||
|
||||
@@ -4,6 +4,9 @@ use std::path::PathBuf;
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -11,6 +14,7 @@ use tokio::io::AsyncWriteExt;
|
||||
const STATE_SCHEMA: &str = "reflections.state.v1";
|
||||
const TRANSCRIPT_FILE: &str = "transcript.md";
|
||||
const WINDOW_DIR_WIDTH: usize = 5;
|
||||
const MAX_SHARED_NOTES_PARENT_DEPTH: usize = 64;
|
||||
pub(crate) const WINDOW_DIR_PATTERN: &str = "cwNNNNN";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -53,6 +57,57 @@ pub(crate) async fn ensure_sidecar_dirs(sidecar_path: &Path) -> std::io::Result<
|
||||
tokio::fs::create_dir_all(sidecar_path.join("logs")).await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_reflections_shared_notes_path(
|
||||
codex_home: &Path,
|
||||
current_rollout_path: &Path,
|
||||
current_thread_id: ThreadId,
|
||||
session_source: &SessionSource,
|
||||
) -> Option<PathBuf> {
|
||||
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) = session_source
|
||||
else {
|
||||
return Some(sidecar_path_for_rollout(current_rollout_path).join("shared_notes"));
|
||||
};
|
||||
|
||||
let mut next_parent_thread_id = *parent_thread_id;
|
||||
let mut seen_thread_ids = std::collections::HashSet::new();
|
||||
seen_thread_ids.insert(current_thread_id.to_string());
|
||||
|
||||
for _ in 0..MAX_SHARED_NOTES_PARENT_DEPTH {
|
||||
let parent_thread_id = next_parent_thread_id.to_string();
|
||||
if !seen_thread_ids.insert(parent_thread_id.clone()) {
|
||||
return None;
|
||||
}
|
||||
let parent_rollout_path =
|
||||
crate::rollout::find_thread_path_by_id_str(codex_home, &parent_thread_id)
|
||||
.await
|
||||
.ok()??;
|
||||
let parent_session_meta =
|
||||
crate::rollout::read_session_meta_line(parent_rollout_path.as_path())
|
||||
.await
|
||||
.ok()?;
|
||||
match parent_session_meta.meta.source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) => {
|
||||
next_parent_thread_id = parent_thread_id;
|
||||
}
|
||||
SessionSource::Cli
|
||||
| SessionSource::VSCode
|
||||
| SessionSource::Exec
|
||||
| SessionSource::Mcp
|
||||
| SessionSource::Custom(_)
|
||||
| SessionSource::SubAgent(_)
|
||||
| SessionSource::Unknown => {
|
||||
return Some(sidecar_path_for_rollout(&parent_rollout_path).join("shared_notes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) async fn write_window(
|
||||
sidecar_path: &Path,
|
||||
rollout_path: &Path,
|
||||
@@ -164,10 +219,20 @@ fn trigger_label(trigger: CompactionTrigger) -> &'static str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_reflections_shared_notes_path;
|
||||
use super::sidecar_path_for_rollout;
|
||||
use super::write_window;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_window_allocates_transcript_and_state() -> std::io::Result<()> {
|
||||
@@ -210,4 +275,147 @@ mod tests {
|
||||
assert!(state.contains("\"rollout_end_line\": 2"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_notes_resolve_to_current_sidecar_for_root_thread() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let thread_id = ThreadId::new();
|
||||
let rollout = temp.path().join(format!(
|
||||
"sessions/2026/04/16/rollout-2026-04-16T00-00-00-{thread_id}.jsonl"
|
||||
));
|
||||
|
||||
let shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&rollout,
|
||||
thread_id,
|
||||
&SessionSource::Cli,
|
||||
)
|
||||
.await
|
||||
.expect("root shared notes should resolve");
|
||||
|
||||
assert_eq!(
|
||||
shared_notes,
|
||||
sidecar_path_for_rollout(&rollout).join("shared_notes")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_notes_resolve_to_root_sidecar_for_descendants() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let root_id = ThreadId::new();
|
||||
let child_id = ThreadId::new();
|
||||
let grandchild_id = ThreadId::new();
|
||||
let root_rollout = write_minimal_rollout(temp.path(), root_id, SessionSource::Cli)?;
|
||||
let child_rollout =
|
||||
write_minimal_rollout(temp.path(), child_id, thread_spawn_source(root_id, 1))?;
|
||||
let grandchild_rollout = temp.path().join(format!(
|
||||
"sessions/2026/04/16/rollout-2026-04-16T00-02-00-{grandchild_id}.jsonl"
|
||||
));
|
||||
|
||||
let child_shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&child_rollout,
|
||||
child_id,
|
||||
&thread_spawn_source(root_id, 1),
|
||||
)
|
||||
.await
|
||||
.expect("child shared notes should resolve");
|
||||
let grandchild_shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&grandchild_rollout,
|
||||
grandchild_id,
|
||||
&thread_spawn_source(child_id, 2),
|
||||
)
|
||||
.await
|
||||
.expect("grandchild shared notes should resolve");
|
||||
|
||||
let expected = sidecar_path_for_rollout(&root_rollout).join("shared_notes");
|
||||
assert_eq!(child_shared_notes, expected);
|
||||
assert_eq!(grandchild_shared_notes, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_notes_resolution_failure_returns_none() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let current_id = ThreadId::new();
|
||||
let missing_parent_id = ThreadId::new();
|
||||
let rollout = temp.path().join("rollout.jsonl");
|
||||
|
||||
let shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&rollout,
|
||||
current_id,
|
||||
&thread_spawn_source(missing_parent_id, 1),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(shared_notes, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_notes_resolution_cycle_returns_none() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let current_id = ThreadId::new();
|
||||
let parent_id = ThreadId::new();
|
||||
write_minimal_rollout(temp.path(), parent_id, thread_spawn_source(current_id, 1))?;
|
||||
let rollout = temp.path().join("rollout.jsonl");
|
||||
|
||||
let shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&rollout,
|
||||
current_id,
|
||||
&thread_spawn_source(parent_id, 1),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(shared_notes, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn thread_spawn_source(parent_thread_id: ThreadId, depth: i32) -> SessionSource {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_minimal_rollout(
|
||||
codex_home: &Path,
|
||||
thread_id: ThreadId,
|
||||
source: SessionSource,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let sessions_dir = codex_home.join("sessions/2026/04/16");
|
||||
std::fs::create_dir_all(&sessions_dir)?;
|
||||
let rollout = sessions_dir.join(format!("rollout-2026-04-16T00-00-00-{thread_id}.jsonl"));
|
||||
let line = RolloutLine {
|
||||
timestamp: "2026-04-16T00:00:00.000Z".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
id: thread_id,
|
||||
forked_from_id: None,
|
||||
timestamp: "2026-04-16T00:00:00Z".to_string(),
|
||||
cwd: codex_home.to_path_buf(),
|
||||
originator: "test".to_string(),
|
||||
cli_version: "test".to_string(),
|
||||
source,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
agent_path: None,
|
||||
model_provider: None,
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
memory_mode: None,
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
};
|
||||
std::fs::write(&rollout, format!("{}\n", serde_json::to_string(&line)?))?;
|
||||
Ok(rollout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use chrono::DateTime;
|
||||
@@ -8,6 +13,7 @@ use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use super::storage::WindowState;
|
||||
use super::storage::ensure_sidecar_dirs;
|
||||
@@ -17,6 +23,7 @@ use super::transcript;
|
||||
use crate::RolloutRecorder;
|
||||
|
||||
const NOTE_VERSIONS_FILE: &str = "note_versions.json";
|
||||
const SHARED_NOTE_VERSIONS_FILE: &str = ".versions.json";
|
||||
const WINDOW_ID_WIDTH: usize = 5;
|
||||
pub(crate) const MAX_NOTE_CHARS: usize = 65_536;
|
||||
const DEFAULT_LIST_WINDOW: usize = 50;
|
||||
@@ -28,6 +35,9 @@ const MAX_NOTE_READ_LINES: usize = 1000;
|
||||
const DEFAULT_SEARCH_WINDOW: usize = 20;
|
||||
const MAX_SEARCH_RESULTS: usize = 100;
|
||||
|
||||
static NOTE_STORE_WRITE_LOCKS: LazyLock<StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>> =
|
||||
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct RangeInfo {
|
||||
pub(crate) start: usize,
|
||||
@@ -144,48 +154,23 @@ pub(crate) async fn list_notes(
|
||||
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),
|
||||
})
|
||||
list_notes_in_store(&sidecar_path.join("notes"), "note", "notes", start, stop).await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_shared_notes(
|
||||
shared_notes_path: &Path,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
) -> StorageToolResult<ListResponse> {
|
||||
list_notes_in_store(
|
||||
shared_notes_path,
|
||||
"shared_note",
|
||||
"shared_notes",
|
||||
start,
|
||||
stop,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_log(
|
||||
@@ -224,36 +209,16 @@ pub(crate) async fn read_note(
|
||||
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),
|
||||
})
|
||||
read_note_in_store(&sidecar_path.join("notes"), "note", id, start, stop).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_shared_note(
|
||||
shared_notes_path: &Path,
|
||||
id: &str,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
) -> StorageToolResult<ReadNoteResponse> {
|
||||
read_note_in_store(shared_notes_path, "shared_note", id, start, stop).await
|
||||
}
|
||||
|
||||
pub(crate) async fn search(
|
||||
@@ -313,37 +278,15 @@ pub(crate) async fn search(
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
results.extend(
|
||||
search_note_hits_in_store(
|
||||
&sidecar_path.join("notes"),
|
||||
"note",
|
||||
query,
|
||||
"reflections_read",
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
let total = results.len();
|
||||
@@ -356,11 +299,237 @@ pub(crate) async fn search(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn search_shared_notes(
|
||||
shared_notes_path: &Path,
|
||||
query: &str,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
) -> StorageToolResult<SearchResponse> {
|
||||
search_notes_in_store(
|
||||
shared_notes_path,
|
||||
"shared_note",
|
||||
query,
|
||||
start,
|
||||
stop,
|
||||
"reflections_read_shared_note",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn write_note(
|
||||
sidecar_path: &Path,
|
||||
id: &str,
|
||||
operation: &str,
|
||||
content: &str,
|
||||
) -> StorageToolResult<WriteNoteResponse> {
|
||||
ensure_sidecar_dirs(sidecar_path).await?;
|
||||
write_note_in_store(
|
||||
&sidecar_path.join("notes"),
|
||||
&sidecar_path.join(NOTE_VERSIONS_FILE),
|
||||
"note",
|
||||
id,
|
||||
operation,
|
||||
content,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn write_shared_note(
|
||||
shared_notes_path: &Path,
|
||||
id: &str,
|
||||
operation: &str,
|
||||
content: &str,
|
||||
) -> StorageToolResult<WriteNoteResponse> {
|
||||
write_note_in_store(
|
||||
shared_notes_path,
|
||||
&shared_notes_path.join(SHARED_NOTE_VERSIONS_FILE),
|
||||
"shared_note",
|
||||
id,
|
||||
operation,
|
||||
content,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_notes_in_store(
|
||||
notes_dir: &Path,
|
||||
kind: &'static str,
|
||||
collection: &str,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
) -> StorageToolResult<ListResponse> {
|
||||
let selection = selection(start, stop, DEFAULT_LIST_WINDOW, MAX_LIST_ITEMS)?;
|
||||
tokio::fs::create_dir_all(notes_dir).await?;
|
||||
let mut entries = tokio::fs::read_dir(notes_dir).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": kind,
|
||||
"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: collection.to_string(),
|
||||
items,
|
||||
range: range_info(selection, total),
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_note_in_store(
|
||||
notes_dir: &Path,
|
||||
kind: &'static str,
|
||||
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 = notes_dir.join(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,
|
||||
id: id.to_string(),
|
||||
content: selected_content.clone(),
|
||||
content_chars: selected_content.chars().count(),
|
||||
range: range_info(selection, total),
|
||||
})
|
||||
}
|
||||
|
||||
async fn search_notes_in_store(
|
||||
notes_dir: &Path,
|
||||
kind: &'static str,
|
||||
query: &str,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
read_tool_name: &'static str,
|
||||
) -> StorageToolResult<SearchResponse> {
|
||||
if query.is_empty() {
|
||||
return Err(StorageToolError::Invalid(
|
||||
"`query` is required and must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let selection = selection(start, stop, DEFAULT_SEARCH_WINDOW, MAX_SEARCH_RESULTS)?;
|
||||
let mut results = search_note_hits_in_store(notes_dir, kind, query, read_tool_name).await?;
|
||||
let total = results.len();
|
||||
results = slice_items(results, selection);
|
||||
Ok(SearchResponse {
|
||||
scope: if kind == "shared_note" {
|
||||
"shared_notes"
|
||||
} else {
|
||||
"notes"
|
||||
}
|
||||
.to_string(),
|
||||
query: query.to_string(),
|
||||
results,
|
||||
range: range_info(selection, total),
|
||||
})
|
||||
}
|
||||
|
||||
async fn search_note_hits_in_store(
|
||||
notes_dir: &Path,
|
||||
kind: &'static str,
|
||||
query: &str,
|
||||
read_tool_name: &'static str,
|
||||
) -> StorageToolResult<Vec<Value>> {
|
||||
tokio::fs::create_dir_all(notes_dir).await?;
|
||||
let needle = query.to_ascii_lowercase();
|
||||
let mut entries = tokio::fs::read_dir(notes_dir).await?;
|
||||
let mut results = 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 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;
|
||||
let read = if kind == "shared_note" {
|
||||
json!({
|
||||
"tool": read_tool_name,
|
||||
"note_id": ¬e_id,
|
||||
"start": line_number,
|
||||
"stop": line_number,
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"kind": "note",
|
||||
"id": ¬e_id,
|
||||
"start": line_number,
|
||||
"stop": line_number,
|
||||
})
|
||||
};
|
||||
results.push(json!({
|
||||
"kind": kind,
|
||||
"note_id": ¬e_id,
|
||||
"line": line_number,
|
||||
"snippet": snippet(line, query),
|
||||
"read": read,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn write_note_in_store(
|
||||
notes_dir: &Path,
|
||||
version_file: &Path,
|
||||
kind: &'static str,
|
||||
id: &str,
|
||||
operation: &str,
|
||||
content: &str,
|
||||
) -> StorageToolResult<WriteNoteResponse> {
|
||||
validate_note_id(id)?;
|
||||
if content.chars().count() > MAX_NOTE_CHARS {
|
||||
@@ -368,8 +537,10 @@ pub(crate) async fn write_note(
|
||||
"Reflections note writes are limited to {MAX_NOTE_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
ensure_sidecar_dirs(sidecar_path).await?;
|
||||
let path = note_path(sidecar_path, id);
|
||||
tokio::fs::create_dir_all(notes_dir).await?;
|
||||
let write_lock = note_store_write_lock(notes_dir);
|
||||
let _guard = write_lock.lock().await;
|
||||
let path = notes_dir.join(id);
|
||||
let existing = tokio::fs::read_to_string(&path).await;
|
||||
let existing = match existing {
|
||||
Ok(existing) => Some(existing),
|
||||
@@ -408,9 +579,9 @@ pub(crate) async fn write_note(
|
||||
)));
|
||||
}
|
||||
write_atomic(&path, new_content.as_bytes()).await?;
|
||||
let version = increment_note_version(sidecar_path, id).await?;
|
||||
let version = increment_note_version(version_file, id).await?;
|
||||
Ok(WriteNoteResponse {
|
||||
kind: "note",
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
operation: operation.to_string(),
|
||||
content_chars: content.chars().count(),
|
||||
@@ -581,8 +752,14 @@ fn valid_note_id(id: &str) -> bool {
|
||||
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 note_store_write_lock(notes_dir: &Path) -> Arc<AsyncMutex<()>> {
|
||||
let mut locks = NOTE_STORE_WRITE_LOCKS
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
locks
|
||||
.entry(notes_dir.to_path_buf())
|
||||
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn line_count(content: &str) -> usize {
|
||||
@@ -596,12 +773,12 @@ fn system_time_rfc3339(time: Option<SystemTime>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn increment_note_version(sidecar_path: &Path, id: &str) -> StorageToolResult<u64> {
|
||||
let path = sidecar_path.join(NOTE_VERSIONS_FILE);
|
||||
async fn increment_note_version(path: &Path, id: &str) -> StorageToolResult<u64> {
|
||||
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(),
|
||||
Ok(contents) => {
|
||||
serde_json::from_str::<BTreeMap<String, u64>>(&contents).unwrap_or_default()
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
|
||||
Err(err) => return Err(StorageToolError::Io(err)),
|
||||
};
|
||||
let version = versions.get(id).copied().unwrap_or(0).saturating_add(1);
|
||||
@@ -609,7 +786,7 @@ async fn increment_note_version(sidecar_path: &Path, id: &str) -> StorageToolRes
|
||||
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?;
|
||||
write_atomic(path, &bytes).await?;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
@@ -662,10 +839,14 @@ mod tests {
|
||||
use super::MAX_NOTE_CHARS;
|
||||
use super::list_logs;
|
||||
use super::list_notes;
|
||||
use super::list_shared_notes;
|
||||
use super::read_log;
|
||||
use super::read_note;
|
||||
use super::read_shared_note;
|
||||
use super::search;
|
||||
use super::search_shared_notes;
|
||||
use super::write_note;
|
||||
use super::write_shared_note;
|
||||
use crate::reflections::storage::sidecar_path_for_rollout;
|
||||
use crate::reflections::storage::write_window;
|
||||
|
||||
@@ -720,6 +901,64 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_notes_use_shared_labels_limits_and_read_locators() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let shared_notes = temp.path().join("root.reflections").join("shared_notes");
|
||||
|
||||
let invalid = write_shared_note(&shared_notes, "../bad", "append", "content").await;
|
||||
assert!(invalid.is_err());
|
||||
|
||||
write_shared_note(
|
||||
&shared_notes,
|
||||
"handoff",
|
||||
"create_only",
|
||||
"Current task: parser tests\n",
|
||||
)
|
||||
.await
|
||||
.expect("create shared note");
|
||||
let too_large_append = write_shared_note(
|
||||
&shared_notes,
|
||||
"handoff",
|
||||
"append",
|
||||
&"x".repeat(MAX_NOTE_CHARS),
|
||||
)
|
||||
.await;
|
||||
assert!(too_large_append.is_err());
|
||||
|
||||
let listed = list_shared_notes(&shared_notes, Some(1), Some(50))
|
||||
.await
|
||||
.expect("list shared notes");
|
||||
assert_eq!(listed.collection, "shared_notes");
|
||||
assert_eq!(listed.items[0]["kind"], "shared_note");
|
||||
assert_eq!(listed.items[0]["id"], "handoff");
|
||||
assert!(listed.items[0].get("path").is_none());
|
||||
|
||||
let read = read_shared_note(&shared_notes, "handoff", Some(1), Some(1))
|
||||
.await
|
||||
.expect("read shared note");
|
||||
assert_eq!(read.kind, "shared_note");
|
||||
assert_eq!(read.content, "Current task: parser tests\n");
|
||||
|
||||
let latest = read_shared_note(&shared_notes, "latest", Some(1), Some(1)).await;
|
||||
assert!(latest.is_err());
|
||||
|
||||
let search_result = search_shared_notes(&shared_notes, "parser tests", Some(1), Some(20))
|
||||
.await
|
||||
.expect("search shared notes");
|
||||
assert_eq!(search_result.scope, "shared_notes");
|
||||
assert_eq!(search_result.results[0]["kind"], "shared_note");
|
||||
assert_eq!(search_result.results[0]["note_id"], "handoff");
|
||||
assert_eq!(
|
||||
search_result.results[0]["read"]["tool"],
|
||||
"reflections_read_shared_note"
|
||||
);
|
||||
assert_eq!(search_result.results[0]["read"]["note_id"], "handoff");
|
||||
assert_eq!(search_result.results[0]["read"]["start"], 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_read_and_search_use_rollout_jsonl_not_markdown() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
@@ -850,6 +1089,63 @@ mod tests {
|
||||
.to_string(),
|
||||
),
|
||||
}),
|
||||
RolloutItem::ResponseItem(ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "reflections_write_shared_note".to_string(),
|
||||
namespace: None,
|
||||
arguments: serde_json::json!({
|
||||
"note_id": "team",
|
||||
"operation": "append",
|
||||
"content": "SECRET SHARED BODY"
|
||||
})
|
||||
.to_string(),
|
||||
call_id: "call-2".to_string(),
|
||||
}),
|
||||
RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-2".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(
|
||||
serde_json::json!({
|
||||
"kind": "shared_note",
|
||||
"id": "team",
|
||||
"operation": "append",
|
||||
"content_chars": 18,
|
||||
"total_content_chars": 18,
|
||||
"line_count": 1,
|
||||
"version": 1,
|
||||
"ok": true
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
}),
|
||||
RolloutItem::ResponseItem(ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "reflections_read_shared_note".to_string(),
|
||||
namespace: None,
|
||||
arguments: serde_json::json!({
|
||||
"note_id": "team",
|
||||
"start": 1,
|
||||
"stop": 1
|
||||
})
|
||||
.to_string(),
|
||||
call_id: "call-3".to_string(),
|
||||
}),
|
||||
RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-3".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(
|
||||
serde_json::json!({
|
||||
"kind": "shared_note",
|
||||
"id": "team",
|
||||
"content": "SECRET READ BODY",
|
||||
"content_chars": 16,
|
||||
"range": {
|
||||
"start": 1,
|
||||
"stop": 1,
|
||||
"total": 1
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
}),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
@@ -859,7 +1155,7 @@ mod tests {
|
||||
CompactionTrigger::Manual,
|
||||
Some(9500),
|
||||
1,
|
||||
2,
|
||||
6,
|
||||
String::new(),
|
||||
)
|
||||
.await?;
|
||||
@@ -867,10 +1163,13 @@ mod tests {
|
||||
let read = read_log(&sidecar, &rollout, "cw00000", Some(1), Some(50))
|
||||
.await
|
||||
.expect("read log");
|
||||
assert_eq!(read.entries.len(), 2);
|
||||
assert_eq!(read.entries.len(), 6);
|
||||
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"));
|
||||
assert!(!read.entries[2].content.contains("SECRET SHARED BODY"));
|
||||
assert!(!read.entries[3].content.contains("SECRET SHARED BODY"));
|
||||
assert!(!read.entries[5].content.contains("SECRET READ BODY"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -45,10 +45,14 @@ pub use mcp_resource::McpResourceHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use reflections::ReflectionsGetContextRemainingHandler;
|
||||
pub use reflections::ReflectionsListHandler;
|
||||
pub use reflections::ReflectionsListSharedNotesHandler;
|
||||
pub use reflections::ReflectionsNewContextWindowHandler;
|
||||
pub use reflections::ReflectionsReadHandler;
|
||||
pub use reflections::ReflectionsReadSharedNoteHandler;
|
||||
pub use reflections::ReflectionsSearchHandler;
|
||||
pub use reflections::ReflectionsSearchSharedNotesHandler;
|
||||
pub use reflections::ReflectionsWriteNoteHandler;
|
||||
pub use reflections::ReflectionsWriteSharedNoteHandler;
|
||||
pub use request_permissions::RequestPermissionsHandler;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
pub use shell::ShellCommandHandler;
|
||||
|
||||
@@ -10,6 +10,9 @@ use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::handlers::ReflectionsReadSharedNoteHandler;
|
||||
use crate::tools::handlers::ReflectionsSearchSharedNotesHandler;
|
||||
use crate::tools::handlers::ReflectionsWriteSharedNoteHandler;
|
||||
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
@@ -46,6 +49,7 @@ use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::TempDirExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
@@ -84,6 +88,99 @@ fn parse_agent_id(id: &str) -> ThreadId {
|
||||
ThreadId::from_string(id).expect("agent id should be valid")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_reflections_shared_notes_are_visible_across_turn_contexts() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let shared_notes_path =
|
||||
AbsolutePathBuf::from_absolute_path(temp_dir.path().join("shared_notes"))
|
||||
.expect("shared notes path should be absolute");
|
||||
let (parent_session, mut parent_turn) = make_session_and_context().await;
|
||||
parent_turn.reflections_shared_notes_path = Some(shared_notes_path.clone());
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let (child_session, mut child_turn) = make_session_and_context().await;
|
||||
child_turn.reflections_shared_notes_path = Some(shared_notes_path);
|
||||
child_turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
let parent_session = Arc::new(parent_session);
|
||||
let child_session = Arc::new(child_session);
|
||||
let parent_turn = Arc::new(parent_turn);
|
||||
let child_turn = Arc::new(child_turn);
|
||||
|
||||
ReflectionsWriteSharedNoteHandler
|
||||
.handle(invocation(
|
||||
Arc::clone(&parent_session),
|
||||
Arc::clone(&parent_turn),
|
||||
"reflections_write_shared_note",
|
||||
function_payload(json!({
|
||||
"note_id": "handoff",
|
||||
"operation": "create_only",
|
||||
"content": "Parent progress: inspect parser tests\n"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("parent should write shared note");
|
||||
|
||||
let read_output = ReflectionsReadSharedNoteHandler
|
||||
.handle(invocation(
|
||||
Arc::clone(&child_session),
|
||||
Arc::clone(&child_turn),
|
||||
"reflections_read_shared_note",
|
||||
function_payload(json!({
|
||||
"note_id": "handoff",
|
||||
"start": 1,
|
||||
"stop": 1
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("child should read shared note");
|
||||
let (content, _) = expect_text_output(read_output);
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&content).expect("read output should be json");
|
||||
assert_eq!(value["kind"], "shared_note");
|
||||
assert_eq!(value["content"], "Parent progress: inspect parser tests\n");
|
||||
|
||||
ReflectionsWriteSharedNoteHandler
|
||||
.handle(invocation(
|
||||
Arc::clone(&child_session),
|
||||
Arc::clone(&child_turn),
|
||||
"reflections_write_shared_note",
|
||||
function_payload(json!({
|
||||
"note_id": "handoff",
|
||||
"operation": "append",
|
||||
"content": "Child progress: parser tests pass\n"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("child should append shared note");
|
||||
|
||||
let search_output = ReflectionsSearchSharedNotesHandler
|
||||
.handle(invocation(
|
||||
parent_session,
|
||||
parent_turn,
|
||||
"reflections_search_shared_notes",
|
||||
function_payload(json!({
|
||||
"query": "parser tests pass",
|
||||
"start": 1,
|
||||
"stop": 20
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("parent should search shared notes");
|
||||
let (content, _) = expect_text_output(search_output);
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&content).expect("search output should be json");
|
||||
assert_eq!(value["results"][0]["kind"], "shared_note");
|
||||
assert_eq!(
|
||||
value["results"][0]["read"]["tool"],
|
||||
"reflections_read_shared_note"
|
||||
);
|
||||
}
|
||||
|
||||
fn thread_manager() -> ThreadManager {
|
||||
ThreadManager::with_models_provider_for_tests(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
|
||||
@@ -6,11 +6,15 @@ 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_SHARED_NOTES_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_LIST_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
@@ -47,6 +51,26 @@ struct WriteNoteArgs {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SharedListArgs {
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SharedReadArgs {
|
||||
note_id: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SharedSearchArgs {
|
||||
query: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct ReflectionsNewContextWindowHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsNewContextWindowHandler {
|
||||
@@ -238,6 +262,106 @@ impl ToolHandler for ReflectionsWriteNoteHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsListSharedNotesHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsListSharedNotesHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: SharedListArgs =
|
||||
parse_function_args(&invocation, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME)?;
|
||||
let shared_notes_path = shared_notes_path(&invocation)?;
|
||||
let output =
|
||||
crate::reflections::list_shared_notes(&shared_notes_path, args.start, args.stop)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsReadSharedNoteHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsReadSharedNoteHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: SharedReadArgs =
|
||||
parse_function_args(&invocation, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME)?;
|
||||
let shared_notes_path = shared_notes_path(&invocation)?;
|
||||
let output = crate::reflections::read_shared_note(
|
||||
&shared_notes_path,
|
||||
&args.note_id,
|
||||
args.start,
|
||||
args.stop,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsSearchSharedNotesHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsSearchSharedNotesHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: SharedSearchArgs =
|
||||
parse_function_args(&invocation, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME)?;
|
||||
let shared_notes_path = shared_notes_path(&invocation)?;
|
||||
let output = crate::reflections::search_shared_notes(
|
||||
&shared_notes_path,
|
||||
&args.query,
|
||||
args.start,
|
||||
args.stop,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsWriteSharedNoteHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsWriteSharedNoteHandler {
|
||||
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_SHARED_NOTE_TOOL_NAME)?;
|
||||
let shared_notes_path = shared_notes_path(&invocation)?;
|
||||
let output = crate::reflections::write_shared_note(
|
||||
&shared_notes_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,
|
||||
@@ -280,6 +404,19 @@ async fn reflections_paths(
|
||||
Ok((sidecar_path, rollout_path))
|
||||
}
|
||||
|
||||
fn shared_notes_path(invocation: &ToolInvocation) -> Result<PathBuf, FunctionCallError> {
|
||||
invocation
|
||||
.turn
|
||||
.reflections_shared_notes_path
|
||||
.as_ref()
|
||||
.map(|path| path.as_path().to_path_buf())
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"Shared Reflections notes are unavailable in this thread.".to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn storage_error(err: crate::reflections::StorageToolError) -> FunctionCallError {
|
||||
FunctionCallError::RespondToModel(err.to_string())
|
||||
}
|
||||
|
||||
@@ -74,10 +74,14 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::ReflectionsGetContextRemainingHandler;
|
||||
use crate::tools::handlers::ReflectionsListHandler;
|
||||
use crate::tools::handlers::ReflectionsListSharedNotesHandler;
|
||||
use crate::tools::handlers::ReflectionsNewContextWindowHandler;
|
||||
use crate::tools::handlers::ReflectionsReadHandler;
|
||||
use crate::tools::handlers::ReflectionsReadSharedNoteHandler;
|
||||
use crate::tools::handlers::ReflectionsSearchHandler;
|
||||
use crate::tools::handlers::ReflectionsSearchSharedNotesHandler;
|
||||
use crate::tools::handlers::ReflectionsWriteNoteHandler;
|
||||
use crate::tools::handlers::ReflectionsWriteSharedNoteHandler;
|
||||
use crate::tools::handlers::RequestPermissionsHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
@@ -221,6 +225,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
ToolHandlerKind::ReflectionsList => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsListHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsListSharedNotes => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsListSharedNotesHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsNewContextWindow => {
|
||||
builder
|
||||
.register_handler(handler.name, Arc::new(ReflectionsNewContextWindowHandler));
|
||||
@@ -228,9 +235,19 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
ToolHandlerKind::ReflectionsRead => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsReadHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsReadSharedNote => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsReadSharedNoteHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsSearch => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsSearchHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsSearchSharedNotes => {
|
||||
builder
|
||||
.register_handler(handler.name, Arc::new(ReflectionsSearchSharedNotesHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsWriteSharedNote => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsWriteSharedNoteHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsWriteNote => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsWriteNoteHandler));
|
||||
}
|
||||
|
||||
@@ -31,4 +31,6 @@ pub struct ReflectionsConfigToml {
|
||||
pub usage_hint_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub storage_tools_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shared_notes_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -367,6 +367,7 @@ fn reflections_feature_config_deserializes_table_without_feature_toggle() {
|
||||
usage_hint_enabled = false
|
||||
usage_hint_text = "Custom recovery guidance."
|
||||
storage_tools_enabled = false
|
||||
shared_notes_enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("features table should deserialize");
|
||||
@@ -378,6 +379,7 @@ storage_tools_enabled = false
|
||||
usage_hint_enabled: Some(false),
|
||||
usage_hint_text: Some("Custom recovery guidance.".to_string()),
|
||||
storage_tools_enabled: Some(false),
|
||||
shared_notes_enabled: Some(false),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,17 +76,25 @@ 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_SHARED_NOTES_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_SHARED_NOTE_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_READ_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME;
|
||||
pub use reflections_tool::create_reflections_get_context_remaining_tool;
|
||||
pub use reflections_tool::create_reflections_list_shared_notes_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_shared_note_tool;
|
||||
pub use reflections_tool::create_reflections_read_tool;
|
||||
pub use reflections_tool::create_reflections_search_shared_notes_tool;
|
||||
pub use reflections_tool::create_reflections_search_tool;
|
||||
pub use reflections_tool::create_reflections_write_note_tool;
|
||||
pub use reflections_tool::create_reflections_write_shared_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;
|
||||
|
||||
@@ -10,6 +10,10 @@ 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 const REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME: &str = "reflections_list_shared_notes";
|
||||
pub const REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME: &str = "reflections_read_shared_note";
|
||||
pub const REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME: &str = "reflections_search_shared_notes";
|
||||
pub const REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME: &str = "reflections_write_shared_note";
|
||||
|
||||
pub fn create_reflections_new_context_window_tool(
|
||||
usage_hint: Option<&str>,
|
||||
@@ -210,6 +214,138 @@ pub fn create_reflections_write_note_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_list_shared_notes_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME.to_string(),
|
||||
description: "Lists shared Reflections notes visible to agents in the same agent tree using backend-neutral note IDs. Use this to discover coordination notes before reading them.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"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::new(),
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_read_shared_note_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME.to_string(),
|
||||
description: "Reads a shared Reflections note by explicit note ID. Shared notes are for coordination across agents in the same agent tree. The `latest` alias is not supported.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"note_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Shared note slug matching ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ with no `..`."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start line. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop line. Defaults to 1000 and returns at most 1000 lines."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["note_id"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_search_shared_notes_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME.to_string(),
|
||||
description: "Searches shared Reflections notes visible to agents in the same agent tree. Search results include a one-call locator for `reflections_read_shared_note`.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"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(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["query"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_write_shared_note_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME.to_string(),
|
||||
description: "Creates, appends to, or replaces a shared Reflections note visible to agents in the same agent tree. Shared notes are for coordination state that other agents should see. 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(
|
||||
"Shared 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 shared note.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"content".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"UTF-8 note content. The individual write and final shared 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())
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ pub struct ToolsConfig {
|
||||
pub reflections: bool,
|
||||
pub reflections_usage_hint_text: Option<String>,
|
||||
pub reflections_storage_tools: bool,
|
||||
pub reflections_shared_notes: bool,
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub spawn_agent_usage_hint: bool,
|
||||
pub spawn_agent_usage_hint_text: Option<String>,
|
||||
@@ -231,6 +232,7 @@ impl ToolsConfig {
|
||||
reflections: false,
|
||||
reflections_usage_hint_text: None,
|
||||
reflections_storage_tools: true,
|
||||
reflections_shared_notes: true,
|
||||
hide_spawn_agent_metadata: false,
|
||||
spawn_agent_usage_hint: true,
|
||||
spawn_agent_usage_hint_text: None,
|
||||
@@ -265,10 +267,12 @@ impl ToolsConfig {
|
||||
reflections: bool,
|
||||
reflections_usage_hint_text: Option<String>,
|
||||
reflections_storage_tools: bool,
|
||||
reflections_shared_notes: bool,
|
||||
) -> Self {
|
||||
self.reflections = reflections;
|
||||
self.reflections_usage_hint_text = reflections_usage_hint_text;
|
||||
self.reflections_storage_tools = reflections_storage_tools;
|
||||
self.reflections_shared_notes = reflections_shared_notes;
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use crate::CommandToolOptions;
|
||||
use crate::REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME;
|
||||
use crate::REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME;
|
||||
use crate::REFLECTIONS_LIST_TOOL_NAME;
|
||||
use crate::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
use crate::REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME;
|
||||
use crate::REFLECTIONS_READ_TOOL_NAME;
|
||||
use crate::REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME;
|
||||
use crate::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
use crate::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
use crate::REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME;
|
||||
use crate::REQUEST_USER_INPUT_TOOL_NAME;
|
||||
use crate::ShellToolOptions;
|
||||
use crate::SpawnAgentToolOptions;
|
||||
@@ -40,11 +44,15 @@ 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_shared_notes_tool;
|
||||
use crate::create_reflections_list_tool;
|
||||
use crate::create_reflections_new_context_window_tool;
|
||||
use crate::create_reflections_read_shared_note_tool;
|
||||
use crate::create_reflections_read_tool;
|
||||
use crate::create_reflections_search_shared_notes_tool;
|
||||
use crate::create_reflections_search_tool;
|
||||
use crate::create_reflections_write_note_tool;
|
||||
use crate::create_reflections_write_shared_note_tool;
|
||||
use crate::create_report_agent_job_result_tool;
|
||||
use crate::create_request_permissions_tool;
|
||||
use crate::create_request_user_input_tool;
|
||||
@@ -313,6 +321,45 @@ pub fn build_tool_registry_plan(
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsWriteNote,
|
||||
);
|
||||
|
||||
if config.reflections_shared_notes {
|
||||
plan.push_spec(
|
||||
create_reflections_list_shared_notes_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_read_shared_note_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_search_shared_notes_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_write_shared_note_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsListSharedNotes,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsReadSharedNote,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsSearchSharedNotes,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsWriteSharedNote,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -636,7 +636,8 @@ fn request_permissions_tool_is_independent_from_additional_permissions() {
|
||||
#[test]
|
||||
fn reflections_tools_require_config_flag() {
|
||||
let model_info = model_info();
|
||||
let features = Features::with_defaults();
|
||||
let mut features = Features::with_defaults();
|
||||
features.disable(Feature::Collab);
|
||||
let available_models = Vec::new();
|
||||
let default_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -660,11 +661,16 @@ fn reflections_tools_require_config_flag() {
|
||||
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_lacks_tool_name(&tools, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME);
|
||||
|
||||
let reflections_tools_config = default_tools_config.clone().with_reflections(
|
||||
true,
|
||||
Some("Custom Reflections hint.".to_string()),
|
||||
/*reflections_storage_tools*/ true,
|
||||
/*reflections_shared_notes*/ false,
|
||||
);
|
||||
let (tools, handlers) = build_specs(
|
||||
&reflections_tools_config,
|
||||
@@ -683,6 +689,10 @@ fn reflections_tools_require_config_flag() {
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME,
|
||||
],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME);
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsNewContextWindow,
|
||||
@@ -718,6 +728,7 @@ fn reflections_tools_require_config_flag() {
|
||||
true,
|
||||
Some("Custom Reflections hint.".to_string()),
|
||||
/*reflections_storage_tools*/ false,
|
||||
/*reflections_shared_notes*/ true,
|
||||
);
|
||||
let (tools, handlers) = build_specs(
|
||||
&path_based_reflections_tools_config,
|
||||
@@ -750,6 +761,121 @@ fn reflections_tools_require_config_flag() {
|
||||
assert!(description.contains("under the Reflections notes directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflections_shared_note_tools_require_reflections_storage_and_availability() {
|
||||
let model_info = model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Collab);
|
||||
let available_models = Vec::new();
|
||||
let default_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
|
||||
let disabled_by_storage = default_tools_config.clone().with_reflections(
|
||||
true, None, /*reflections_storage_tools*/ false,
|
||||
/*reflections_shared_notes*/ true,
|
||||
);
|
||||
let (tools, _) = build_specs(
|
||||
&disabled_by_storage,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME);
|
||||
|
||||
let disabled_by_config = default_tools_config.clone().with_reflections(
|
||||
true, None, /*reflections_storage_tools*/ true,
|
||||
/*reflections_shared_notes*/ false,
|
||||
);
|
||||
let (tools, _) = build_specs(
|
||||
&disabled_by_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME);
|
||||
|
||||
let mut no_collab_features = Features::with_defaults();
|
||||
no_collab_features.disable(Feature::Collab);
|
||||
let no_collab_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &no_collab_features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.with_reflections(
|
||||
true, None, /*reflections_storage_tools*/ true, /*reflections_shared_notes*/ true,
|
||||
);
|
||||
let (tools, _) = build_specs(
|
||||
&no_collab_tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, "spawn_agent");
|
||||
assert_contains_tool_names(
|
||||
&tools,
|
||||
&[
|
||||
REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME,
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME,
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME,
|
||||
REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME,
|
||||
],
|
||||
);
|
||||
|
||||
let enabled = default_tools_config.with_reflections(
|
||||
true, None, /*reflections_storage_tools*/ true, /*reflections_shared_notes*/ true,
|
||||
);
|
||||
let (tools, handlers) = build_specs(
|
||||
&enabled,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_contains_tool_names(
|
||||
&tools,
|
||||
&[
|
||||
REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME,
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME,
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME,
|
||||
REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME,
|
||||
],
|
||||
);
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsListSharedNotes,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsReadSharedNote,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsSearchSharedNotes,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsWriteSharedNote,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_requires_feature_flag() {
|
||||
let model_info = model_info();
|
||||
|
||||
@@ -28,9 +28,13 @@ pub enum ToolHandlerKind {
|
||||
Plan,
|
||||
ReflectionsGetContextRemaining,
|
||||
ReflectionsList,
|
||||
ReflectionsListSharedNotes,
|
||||
ReflectionsNewContextWindow,
|
||||
ReflectionsRead,
|
||||
ReflectionsReadSharedNote,
|
||||
ReflectionsSearch,
|
||||
ReflectionsSearchSharedNotes,
|
||||
ReflectionsWriteSharedNote,
|
||||
ReflectionsWriteNote,
|
||||
RequestPermissions,
|
||||
RequestUserInput,
|
||||
|
||||
Reference in New Issue
Block a user