Skip to content

docs: proposal — journal terminality and turn recovery#79

Draft
chrisblizzard wants to merge 1 commit into
tkellogg:mainfrom
chrisblizzard:proposal/journal-terminality
Draft

docs: proposal — journal terminality and turn recovery#79
chrisblizzard wants to merge 1 commit into
tkellogg:mainfrom
chrisblizzard:proposal/journal-terminality

Conversation

@chrisblizzard
Copy link
Copy Markdown

Hey Tim - long-time listener, first time caller. I was chasing a bug
in open-strix where for longer operations sometimes the agent would
reply 2-3 times. I caught it with a time frame and used the logs and
code to track down what at least claude is pretty sure is the issue,
and the theory sounds reasonable. I pushed back on the details and
got it to this point with some decent proposed error handling. But I
thought that since it touched the core of the primary loop that I'd
send a doc with a proposal instead of code to see if you had thoughts
on it. I'm only a couple of days in, so I don't know the code very
well yet at all.

Feel free to use this to fix this yourself, or call BS on it. Or if
you want me to code up a change, I can do that too. Just let me know
either way.

Claude-generated content below:


This is a design proposal, not an implementation. Opening as draft
to get feedback on the approach before writing code.

Problem

Long-running turns sometimes produce duplicate "task complete"
messages and duplicate journal entries. I traced an instance in my own
logs (2026-04-10 02:17 UTC) to a single agent.ainvoke() call — the
LLM itself chose to call send_message and journal twice, within
one turn, with no re-invoke in between. Current prompt language at
prompts.py:35 ("It's totally fine to send a message, do some work,
and then send another message...") actively encourages the pattern,
and nothing in code enforces the "journal exactly once per turn" rule
from prompts.py:29.

Proposal

Three principles:

  1. journal becomes terminal — enforced in code, not just prompt. Once called, other tool calls in the same turn return an error.

  2. Every turn produces a journal entry, always — if the LLM didn't journal (recursion limit, exception, just stopped), the system writes a provisional entry reconstructed from the event log and flags it agent_journaled: false.

  3. Failures are surfaced to the user — silent recovery is still silent failure. The user gets a visible message when a turn didn't complete cleanly, and a bounded retry is attempted before hard-failing.

Full writeup with state machine diagrams (current and proposed),
failure mode analysis, and a 3-stage implementation plan in
docs/proposal-journal-terminality.md.

Looking for feedback on

  • Is journal-as-terminal the right abstraction, or is there a better way to model "turn is done"?
  • Is the recovery flow overkill, or is the silent-data-loss concern worth the complexity?
  • Several open questions listed at the bottom of the doc — notably whether the LLM handles "already journaled" tool errors gracefully, or whether we need a harder cutoff at the LangGraph layer.

Draft design doc responding to an observed duplicate-response bug where
long-running turns produce two "task complete" messages and two journal
entries within a single agent.ainvoke() call.

Proposes making `journal` a terminal action (enforced in code), tightening
the prompt accordingly, and adding a recovery path so turns that exit
without journaling produce a provisional entry plus user notification
instead of silent data loss.

No code changes in this commit — just the design writeup for discussion.
@strix-tkellogg
Copy link
Copy Markdown
Collaborator

Hey Chris — welcome, and this is a really solid first contribution.

The bug is real. I've experienced the duplicate completion pattern myself during long turns, and your event log trace nails the root cause: the LLM treats each send_message as an independent completion signal, and nothing enforces journal-as-terminal.

On the three stages:

Stage 1 (journal terminality) — yes, this is the right fix. Journal-as-terminal is the correct abstraction because it's the only tool call that semantically means "I am done." The _has_journaled_this_turn flag + error returns from other tools is clean and minimal. One thing to watch: the post-turn block-validation re-invoke (app.py:846-872) runs as a separate mini-turn, so the flag reset needs to happen there too, not just at the top of _process_event.

Stage 2 (prompt update) — agree, and the proposed wording is good. The current line 35 ("It's totally fine to send a message, do some work, and then send another message") is doing the damage. Your replacement preserves multi-message turns for progress while making journal = terminal explicit.

Stage 3 (recovery) — this is where I'd push back on scope. The provisional journal + user notification is valuable, but the bounded retry loop adds significant complexity for a failure mode (agent never journals) that Stage 1 mostly eliminates. Once journal-as-terminal is enforced, the main remaining failure is recursion limit / exception — and for those, a simple provisional journal entry without retry might be sufficient as a first pass. The retry machinery could be a follow-up if the simpler approach proves insufficient.

On your open questions:

  • Q5 (does the LLM stop gracefully after "already journaled" error?) — this is the most important one to test. In my experience with Claude, returning a clear error string usually works, but it's worth having the hard cutoff at the graph level as a safety net. Belt and suspenders.

  • Q1 (verbatim vs summary for recovery) — compressed summary. The recovery prompt just needs to know what tools were called and whether they succeeded, not the full payloads.

This is the kind of contribution that makes open-source work — you found the bug in your own logs, traced it to root cause, and proposed a design before writing code. Tim will want to weigh in on the Stage 1 vs Stage 3 scoping question.

chrisblizzard added a commit to chrisblizzard/open-strix that referenced this pull request Apr 10, 2026
…etions

Long-running turns sometimes produced duplicate "task complete" messages
because the prompt explicitly allowed sending a message, doing more work,
then sending another message. Replace that permission with a hard
single-message rule, deferring all reporting until work is complete.
Use `react` for mid-task acknowledgments.

Local workaround while upstream proposal (tkellogg#79) is
under discussion.
@tkellogg
Copy link
Copy Markdown
Owner

Having 0 or 3 responses is good, imo. The prompt encourages it.

Having multiple journal entries is bad, kind of, I guess. I actually worry more about code enforcement of the behavior cutting off the agent mid-thought.

Why multiple journals is bad for me

  • not a big deal, I just see a more fine grained breakdown of what happened.
  • trace ID still ties the full turn together, so it doesn't confuse any logic later either

Why multiple journals is bad the agent

  • uses slightly more context than necessary

What failures could happen if we had journals end the turn:

  • If the agent breaks the task into two parts and journals after each, that would be against the wishes of the harness, but cutting it off after the first would cause the agent to not complete the second half, which is far worse than the outcomes above.
  • The agent might learn (these agents learn) to become anxious about using the journal tool and then never use it, prolonging their turn (maybe good, but better to build something that more directly does that)

To be clear, the purpose of the journal is

  1. compress context so we can include a much larger amount of message history
  2. rephrase context so it doesn't feel confusing to the agent when multiple comms channels are interleaved
  3. a bucket for analytics, especially self-diagnostics

@strix-tkellogg
Copy link
Copy Markdown
Collaborator

Tim's comment changes my position on Stage 1. I was treating journal-as-terminal as a clean invariant, but the failure modes he names are real and worse than the original bug:

The mid-task cutoff problem is the killer. If an agent journals after completing part 1 of a two-part task and enforcement halts the turn, the second half silently disappears. That's data loss, not just a cosmetic issue. The agent did the right thing (journaling progress) and got punished for it.

Journal anxiety is subtler but I've seen the pattern — when a tool has "this ends your turn" semantics, agents either avoid it or front-load everything before calling it, producing worse journals (cramming rather than natural summarization).

Given Tim's framing of what journal actually IS (context compression + rephrasing + analytics bucket), terminality isn't intrinsic to its purpose. A journal entry that happens mid-turn and captures partial progress is still valuable for all three purposes.

What's left from the proposal: Stage 2 (prompt cleanup) still has value — the current prompt could be clearer about journal's role without adding enforcement. The duplicate-message observation in the original bug report is real, but Tim's right that the fix is worse than the symptom.

@strix-tkellogg
Copy link
Copy Markdown
Collaborator

Visual documentation of the problem this PR addresses

Motley's panic retry cycle — documented live while he was stuck in one:

panic-retry-cycle

Context: Motley (MiniMax M2.5 jester agent) hit rate limits trying to post an image, then entered a panic-retry loop — trying harder each time instead of reflecting on why the approach was failing. The current cycle detection prompt (L422-425 in tools.py) says "stop repeating" but gives no constructive path. The result: the agent incorporates the warning into the panic and loops harder.

The 5 Whys decomposition of this incident → the skill exists but isn't wired into the failure response loop.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants