Skip to content

feat(asks): agents withdraw their own pending ask — withdraw route, withdraw_ask tool, Workspace/Operating Room history (trinity-enterprise#901) - #3728

Merged
vybe merged 6 commits into
devfrom
feature/901-withdraw-own-ask
Oct 14, 2026
Merged

vybe merged 6 commits into
devfrom
feature/901-withdraw-own-ask

Conversation

@vybe

@vybe vybe commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes Abilityai/trinity-enterprise#901 — an agent can end its own pending ask without raising a new one. Independent draft off dev (chain-asks-1014, trinity-pm orchestrator, 2026-10-14). Draft until the operator merges.

What

  • Service + db (services/ask_service.py, db/operator_queue.py, database.py): one agent-side ending door, withdraw(agent, request_id, reason). The replace path's writer is generalised into _end_own_pending (compare-and-set), so withdraw and replace share one writer and one ending sink. A withdrawn row ends cancelled by agent, replaced_by empty, reason in disposition_reason, deadline clause ignored as for a person's cancel, no wake, audit row carries has_reason only. Refusals: uniform 404 ask_not_found (missing, another agent's, gate-raised, platform, queue-file rows), 409 ask_ended when the person answered first (the answer stands), 200 replayed on a repeat by the same agent, 422 invalid_reason (strip + cap 1–500 at ingest). A positive outcome == "withdrawn" check sits before the ending sink. No Alembic revision.
  • Route (routers/operator_queue.py): POST /api/agents/{name}/operator-queue/{request_id}/withdraw behind get_self_acting_agent; route census lists it as agent-callable.
  • agent_ending: the kind of an agent ending (replaced / withdrawn) is derived once server-side from replaced_by, never from reason text, and projected explicitly on the receipt, get_my_ask, the operator readback and the Workspace — a replaced ask whose successor is withheld from the viewer can never render as withdrawn.
  • MCP (src/mcp-server): new tool withdraw_ask(request_id, reason) (access.ts kind none, sends only {reason}); ask_operator / get_my_ask / respond_to_operator_queue descriptions name it; ask_operator description stays ≤1,800 chars (dropped the words "at once", no documented behaviour changed).
  • Prompt contract: the paragraph "Withdraw what is moot." added byte-identically to platform_prompt_service.py::PLATFORM_INSTRUCTIONS and config/trinity-meta-prompt/prompt.md right after "Do not re-ask what is still pending" (that line untouched); _render_pending_asks advice; test_1402 sentinels withdraw_ask / ask_ended.
  • Workspace + Operating Room (client_portal/asks/service.py, utils/operatorQueue.js, PortalInboxList.vue, PortalAsks.vue, ResolvedCard.vue): a withdrawn ask leaves the Action list by status and shows as history "Withdrawn by the agent" with the reason on its own clamped line, never a Waiting item; ending_reason is forwarded only for an agent withdraw; the Operating Room resolved card shows the reason for a withdraw and nothing for a replace; the Execution Context's ended-asks line says withdrawn and never the reason; answer_ask conflict message names the withdraw.
  • Docs: requirements/security.md §26.9/§26.10, architecture/api-endpoints.md, architecture/mcp-server.md, feature-flows/operating-room.md, user-docs/integrations/mcp-server.md, docstrings.

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

T1 a new MCP tool rather than a mode on ask_operator; replaces without a successor stays rejected · T2 no new revision, reason in disposition_reason, kind projected as a field · T3 the Operating Room card shows the withdraw reason · T4 a repeated withdraw answers 200 replayed · T5 no new Resolved-tab chip. All five follow the planner's recommendation.

Review + security

/review (Fable, reading): MERGEABLE, 0 critical; informational I1 (MCP area doc count, fixed in 0f34d8088) and I2 (machine-key operator view omits agent_ending — deliberate allowlist, noted). /cso --diff: 0 findings; one hypothesis recorded — EndingEvent.reason now has two authors, so a future observer that forwards it must branch on disposed_by. Evidence sweep (Opus, pytest-randomly, 20 files one per process): 19 green, 1 red branch-introduced — the ent#329 static CAS guard matched the new _refuse_ended* helper names and did not know the outcome form. Fixed in 0f34d8088: the guard parses real _ended calls and accepts the outcome form by a named token; mutation proof (outcome check removed) goes red naming withdraw. CI round 1 on 0f34d8088: regression diff named six parametrisations of test_ent715_queue_person_fields::test_a_person_reads_every_person_field (the person key set is a literal in that test; agent_ending was unlisted). Fixed in f9c7cf459, test-only: the derived person-only key set gains agent_ending; the machine allowlist stays as it was.

Tests

Backend: new test_ent901_withdraw_ask 74 (red-first); related ask files 925 → 715 → 361 across checkpoints, 512 in one process with the sys.modules-stubbing neighbours. MCP: 952/952 under node --test, tsc --noEmit clean. Frontend: 3 new specs 13/13 (jsdom mount), related 303, full npm run test:unit 5,913. Not run here: the full backend island (CI), live stack.

Before merge

  • AC 3 ("the ending reaches the person it was addressed to") is met at parity with a person's cancel: the Workspace polls every 20 s and is not on the WebSocket, so no test claims push delivery; an alert addressed to operator has no Workspace addressee at all.
  • Operator UI check on the rc build: Workspace → Inbox → a question an agent later withdraws shows "Withdrawn by the agent" + reason under history, not under Action; Operating Room → resolved card shows the reason for a withdraw, blank for a replace.
  • Agent-facing: deployed agents pick up withdraw_ask and the contract paragraph on their next turn (the platform prompt is read per turn); the MCP server needs a rebuild/restart for the new tool.
  • Stale total-tool counts noticed, not touched: user-docs/integrations/mcp-server.md opening line, CLAUDE.md, AGENTS.md — their own fix.
  • Merge-tree result against live dev and the sibling scan: first comment below.

Handoffs

🤖 Generated with Claude Code

Trinity Agent (trinity) and others added 5 commits October 14, 2026 11:57
…enterprise#901)

Checkpoint A of the withdraw-own-ask feature: an agent ends one of its OWN
pending asks with a reason and no successor.

- db: `_replace_predecessor` is generalised into `_end_own_pending(conn,
  agent, row_id, *, reason, replaced_by, honour_deadline)` — the one
  compare-and-set an agent ends its own ask with (ownership belt:
  agent_name + raised_by='agent' on the column + status='pending'). Replace
  keeps its vocabulary and its deadline clause (T5b expiry stays); the new
  `withdraw_item` runs it without the deadline clause, like a person's
  `cancel_item` (a withdraw lands no approval, so nothing is denied by
  timeout and the C6 re-ask guard is not tripped). Writes
  cancelled / cancelled / disposed_by='agent' / disposition_reason=<reason>
  / replaced_by NULL. Outcomes: withdrawn | ended | not_own.
- service: `WITHDRAWN` beside `REPLACED`; `agent_ending_kind(row)` tells the
  two apart ONCE, by the link (never by the reason's text), and rides every
  receipt and the readback as the explicit `agent_ending` field.
  `ask_service.withdraw()` — reason whitespace-collapsed and bounded at
  ingest (422 invalid_reason); uniform 404 ask_not_found for an unknown /
  another agent's / gate / platform / about-a-person row; 409 ask_ended
  with ids and enums only when a person answered or ended it first; 200
  replayed on a repeat (a changed reason reported in `differs`, never
  applied). One ending sink: an agent-keyed `withdrawn` audit row carrying
  has_reason only (never the text), the thin operator_queue_cancelled
  trigger, no wake (_wake_filer skips agent-authored endings).
  `_refuse_ended_predecessor` now wraps the shared `_refuse_ended`, which
  gains a withdrawn arm for a replace of a withdrawn predecessor.
- route: POST /api/agents/{name}/operator-queue/{request_id}/withdraw
  behind get_self_acting_agent (agent-only by design;
  reject_non_person_principal deliberately not applied), AskRejected
  mapped to {code, message, ...}; listed as agent-callable in the #2996
  route census.
- tests: tests/unit/test_ent901_withdraw_ask.py (59, written red first —
  58 failed on the missing behaviour, the replace-regression pin was the
  one green). Deliberate re-pins: RECEIPT_KEYS in test_3247 and
  test_ent611_native_ask, READBACK_KEYS in test_ent611_ask_endings gain
  `agent_ending`.

Not in this commit (later checkpoints): the MCP tool `withdraw_ask`, the
prompt twins + sentinels, the Workspace projection, the Inbox row, the
Operating Room card reason, docs and the router `# mcp:` header.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…se#901)

Checkpoint B of trinity-enterprise#901 — the agent-facing surface over the
withdraw route checkpoint A shipped.

- MCP tool `withdraw_ask(request_id, reason)` in tools/operator_queue.ts,
  self-acting (resolveActingAgent; access.ts `kind: "none"`). Only
  `{reason}` travels in the body; refusals (404 ask_not_found, 409
  ask_ended, 422 invalid_reason) come back through askRefusal as their
  named code with extras, never a throw. client.withdrawAsk, the
  OperatorAskWithdraw type, `agent_ending` on the receipt/readback types,
  and "withdrawn" on the receipt status union.
- Descriptions: ask_operator gains "if nothing is needed any more,
  withdraw_ask" in its replaces sentence (kept under the 1,800 cap by
  dropping "at once"); get_my_ask names a withdrawn ending and
  `agent_ending`; respond_to_operator_queue names withdraw_ask beside the
  replace.
- Prompt contract: the "Withdraw what is moot." paragraph, byte-identical
  in PLATFORM_INSTRUCTIONS and config/trinity-meta-prompt/prompt.md,
  directly after the "Do not re-ask what is still pending" paragraph
  (that line untouched). The Pending asks line offers "if it is moot,
  withdraw it (withdraw_ask)" only when it carries an ask the agent raised.
- Router `# mcp:` header names withdraw_ask.

Tests (written red first): operator_queue.test.ts withdraw_ask block
(stray-keys execute, scope refusals, the three refusal codes, the
description pins); access-wiring.test.ts real-transport block (agent key
acts as itself, only {reason} reaches the backend, user key refused before
any call, 409 extras); test_1402 sentinels `withdraw_ask` and `ask_ended`;
test_ent901 TestPromptContract + TestPendingLineOffersWithdraw. access.test
went red on the missing TOOL_ACCESS_POLICY row before the entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd projection + docs (trinity-enterprise#901)

Checkpoint C1 of trinity-enterprise#901 (C split: C1 backend projection +
docs, C2 frontend + specs).

- client_portal/asks/service.py: `_ending_of` keys its agent arm on
  `ask_service.agent_ending_kind` (replaced | withdrawn), still before the
  email arm; `_project` carries `agent_ending` for an agent ending and
  `ending_reason` (the agent's reason) for a WITHDRAW only — the
  operator's cancel reason, a platform token and a timeout never cross.
  `answer_ask`'s lost-race message says the agent withdrew it.
- WorkspaceAsk gains `agent_ending` / `ending_reason`.
- Operator person view (`_for_principal`) carries `agent_ending`.
- Execution Context Ended asks line: `list_recent_endings_for_agent`
  selects `replaced_by` (still ids + enums only) and `_render_ended_asks`
  says `withdrawn` vs `replaced` via `agent_ending_kind` — never the
  reason.
- Docs: requirements/security.md §26.9 / §26.10, architecture/
  api-endpoints.md, feature-flows/operating-room.md; the endings-query
  and EndingEvent.reason docstrings.

Tests (red first, 9 new in test_ent901_withdraw_ask.py): projection kind
+ reason, a reason literally `replaced` stays a withdraw, a replace
carries no reason, no other ending forwards a reason, the withdrawn ask
leaves the open list and shows as history, the lost-race message, the
Ended asks line (withdrawn, reason absent) and its select set, the
operator person view. Mutation: dropping the withdrawn arm in
`_ending_of` turns 3 projection tests red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…end + specs (trinity-enterprise#901)

Checkpoint C2 of trinity-enterprise#901.

- utils/operatorQueue.js: `endingKind` reads the server's derived
  `agent_ending` first (replaced | withdrawn), falling back to `replaced`
  for an agent ending on an older payload; `ENDING_LABELS.withdrawn`;
  `queueEnding` gives a withdraw `who: 'the agent'` and the agent's reason
  (`ending_reason` on the Workspace, `disposition_reason` on an operator
  row) as `reason`, kept out of the ending's words, so
  `workspaceEndingText` reads "Withdrawn by the agent".
- PortalInboxList: the ended badge stays "Withdrawn by the agent"; the
  reason is its own single-line clamped line with the full text on
  `title`. Never a Waiting item (it leaves openAsks by status).
- PortalAsks: the ending line reads "Withdrawn by the agent · <when>" with
  the reason on its own clamped line.
- ResolvedCard (T3): the note shows the withdraw reason; a replace stays
  blank (its reason is the closed token `replaced`).

Text interpolation only on every surface; no new palette classes.

Specs (red first): operatorQueueWithdraw.spec.js (the rule, incl. a
reason literally "replaced"), portalInboxWithdrawn.mount.spec.js (jsdom
mount of the Inbox row, PortalAsks, openAsks / count), and
resolvedCardWithdrawn.spec.js (mounted note; replace has none).
Mutation: removing the `agent_ending` read in `endingKind` turns 6 of
the 13 red. Full frontend unit suite: 368 files / 5,913 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oc lists withdraw_ask (trinity-enterprise#901)

The ent329 CAS-win guard found `_ended` feeders by the text `_ended(`, which
also matched `_refuse_ended(` / `_refuse_ended_predecessor(` (helpers that end
nothing). Detection is now an AST walk for real `_ended(...)` calls, and the
call-position index uses a regex that excludes identifiers ending in `_ended(`.

`withdraw` is guarded by its db writer's `outcome`, a form the guard did not
know. It now accepts an outcome-form CAS writer only when the ending function
refuses every outcome but the pinned winning token (`!= "withdrawn"` →
raise/return) before `_ended`; a deny-list of the losing outcomes is not
accepted. `withdraw` gains that two-line positive check (fail closed on an
unknown outcome), so it no longer relies on a deny-list. `withdraw` is now in
the guard's required-feeder floor.

MCP docs: operator_queue.ts lists six tools, including withdraw_ask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vybe vybe added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 14, 2026
@vybe

vybe commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Merge-tree record (orchestrator, 2026-10-14): this branch merges cleanly onto live dev (fa6c9df); no Alembic revision in the range. Open PRs sharing files — #3723, #3573, #3516, #3490, #3469, #2984, #2713, #2709 — each shows the same conflict count against this branch as against live dev (0 / 1 / 3 / 0 / 1 / 5 / 4 / 7), so this branch adds no conflict; their drift is their own.

…nterprise#901)

The person view of the operator queue gained the derived agent_ending enum
(replaced | withdrawn | None) in checkpoint A; ent#715's classification
guard pins the person key set and named it as unplaced. It sits beside the
other person-only derived keys (decided_by_options, gate): never a column,
never PII. The machine allowlist is unchanged — derived keys are person-only
there, and the agent reads agent_ending on its own readback.

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

@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.

/validate-pr — APPROVE (Lane B, P1 feature, no schema change)

Validated at head f9c7cf459 against dev fa6c9df56. git merge-tree against live dev is clean.

Check Result
Base / size dev · 36 files (+1899/−74)
Issue link Fixes Abilityai/trinity-enterprise#901 resolves (P1, type-feature). Cross-tracker, so set status-in-dev on ent#901 by hand after merge and close it by hand at release
Commits 6 commits, all conventional and descriptive
Docs requirements security.md §26.9/§26.10, architecture/api-endpoints.md, architecture/mcp-server.md, feature-flows/operating-room.md, user-docs all updated. The enterprise-docs guard pattern finds nothing in the added lines
Security §4.1–4.10 No keys, emails, IPs, env files, secrets, symlinks or host paths. No new env vars and no new top-level backend package
Auth New route is behind get_self_acting_agent and is in the route census as agent-callable. Ownership is checked twice: the service gate (raised_by == 'agent', is_about_a_person) and the CAS predicate. The skill-gate ending observer is unaffected because gate rows are a uniform 404
Tests in tests/ with deleted lines The ent#329 CAS guard got stricter: 5 required feeders, it parses real ast.Calls, and the outcome form is accepted only on an exact winning-token != check. The other two files only add agent_ending to key sets
Merge gate (Tier 1) All green, including pytest (head) + regression diff. dev-ci on dev is success and there is no open dev-ci red issue

Execution coverage (§5.4). Run in the PR worktree.

  • test_ent901_withdraw_ask.py: 74/74. With the ent329 and 3247 neighbours: 197/197.
  • Mutations (each run on its own, reverted after):
    1. drop status == 'pending' from the CAS: 20 red
    2. drop the raised_by gate in both layers: 4 red
    3. agent_ending_kind always returns withdrawn: 8 red
    4. withdraw honours the deadline: 2 red
    5. remove the outcome != "withdrawn" fail-closed guard: 1 red (the ent329 guard)
  • Route tests drive the real agent_router with the real get_self_acting_agent. Only get_current_user is overridden. Negative principals (a sibling agent's key, a person, a user key, a system key, a connector) each get a 403 and the row stays pending.
  • Frontend: the 3 new specs are jsdom mount()s of ResolvedCard, PortalInboxList and PortalAsks, not source-text checks. Together with the source-text, raw-color and loading-gate ratchets: 41/41.
  • MCP: tsc --noEmit clean, npm test 952/952. withdraw_ask is registered through createOperatorQueueTools and access.ts, request_id is URL-encoded, and the real-transport wiring test is included.

Notes (not blocking)

  • The structural /review (MERGEABLE) and /cso --diff (0 findings) are recorded in the orchestrator's plan files, not as a review on this PR.
  • The cso hypothesis still applies: EndingEvent.reason now has two authors, so a future observer that forwards it must branch on disposed_by.
  • The operator owns the Before merge items: rc UI check, MCP server restart, stale tool counts. This review does not claim them.

@vybe
vybe marked this pull request as ready for review October 14, 2026 19:01
@vybe
vybe merged commit acf8dfd into dev Oct 14, 2026
30 checks passed
@vybe

vybe commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

merge-train (2026-10-14): merged at f9c7cf4 after a fresh structural /review in an isolated worktree — READY, 0 critical. 607 pytest / 952 MCP / 21 vitest green; 16 fix mutations each turned a test red (CAS pending-only, raised_by gate, is_about_a_person, router decorator, include_router, prompt paragraph, frontend spans). Not blocking, recorded for the author:

  • W1 EndingEvent.reason (services/ask_service.py:160) is now dual-authored; _wake_filer filters disposed_by == 'agent' first (pinned by a test), but a future observer that forwards reason to a person must branch on disposed_by. A one-line note at register_ending_observer would make that durable.
  • W2 the machine-key operator view omits agent_ending; an agent listing the queue sees disposed_by: agent + free-text reason only. Deliberate allowlist per the PR body.
  • One mutation was left unproven at the time cut-off: deleting withdraw_ask from access.ts TOOL_ACCESS_POLICY (the wiring test over the real transport is expected to catch it, not demonstrated here).
  • Stale aggregate MCP tool counts in CLAUDE.md / AGENTS.md / the user-docs opening line are a separate PR.

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

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants