Skip to content

feat(operator-queue): atomic asks — authoring rules in the ask_operator contract, option and title limits (#3243) - #3253

Merged
vybe merged 17 commits into
devfrom
feature/3243-atomic-asks
Oct 6, 2026
Merged

vybe merged 17 commits into
devfrom
feature/3243-atomic-asks

Conversation

@vybe

@vybe vybe commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3243 — second of the chain #3242 → #3243 → #3247. Stacked on #3250 — base is feature/3242-approval-something-else; the stack merges together (operator, 2026-10-05). Draft until the chain is in.

What

Agents are taught to write short, atomic asks, and the platform refuses the ones that are not.

  • Limits (services/operator_queue_choices.py, one check for both create paths): by default at most 5 options (the reserved (something else) is never counted), at most 60 characters per option, and an agent-raised title of at most 120 characters. Nothing is truncated: a cut option changes what an approval means.
  • ask_operator refuses with a code the agent can act on — too_many_options, option_too_long, title_too_long, invalid_options — each naming the limit in force and the remedy. The check runs after the replay lookup, so a retry of an older ask still replays.
  • Queue-file fallback: an over-limit entry is held, never ingested; the file's ingestion block lists the offending ids (invalid_options, invalid_title) and an id leaves the list once the agent fixes the entry. Held entries do not count toward the flood alert. Asks already pending are not re-checked.
  • Lookalikes: an option that reads as the platform's "Something else" chip (any case, whitespace, parentheses, fullwidth forms, zero-width insertions) is refused on both paths.
  • Guidance: the ask_operator description carries five rules — one decision per ask; a title read at a glance; options name the choice only; few options; context is for people — and the platform prompt carries three lines that point at the tool. Detail that belongs to one field (codes, remedies, the bad-then-good example) lives in that field's parameter description.
  • Description budget: the published ask_operator description is 1,710 characters (was 2,676), under Claude Code's 2,048-character cap that dev's bug: two MCP tool descriptions run past Claude Code's 2,048-character cap, so their tails never reach the model #3234 census enforces, with a ≤ 1,800 test in this branch.
  • Settings: OPERATOR_QUEUE_* knobs in .env.example and all three compose files, with floors so a skill-gate approval stays raisable.

Rulings carried (orchestrator, on the operator's behalf — plan file)

  • T1 deviates from the plan: 5 options / 60 characters (plan: 40). Evidence from a live queue the planner could not see: option length median 24, p90 59, max 85; a 40 cap would have refused 4 of 10 real asks, 60 refuses 1.
  • T2 deviates: a hard title limit of 120 (plan: guidance only). Live titles had a median of 125 characters.
  • T3–T9 as recommended: refuse, never truncate; the file path holds and lists ids; pending asks untouched; over-limit holds stay out of the flood alert.
  • Added from the Approval asks have no "something else" answer — the person must pick an option they reject before Send unlocks #3242 security pass: the lookalike refusal.

Review + security

/review (claude-fable-5-1, report-only): MERGEABLE AFTER FIXES. One rule on both paths; a retry replays even when over the new limits; seven mutations all red; queue suites green twice in shuffled order. Fixed in 9bcc39db (C1: the three knobs were missing from docker-compose.hosted.yml, parity test red), aea0d502 ("by default" wording and an env-independent guard; one refusal for a title over 300; NFKC + zero-width normalisation), 3c14c23a (docs), 97376394 (the description budget — found by the next issue's plan, not by the review). /cso --diff: nothing supported.

Tests

Reported by the builder on the fix tips: pytest 499 passed on the listed suites and 1,665 on the files that read .env.example / the edited docs; mcp-server 691 passed, tsc --noEmit clean. Not run here: the full Python unit island and the frontend suite (no frontend change in this PR) — CI.

Before merge

Handoffs

🤖 Generated with Claude Code

Trinity Agent (trinity) and others added 16 commits October 6, 2026 10:48
…se) answer (#3242)

The #2376 sink now handles SOMETHING_ELSE = "(something else)" before
membership: accepted on any approval with an instruction in response_text,
refused by name otherwise (instruction_required, reserved_value), and refused
on platform-minted / gate approvals (not_off_menu). Every other unoffered
string stays refused. validate_response_choice takes response_text
keyword-only and required.

- ask_service: gate refusal; ask_operator refuses the literal as an option
- both writers map ReservedAnswerError to a named 422
- OperatorResponse.response_text bounded at 4000
- Workspace projection: decided_by_options boolean (T8 ruling)
- recent answers render "Something else: <instruction>"
- resume frame: platform sentence above the data fence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e) (#3242)

- platform prompt (both authored copies) + agent guide: the approval bullet
  and the queue-file write-back say what the reserved value means and that
  the instruction is in response_text; never list it as an option
- test_1402 SENTINELS += "(something else)"
- MCP: types.ts exports SOMETHING_ELSE; ask_operator, get_my_ask and
  respond_to_operator_queue descriptions name it; the respond tool keeps
  `error` and adds the backend's {status, code, message, offered_options?}
  (T5, additive)
- py <-> ts parity test for the literal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- utils/operatorQueue.js: SOMETHING_ELSE (mirrored, parity-tested),
  offeredChips (drops the literal and the size-cap marker), decisionLabel,
  decidedByOptions; buildQueueResponse needs an instruction with the literal
- QueueCard + QueueItemDetail: chips from offeredChips, a 'Something else'
  chip outside the v-for (hidden on gate approvals); typing with no pick arms
  it, the label and Send copy flip; Enter sends only after an explicit pick;
  maxlength 4000; Send rule is the shared builder
- respondToItem returns true/false; the detail panel clears only on success
- ResolvedCard + detail resolved view render 'Something else', never the literal

Raw-colour counts unchanged (QueueCard 28/59, QueueItemDetail 26/55).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- /m: a 'Something else' chip in the options group (reuses ops-option-btn,
  hidden on gate approvals) opens the form; the consequence line says none of
  the options will run; Send reads 'Send instruction' and stays disabled until
  the instruction is typed; maxlength 4000
- PortalAsks: the chip after the offered chips, hidden when the projection
  says decided_by_options (T8 ruling); typing with no pick arms it, placeholder
  and Send aria-label flip; Enter never sends the auto-armed state; submit()
  sends the armed option through the shared builder. Diff kept to the approval
  template, helpers beside pick(), the body of submit() and one import line
  (open PR #3181 edits other hunks)
- portalAsksTestidPrefix: the id inventory gains the chip's id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Document what #3242 built: the reserved (something else) decision an
approval accepts besides its own options, with the instruction in
response_text; the named 422s (instruction_required, reserved_value,
not_off_menu, invalid_options at raise); the Something else chip on the
desktop card and detail, /m and the Workspace, hidden on gate approvals
on every surface (decided_by_options on the Workspace projection); the
resume frame and contract text. Corrects every doc that still said an
approval's answer must be one of its options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dead optionsOf import (C1, I1) (#3242)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-miss literals; public decided_by_options; learnings (I2 I4 I5 I6 I7 I12) (#3242)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tor projections; gate- is only the fallback (I3) (#3242)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lookalikes on the native raise (#3243)

One pure predicate in operator_queue_choices (shared with the file path in the
next checkpoint): at most 5 options, the reserved (something else) never
counted; at most 60 characters per option; an option reading as the chip
("Something else", any case, parenthesised or not) refused as invalid_options.
Agent raises also get a hard 120-character title (title_too_long). Checked
after the replay lookup, so a pre-cap ask's retry still replays, and before
the rate cap, so a refusal spends no token. Env-overridable, floored at load
(2 options / 16 chars / 40-char title).

Mutation: with the _refuse_over_caps call removed, 9 tests in
test_3243_atomic_asks.py go red (TestNativeRaise refusals).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on the marker (#3243)

A NEW file entry over the option caps (or a chip lookalike) is held as
invalid_options, an over-long title as invalid_title — the same predicate as
the native raise, after the id check and before the depth/rate caps, so no
rate token is spent. The ids (<=10) and the limits ride on
platform.ingestion beside `reason`, so a self-clearing queue_full cannot hide
them; _marker_differs now compares the key set so a fixed entry's id drops
off. Cap holds never increment `held` (no "runaway or compromised agent"
flood alert). Rows already ingested are never re-judged.

Also replaces the CP1a env test's module reload (it handed other suites a
stale OperatorQueueSyncService) with a test of the loader itself.

Mutation: with the hold disabled, 4 TestFileHold tests go red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and the prompt (#3243)

ask_operator's description carries five authoring rules (one decision per
ask; a one-glance title, at most 120; options name the choice only, with one
bad-then-good example; at most 5 options of at most 60 characters, aim for
under 40; context is labelled facts for people), the chip-lookalike rule, and
the three new refusal codes with what to do about each. Field descriptions
for title/question/options/context/proposal carry the per-field rule.

The platform prompt and prompt.md gain a three-line "Write atomic asks"
paragraph pointing at the tool, and the queue-file paragraph names the
invalid_options / invalid_title holds. Agent guide: one pointer sentence.
Sentinels: "One decision per ask", "invalid_options". A pytest pins the
description and prompt to the phrases built from the Python caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#3243)

.env.example and both compose files gain OPERATOR_QUEUE_MAX_OPTIONS (5),
OPERATOR_QUEUE_OPTION_MAX_CHARS (60) and OPERATOR_QUEUE_ASK_TITLE_MAX_CHARS
(120). security.md §26, the operating-room flow (step + revision row) and the
api-endpoints catalog name the new codes, where they run, and the file hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se so parity is green (#3243)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…irst, fold NFKC/zero-width chip lookalikes, register the test (#3243)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion docs and add the §26.7 authoring-caps bullet (#3243)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… cap (#3243)

Claude Code shows a model only the first 2,048 characters of a tool
description (#3234); ask_operator was 2,676 at 3c14c23, so its last rules
(the refusal codes and their remedies) were invisible to every agent.

The description is now 1,710 characters, ordered by what a model acts on
first: what the tool does and the fire-and-park contract, then the five
atomic-ask rules, then the reserved (something else) rule. Field detail
moved into the parameter descriptions, which are not cut: the receipt
fields, differs and idempotency (request_id); title_too_long and its fix
(title); the bad-then-good option example, options_required,
too_many_options / option_too_long / invalid_options with their fixes
(options); field_too_large (question, context, proposal); role_unassigned
(to); how an ask ends (expires_at); reask_requires_link
(supersedes_expired).

Tests: the published descriptions of ask_operator, get_my_ask and
respond_to_operator_queue are <= 2,048 over a real listTools, and every
moved phrase is published on its field; the unit test keeps ask_operator
<= 1,800 for headroom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vybe
vybe force-pushed the feature/3242-approval-something-else branch from 210fc6b to efedeaa Compare October 6, 2026 10:10
@vybe
vybe force-pushed the feature/3243-atomic-asks branch from 9737639 to 33a0cc6 Compare October 6, 2026 10:10
@vybe
vybe marked this pull request as ready for review October 6, 2026 10:15
@vybe
vybe changed the base branch from feature/3242-approval-something-else to dev October 6, 2026 10:58
…sh-landed (#3253) — mechanical, per the merge-train note on the PR

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vybe

vybe commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

merge-train (2026-10-06): retargeted this PR to dev (its base #3250 squash-landed) and pushed one merge commit bringing dev in. Eight files conflicted on the squash echo + #3259's prompt edits; each was resolved as dev's version + this PR's own delta re-applied 3-way. Result diff vs dev is exactly this PR's 21 files (+869/−55, unchanged). Verified: test_3243/3242/1402/ent611/ent801 → 241 passed; MCP tsc clean, operator_queue.test.ts 40/40.

@trinity-ability trinity-ability left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20261006-1038 (#3270)

@vybe
vybe merged commit fa50b38 into dev Oct 6, 2026
27 checks passed
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.

2 participants