Repository navigation
feat(positioning): runtime prompts carry the Trinity one-liner (abilityai/trinity-enterprise#801) - #3259
Merged
Conversation
…tyai/trinity-enterprise#801)
…lines (abilityai/trinity-enterprise#801) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trinity-ability
approved these changes
Oct 6, 2026
trinity-ability
left a comment
Contributor
There was a problem hiding this comment.
merge-train: batch validated on train/20261006-1038 (#3270)
vybe
added a commit
that referenced
this pull request
Oct 20, 2026
* fix(cleanup): scope the #1581 orphan agent-volume sweep to this stack (#3214) (#3231)
* fix(cleanup): scope the #1581 orphan agent-volume sweep to this stack (#3214)
Docker volumes are daemon-global while the sweep's ownership check reads
one stack's DB, so a second stack on a shared daemon reclaimed every other
stack's unattached agent volumes — force-removed, logged at INFO.
- Every agent data volume is created through docker_utils.
agent_volume_labels, which stamps trinity.instance=<installation_id>
(instance_identity.get_instance_id — the durable id the alert label
already abbreviates; one source). All six creation sites use it, pinned
by a guard test.
- The sweep lists candidates on the instance label's key AND value and
skips the cycle when the id is unresolvable.
- is_reclaimable_agent_volume refuses a volume labelled for another
instance, closing the same hole on the retention-purge path (two stacks
can both have an agent `alpha`).
- Legacy (unlabelled) volumes fail closed: labels are immutable, so the
sweep never reclaims them; unowned+unattached ones are named in one
WARNING per change of that set for a human. Own agents' volumes are
still removed at retention purge, which is ownership-row driven.
- Reclaims and removals log at WARNING as unrecoverable, naming volumes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(#3214): classify the volume-stamp mint; stub the new lifecycle import
- test_2669: get_instance_id() mints installation_id on purpose. A volume
created before the id exists would carry no owner and never be
reclaimed. It gets an _ALLOWED_USES entry with that reason, like the
label tier's existing entry.
- test_agent_readiness_probe: the hand-written services.docker_utils stub
lacked agent_volume_labels, which lifecycle.py now imports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(delegation): every caller is taught that a receipt means running — never re-send on silence (abilityai/trinity-enterprise#568) (#3240)
The receipts already existed on every dispatch route (#914, #2661, #2670); what
was missing was the caller knowing what they mean. In the 2026-09-08 cascade
every duplicate dispatch was an agent re-sending after "could not confirm
delivery". The delegation contract is now one text, taught wherever a caller
reads it:
- the platform prompt, §Agent Collaboration (DELEGATION_CONTRACT spliced into
PLATFORM_INSTRUCTIONS): every agent, every turn, no image rebuild;
- verbatim in the chat_with_agent and every dynamic chat_with_<agent>
description (src/mcp-server/src/delegation_contract.ts); fan_out and
send_message repeat its rule sentence and point at it;
- the receipt itself: every execution_id receipt a chat_with_* caller gets
says "Do not re-send: read the outcome with get_execution_result(...)".
That covers the queued_timeout that chat() and task() write, the 409
in-flight replay, and the backend-authored accepted / queued /
queued_timeout receipts, whose REST-only "Poll GET ..." line runAgentChat
replaces. The leads claim nothing about liveness, because the backend
replays an async receipt for 24 h (#3245). fan_out_timeout keeps its own
#2670 wording.
Claude Code shows a model only the first 2,048 characters of an MCP tool
description (CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH). #2958 took the
chat_with_agent description from 2,035 to 2,424 characters, so since
2026-09-24 Claude Code has cut its tail (the list_recent_executions advice and
"prefer parallel=true, async=true"). It now publishes at 1,940 characters;
per-mode detail moved into parameter descriptions, which are not cut. Other
runtimes' MCP clients were not measured.
The text names only what the platform produces today: no `replayed` (ent#566
has not shipped it), and agent_busy is checked against list_recent_executions
before a re-send, because a usage-limit 429 is raised after the turn ran
(#3244). The unverified "concurrent-duplicate guard will kill mid-execution"
claim is gone; no such guard exists.
Also drops chat_with_agent from PENDING in tool-description-budget.test.ts
(#3234): the entry fits the cap now, and the test fails an entry that fits.
Tests: tests/unit/test_ent568_delegation_contract.py (byte parity with the TS
copy, every tool, argument, status and event it names proven real, budget) and
src/mcp-server/src/delegation-contract.test.ts (the descriptions a real
tools/list publishes, and the receipt messages on chat/task timeout, 409
replay, async accepted/queued and the pull-routed path). The CI boot smoke
also imports dist/tools/dynamic-agents.js.
Docs: requirements §37.5, architecture/mcp-server.md and execution.md, the
feature flows that teach delegation, the agent guides, user docs.
Follow-ups: #3232, #3233, #3244, #3245.
Fixes abilityai/trinity-enterprise#568
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(setup): deploy the system agent once /setup creates the admin (#3237) (#3261)
* fix(setup): deploy the system agent once /setup creates the admin (#3237)
On a browser-claimed install (every one-click / marketplace path) the
only system-agent deploy runs at boot, before /setup has created the
admin, and fails with "Admin user 'admin' not found". Nothing retried
it, so trinity-system stayed absent until a backend restart.
/setup now schedules ensure_deployed() as a background task after the
admin row is written, ahead of first-run seeding (the boot order; the
seeder hosts its alerts on trinity-system). The task never raises, since
Starlette runs background tasks in sequence, and skips without Docker.
Fixes #3237
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(setup): show the system-agent deploy step in the first-time setup flow (#3237)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(workspace): Discuss and Dismiss on asks (abilityai/trinity-enterprise#747, #748) (#3181)
* feat(workspace): Discuss and Dismiss on asks (ent#747, ent#748)
Dismiss (abilityai/trinity-enterprise#748): one click ends a pending
question or approval as disposition=dismissed (status stays cancelled)
through the ask ending sink — audit, thin broadcast, and the filer's
wake with its own framing. 5s client-side Undo window; nothing is sent
until it lapses. Addressee only (person gate, uniform 404); a dismiss
that loses a race, or lands past the deadline, is a no-op.
Discuss (abilityai/trinity-enterprise#747): opens one chat per ask with
the asking agent (CAS link in a new platform-only context key), titled
after the ask, with the ask as its tile; a turn-context provider puts
the ask (id, kind, live status, options) in every turn there. The ask
stays one pending row; a question can be answered from the composer
with "Send as answer" (written as response).
Also renders agent markdown in the ask context's "Your recent answers"
list, the surface #3115 missed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(workspace): Discuss opens its new chat instead of "isn't available"
The Inbox turned Discuss's open-thread into a bare /workspace/c/<id>
push, and the #3140 guard read the just-created chat (not yet in the
thread list) as not the viewer's. Route it through the shell's
openThread, which adopts the id, and refresh the list. The chat tile
now forwards open-thread too — its Discuss did nothing before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(workspace): an answered ask's result comes back to its chat (ent#747)
An answer that woke an opted-in agent left the woken run's output in the
execution history only — the person saw the agent start and never saw
what it did. The resume run now carries the Workspace destination (the
ask's discussion chat, else its attached chat; only when the answerer is
the addressee), so the ent#457 completion report posts the result there
as an agent message. The open chat watches for it for up to 5 minutes,
since it has no history poll.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(workspace): address #3181 review — discussed-ask audience, link-write errors
F1: an answered ask's resume run reports into the ask's DISCUSSION chat
only. The attached-chat fallback (Main for a background ask) widened every
answered ask's audience to the client — verbatim final reply, shared files,
inherited by delegated children. The run is now told its reply goes to
the person. Store watch narrowed to match. Tests pin turn_audience and
delegation inheritance for the stamped run.
F2: the discussion-link write tolerates only SQLite's busy snapshot
(OperationalError). Any other failure, or a busy write with no racing
link while the ask is still pending, is a retryable 503 instead of
"409 This ask is already pending".
F3: the discussion's system notice no longer quotes the agent-written title.
F4: options are truncated before JSON encoding, keeping the closing quote.
F5: Dismiss refuses alerts (422), like Discuss and the card.
F7: real TestClient tests through get_portal_principal (agent key 403)
and the live in-process limiter (429).
Send as answer stands down while a reply chip or attachment is in the
composer (#3168 collision). tables.py disposition comment lists dismissed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(workspace): correct #3181 review I1/I2 doc staleness
operating-room.md: an answered ask's result returns to its discussion
chat only; the attached chat is not a destination (F1), so an
undiscussed answer's run stays owner-only.
backend.md: turn_context now has one OSS provider, the discussed-ask line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pull): the pull sink applies post-turn delivery settings (#2329) (#3227)
* fix(pull): the pull sink applies post-turn delivery settings (#2329)
A queued /task row carries delivery settings in backlog_metadata. The push
drain applied them in run_async_task; the pull sink wrote the terminal and
stopped. On a pull pilot a Chat-tab /task (save_to_session, trigger manual)
succeeded and never reached the chat session, and the collaboration activity
stayed started.
- run_post_turn_delivery: the three post-turn steps (chat-session save and
broadcast, collaboration activity, self-task finalisation), moved out of
run_async_task. The push drain and the pull sink both call it.
- request_from_metadata: the queued-request rebuild, shared by _spawn_drain
and the pull sink.
- Pull sink: on the CAS-won branch, spawns delivery when the metadata asks for
it, then signals the sync waiter with the chat session id; signals at once
when there is nothing to deliver. Delivery failure never blocks the signal.
- A token-gated SUCCESS write refuses a row already SUCCESS or SKIPPED. The
claim token survives the terminal write, so a worker's retried result POST
won the CAS twice and would have saved the chat turn twice. A late SUCCESS
still corrects a FAILED row.
- close_execution_activity and the bulk close also close the collaboration /
self-task activity a queued row names, so a terminal written outside
run_async_task (pull sink, lease-reaper park, expire, watchdog) does not
leave it started.
- Sync /task keeps the chat_session_id the sink signals.
Fixes abilityai/trinity#2329
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pull): keep a re-queued row's activities open; deliver only the applied terminal (#2329)
- The lease reaper closes the dead attempt as CANCELLED on a row it has just
re-queued. The queued-activity close now runs only for a row that is
terminal, so the collaboration / self-task activity stays open for the
re-delivered attempt (a CANCELLED close is never upgraded).
- Sink delivery runs only when the row still holds the status its own CAS
wrote, so FAILED then a late SUCCESS under one token saves the turn once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(pull): backlog expiry calls no activity closer — drop it from the #2329 close-owner docstrings
merge-train: expire_stale_queued writes FAILED and calls no closer, so the
docstrings and the test docstring overstated coverage. Mechanical, per the
merge-train note on the PR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
* feat(skills): a gated skill is refused inside the agent unless its run was approved for it (abilityai/trinity-enterprise#752) (#3252)
* test(isolation): restore the adapters.message_router binding after a patch.dict re-import
test_channel_image_vision re-imports adapters.message_router inside a
patch.dict(sys.modules, ...) that mocks adapters.base. patch.dict puts the
original module back in sys.modules on exit, but not the `adapters` package
attribute the re-import rebound, and `import adapters.message_router as mr`
resolves through that attribute. A later test then received the module built on
the mocked ChannelResponse: test_ent751_gate_callers::
test_a_channel_message_held_by_the_gate_is_answered_in_the_conversation failed
after test_1533 + test_channel_image_vision, reproduced on the base commit
09f9d0868 (pre-existing, order-dependent; surfaced by an -n auto run).
An autouse fixture in the polluting file puts the binding back after every
test. The three-file reproduction goes from 1 failed to 88 passed. Learnings
fragment added (the 2026-08-10 package-attribute / sys.modules class).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(skills): a gated skill is refused inside the agent unless its run was approved for it (abilityai/trinity-enterprise#752)
The dispatch-time gate (#751) reads what a requester typed, so a request
that names a gated skill only in prose ("please pay the invoice") reached
the executor, and the agent's own Skill call loaded the skill. On Claude Code
agents a PreToolUse hook now asks the platform before a skill loads into a
run, and refuses it unless that run was cleared for it.
- Hook: docker/base-image/hooks/skill-gate.py (bootstrap) + _skill_gate.py,
registered by its own managed-settings drop-in
(/etc/claude-code/managed-settings.d/50-skill-gate.json, root 0444, exec
form under `env -i` + `-I -S`, LD_* pinned empty) on Skill and on
Agent/Task subagent `skills:` preloads. Identity from /proc (the claude
process's launch env, the container's), never its own env. Every outcome
is exit 0 or 2 under a 10 s deadline — for this CLI exit 1, a crash or a
hook timeout lets the tool run. No verdict → a root-owned marker decides:
an agent with gates fails closed, every other agent keeps its skills.
- Check: POST /api/skill-gate/check (routers/skill_gate.py), the agent from
its key (get_self_agent), every verdict a 200; refusals hand the request
back ("…with a message that includes /pay-invoice…"), name no person or
role, and are audited skill_gate_refused (throttled).
- Clearance is execution + skill (D3), not an input hash: #751's approved-run
record, or a new `self_approved` record that record_self_approval writes at
/task, /chat (moved from admission to the row's setup — the agent receives
the row's id, not the capacity slot's) and the execute_task backstop. No
migration: a new value in skill_gate_requests.state, outside the lattice.
- A self-approved /chat turn runs in its own session (isolated_session), so
the next caller never resumes a context with the skill loaded.
- Marker synced at start/recreate (gated agents only), self-healed from
the check (rate-limited) and serialised per agent; /health reports
skill_gate_hook.
- Review hardening: a skill or subagent definition whose name the hook
cannot read (or an unterminated front matter) is "could not tell", never
"not gated"; block-scalar names are read; no subagent_type means
general-purpose (a definition may override it); CLAUDE_CONFIG_DIR from
the claude env adds its dirs; refusal audits are capped per agent (the
run id is the caller's); a logger that cannot start never changes the
verdict. Every wired call site and each review fix was mutation-checked.
Inert until ent#753 supplies the gate map. Stated limits in
requirements/security.md §26.13 (Claude Code only; not a boundary against an
adversarial executor — credential confinement is, R23 / ent#558).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): the hook allows a Skill call inside a cleared run; a leading slash never reaches it (abilityai/trinity-enterprise#752)
The localhost eyeball showed that Claude Code expands an approved or
self-approved request that starts with /pay-invoice without any Skill call,
so the hook is never asked about it; the dispatch-time check decides those.
The hook's allow path is a Skill call inside the cleared run (a slash
mid-sentence, or the model choosing the skill).
- skill-gate.md Testing step 7 now uses the mid-sentence form for the approved
and self-approved cases, adds the isolated /chat probe and the no-run case,
replaces "stop the backend" (the chat itself goes through it) with a check
route that answers 503, and saves the running base image before the build so
the restore never downgrades the agents.
- §26.13 and the flow's limits say what a clearance lets through.
- "the session tab" is the Chat tab.
- Status records the 2026-10-05 eyeball.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(skills): the real-/proc reader test says it is Linux only, so test-runner classifies its skip (abilityai/trinity-enterprise#752)
The skip matched no rule in test-runner's skip-patterns.txt and showed up as an unclassified warning in /verify-local's unit stage. Its skipif is exactly 'not Linux', so the reason now says so and the existing GATED:Linux only rule classifies it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
* docs(positioning): lead 'What is Trinity' with the operating-system line (abilityai/trinity-enterprise#798) (#3257)
Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* docs(positioning): README, distribution surfaces and first-run screen carry the ADR-0011 line (abilityai/trinity-enterprise#797) (#3258)
* docs(positioning): README carries the ADR-0011 headline (abilityai/trinity-enterprise#797)
Hero alt text, H2, lede, "The solution" line and footer move onto the
ADR-0011 line; the enterprise-modules sentence drops the bare "SSO" (T3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(positioning): installer, image listings and CLI carry the one-line form (abilityai/trinity-enterprise#797)
install.sh banner (width kept at 60), AWS/DO listing titles, AMI
ami_description, CFN Description first line, DO/Vultr MOTD taglines, and
the CLI docstring, pyproject description, Homebrew desc, src/cli/README
and docs/CLI.md now read "the operating system for the AI-native company".
Description strings only; no other value changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(positioning): first-run screen carries the Trinity one-liner (abilityai/trinity-enterprise#797)
The /setup lede now opens with the ADR-0011 line; the CTA and the
Governed/Auditable/Your-infrastructure chips are unchanged. Text only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(positioning): runtime prompts carry the Trinity one-liner (abilityai/trinity-enterprise#801) (#3259)
* feat(positioning): runtime prompts carry the Trinity one-liner (abilityai/trinity-enterprise#801)
* test(positioning): case-insensitive absence guard for the retired taglines (abilityai/trinity-enterprise#801)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* docs(positioning): engineer context carries the ADR-0011 line (abilityai/trinity-enterprise#800) (#3260)
* docs(positioning): engineer context and template card carry the ADR-0011 line (abilityai/trinity-enterprise#800)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(positioning): memory, onboarding and diagram docs carry the ADR-0011 line (abilityai/trinity-enterprise#800)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(positioning): name all three shipped runtimes in the supporting-facts line (abilityai/trinity-enterprise#800)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(charts): the bar tooltip renders in <body> and flips below when there is no room above (#3264) (#3267)
On the Workspace agent band the activity chart is 23px tall, sits just
under the page header and inside ScanlineReveal's clip-path. The
tooltip was an `absolute bottom-full` child of the bar, so it always
opened upward and its top rows (the date, the top buckets) were covered.
StackedBarChart now teleports one tooltip to <body>, position: fixed,
placed from the hovered bar's rect: above when it fits, below when it
does not, clamped 8px inside the viewport on every side. It stays
hidden until measured and follows the bar through scroll and resize.
Applies to every caller (Workspace band, agent Overview, canvas charts).
Fixes #3264
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(nevermined): card plans pay — per-plan x402 scheme, caller-origin resource.url, A2A settled-reply metadata (#3215) (#3217)
* fix(nevermined): resolve the plan's x402 scheme for 402/verify/settle (abilityai/trinity#3215)
`_build_payment_required` passed no `scheme=` to the SDK helper, so every 402 —
and every verify and settle built from the same helper — advertised the SDK
default `nvm:erc4337`. The facilitator is POSTed that requirements document
verbatim, so `accepts[0].scheme` is the only channel the scheme travels
through: a fiat (card) plan's token was always checked against crypto
requirements and rejected. Card plans were unpayable.
- `PlanScheme(scheme, network)` + `default_plan_scheme(env)`; the erc4337
environment map stays an explicit frozen literal (a payments-py bump must not
move a live agent's network, #3216).
- `_build_payment_required(..., plan_scheme=None)` — `None` is the pre-#3215
document, so crypto bytes are unchanged per environment (golden test).
- `scheme_from_token` reads `accepted.{scheme,network,planId}` off the presented
token, allow-listed against the SDK's scheme/network vocabularies and checked
against this agent's plan id. This is what verify and settle use: it removes
every network call from the money path and makes a settle re-driven hours
later byte-stable with its own verify.
- `resolve_plan_scheme` (402 and `/info` only) parses `plans.get_plan` with the
SDK's own keys — parity-tested against `resolve_scheme`/`resolve_network` —
behind a per-(env, plan_id) cache, single-flight futures, its own 2-slot gate
(never a facilitator slot, so an anonymous 402 flood cannot starve paying
traffic), stale-while-revalidate and a WARN once per negative window. The SDK
swallows these failures at DEBUG, which is how a card plan silently stayed
crypto with nothing in Trinity's logs.
61 new tests; the 196-test ent#679/#3185 baseline stays green unchanged.
Mutations proven red: dropping `scheme=` (5 red), swapping settle's
token-derived scheme for a plan lookup (2 red).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(nevermined): one public origin for the card and both 402 doors (abilityai/trinity#3215)
AC5. The x402 402's `resource.url` is what a buyer's token is minted and
verified against, and the facilitator compares origin+path — so one wrong scheme
character mints a token for a URL the client never calls. Both payment doors
built it from `request.base_url`, which is http on a standard Trinity
deployment for two independent reasons:
- `docker-compose.prod.yml` / `docker-compose.hosted.yml` override the image
`command:` and drop the Dockerfile CMD's `--proxy-headers
--forwarded-allow-ips=*`, so uvicorn does not trust `X-Forwarded-*` at all and
`request.url.scheme` stays http behind ANY proxy. Restoring those flags is a
trust change (it re-prices `request.client.host` for every per-IP limiter, and
agents on the agent network reach `backend:8000` directly), so it stays
deferred — which is why `utils/public_url.py` reads the RAW header and never
`request.url.scheme`.
- the frontend nginx forwarded `X-Forwarded-Proto $scheme`, and `$scheme` is
http on that hop (TLS terminates upstream), clobbering the real https before
the backend saw it. Now a `$fwd_proto` map preserves an upstream https and
otherwise falls back to this hop's scheme, on all three proxied locations
(`/api/`, `/a2a/`, `/mcp`) — a door that lied about the scheme on one of them
would mint tokens for the wrong origin just the same.
`public_base_url(request, configured=, frontend_url=)` owns the precedence: the
operator's declared origin (Settings `public_chat_url` → `PUBLIC_CHAT_URL` →
`FRONTEND_URL`) when it is the host the caller actually used, else the request
host with an https upgrade — never a downgrade — from the raw header. The
same-host restriction keeps a caller that reached a private host from being sent
to a public one whose narrow tunnel may not route that path. One helper now
serves the agent card, the paid door and the A2A door, so a single request
yields a single origin; `_base_url_from_request` also starts honouring the
Settings row, as the Telegram/WhatsApp webhook URLs already do.
`GET /api/paid/{name}/info` — the card's `paymentInfoUrl`, from which a
payment-aware client pays on its first request — now loads the config WITH the
key so it can resolve the plan's real scheme, and emits an absolute
`resource.url`. The key is used server-side only; a test pins that it never
reaches the body.
34 new tests; 291 green across the #3215 files and the ent#679/#3185 baseline.
Mutation proven red: removing the header upgrade → 9 red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(nevermined): mirror the A2A payment receipt onto Task.metadata, plus docs (abilityai/trinity#3215)
Operator scope-add (2026-10-04T11:48:59Z): a settled `message/send` returned a
Task with `metadata: null` while `a2a-protocol.md` promised "a normal A2A Task
whose metadata carries the payment status and a receipt". The metadata WAS built
and placed — on `status.message.metadata`, which is the spec location (the
provider SDK's own `X402A2AUtils` reads exactly there) — but top-level
`Task.metadata` was never set, so a typed `a2a-sdk` client rendered a charged
turn as `metadata: null`.
`_task_object` now mirrors it from the same assignment, the same dict object, so
the two locations cannot drift. The docs name `status.message.metadata` as the
primary location and the mirror as the compatibility half. The free path still
emits no `metadata` key at all, so an unpaid Task's bytes and every idempotency
snapshot built from them are unchanged.
Docs:
- `user-docs/integrations/a2a-protocol.md` — names both locations precisely.
- `user-docs/integrations/nevermined-payments.md` — crypto vs card plans and the
scheme/network each advertises; why the 402's `resource.url` origin matters and
which knob fixes it.
- `user-docs/guides/deploying/public-access.md` — the A2A door was published by
#3213 and the narrow tunnel table had no row for it. Added with ANCHORED rules
(`^/a2a/[^/]+$`, `^/a2a/[^/]+/\.well-known/agent-card\.json$`): an unanchored
`/a2a/*` would also match `/api/agents/{name}/a2a/call`, Trinity's authenticated
OUTBOUND caller, which has no business on a public hostname.
- `memory/requirements/public-access.md` §23.2–23.3, `memory/requirements/mcp.md`
FR-4 + new FR-4a, `memory/feature-flows/nevermined-payments.md` (two new
sections + the two new knobs), `memory/architecture/backend.md` one-liners for
`nevermined_payment_service.py`, `paid.py` and `a2a.py`.
11 new tests; 382 green across the #3215 files and every payments/A2A neighbour.
Mutation proven red: removing the mirror → 6 red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(a2a): card keeps the configured public origin; doors stay same-host (#3215 review I3)
#3215 made ONE origin rule serve the agent card and both payment 402s. The
doors needed same-host — an x402 token is minted and facilitator-verified
against `resource.url`, so a 402 quoting another origin is unpayable. But the
card is the opposite kind of document, and same-host regressed it: the
`get_agent_a2a_card` MCP tool proxies the card route from `backend:8000`, so a
card fetched there advertised `http://backend:8000/a2a/{name}` to every external
buyer. That is dev's behaviour broken on an existing surface, and the plan's own
T5 text named configured-first as "keep the card as is".
Still one module and one upgrade rule, now one keyword apart:
* `public_base_url(..., configured_wins=False)` — default unchanged, so every
door's bytes are identical. `True` returns the declared origin whatever host
the caller used, and falls through to the SAME request-host + upgrade-only
raw `X-Forwarded-Proto` logic when nothing is configured.
* `routers/a2a._card_base_url` wraps it for the two card routes only (the
authenticated per-agent card and the public well-known card). The A2A
JSON-RPC door, the paid door and `/info` keep T5 exactly as built.
Safe because the two only differ where it cannot matter: a buyer that follows
the card arrives on the configured host, so the door's own same-host rule mints
that 402 for that very origin — the 402 is byte-identical to the URL the card
sent it to (pinned by a test). With nothing configured the two are the same
function.
Tests: 14 new in `test_3215_public_base_url.py`, 10 red before the fix —
`TestConfiguredWins` at the helper, `TestCardAndDoorOverTheRealRoutes` over the
real routes (both card surfaces and the anonymous 402 on one internal host,
which is the only layer that can catch a call site passing the wrong
precedence), and `TestOneOriginPerRequest` adjusted to the new rule rather than
trimmed. The route-level fixture imports `dependencies` at collection time on
purpose: `tests/unit/conftest.py` pops it from `sys.modules` between tests, so a
fixture-local import makes `dependency_overrides` stop matching after the first
test. 405 green in the #3215/#3185/ent#679 batch (391 before).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(nevermined): cancelled plan-lookup leader fails followers loudly (#3215 review I4)
The single-flight leader's `finally` answered its followers either way, but with
the WRONG answer when it had nothing: a cancelled leader (its caller
disconnected, its request shut down) completed the shared future with
`default_plan_scheme(...)`. Every follower then returned crypto for a card plan
with no WARN, no negative window opened, and last-known-good never consulted —
the exact silent mis-advertisement #3215 exists to stop, reintroduced at the one
place the service is not looking.
A leader that resolved nothing now sets `PlanLookupLeaderLost` on the future
instead. Each follower already catches it, so it falls into its existing
`_record_plan_lookup_failure` branch: the 402 still gets an answer (fail-open is
the accepted design), but it is last-known-good when there is one, the negative
window opens, and the WARN names the reason. The leader's own `CancelledError`
still propagates unchanged — `finally` swallows nothing. The future's exception
is marked retrieved, so a cancelled 402 with no followers does not log "Future
exception was never retrieved" at GC.
Tests: 4 new in `test_3215_plan_scheme.py::TestCancelledLeaderFailsFollowersLoudly`
— the WARN and the negative window, last-known-good preferred over the default,
no asyncio noise with zero followers, and the successful-leader path proved
untouched. 2 go red with the fix reverted (verified against a scratch copy, then
restored byte-identical); the other 2 are the no-regression arms. 409 green in
the #3215/#3185/ent#679 batch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(nevermined): the cancelled-leader test uses a genuinely slow leader (#3215)
`svc2._get_payments_client = svc._get_payments_client` handed the leader
svc's delay-less `_Plans`, so its lookup finished in microseconds and
whether `leader.cancel()` landed first was a thread-vs-event-loop race:
green on Linux CI, red on every macOS run. The cache and the in-flight
registry are module-global, so last-known-good is shared without sharing
the client — drop the override and let the 0.2 s delay do its job.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
* feat(workspace): opening an agent starts a new chat with the cursor in the message field (abilityai/trinity-enterprise#784) (#3219)
* feat(workspace): opening an agent resolves to a new chat (abilityai/trinity-enterprise#784)
Replaces `landingThread` with one pure rule, `agentLanding`, that every door
which has to RESOLVE a landing calls. ent#523 landed you in the chat you were
most recently active in; most visits to an agent start new work, so resuming
cost two actions every time. The default is now a new, empty chat.
Nothing is minted by the landing: the helper is pure and returns a null
session, and the row is born on the first send (`newThread`, ent#451), so
repeated visits accumulate no empty chats. An unused Main is already kept out
of the chat list by `sidebarThreadsOf` — the AC 4 guard, still green.
`agentLanding` takes `lastOpenSessionId` as the seam ent#621's agent-switch
keys will pass, honoured only when it still names a live, unarchived chat of
this agent in the principal's own thread list — so a stale or forged id falls
back to the default rather than landing somewhere it should not (#3140 class).
No Map is built here; ent#621 adds the state and the handler.
`resolveAgentLanding` keeps its signature and delegates, so the `?agent=` deep
link and the sidebar row cannot drift. `forceNew` stays accepted for existing
`?new=1` links and is now redundant rather than wrong.
Shell wiring (`landOnAgent`, the focus gate) follows in the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(workspace): land on the new chat, and focus only when asked (abilityai/trinity-enterprise#784)
Wires the shell to the ent#784 rule and gates the composer focus.
`landOnAgent` is now synchronous. The awaited `ensureMainListed` is gone from
the landing path — the rule needs nothing from the network — and with it the
overtake race that awaited round trip required. The pinned Main is still
minted on the first visit; `watch(activeAgentName)` owns that promise (ent#523)
and no longer blocks the landing.
The landed chat KEEPS `/workspace/a/:name` rather than escaping to bare
`/workspace`, so a reload, a bookmark or a copied link still names the agent;
the first send replaces it with the thread's own URL as today. An idempotency
guard makes the second watcher fire (the thread list arriving) a no-op, so it
cannot remount an unsent chat and throw away what was being typed.
`landOnAgent` now asks `guardLeaveCall` BEFORE touching `activeAgentName`,
which feeds `convKey`. Back/forward and a typed `/workspace/a/:name` reach the
landing without passing a click door, so they could end a live voice call
without a word (the ent#551 class; the click doors were already guarded).
Focus is gated on WHY the fresh composer mounted, not on the pointer alone.
A gesture (New chat, ⌘J, the agent picker, switch-agent) passes `always` and
focuses on any pointer, keeping #2579 AC 2 on Android. A landing passes
`fine-pointer` and focuses only where that cannot summon an on-screen
keyboard — the "unprompted" the issue names. `shouldFocusOnRestore` is renamed
`shouldAutoFocusComposer`: two reasons now share the one rule.
Tests: the focus gate is proven by MOUNTING PortalConversation under a stubbed
`matchMedia` (#2918), asserting `document.activeElement`, with focus shown to
be elsewhere first. Reverting either the rule or the gate turns 10 cases red on
behaviour — session ids and activeElement, not source text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(workspace): document the new-chat landing rule (abilityai/trinity-enterprise#784)
Tiered docs for a feature change: the owning area file, the requirement, and
the feature flow.
- `architecture/workspace.md` — `agentLanding` is the one landing rule and what
it now answers; the synchronous `landOnAgent`, the kept URL, the guard order,
and the `focusOnMount` mode.
- `requirements/core-agent.md` — the ent#523 "One page" rule amended rather
than rewritten: what reversed, and that landing mints no row.
- `feature-flows/workspace-agents-at-the-centre.md` — the rule table, the "a
tab is not a landing" note (the strip is now the only way back), the two
doors that RESOLVE vs the gesture doors that ASSERT, and a `## Changed by
ent#784` section rather than edited history. Names the follow-up: backend
adoption of an empty Main on `new_thread=True` is not done here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(portal): [I1] a thread-list re-fire must not re-mint over an adopted chat (abilityai/trinity-enterprise#784)
ent#784's landing stays on `/workspace/a/:name`, so the watcher that resolves
that route can now re-fire with the route unchanged — once per thread-list
refresh — for as long as the person stays there. `landOnAgent`'s idempotency
guard keys on `startingNewChat`, which the first send clears, so a refresh
settling in the window between `onSessionAdopted` and the asynchronous
`router.replace` nulled `pendingSession` and bumped `convGen`, remounting an
empty composer over the thread whose first reply was streaming (and handing the
`/c/:id` watcher a #3140 "chat isn't available" for the chat just created).
Guarded in the watcher instead: a list-only re-fire is a no-op, keyed on "the
route did not change on this fire" AND on having already landed this name. Both
clauses are load-bearing — the name alone swallows the cold deep link, whose
only fire IS the list arriving with the param already in place. Deliberately
NOT keyed on `pendingSession`: back/forward from `/workspace/c/:id` arrives
with a session set and must still land fresh (T4).
New behavioural pin `portalAgentLandingRemount.mount.spec.js` (shallow-mounts
Portal.vue, asserts the conversation's props and instance identity, not source
text): the adopted-session case went red before this change with
`sessionId: null`; the three regression cases — composing, same-agent
back/forward, cold deep link — were green before and stay green.
Also fixes `portalUnavailableTargets.mount.spec.js`, which still asserted
ent#523's landing (`/workspace/a/scout` → `/workspace/c/t1`) and was failing on
this branch; it now proves the page opened by the conversation on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(portal): [I3] mount cases for decision #13 and the T4 guard, not source-text pins (abilityai/trinity-enterprise#784)
`landOnAgent` was pinned only by regexes over its own function body
(`workspaceNewChat.spec.js:181-211`), so nothing executed it: an inverted
guard, or the right lines in the wrong order, reads byte-identically to a
regex. The shell is mount-testable (`portalUnavailableTargets.mount.spec.js`),
so these assert STATE instead.
Decision #13 (ent#551 class) — back/forward reaches the landing without passing
a click door, and `activeAgentName` feeds `convKey`, so a live call must be
asked about BEFORE anything is written: mid-call, the dialog opens and the chat,
its props and its instance identity are untouched; confirming then performs the
landing it deferred; cancelling leaves call and chat exactly as they were.
T4 — one navigation to an agent page mounts exactly ONE conversation, however
often the thread list moves underneath it.
Mutation-verified, both directions: with `guardLeaveCall` removed from
`landOnAgent`, the two mid-call cases go red; with the T4 guards removed, the
composing and one-mint cases go red. The source-text pins are kept as
supplements (they still pin "no await / no ensureMainListed / no router.push"
inside the function, which state cannot see).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(portal): opening an agent lands on its newest draft (abilityai/trinity-enterprise#784)
F1 of the operator's 2026-10-05 reopen. `agentLanding` always answered a new
chat, so a draft typed into an existing chat was not where opening the agent
landed — and the draft mark on the agent's row pointed at words that clicking
it could not get back to. `e2e/workspace-drafts.spec.js:92` and `:116` describe
the wanted behaviour and were red at c8af3d70.
The rule is now the ruling's precedence: a link naming a chat (never reaches
here), then the ent#621 `lastOpenSessionId` seam unchanged as the first arm,
then the agent's chat holding an unsent draft — newest `updatedAt`, with the
unsaved `new:<agent>` chat weighed on the same clock — then a new chat. A
`new:` winner is `sessionId: null`, which is where those words already live; a
thread winner opens that thread and its remount restores the draft.
The candidate set is the set that lights the sidebar's mark: `isDraftedThread`
is shared by `agentsWithDrafts` and the new `draftedLandingFor` rather than
copied, so "the mark means click here to continue" cannot drift — a room and an
archived thread are excluded on both sides. The drafts map is passed IN by both
doors (`landOnAgent`, `resolveAgentLanding`), never read inside the rule, so
one rule serves every door and stays pure.
`composerFocusMode` moves above the branch in `landOnAgent`: a landing on a
drafted chat is the same kind of arrival, and `openThread` does not touch that
ref, so a previous gesture's `always` would otherwise have made the next
landing focus on a touch device (T3(b)). The restore's own caret already shares
`shouldAutoFocusComposer`. The T4 remount guard is untouched: the watcher still
returns on a list-only re-fire, and the drafted-thread arm leaves the agent URL
through `openThread`, so the guard is never reached with a stale answer.
Tests: `portalDraftLanding.spec.js` (each arm, newest-wins both ways, the tie,
room/archived exclusion, and the property that every MARKED row is a landing
candidate) and `portalDraftLanding.mount.spec.js`, which replays both e2e flows
at the shell's seam — red at c8af3d70 on the e2e's own symptom, 2 of 3 failing
with `sessionId: null` where the draft was.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(portal): opening an agent reuses its empty chat (abilityai/trinity-enterprise#784)
F2 of the operator's 2026-10-05 reopen: "opening an agent must not create a new
chat every time; if an empty chat with that agent already exists it is reused,
and at most one empty chat per agent exists at any time."
Arm 4 of the precedence, under the drafts arm and above a new chat.
`agentEmptyChat` is the read side: an unarchived, non-room thread of this agent
with no message sent — ent#523's own "unused Main" test, widened to any row
that fits it. Both fields are required because the two reads disagree: the
cross-agent batch omits `message_count` (so an absent count must not read as
used) while the per-agent read carries it (so a count of 2 must not read as
empty just because `last_message_at` is missing). Several empty rows — legacy
data — resolve Main first, then newest `created_at`, then id, so list order
never decides and two doors reading one list cannot reuse two different rows.
`ensureMainListed` is the write side, and the only place the "at most one" half
can be held: it is a GET that INSERTS (`list_sessions` → `ensure_main_session`),
so for an agent with an empty chat but no Main — legacy, since nothing mints a
non-Main empty row today — visiting it would add a SECOND empty chat and then
land on one of the two. It now returns early for that case. #2579's "the pinned
tab has to be there" still holds for every agent whose chats are all used,
which is the case it was about; that is pinned as its own mount case rather
than left to the comment. No backend change: nothing here asks the server for
anything it does not already do.
Three existing specs carried fixtures with neither message field, which under
the old rule meant "a thread exists" and under the ruling means "an empty chat
to reuse" — the assertion they were written for. Each is updated to a USED row
and the empty case is asserted separately, so none of them went green by
accident: `portalAgentsAtCentre` (the landing arms), `workspaceAgentLanding`
(the `?agent=` door, both id shapes) and `workspaceNewChat`, where `?new=1` is
load-bearing again — it is the one way past the whole precedence, so the two
answers now have to differ.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(portal): a landed new chat keeps its rail, its new-thread intent and the caret (abilityai/trinity-enterprise#784 review)
ent#784 made /workspace/a/:name the URL a landed new chat RESTS on. Three
things had only been true because nobody stayed there; each was reproduced
in a browser against the rebased branch.
- Clicking the row of the agent whose new chat is already on stage cleared
`startingNewChat` in `openAgentPage` and then pushed the URL it was
already on, so the landing never re-ran. The composer still read "New
chat" and sent its first message without `new_thread`, which the server
resolves to the agent's Main chat. The click is now a no-op that hands
the caret back.
- The rail rules still read the agent URL as "a page, not a conversation",
so a landed new chat had no rail, and the first send (which moves the URL
to /workspace/c/:id) slid one in beside a reply mid-stream. The URL is
rail-free only until the landing has put the named agent on stage, and
its column is reserved mid-load like any 1:1 route.
- A landing that reuses a chat (the agent's empty one) goes through
`openThread`, and a thread mount focuses nothing, so the caret stayed on
the sidebar row. The shell hands it over under the landing's own
fine-pointer rule.
Tests: portalAgentLandingStage.mount.spec.js (10 cases, mounted shell).
Four were red before the fix, for these reasons.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
* feat(operator-queue): approvals get a "Something else" answer (#3242) (#3250)
* feat(operator-queue): backend sink accepts the reserved (something else) 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>
* feat(operator-queue): contract text and MCP tools name (something else) (#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>
* feat(operator-queue): desktop Something else chip (#3242)
- 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>
* feat(operator-queue): Something else on /m and the Workspace (#3242)
- /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>
* docs(operator-queue): the something-else answer on approvals (#3242)
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>
* fix(operator-queue): drop the reversed Send-predicate source pin and dead optionsOf import (C1, I1) (#3242)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(operator-queue): pin readback, marker mirror, guide literal, near-miss literals; public decided_by_options; learnings (I2 I4 I5 I6 I7 I12) (#3242)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(operator-queue): carry the sink's decided_by_options on the operator projections; gate- is only the fallback (I3) (#3242)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(operator-queue): atomic asks — authoring rules in the ask_operator contract, option and title limits (#3243) (#3253)
* feat(operator-queue): backend sink accepts the reserved (something else) 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>
* feat(operator-queue): contract text and MCP tools name (something else) (#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>
* feat(operator-queue): desktop Something else chip (#3242)
- 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>
* feat(operator-queue): Something else on /m and the Workspace (#3242)
- /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>
* docs(operator-queue): the something-else answer on approvals (#3242)
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>
* fix(operator-queue): drop the reversed Send-predicate source pin and dead optionsOf import (C1, I1) (#3242)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(operator-queue): pin readback, marker mirror, guide literal, near-miss literals; public decided_by_options; learnings (I2 I4 I5 I6 I7 I12) (#3242)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(operator-queue): carry the sink's decided_by_options on the operator projections; gate- is only the fallback (I3) (#3242)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(operator-queue): cap option count/length, agent titles and chip 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>
* feat(operator-queue): hold over-cap queue-file entries and name them 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>
* feat(operator-queue): teach atomic asks in the ask_operator contract 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>
* feat(operator-queue): document the atomic-ask caps and their env knobs (#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>
* fix(operator-queue): forward the three #3243 caps on the hosted compose so parity is green (#3243)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(operator-queue): quote cap defaults, name the agent title limit first, fold NFKC/zero-width chip lookalikes, register the test (#3243)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(operator-queue): describe the #3243 cap holds in the stale ingestion docs and add the §26.7 authoring-caps bullet (#3243)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): ask_operator fits Claude Code's 2,048-character description cap (#3243)
Claude Code shows a model only the first 2,048 characters of a tool
description (#3234); ask_operator was 2,676 at 3c14c23a, 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>
---------
Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
* fix(files): leading slashes no longer skip the deny list and fences; DELETE carries the deny list (abilityai/trinity-enterprise#792) (#3276)
* fix(files): normalise leading slashes before the deny list and fences; DELETE carries the deny list (abilityai/trinity-enterprise#792)
The backend file routes normalised a user path with posixpath.normpath, which
keeps exactly two leading slashes, while the agent server's Path.resolve()
reads them as one. A `//`-prefixed path matched none of the path-anchored deny
patterns and neither capability fence.
- _normalize_user_path collapses any run of leading slashes to one.
- PUT, mkdir and DELETE send the agent the normalised path that was checked,
not the raw input, so the check and the action read one string.
- DELETE /files applies the deny list too (_is_user_deletable_path), after the
access check and the skills.manage fence, before the container lookup. A
directory that holds a path-anchored protected path is refused as well
(.ssh, .claude, the home dir); anchors are derived from the pattern tuple.
Refusal: 403 "Cannot delete protected path: {path}".
Tests run against the shipped module: test_files_protected_paths.py no longer
tests an inline copy. Added // rows, a delete table, the pinned anchor set,
three Hypothesis properties, route-logic checks and a real-router check on all
three routes (mkdir takes its path in the JSON body); // rows on the ent#596
fence; live-tier DELETE and // rows in test_files_guardrail_bypass.py.
Mutations, each restored byte-identical: reverting the normaliser line turns
30 tests red; dropping the DELETE check 11; dropping the holding-directory
clause 22; forwarding the raw path 4; cutting anchors at the first glob 2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(guardrails): the in-agent file and read-only guards collapse leading slashes too (abilityai/trinity-enterprise#792)
PR review found the agent-side copy of the policy with the same shape: the
PreToolUse file-guardrail.py and read-only-guard.py normalised file_path with
os.path.normpath, which keeps exactly two leading slashes, then matched absolute
patterns such as /home/developer/.ssh/*. In the shipped image a //-prefixed
.ssh or .claude/settings.json path exited 0 where the one-slash form exited 2.
files.py names file-guardrail.py as KEEP IN SYNC.
- Both _normalise functions collapse any run of leading slashes to one.
- tests/unit/test_ent792_file_guardrail_hook.py runs the shipped hook's main()
against the shipped baseline; test_read_only_guard.py gains // rows.
Reverting the hook fix turns 7 and 2 tests red; the fixed hooks, mounted into
trinity-agent-base, deny the // forms in the real image.
Review follow-ups in the same commit:
- a string prefix of an anchor ("/et", "/home/dev") is not a directory above
it: allowed rows plus a segment-based oracle, so `a.startswith(head)` no
longer survives (5 red);
- docs: the agent echoes the normalised path (path/deleted fields and its
404/409 messages); the flow's backend list names .env.*; only the skills.manage
fence exists on dev; the security report records the hook finding the first
pass missed and corrects its…
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.
Fixes abilityai/trinity-enterprise#801 — the runtime-prompt half of the ADR-0011 positioning epic (abilityai/trinity-enterprise#796). Independent branch off
dev— the four #796 children touch disjoint files and merge in any order (operator, 2026-10-05). Draft until the chain is in.What
src/backend/services/platform_prompt_service.py— thePLATFORM_INSTRUCTIONSheader line every agent receives on every turn now reads "This agent runs on Trinity, the operating system for AI-native companies." (one neutral sentence, not the two-line pitch; about the same token count as before). Rebuilt on every invocation, so every agent on an instance carries it from its next turn after deploy — no restart, no fleet pass.config/trinity-meta-prompt/prompt.md— the sentinel copy's line 3 reworded to the same sentence (ruling: reword, not delete).config/agent-templates/trinity-system/CLAUDE.md— the system-agent seed now says "for this Trinity instance." and carries no positioning claim.tests/unit/test_ent801_positioning_prompts.py(new) — builds the live header throughget_platform_system_prompt, reads the meta-prompt and the seed from disk, pins the three retired phrases absent (case-insensitive, matching the issue'sgrep -i) and the new sentence present in the two live surfaces.Two commits:
f8632f33(the three edits + the test) andb6f0d83a(review fix I1, the case-insensitive guard). The issue's acceptance grep returns nothing on the branch.Rulings carried (orchestrator, on the operator's behalf — plan file)
test_1402_prompt_contract.pypins six operator-communication phrases and one absence, nothing about line 3, so it stays untouched and the file remains a faithful copy of the live header.sovereignbeyond absence, kept out oftest_1402.docs/are not this issue's rows: six belong to abilityai/trinity-enterprise#800, three are dated records (docs/research,docs/security-reports,docs/archive).Review + security
/review(claude-fable-5-1, report-only): MERGEABLE — 0 critical, 1 informational (I1: the absence guard compared case-sensitively while the acceptance grep is-i), fixed inb6f0d83a. Exactly four files in the range, every edit the plan's After text; the system-agent line ends "for this Trinity instance." with no claim;test_1402_prompt_contract.pyuntouched; the new test not being listed intests/registry.jsonrated not a finding (CI collects by glob; 635 of 968 unit files are unlisted);.claude,src/backend/enterpriseand the rootCLAUDE.mduntouched; the line-53 hunk sits outside the ask section that the chain-3242 drafts edit./cso --diff: 0 findings.Tests
test_ent801_positioning_prompts,test_1402_prompt_contract,test_ent243_prompt_tier,test_platform_prompt_unit,test_platform_prompt_runtime,test_1816_system_agent_adoption,test_1816_system_agent_convergence) atf8632f33; 23 passed overtest_ent801+test_1402after the fix.Before merge
platform_prompt_service.pyandprompt.md— feat(operator-queue): an agent sees its pending asks and can replace one (#3247) #3256, feat(operator-queue): atomic asks — authoring rules in the ask_operator contract, option and title limits (#3243) #3253, feat(operator-queue): approvals get a "Something else" answer (#3242) #3250 (chain-3242, stacked) and feat(workspace): Discuss and Dismiss on asks (abilityai/trinity-enterprise#747, #748) #3181 (ent#747/748):git merge-treeof each head against this branch is clean, and this branch adds no conflict beyond what each already has againstdev(none, 2026-10-05 23:20 UTC). Merge in any order.dev@ 493d2a6 clean.ask_trinitycloud function's own prompt (ask_trinity_client.ts:35, outside this repo) and confirm it carries no tagline; the corpus half is fixed by docs(positioning): user-docs 'What is Trinity' leads with the operating-system line (abilityai/trinity-enterprise#798) #3257 (ent#798).trinity_promptsetting on trinity.ability.ai, unless it is a deliberate canary.Handoffs
docs/memory/CLAUDE.mdhits (branchfeature/800-positioning-engineer-context).ask_trinity.🤖 Generated with Claude Code