Repository navigation
feat(asks): agents withdraw their own pending ask — withdraw route, withdraw_ask tool, Workspace/Operating Room history (trinity-enterprise#901) - #3728
Conversation
…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>
|
Merge-tree record (orchestrator, 2026-10-14): this branch merges cleanly onto live |
…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
left a comment
There was a problem hiding this comment.
/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):
- drop
status == 'pending'from the CAS: 20 red - drop the
raised_bygate in both layers: 4 red agent_ending_kindalways returnswithdrawn: 8 red- withdraw honours the deadline: 2 red
- remove the
outcome != "withdrawn"fail-closed guard: 1 red (the ent329 guard)
- drop
- Route tests drive the real
agent_routerwith the realget_self_acting_agent. Onlyget_current_useris 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 ofResolvedCard,PortalInboxListandPortalAsks, not source-text checks. Together with the source-text, raw-color and loading-gate ratchets: 41/41. - MCP:
tsc --noEmitclean,npm test952/952.withdraw_askis registered throughcreateOperatorQueueToolsandaccess.ts,request_idis 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.reasonnow has two authors, so a future observer that forwards it must branch ondisposed_by. - The operator owns the Before merge items: rc UI check, MCP server restart, stale tool counts. This review does not claim them.
|
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:
|
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
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 endscancelledbyagent,replaced_byempty, reason indisposition_reason, deadline clause ignored as for a person's cancel, no wake, audit row carrieshas_reasononly. Refusals: uniform 404ask_not_found(missing, another agent's, gate-raised, platform, queue-file rows), 409ask_endedwhen the person answered first (the answer stands), 200replayedon a repeat by the same agent, 422invalid_reason(strip + cap 1–500 at ingest). A positiveoutcome == "withdrawn"check sits before the ending sink. No Alembic revision.routers/operator_queue.py):POST /api/agents/{name}/operator-queue/{request_id}/withdrawbehindget_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 fromreplaced_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.src/mcp-server): new toolwithdraw_ask(request_id, reason)(access.tskindnone, sends only{reason});ask_operator/get_my_ask/respond_to_operator_queuedescriptions name it;ask_operatordescription stays ≤1,800 chars (dropped the words "at once", no documented behaviour changed).platform_prompt_service.py::PLATFORM_INSTRUCTIONSandconfig/trinity-meta-prompt/prompt.mdright after "Do not re-ask what is still pending" (that line untouched);_render_pending_asksadvice; test_1402 sentinelswithdraw_ask/ask_ended.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_reasonis 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_askconflict message names the withdraw.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;replaceswithout a successor stays rejected · T2 no new revision, reason indisposition_reason, kind projected as a field · T3 the Operating Room card shows the withdraw reason · T4 a repeated withdraw answers 200replayed· 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 in0f34d8088) and I2 (machine-key operator view omitsagent_ending— deliberate allowlist, noted)./cso --diff: 0 findings; one hypothesis recorded —EndingEvent.reasonnow has two authors, so a future observer that forwards it must branch ondisposed_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 in0f34d8088: the guard parses real_endedcalls and accepts the outcome form by a named token; mutation proof (outcome check removed) goes red namingwithdraw. CI round 1 on0f34d8088:regression diffnamed six parametrisations oftest_ent715_queue_person_fields::test_a_person_reads_every_person_field(the person key set is a literal in that test;agent_endingwas unlisted). Fixed inf9c7cf459, test-only: the derived person-only key set gainsagent_ending; the machine allowlist stays as it was.Tests
Backend: new
test_ent901_withdraw_ask74 (red-first); related ask files 925 → 715 → 361 across checkpoints, 512 in one process with thesys.modules-stubbing neighbours. MCP: 952/952 undernode --test,tsc --noEmitclean. Frontend: 3 new specs 13/13 (jsdom mount), related 303, fullnpm run test:unit5,913. Not run here: the full backend island (CI), live stack.Before merge
operatorhas no Workspace addressee at all.withdraw_askand 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.user-docs/integrations/mcp-server.mdopening line,CLAUDE.md,AGENTS.md— their own fix.devand the sibling scan: first comment below.Handoffs
replacesas a close-out once this ships (rule already recorded on its side).🤖 Generated with Claude Code