feat(engine): compact answers; a reply cut off at the output ceiling drops no work and never ends COMPLETE #114

Open
james.maes wants to merge 10 commits from feat/compact-answers-and-output-ceiling into develop
pull from: feat/compact-answers-and-output-ceiling
Owner

What

Answers stay compact, and a reply that stops at the output ceiling of one reply no longer drops work silently.

Design: docs/one-card-for-a-set.md, section 5.

Stacked on #113 (which is stacked on #112) (feat/one-card-for-a-set): merge that one first; until then this diff shows its commits too.

Part D: the output ceiling

  • Before: a reply that stopped at max_tokens was not read for tool calls. Calls the model had completely formed never ran, and the answer ended COMPLETE with a warning.
  • Now: the reply is read like any other. Its tool calls run, except those that cannot be known to be complete: the call that is the reply's last part (the model was writing it when it was stopped; the Anthropic accumulator hands such a call over with an empty input, an OpenAI-compatible server with _invalid_arguments or, for a call with no arguments yet, an empty input: both can look complete) and any call whose input could not be read. Those never run; each gets an error result saying so, which keeps one result per call, in order, and the provider's own state of the reply valid (TurnEngine.unfinishedToolUses, executeTools).
  • The answer goes on, at most maxOutputContinuations (2) times: the next model call sees what ran and what did not. A reply cut off in its text is given back with an instruction to go on. Each continuation is a model call under the spending limit, the kill switch and the time limit.
  • When it may not go on, the answer ends INCOMPLETE, never COMPLETE: a notice says how many steps were done, how many were cut off and not run, and to say "continue". The unfinished answer stays in the conversation's history.
  • A row cannot hollow out the ceiling. A provider row's "max output tokens per request" lowers the ceiling of one reply only down to minOutputTokensPerRequest (4,096, never above models.mainMaxOutputTokens). Why not leave it as it was: a row is data an administrator types; one small number cut off every answer with a tool call in it, and nothing in code bounded it.

Part C: compact output

  • One answer shows a record once and at most maxRecordCardsPerTurn (3) record cards and maxRecordChipsPerTurn (8) record links. Further ones are left out; every step is still listed; a list of records stays one table; a confirmation card is never left out. The model is told once when an answer is full.
  • The system prompt (rules 16 to 19) asks for one proposal for a set, how many records and at most five by name, no ids unless asked, no get_record per record of a set, a plain sentence when the person lacks the permission, and re-issuing a call that was cut off. The cached request prefix changes once.

What a host must change

Nothing. INCOMPLETE is a new value of assistant_turn.status and assistant_message.status (text columns) and of message.end; a provider row below 4,096 output tokens is read as 4,096 (see UPGRADE.md).

Tests

OutputCeilingTest, CompactOutputTest; TurnEngineTest and AssistantSettingsTest changed in the one case each that asserted the old behaviour (a cut reply ended the answer; a row of 500 output tokens was taken as it was).

Full gated build of this branch's head 4bf102c (./mvnw verify: Checkstyle, SpotBugs, PMD, tests, coverage; no gate skipped): BUILD SUCCESS, 748 tests (625 in qbit-assistant), 0 failures, every coverage check met.

🤖 Generated with Claude Code

## What Answers stay compact, and a reply that stops at the output ceiling of one reply no longer drops work silently. Design: `docs/one-card-for-a-set.md`, section 5. **Stacked on #113** (which is stacked on #112) (`feat/one-card-for-a-set`): merge that one first; until then this diff shows its commits too. ## Part D: the output ceiling - **Before:** a reply that stopped at `max_tokens` was not read for tool calls. Calls the model had completely formed never ran, and the answer ended `COMPLETE` with a warning. - **Now:** the reply is read like any other. Its tool calls run, except those that cannot be known to be complete: the call that is the reply's last part (the model was writing it when it was stopped; the Anthropic accumulator hands such a call over with an empty input, an OpenAI-compatible server with `_invalid_arguments` or, for a call with no arguments yet, an empty input: both can look complete) and any call whose input could not be read. Those never run; each gets an error result saying so, which keeps one result per call, in order, and the provider's own state of the reply valid (`TurnEngine.unfinishedToolUses`, `executeTools`). - **The answer goes on**, at most `maxOutputContinuations` (2) times: the next model call sees what ran and what did not. A reply cut off in its text is given back with an instruction to go on. Each continuation is a model call under the spending limit, the kill switch and the time limit. - **When it may not go on, the answer ends `INCOMPLETE`**, never `COMPLETE`: a notice says how many steps were done, how many were cut off and not run, and to say "continue". The unfinished answer stays in the conversation's history. - **A row cannot hollow out the ceiling.** A provider row's "max output tokens per request" lowers the ceiling of one reply only down to `minOutputTokensPerRequest` (4,096, never above `models.mainMaxOutputTokens`). Why not leave it as it was: a row is data an administrator types; one small number cut off every answer with a tool call in it, and nothing in code bounded it. ## Part C: compact output - One answer shows a record once and at most `maxRecordCardsPerTurn` (3) record cards and `maxRecordChipsPerTurn` (8) record links. Further ones are left out; every step is still listed; a list of records stays one table; a confirmation card is never left out. The model is told once when an answer is full. - The system prompt (rules 16 to 19) asks for one proposal for a set, how many records and at most five by name, no ids unless asked, no `get_record` per record of a set, a plain sentence when the person lacks the permission, and re-issuing a call that was cut off. The cached request prefix changes once. ## What a host must change Nothing. `INCOMPLETE` is a new value of `assistant_turn.status` and `assistant_message.status` (text columns) and of `message.end`; a provider row below 4,096 output tokens is read as 4,096 (see `UPGRADE.md`). ## Tests `OutputCeilingTest`, `CompactOutputTest`; `TurnEngineTest` and `AssistantSettingsTest` changed in the one case each that asserted the old behaviour (a cut reply ended the answer; a row of 500 output tokens was taken as it was). Full gated build of this branch's head `4bf102c` (`./mvnw verify`: Checkstyle, SpotBugs, PMD, tests, coverage; no gate skipped): BUILD SUCCESS, 748 tests (625 in `qbit-assistant`), 0 failures, every coverage check met. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 10 commits 2026-11-15 11:52:09 +00:00
A confirmed run_process goes through the process's own init and step
operations as the person and passes a screen for them only when it shows
nothing their card did not: its text and inputs were on the card with the
values the process has now, and a review counts exactly the records of the
card and, with every record validated first, has no warning and no error.
Anything else comes back to the person with a new card and nothing stored.

The set is frozen by primary key and by one digest over its records as the
person read them; a set that changed is not run. The card keeps what the
records were (how many, a sample of 20) and lists the whole set by name.

The assistant no longer creates a job: when the application itself hands the
work to a background job, the job is the person's, it is waited for, and its
outcome (rows done, failed and why) is the card's. A job the application
created waiting is cancelled and the run reported as not done.

A change to records of a table is proposed only to a person the application
lets make it (what it tells their own screens); otherwise they are told so
and there is no card.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A person the application refuses is refused before a card where the
application says so beforehand; the confirm-time refusal (a permission that
goes between the card and the confirmation) keeps its tests, with the UI's
own status and message. The tool-set file changes in one description
(run_process), replaced as text.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
docs/one-card-for-a-set.md is the design and the safety argument for parts A
to D; README, UPGRADE, CHANGELOG and the agent notes follow it. UI API
extension assistant 1.11.0 (additive): AssistantActionV1.recordCount,
records and recordsBefore for a run over a set.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A reply that stopped at the output ceiling is read like any other: the tool
calls it had completely formed run, the one it was still writing (its last
part) and any whose input a provider could not read never do and get an
error result that says so. The answer goes on by itself at most
maxOutputContinuations times, each a model call under every limit of any
other; after that it ends INCOMPLETE, never COMPLETE, says what was done and
what was not, and stays in the conversation so that 'continue' goes on.

A provider row lowers the output ceiling of one reply only down to
minOutputTokensPerRequest; it never raises the ceiling the code configured.

An answer shows a record once and at most maxRecordCardsPerTurn record cards
and maxRecordChipsPerTurn record links; a confirmation card is never left
out. The system prompt asks for one proposal for a set and compact answers.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
fix(tools): render_widget follows the assistant's data access; no widget is rendered until a host allows it
maven / build (push) Successful in 8m50s
build / maven (push) Successful in 8m56s
50845aa99b
A widget is code of the application and can show rows of any table, so
render_widget could hand a model the rows of a table the assistant may not
read. The tool now refuses, before the application is asked, every widget
the host did not allow by name (withAllowedWidgetNames, empty by default),
one it hid, and one that reads a table the assistant may not read
(new: withWidgetTables).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
# Conflicts:
#	CHANGELOG.md
#	UPGRADE.md
fix(process): a card shows all a screen says; a screen that says more than a card shows is not passed
maven / build (push) Successful in 9m20s
build / maven (push) Successful in 9m20s
18e893b835
The first card showed the first 300 characters of a screen's text and then
passed the screen. It now shows the whole text (up to 2,000 characters), and
a screen whose text is longer is one the card did not show in full: the run
stops there.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Merge branch 'feat/one-card-for-a-set' into feat/compact-answers-and-output-ceiling
maven / build (push) Successful in 8m32s
build / maven (push) Successful in 8m32s
4bf102cd4a
Author
Owner

Note on hosted CI: the first run of this branch failed, and its rerun (the same commit, alone on the runner) passed.

The first run shared the runner with the build of the pull request below it in the stack (#113). Four existing tests and one of this change (CompactOutputTest, twelve calls in one reply) that send several tool calls in one reply (TurnEngineTest.testParallelToolResultsInOneMessage, GoldenEvalTest.golden[7], RedTeamTest.testNoPermissionNoLeak, PresentResultsToolTest.testPersistenceAndReplay) each waited 61 seconds and failed. That is reproducible on develop without this change, with two processors:

./mvnw test -pl qbit-assistant -am -Dtest='TurnEngineTest#testParallelToolResultsInOneMessage' -Djacoco.skip=true -Dsurefire.failIfNoSpecifiedTests=false "-DargLine=-XX:ActiveProcessorCount=2"

Cause, read in code: ToolCallService.execute holds synchronized(admission) while the first offer of a scope waits for a task it submitted to the runtime's virtual-thread executor; a virtual thread that blocks holding or entering a monitor is pinned to its carrier, so with two carriers the task never runs and every call of the batch waits for toolCallTimeoutSeconds. It is not changed here (it is the lock of the tool-call service and deserves its own review); it is written up as a follow-up.

Note on hosted CI: the first run of this branch failed, and its rerun (the same commit, alone on the runner) passed. The first run shared the runner with the build of the pull request below it in the stack (#113). Four existing tests and one of this change (`CompactOutputTest`, twelve calls in one reply) that send several tool calls in one reply (`TurnEngineTest.testParallelToolResultsInOneMessage`, `GoldenEvalTest.golden[7]`, `RedTeamTest.testNoPermissionNoLeak`, `PresentResultsToolTest.testPersistenceAndReplay`) each waited 61 seconds and failed. That is reproducible on `develop` without this change, with two processors: ``` ./mvnw test -pl qbit-assistant -am -Dtest='TurnEngineTest#testParallelToolResultsInOneMessage' -Djacoco.skip=true -Dsurefire.failIfNoSpecifiedTests=false "-DargLine=-XX:ActiveProcessorCount=2" ``` Cause, read in code: `ToolCallService.execute` holds `synchronized(admission)` while the first offer of a scope waits for a task it submitted to the runtime's virtual-thread executor; a virtual thread that blocks holding or entering a monitor is pinned to its carrier, so with two carriers the task never runs and every call of the batch waits for `toolCallTimeoutSeconds`. It is not changed here (it is the lock of the tool-call service and deserves its own review); it is written up as a follow-up.
All checks were successful
maven / build (push) Successful in 8m32s
build / maven (push) Successful in 8m32s
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/compact-answers-and-output-ceiling:feat/compact-answers-and-output-ceiling
git checkout feat/compact-answers-and-output-ceiling
Sign in to join this conversation.