Skip to content

Summariser can block a chat turn for 10 minutes, and the memory write rule is dropped for delegated agents #6200

Description

@YellowSnnowmann

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_closedsummarize_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.

  1. 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.
  2. 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

  • Bounded — a summariser that never responds cannot hold a chat turn open for more than the chosen ceiling.
  • The comment matches the code — either the await is gone, or the comment no longer claims the turn returns immediately.
  • The trailing-segment guarantee is preserved or explicitly revised, not silently dropped.
  • Regression test — a summariser double that never returns; assert the turn path completes within the ceiling.
  • Diff coverage ≥ 80%.

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

  • The write rule appears for an agent whose only write path is manage_profile_memory.
  • It names a tool that agent actually holds — never memory_store when only the delegate is visible.
  • No prompt change for agents holding the direct tools.
  • Symmetry test with the read side, so the two cannot drift apart again.
  • Diff coverage ≥ 80%.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugmemoryMemory store, memory tree, recall, summarization, and embeddings in src/openhuman/memory/.

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions