Complete run capture, prose tool results, forced-submit notice, tool result renderer - #133
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
:infinityImp.Run.Control.init/1demanded positive integers for:max_events,:max_event_bytesand: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/2returns the event untouched (it is not even measured) andbound_snapshot/1never evicts. Integer behaviour and the defaults (64 KiB / 512 events / 4 MiB) are unchanged. Moduledoc anddocs/TRAJECTORIES.mdupdated.Imp.Redaction.redact/1andImp.Run.Event.to_map/1were checked for a second cap: neither truncates.Imp.Observability.Inspection.compact/2does, but it is only reached fromInspection.new/5, not fromto_map/1.2. Failed submit and structured reasons render as sentences
Imp.Adapter.Chat.format_tool_result/1exists so the model reads words, but the three submit rejections fell through toinspect/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:reason/"reason"key → the humanized reason, plus(limit N)when a:limitis presentExisting 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: submitand 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. Defaultnilis a no-op, so the forced request is unchanged for every existing caller.omit_empty_requeststill drops the blank trailing request.4.
tool_result_rendereronImp.Adapter.ChatA 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 isformat_tool_result/1, i.e. today's prose. ReActV2 already passesadapter_optsthrough toChat.format/3, soadapter_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 inImp.Run.events/1with all three bounds:infinity; an integer:max_event_bytesstill truncates while:max_eventsis:infinity;0,-1and:unboundedare still rejected.:infinityis passed throughImp.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— withImp.LM.Staticcapturing messages andmax_iters: 1: the notice is the last user message of the forced request (identified bytool_choice), the reason:max_itersreaches the function, the notice turn is in the returned:history, a string works, anil-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 undertool_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
checkandstranger.checkwith a different set of task-admission tests failing each attempt (TaskSupervisionTest,Imp.ComposedStreamingTest, GEPA parallel proposal), mostlyExUnit.TimeoutErrorafter 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/3admits its task throughImp.Tasks.Admission.Imp.Run.stop/1is 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 withstop/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 setasync_max_workers: 1or2and assert exactadmission_status/0, soreserve!/1— which calls with an:infinitytimeout — 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/3terminates the task and releases the lease. The task-death test keepsstop/1, which is correct there because its run has completed.No library change:
stop/1behaves as documented, and Dwell already routes every non-completion path (spend cap, expiry, owner stop) throughImp.Run.cancel, reachingstop/1only on the completed-turn path.TaskSupervisionTestgains one test asserting admission returns to%{active: 0, queued: 0}aftercancel/3, so the leak cannot return silently. It is there rather than inrun_observation_test.exsbecause that file isasync: trueand an exact global admission assertion is only sound in anasync: falsemodule. Reverting that test tostop/1makes 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.mix test --raisewith check's exclusions) across seeds 1, 2 and 3: 2832 tests, 0 failures each time.run_observation_test.exsplus the three admission test files in one run — fails 3/3 before the fix (5, 6 and 3 failures) and passes 3/3 after.mainata970368cin a separate worktree: 2815 tests, 0 failures — so nothing here regressed.mix docs.check(ExDoc withwarnings_as_errors+ livebooks): clean, 5 livebooks pass.mix dialyzer.check: passes. One line-pinned ignore entry forchat.exmoved with the code (730 → 742); same warning, same reason, its own commit.mix credo --only warning: no issues.mix imp.public_apiregenerated: the only manifest change is the newforced_submit_noticestruct field onImp.Predict.ReActV2.Not verified
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.checkand the pinned-DSPymix differential.checkwere not run locally (they need environments this machine does not provision); CI runs them.:tool_result_rendererand:forced_submit_noticethere is a separate change.mix testwithout check's exclusions has 18-19 failures across seeds, all indspy_parityand benchmark-truth modules thatmix checkand CI exclude. They are untouched by this PR and were not investigated here.:infinityon all three bounds holds every event in memory, and nothing here caps that.