Skip to content

Complete run capture, prose tool results, forced-submit notice, tool result renderer - #133

Merged
deepfates merged 7 commits into
mainfrom
claude/record-and-prose
Sep 18, 2026
Merged

deepfates merged 7 commits into
mainfrom
claude/record-and-prose

Conversation

@deepfates

@deepfates deepfates commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Four changes a host (Dwell) needs so that the record of a run is complete and what the model reads is plain prose. Each is opt-in; defaults are unchanged.

1. Run capture bounds accept :infinity

Imp.Run.Control.init/1 demanded positive integers for :max_events, :max_event_bytes and :max_snapshot_bytes, so a host whose record must be complete had no way to say so: a large tool result was erased to a digest before the sink ever saw it. Each now also takes :infinity, meaning no bound — with it, bound_event/2 returns the event untouched (it is not even measured) and bound_snapshot/1 never evicts. Integer behaviour and the defaults (64 KiB / 512 events / 4 MiB) are unchanged. Moduledoc and docs/TRAJECTORIES.md updated.

Imp.Redaction.redact/1 and Imp.Run.Event.to_map/1 were checked for a second cap: neither truncates. Imp.Observability.Inspection.compact/2 does, but it is only reached from Inspection.new/5, not from to_map/1.

2. Failed submit and structured reasons render as sentences

Imp.Adapter.Chat.format_tool_result/1 exists so the model reads words, but the three submit rejections fell through to inspect/2. New clauses:

  • {:missing_output_fields, names}submit is missing: answer, note
  • {:invalid_submit_outputs, reason}submit outputs were not accepted: <prose>
  • {:invalid_submit_arguments, _}submit needs a map of outputs
  • a non-struct map with a :reason / "reason" key → the humanized reason, plus (limit N) when a :limit is present

Existing clauses are untouched; a map with no reason key still falls back to inspect/2.

3. Forced-submit notice

ReActV2's forced submit re-asks with tool_choice: submit and no message, so the model is made to finish without being told that it ran out of turns or that its last request failed. New option :forced_submit_notice — a string, or a 1-arity function of the termination reason (:max_iters, :empty_tool_calls, :prediction_error, …) — appends one history turn setting the signature's first task input to that text. It renders as the last user message of the forced request and stays in the returned prediction's :history, because the record has to hold what the model was told. Default nil is a no-op, so the forced request is unchanged for every existing caller. omit_empty_request still drops the blank trailing request.

4. tool_result_renderer on Imp.Adapter.Chat

A host that must bound what the model reads of a large tool result had only one place to do it: wrapping the tool's run function — which made the loop record the cut value and lose the real one. Bounding belongs in rendering. New format option :tool_result_renderer, a 2-arity (result, %{id:, name:}) -> String.t(), is used at the one site where a tool result becomes message content (render_native_tool_history_turn/3). History and run events keep the whole result; only the prompt carries the bounded view. Errors reach the renderer too, so the host decides how a failure reads. The default is format_tool_result/1, i.e. today's prose. ReActV2 already passes adapter_opts through to Chat.format/3, so adapter_opts: [tool_result_renderer: ...] works without further plumbing.

Tests that falsify this

  • test/run_observation_test.exs — a 1 MB tool result reaches the sink whole and appears whole in Imp.Run.events/1 with all three bounds :infinity; an integer :max_event_bytes still truncates while :max_events is :infinity; 0, -1 and :unbounded are still rejected. :infinity is passed through Imp.Run.start/3's top-level opts.
  • test/adapter_chat_format_value_test.exs — one assertion per new rendering, plus a map with no reason key and the pre-existing clauses.
  • test/react_v2_forced_submit_notice_test.exs — with Imp.LM.Static capturing messages and max_iters: 1: the notice is the last user message of the forced request (identified by tool_choice), the reason :max_iters reaches the function, the notice turn is in the returned :history, a string works, a nil-returning function and an absent option leave the forced request byte-for-byte as before, and a bad value raises.
  • test/adapter_chat_tool_result_renderer_test.exs — a ReActV2 run whose tool returns 10 KB: the tool message in the second request is the short rendered string while the prediction's history still carries the full 10 KB under tool_call_results; the default renderer is unchanged; errors go through the renderer; a non-2-arity value raises.

Each implementation hunk was disabled in turn and the corresponding tests were confirmed to fail.

CI failure on the first two attempts, and the fix

The first run was red on check and stranger.check with a different set of task-admission tests failing each attempt (TaskSupervisionTest, Imp.ComposedStreamingTest, GEPA parallel proposal), mostly ExUnit.TimeoutError after 60s. That looked like runner flake. It was not: it reproduces deterministically, and the cause is in this PR's own tests.

Imp.Run.start/3 admits its task through Imp.Tasks.Admission. Imp.Run.stop/1 is documented as "Releases the event/cancellation control process after a run completes" — it releases the control process only. Two of the new :infinity-capture tests ran a program that never returns (Process.sleep(:infinity)) and ended with stop/1, so each left its task alive holding an admission lease: two of the eight default async workers leaked into the rest of the suite. The admission tests then set async_max_workers: 1 or 2 and assert exact admission_status/0, so reserve!/1 — which calls with an :infinity timeout — blocked forever. Admission is global, so which test lost varied per run.

Both tests now use cancel/3, like the rest of the file; cancel_with_events/3 terminates the task and releases the lease. The task-death test keeps stop/1, which is correct there because its run has completed.

No library change: stop/1 behaves as documented, and Dwell already routes every non-completion path (spend cap, expiry, owner stop) through Imp.Run.cancel, reaching stop/1 only on the completed-turn path.

TaskSupervisionTest gains one test asserting admission returns to %{active: 0, queued: 0} after cancel/3, so the leak cannot return silently. It is there rather than in run_observation_test.exs because that file is async: true and an exact global admission assertion is only sound in an async: false module. Reverting that test to stop/1 makes it fail, so it does falsify.

Verified

  • mix check (format, compile --warnings-as-errors, test): 2832 tests, 0 failures, 13 skipped, exit 0. Run twice.
  • The CI-equivalent suite (mix test --raise with check's exclusions) across seeds 1, 2 and 3: 2832 tests, 0 failures each time.
  • The exact combination that reproduced the CI failure — run_observation_test.exs plus the three admission test files in one run — fails 3/3 before the fix (5, 6 and 3 failures) and passes 3/3 after.
  • Same suite on main at a970368c in a separate worktree: 2815 tests, 0 failures — so nothing here regressed.
  • mix docs.check (ExDoc with warnings_as_errors + livebooks): clean, 5 livebooks pass.
  • mix dialyzer.check: passes. One line-pinned ignore entry for chat.ex moved with the code (730 → 742); same warning, same reason, its own commit.
  • mix credo --only warning: no issues.
  • mix imp.public_api regenerated: the only manifest change is the new forced_submit_notice struct field on Imp.Predict.ReActV2.

Not verified

  • No live provider run. Everything here is exercised with Imp.LM.Static; no request was made against a real model, so the prompt shapes are asserted against the messages Imp builds, not against provider behaviour.
  • mix integration.check, mix protocol.check, mix package.check and the pinned-DSPy mix differential.check were not run locally (they need environments this machine does not provision); CI runs them.
  • No host-side change: Dwell still bounds tool results by wrapping run functions. Adopting :tool_result_renderer and :forced_submit_notice there is a separate change.
  • Full mix test without check's exclusions has 18-19 failures across seeds, all in dspy_parity and benchmark-truth modules that mix check and CI exclude. They are untouched by this PR and were not investigated here.
  • The unbounded-capture path was measured at 1 MB in one event, not under sustained load; a run with :infinity on all three bounds holds every event in memory, and nothing here caps that.

A host whose record of a run must be complete could not express that: the
three capture limits demanded positive integers, so a large tool result was
digested before the sink ever saw it. Each now takes :infinity, meaning no
bound -- the event is not measured and nothing is evicted. Integer bounds and
the defaults are unchanged.
format_tool_result/1 exists so the model reads words, but the three submit
rejections and any :reason map fell through to inspect/2 -- the model was
handed {:missing_output_fields, [:answer]} and had to guess. Those now say
what is missing, what was not accepted, and that submit takes a map; a map
reason renders its reason and its limit.
The forced submit re-asks with tool_choice: submit and no message, so the
model is made to finish without being told that it ran out of turns or that
its last request failed. :forced_submit_notice, a string or a function of the
termination reason, appends one history turn carrying that sentence: it
renders as the last user message of the forced request and stays in the
returned history, so the record holds what the model was told. Default nil
leaves the request byte-identical to before.
A host that must bound what the model reads of a large tool result had only
one place to do it: wrapping the tool's run function, which made the loop
record the cut value and lose the real one. :tool_result_renderer moves that
to rendering -- (result, %{id:, name:}) -> String.t() -- so history and run
events keep the whole result and only the prompt carries the bounded view.
Errors reach the renderer too; the default is today's prose.
mix imp.public_api regeneration; forced_submit_notice is part of the struct.
The fetch_meta/3 fallback filter is line-pinned; adding the tool result
renderer moved that clause from 730 to 742, so the gate reported an unused
filter. Same warning, same reason, new line.
Imp.Run.start/3 admits its task through Imp.Tasks.Admission. Imp.Run.stop/1
releases the control process only -- it is documented for a run that has
already completed -- so calling it on a program that never returns leaves the
task alive holding its lease for the life of the node.

The two tests covering :infinity capture bounds ran such a program and ended
with stop/1, leaking two of the eight default async workers into the rest of
the suite. TaskSupervisionTest, ComposedStreamingTest and the GEPA parallel
proposal test then configure async_max_workers: 1 or 2 and assert exact
admission_status/0, so reserve!/1 blocked forever and ExUnit killed them at
60s. Which of them failed varied per run because admission is global, which is
what made this look like a CI timing flake.

Both tests now cancel/3 like the rest of the file. The task-death test keeps
stop/1: its run has completed by then, which is what stop/1 is for.

TaskSupervisionTest gains a test asserting admission returns to
%{active: 0, queued: 0} after cancel/3, so the leak cannot come back silently.
It lives there rather than in run_observation_test.exs because that file is
async: true and an exact global admission assertion is only sound in an
async: false module.
@deepfates
deepfates merged commit 2371dd9 into main Sep 18, 2026
10 checks passed
@deepfates
deepfates deleted the claude/record-and-prose branch September 18, 2026 18:37
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.

1 participant