Summary
Two defects left standing after #6183, both on the memory path and both pre-existing. Filed together because they were found in the same investigation and share one root theme — the orchestrator reaches memory through a delegate, and two places in the code still assume it holds the direct tools.
They are independently fixable; Part A is the higher priority.
Part A — a hanging summariser blocks the user's chat turn for up to 10 minutes
Problem
spawn_session_memory_extraction is .awaited on the turn path (agent/harness/session/turn/core_turn.rs:683), and it awaits flush_open_segment before it spawns anything (session/turn/session_io_impl_01_part_02.rs:294). That awaits on_segment_closed → summarize_entries → the summariser's HTTP call.
The comment directly above the call site says the opposite:
The spawn is fire-and-forget: the main turn returns the user-visible response immediately
It is not fire-and-forget. result is returned only after that await completes.
There is no bound below 600 seconds: DEFAULT_REQUEST_TIMEOUT_SECS = 600 in tinyinference (providers/openai/mod.rs:75), and memory's ChatPrompt carries no timeout_ms to override it — the struct has no timeout field at all (tinymemory-core/src/chat.rs:21).
So a summarise call that connects and then goes silent holds the turn open for up to ten minutes. The assistant's text has already streamed, but the turn never completes: the spinner stays and the next turn is blocked.
Why this is the tail case, not the common one
The confirmed field cause of recap failures is DNS failure, which returns instantly (see the RCA in #6179). A hang needs a connection that establishes and then stalls — a half-open socket after a network transition, or an overloaded upstream. Low frequency, high impact, currently unbounded.
#6183's retry does not compound it. After a 600 s attempt, out_of_time (elapsed + backoff >= MAX_TOTAL_ELAPSED) is already true, so no second attempt starts. Worst case stays one attempt. It does modestly widen the middle band: an attempt failing at 19 s still passes the 20 s gate, so a second can start — roughly 30 s → 50 s where connects time out.
Proposed fix
Two parts; the first is the safe minimum.
- Bound it host-side. Wrap
fold_through_driver(...) in agent/harness/archivist/recap.rs:49 in tokio::time::timeout. This is entirely host-side — no tinymemory contract change, no release, no re-pin — and it bounds the whole retry chain rather than one attempt. A ceiling in the 30–60 s range is far above a healthy summarise and far below 600 s.
- Fix the call site's honesty. Either make the flush genuinely detached (matching the comment) or correct the comment to say the turn awaits it. Detaching would drop the guarantee documented at
session_io_impl_01_part_02.rs:283-288 that the trailing segment always receives its recap, so this is a real decision rather than a cleanup — and it is why it is listed second.
Acceptance criteria
Part B — the memory write rule is dropped for an agent that reaches memory by delegation
Problem
#6183 fixed the read half of this: MEMORY_READ_TOOLS listed only memory_recall / memory_search, so MemoryAccessSection was dropped for an orchestrator whose memory arrives as retrieve_memory (the delegate synthesised from the memory sub-agent's delegate_name). Adding the delegate to that list fixed it.
The write half was left in place, and the same state proves it is live. From the orchestrator's visible tool set in a failing run:
[... load_skill, make_presentation, manage_profile_memory, manage_settings,
manage_tasks, plan, research, retrieve_memory, ...]
manage_profile_memory is present. memory_store and save_preference are not. MemoryWriteSection gates on those two individually (session/builder/helpers.rs:139-140), so it is dropped — the model is given a write path and no rule about using it. That is the behaviour #6048 was filed for: "got it, saved" with zero tool calls.
Why this is not the same one-line change
MemoryAccessSection is on/off — the tool list only decides whether a fixed section appears. The write section names the tool the model must call:
let route = match (preferences, facts) {
(true, true) => "— `save_preference` for preferences, `memory_store` for everything else",
(true, false) => "with `save_preference`",
(false, true) => "with `memory_store`",
(false, false) => return String::new(),
};
Setting either existing boolean for the delegate would tell the model to call memory_store — a tool it does not hold — which teaches exactly the apology behaviour any_tool_offered's own doc warns about.
Proposed fix
Add a route that names the delegate, rather than reusing an existing arm:
- when a direct write tool is visible → unchanged, so no existing agent's prompt moves;
- when only
manage_profile_memory is visible → a new arm naming it, e.g. "write it before you confirm with manage_profile_memory".
The section's hard promise survives delegation, and this was checked rather than assumed. The instruction says "Never say saved, noted, or remembered unless that write succeeded in this turn." profile_memory_agent is a synchronous worker-tier sub-agent (agent_tier = "worker", no background flag) and its [tools] named list holds both memory_store and save_preference — so a delegated write reaches the same tools and completes inside the parent's turn.
Acceptance criteria
Related
Summary
Two defects left standing after #6183, both on the memory path and both pre-existing. Filed together because they were found in the same investigation and share one root theme — the orchestrator reaches memory through a delegate, and two places in the code still assume it holds the direct tools.
They are independently fixable; Part A is the higher priority.
Part A — a hanging summariser blocks the user's chat turn for up to 10 minutes
Problem
spawn_session_memory_extractionis.awaited on the turn path (agent/harness/session/turn/core_turn.rs:683), and it awaitsflush_open_segmentbefore it spawns anything (session/turn/session_io_impl_01_part_02.rs:294). That awaitson_segment_closed→summarize_entries→ the summariser's HTTP call.The comment directly above the call site says the opposite:
It is not fire-and-forget.
resultis returned only after that await completes.There is no bound below 600 seconds:
DEFAULT_REQUEST_TIMEOUT_SECS = 600in tinyinference (providers/openai/mod.rs:75), and memory'sChatPromptcarries notimeout_msto override it — the struct has no timeout field at all (tinymemory-core/src/chat.rs:21).So a summarise call that connects and then goes silent holds the turn open for up to ten minutes. The assistant's text has already streamed, but the turn never completes: the spinner stays and the next turn is blocked.
Why this is the tail case, not the common one
The confirmed field cause of recap failures is DNS failure, which returns instantly (see the RCA in #6179). A hang needs a connection that establishes and then stalls — a half-open socket after a network transition, or an overloaded upstream. Low frequency, high impact, currently unbounded.
#6183's retry does not compound it. After a 600 s attempt,
out_of_time(elapsed + backoff >= MAX_TOTAL_ELAPSED) is already true, so no second attempt starts. Worst case stays one attempt. It does modestly widen the middle band: an attempt failing at 19 s still passes the 20 s gate, so a second can start — roughly 30 s → 50 s where connects time out.Proposed fix
Two parts; the first is the safe minimum.
fold_through_driver(...)inagent/harness/archivist/recap.rs:49intokio::time::timeout. This is entirely host-side — notinymemorycontract change, no release, no re-pin — and it bounds the whole retry chain rather than one attempt. A ceiling in the 30–60 s range is far above a healthy summarise and far below 600 s.session_io_impl_01_part_02.rs:283-288that the trailing segment always receives its recap, so this is a real decision rather than a cleanup — and it is why it is listed second.Acceptance criteria
Part B — the memory write rule is dropped for an agent that reaches memory by delegation
Problem
#6183 fixed the read half of this:
MEMORY_READ_TOOLSlisted onlymemory_recall/memory_search, soMemoryAccessSectionwas dropped for an orchestrator whose memory arrives asretrieve_memory(the delegate synthesised from the memory sub-agent'sdelegate_name). Adding the delegate to that list fixed it.The write half was left in place, and the same state proves it is live. From the orchestrator's visible tool set in a failing run:
manage_profile_memoryis present.memory_storeandsave_preferenceare not.MemoryWriteSectiongates on those two individually (session/builder/helpers.rs:139-140), so it is dropped — the model is given a write path and no rule about using it. That is the behaviour #6048 was filed for: "got it, saved" with zero tool calls.Why this is not the same one-line change
MemoryAccessSectionis on/off — the tool list only decides whether a fixed section appears. The write section names the tool the model must call:Setting either existing boolean for the delegate would tell the model to call
memory_store— a tool it does not hold — which teaches exactly the apology behaviourany_tool_offered's own doc warns about.Proposed fix
Add a route that names the delegate, rather than reusing an existing arm:
manage_profile_memoryis visible → a new arm naming it, e.g. "write it before you confirm withmanage_profile_memory".The section's hard promise survives delegation, and this was checked rather than assumed. The instruction says "Never say saved, noted, or remembered unless that write succeeded in this turn."
profile_memory_agentis a synchronousworker-tier sub-agent (agent_tier = "worker", no background flag) and its[tools] namedlist holds bothmemory_storeandsave_preference— so a delegated write reaches the same tools and completes inside the parent's turn.Acceptance criteria
manage_profile_memory.memory_storewhen only the delegate is visible.Related