diff --git a/docs/memory/architecture/api-endpoints.md b/docs/memory/architecture/api-endpoints.md index bc8391aa2..a8784691b 100644 --- a/docs/memory/architecture/api-endpoints.md +++ b/docs/memory/architecture/api-endpoints.md @@ -43,6 +43,7 @@ | POST | `/api/agents/{name}/a2a/task` | Poll a remote A2A task by id on the same registered endpoint (#736). Same gates; deliberately NOT `effect_guard`-wrapped — a poll is a read, and deduping it would answer "has it finished yet?" from a snapshot of the last time it had not | | GET | `/api/agents/{name}/metrics` | The agent's declared metrics joined to their RECORDED points, with freshness (ent#479) — the ONE read of a business number. Same URL as the `metrics.json` proxy it replaces; **store-only, no container**, so a STOPPED agent answers identically (the proxy returned "Agent must be running", making every number vanish exactly when an operator wanted its last value). `AuthorizedAgentByName` (uniform 404) **then** the metric-read gate — an agent key reads its own numbers, or another agent's while holding an `agent_permissions` grant on it (ent#727; charged to the reader's 240/min budget and audited hourly per reader/target/actor); 240/min per agent. `window ∈ {auto,24h,7d,30d,90d}` + `since`/`until`; `auto` = `max(24h, 12 x cadence)` capped at 90d, because a cadence spans 60s–1y and a fixed 24h shows a weekly metric four points. Bucketed by default (≤120/series); raw `points` only on the single-`metric=` path, truncation keeping the NEWEST. 422 `window_invalid` / `metric_undeclared` (a retired name names `include_retired`); 503 `metric_store_unavailable` + `Retry-After`. NOT 404 for an undeclared metric: the MCP classifier reads 404 as `not_authorized` per the #186 convention, which would tell an agent it lacks access to itself. Echoes the persisted D-010 `metrics.json`-superseded finding with `findings_evaluated_at`, so an empty list before the first compat run reads as *not evaluated* rather than *clean* | | GET | `/api/agents/{name}/objectives` | The agent's objectives joined to its metrics (ent#666) — target, actual, freshness and the gap, computed in ONE place so the role card, the project view (ent#661 v3) and proactivity cannot disagree. Gate order is `/metrics`'s verbatim: `AuthorizedAgentByName` (uniform 404) → agent self-gate (403; an agent key reads only its own) → limiter on the VALIDATED name. **Its own rate knob** `OBJECTIVES_READ_RATE_LIMIT` (default 60/min, a quarter of `/metrics`; ONE bucket per agent, spelled in `services/objectives_read_budget.py` and shared with the Workspace role card's objective read, which leaves the objectives out instead of answering 429 — ent#676) because unlike that store-only read this one drives the CONTAINER: a directory listing plus up to 100 small file reads through the agent door, ≤ 2 in flight, aborting to `unavailable: agent_unreachable` on the first typed transport failure rather than driving open the circuit chat rides on — plus a 30 s wall-clock budget (`objectives_dir: "timeout"`) and a 5 s per-read timeout, because an agent-server that answers SLOWLY raises no typed error and the abort alone would not bound it. Not store-only by design — files are truth and they live in the container (E7/E13), so a stopped agent answers `unavailable: agent_stopped` with copy naming the fix, never a cached number. `gap.status ∈ {behind, on_target, ahead, off_target, not_computable}` is POSITION relative to target given direction, never pace. 503 `metric_store_unavailable` + `Retry-After: 30` is the only non-2xx below the gates — every other failure is a NAMED field on a 200 (`unavailable`, `source.*`, `findings[]`), because "this agent is stopped" is an answer, not an error. `response_model=ObjectiveJoinRead` with a key-parity test, so an additive service field fails the build instead of being silently filtered out | +| GET | `/api/agents/{name}/canon/roles` | The seats the agent's canon defines — `/roles/*.yaml` as `{id, title, updated}` (trinity-enterprise#817), read through the same agent door and the same per-agent `objectives_read_budget` bucket as `/objectives`, same gate order (uniform 404 → agent self-gate 403 → limiter). Every empty answer is named (`unavailable: agent_stopped` / `reason: no_canon|absent|unreadable|timeout`), never a blank; `response_model=CanonRolesRead`. OSS-core; its only consumer is the gated assignments section's seat picker and drift check (`services/canon_roles_service.py`) | | GET | `/api/agents/{name}/metrics/definitions` | The agent's DECLARED metric registry (ent#477) — what `template.yaml metrics:` declares, as the backend reconciled it, NOT what it has measured. `AuthorizedAgentByName` then the same metric-read gate as `/metrics` (ent#727 — before it, any agent key read any owner-accessible registry); `?include_retired` serves definitions the template no longer declares (kept so points stored under their name stay interpretable). Answers on a STOPPED agent (it is a DB read). Carries `declared` + a next-action `message` for the empty state, `type_conflict` for a refused type change, and the retention/cap `policy` — live values resolved through the settings chain, `enforced: true` and a `source` per knob since ent#478 | | POST | `/api/agents/{name}/metrics/points` | Record a batch of observations of the agent's DECLARED metrics (ent#478) — the ONLY metric write path. `routers/metric_points.py`, registered before the agents router's single-segment routes. `AuthorizedAgent` + the #918 self-gate (an agent key records only as itself), then rate limit → size (2 MiB; a point cap is not a size cap — 1000 points x 10 dims x 128 chars is ~1.4 MB of legal input) → batch idempotency → validate → daily cap. **All-or-nothing**: 422 `{reason: "invalid_points", errors: [{index, metric, code, message, hint?}]}` with the frozen reason-code list (requirements §48.4). 429 splits into per-minute `rate_limited` and `daily_point_cap_exceeded` (with `Retry-After` to the next UTC midnight), because the remedies differ. DB failures are CLASSIFIED — connectivity is 503 + `Retry-After`, a content error is a non-retryable 500, since an agent told to retry a permanently unstorable batch retries forever. Every non-2xx exit after the idempotency claim goes through one `_reject` choke point that releases it, or one bad batch wedges the caller's key for 24 h. Replay returns the first snapshot with `X-Idempotent-Replay`; an in-flight duplicate is 409. A different value at an existing `(metric, ts, dims)` restates the row (ent#729): the 201 counts `recorded` / `deduplicated` / `corrected` separately (requirements §48.9) | | POST | `/api/agents/{name}/metrics/definitions/refresh` | Re-read the agent's live `template.yaml` and reconcile its registry (ent#477). `AuthorizedAgentByName` — a **use**, not a grant (the same principal can already `pull`; an agent's own scoped key may refresh itself). The third trigger beside creation and the git hooks, because an agent that edits its own block in-container and auto-syncs PUSHES it, so no backend `pull` ever fires. **Running agent only**: 409 `agent_not_running` (the stopped-agent read spawns a throwaway container, and no request-triggered route may create one as a side effect of a read), 503 `template_unreadable` — both named on `X-Refresh-Unavailable`, and both leave the registry untouched (#2196). Idempotent by construction, so Invariant #18 does not apply | diff --git a/docs/memory/architecture/backend.md b/docs/memory/architecture/backend.md index 48d0fc439..b71038133 100644 --- a/docs/memory/architecture/backend.md +++ b/docs/memory/architecture/backend.md @@ -133,6 +133,7 @@ - `metric_access_service.py` - Who may read another agent's declared metrics (ent#727): `can_read_agent_metrics(reader, target)` — the ONE predicate, self or an `agent_permissions` grant (the `chat_with_agent` edge); ent#80 replaces its body, never adds a second gate — and `audit_cross_agent_read`, one `authorization` row per (reader, target, route, actor) per hour, marker set only after the row is written. - `objective_join_service.py` - The objective ↔ metric join (ent#666) — the ONE place a target and an actual are put side by side, so the role card (ent#527), the project view (ent#661 v3) and proactivity (ent#605) consume it instead of each deriving a gap. Two halves in one module: the PURE half (`canon_root`, `parse_objective`, `objective_concerns`, `objective_is_active`, `select_objectives`, `resolve_direction`, `gap`, `join_objectives`) imports at module top with no store or Docker behind it, and the async half (`read_objective_files`, `read_objective_join`) goes through the agent door, because objective files live in the agent's own canon and nothing is copied platform-side (E7/E13) — a stopped agent answers `unavailable: agent_stopped`, never a cached number. **`gap.status` is position, never pace**: `behind` says the number is on the wrong side of the target now, not that the agent is late against `by`; `by` and `horizon` ride every row so a consumer can judge pace itself, and `hold` has its own arm (`on_target` within `tolerance`, else `off_target`) because a value that should be held has no good side to be on. Direction is the registry's first, falling through to the objective file's `up`/`down`/`hold` when the registry says `neutral` — that is the column DEFAULT, indistinguishable from silence, and no bundled template declares one, so registry-only would ship a feature where every gap is `not_computable` on day one; `direction_source` is what tells a declared `hold` apart from a default. **Stale is orthogonal to gap** (a stale metric keeps its gap and carries `stale: true`) and every failure is a NAMED finding with the fix attached — except a supporting-only agent's undeclared metric, which gets `metric_not_declared_here` rather than "declare it and refresh", advice it cannot take (the registry is per-agent; cross-agent reads are ent#80). The fan-out is deliberately narrow: ≤ 2 reads in flight, aborting to `agent_unreachable` on the first typed transport failure, because `AgentClient`'s circuit trips at three and it is the breaker chat rides on — and it does its own transport rather than `AgentClient.read_file`, which flattens every `AgentClientError` into `{"success": False}` and so cannot tell "this file is unreadable" from "this agent is gone". Scans 100 files, filters, then caps the OUTPUT at 20: capping the listing first hides an agent's own objective behind twenty foreign ones in a shared fleet canon - `objectives_read_budget.py` - The per-agent budget on the objective fan-out, spelled once (ent#676): `enforce` (429) for `GET /api/agents/{name}/objectives`, `admit` (never raises) for the Workspace role card — two doors, one bucket. Detail: `feature-flows/workspace-role-card.md`. +- `canon_roles_service.py` - `read_canon_roles(agent_name)` lists `/roles/*.yaml` with each file's id, `title` and `updated` stamp through the objective join's agent door (trinity-enterprise#817). Never raises and never blank: a stopped/missing/unreachable agent is `unavailable`, and no canon / no `roles/` / unlisted / timeout is a named `reason`. Backs `GET /api/agents/{name}/canon/roles`; the gated assignments section reads it for its seat picker and for drift checked on read (a row's recorded stamp vs the file's current one). - `metric_registry.py` - Declared-metric reconcile service (ent#477). Two entry points: `reconcile_metric_definitions(name, template_dict, source=…)` for the creation path (the resolver already parsed the template — re-reading would be a second source that can disagree) and `refresh_from_running_agent(name, source=…)` for every live trigger, plus `spawn_refresh_from_running_agent` (the fire-and-forget container-start hook, `spawn_gitignore_merge_after_clone`'s shape). The live read is `docker_service.execute_command_in_container` with a **fixed argv** (`head -c 256K /home/developer/template.yaml`, bounded by the primitive's enforced `timeout`, #2969) — the compatibility collector's own door, capped in-container so an oversized file never crosses the socket — then the backend's hardened loader, i.e. the SAME parse creation used. `/api/template-info` is not a door for this (it does not return `metrics`) and neither is `/api/metrics` (the superseded path). **Never retires on absence of evidence** (#2196): exec failure, empty output, oversize, unparseable YAML and a stopped container all raise `RefreshUnavailable` with a named `reason` and leave the registry untouched — only a template that PARSED and carries no block retires rows. Imports `{template_metrics, database, docker_service, utils.safe_yaml}`; never `services.agent_service` (#1991) - `agent_client/` (package, #1028 — `circuit` / `http_pool` / `client`) - HTTP client for agent container communication (chat, session, injection); hosts the transport circuit breaker — see [Circuit Breakers](execution.md#circuit-breakers-transport--dispatch-526) - `settings_service.py` - Centralized settings retrieval (API keys, ops config, agent quotas). ent#582 adds the per-call resolvers for the first-run keys: `get_gemini_api_key` (encrypted `google_api_key` → `config.GEMINI_API_KEY`, i.e. `GEMINI_API_KEY` → `GOOGLE_API_KEY`), `get_resend_api_key`, `get_email_provider` (a Resend key saved in Settings selects Resend over `EMAIL_PROVIDER`) and `get/set/clear_email_from_address` (`email_from_address` → `SMTP_FROM`). Every platform Gemini consumer (voice, portal voice, VoIP, Brain Orb voice, Telegram transcription, image/avatar generation, feature flags, `/api/version`) calls the resolver instead of the import-frozen `config.GEMINI_API_KEY` @@ -142,7 +143,7 @@ - `ask_service.py` - The ask sink (trinity-enterprise#611): the one path that raises an ask (`raise_ask`, the native create behind `ask_operator`; the gate path calls it with `raised_by="gate"`); a `gate` raise may pass `addressee=` to reach exactly one person, bypassing role resolution — an agent's raise that names one is a programming error, so an agent never chooses who is asked (ent#661) and the one path that ends it (`answer` / `cancel` / `bulk_cancel` / `expire`: CAS writer → audit → thin trigger → ending observers); since #3247 `raise_ask(replaces=)` also ends one of the agent's OWN pending asks (`disposed_by='agent'`, reason `replaced`) in the same locked transaction as the create (`_replace_target`, `_refuse_already_pending`, `request_id_of`). See [operating-room.md → Endings / Raising an ask](../feature-flows/operating-room.md#endings-trinity-enterprise611). - `portal_capabilities.py` - Open-core seam for **per-person Workspace capabilities** that depend on data the core does not hold (whether an email was invited to something a module owns): a provider registry keyed by capability name, asked by the roster as `has(name, email)`. OSS registers nothing → False; **fails closed** (a raising provider, or any answer but True, is False) because a bit that errs toward True advertises routes that would refuse the caller. A seam file — grepped by `enterprise-docs-guard.yml` (ent#661) - `turn_context.py` - Open-core seam for **lines added to a Workspace turn**: a list-based provider registry (`register_provider` / `collect(TurnContext)`) that both composers call — the 1:1 chat (`portal_chat`, on BOTH the resumed and the cold arm, beside the open-canvas line) and the room wake (`_wake_agent`, ahead of the file manifest). OSS registers one provider — the discussed-ask line (`client_portal/asks/service.py::_register_turn_context`, registered at import; trinity-enterprise#747), which answers only for a chat that is an ask's discussion chat; every other turn gets `""` and composes byte-identically. A private module may register its own. The context is the PLATFORM's: `chat_id` is the resolved chat row or room, and `internal_audience` is the caller's `is_platform` for a chat and `not room_is_user_facing(...)` for a room, so an internal-only line never reaches a turn an outside client can read. Fails open: a provider that raises is skipped. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (ent#661) -- `assignment_provider.py` - Open-core seam for **role assignments** — which human fills which business role for an agent, and which of them the agent primarily serves. OSS registers no provider → `resolve_assignment(agent_name, triggered_by)` returns `None` and the execution-context block renders byte-identically to a build without the seam; a registered module answers with a display name, a role id, the stakeholder list, and whether proactive contact is on file. **Deliberately SYNC**: `compose_system_prompt` is a plain `def` called from async handlers, so a provider that blocks on I/O here stalls the worker — providers answer from memory, never over HTTP. The seam owns the failure handling rather than delegating it (the `mfa_gate` position, not `a2a_outbound`'s): `compose_system_prompt` has NO exception handler, so an escape costs all three of its callers the execution-context block and costs one of them the platform prompt entirely. Shape validation sits beside the `try` because `try`/`except` cannot see the defect it catches — a `str` where a list was promised iterates into single characters and renders the WRONG prompt without raising, which is harder to notice than a missing line. `triggered_by` is passed through so a provider can suppress the answer for an audience that must not see staff identities; the seam carries the label and never decides that policy. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (trinity-enterprise#500) A provider may also answer `people_for(agent_name, role)` (optional; trinity-enterprise#611): `resolve_role_people` returns the people who fill a role for the ask sink's `to` addressing, and `None` on any failure, so the core defaults hold. +- `assignment_provider.py` - Open-core seam for **role assignments** — which human fills which business role for an agent, and which of them the agent primarily serves. OSS registers no provider → `resolve_assignment(agent_name, triggered_by)` returns `None` and the execution-context block renders byte-identically to a build without the seam; a registered module answers with a display name, a role id, the stakeholder list, and whether proactive contact is on file. **Deliberately SYNC**: `compose_system_prompt` is a plain `def` called from async handlers, so a provider that blocks on I/O here stalls the worker — providers answer from memory, never over HTTP. The seam owns the failure handling rather than delegating it (the `mfa_gate` position, not `a2a_outbound`'s): `compose_system_prompt` has NO exception handler, so an escape costs all three of its callers the execution-context block and costs one of them the platform prompt entirely. Shape validation sits beside the `try` because `try`/`except` cannot see the defect it catches — a `str` where a list was promised iterates into single characters and renders the WRONG prompt without raising, which is harder to notice than a missing line. `triggered_by` is passed through so a provider can suppress the answer for an audience that must not see staff identities; the seam carries the label and never decides that policy. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (trinity-enterprise#500) A provider may also answer `people_for(agent_name, role)` (optional; trinity-enterprise#611): `resolve_role_people` returns the people who fill a role for the ask sink's `to` addressing, and `None` on any failure, so the core defaults hold. Two trigger-free resolvers (trinity-enterprise#811/#814) read the provider's optional `seat_for` / `served_seat_for` through `getattr` and ALWAYS return a dict, never raise: `resolve_seat(agent)` answers the seat the agent **holds** itself or **serves** through its primary (`{case: holds|serves|none, role_id, reason}`) for the readiness gate (ent#813), the objective join (ent#812) and the role card; `resolve_served_seat(agent, email)` answers the seat a run serves for one person — holds wins, the person's own seat next, the primary's last — for the prompt's `Seat this run serves` line and the seat-decision stamp. Every degraded case reads as no seat with a named reason. - `role_addressing.py` - The ONE rule that turns an outbound object's `to:` role (`primary | approver | viewer | operator`) into people (trinity-enterprise#606): a provider's `people_for` answer first, else primary → owner, operator → nobody, approver/viewer refused (`RoleRefused`, named codes). Shared by asks (`ask_service._address` — a role several people fill delivers the one ask to each of them, trinity-enterprise#816), reports (`routers/reports._report_audience`) and messages (`routers/messages._recipient`); agent-supplied emails are still honoured and logged as deprecated (`log_email_addressing`). - `a2a_outbound_service.py` - Outbound orchestration: kill switch → rate bounds (per-agent **and** a fleet Redis key — a per-agent limit bounds one agent, the fleet is the exhaustion path) → resolve → validate → `effect_guard` → call → `agent_activities` row. Ordering is load-bearing: bounds before resolution so a flood can't become a DNS amplifier, and validation before the guard so a refused URL never burns an effect claim (#736) - `a2a_client.py` - The protocol client, FastAPI-free (raises `A2ACallError`, mapped 1:1 at the router). One resolution → one validated IP → **pinned for both hops**, with the registered hostname carried for `Host` + SNI + cert verification, so DNS rebinding is *closed* rather than accepted as a residual (the template-registry validator can accept it; this request carries a credential). `trust_env=False` — every other control reasons about the target IP and a proxy makes the target irrelevant — with the CA context rebuilt explicitly, since that flag also disables httpx's `SSL_CERT_FILE`/`SSL_CERT_DIR`. Wire-byte ceilings over `aiter_raw()`; any `Content-Encoding` refused, not decoded; a 3xx is a failure on both hops; a wall-clock deadline over cancellable awaits, because httpx's `read` timeout is per-read and a trickling tarpit resets it forever. The card is a **hint**: its `url` must be same-origin (default-port-equivalent — Trinity's own card emits no port) and `securitySchemes` never selects the credential, which is why #736 ships while ent#159 is blocked. Errors ride the body on **HTTP 200**, so the body is parsed for `error` even on 200 (#736). **402/403 on the RPC hop are classified BEFORE the encoding and length guards** (#3185): a CDN-gzipped or oversized "pay me" previously reported `rpc_encoding`/`rpc_too_large` — an outage, for an endpoint working perfectly — so the header is read first and the body only when identity-encoded and under a ceiling 16× tighter than the answer cap, with the outcome surviving from the status alone when it cannot be read. The in-band `x402.payment.status` check runs **before `_parse_task`** on both the send and the poll path, or a priced `input-required` reaches the agent as a prompt it polls forever; the `payment` block handed back is allowlisted, leaf-capped and scrubbed against the token **and its base64 forms and the decoded payload's long leaves** diff --git a/docs/memory/architecture/database.md b/docs/memory/architecture/database.md index ed760bd33..2726f1fd5 100644 --- a/docs/memory/architecture/database.md +++ b/docs/memory/architecture/database.md @@ -104,7 +104,8 @@ CREATE TABLE agent_sharing ( shared_with_email TEXT NOT NULL, shared_by_id INTEGER NOT NULL, created_at TEXT NOT NULL, - allow_proactive INTEGER DEFAULT 0, + allow_proactive INTEGER DEFAULT 0, -- three-state since trinity-enterprise#817: NULL = not asked, 0 = declined, 1 = consented; code writes a new share NULL (the DDL default is unchanged, data-only migration + -- SQLite `proactive_consent_not_asked` / Alembic `0098_proactive_consent_not_asked` moved the legacy DEFAULT-0 rows to NULL); only 1 permits proactive contact, before and after UNIQUE(agent_name, shared_with_email), FOREIGN KEY (shared_by_id) REFERENCES users(id) ); diff --git a/docs/memory/feature-flows/workspace-role-card.md b/docs/memory/feature-flows/workspace-role-card.md index 45789f009..e05f06e67 100644 --- a/docs/memory/feature-flows/workspace-role-card.md +++ b/docs/memory/feature-flows/workspace-role-card.md @@ -6,8 +6,9 @@ When a companion has a role (Tandem, ent#497), the Info rail's Agent details sho **Role** card: the role it fills, the objectives it owns or supports with each metric's value / target / freshness, the viewer's relationship to it, and its **readiness**. Framework §8's "organisation UI over the canon" shrunk to one agent — files are truth, -the card is a projection. This cut ships the Role + Readiness half; the relationship -line reads from ent#500 when it lands. +the card is a projection. **The role is the seat on record, not the template +(ent#811/#814):** the seat this agent serves *for the viewer*; the relationship line is +the viewer's assignment kind (`kinds_for`). ## Flow @@ -20,10 +21,16 @@ Info rail ──► PortalAgentRole.vue ──► GET /api/enterprise/client-por │ (never raises — a refused read is a 200 without objectives) ▼ client_portal/role_card.build_role_card(admit_objectives=…) - │ docker state ── not running → {unavailable, readiness(stamp)} + │ _relationship → seat_decision_service.reader_kind → provider.kinds_for + │ (DB only; answered for a stopped agent too) + │ docker state ── not running → {unavailable, readiness(stamp), relationship} │ agent door (get_agent_client): - │ read_file template.yaml → x-role {role, status, seat}, x-canon.clone_path - │ read_file /roles/.yaml + │ read_file template.yaml → x-canon.clone_path, x-role.status (readiness claim only) + │ assignment_provider.resolve_served_seat(agent, viewer) + │ → {role_id, source: holds|person|primary|none} + │ no seat AND no x-canon AND no x-role → no card + │ no seat → role.error = no_seat (objectives still read) + │ read_file /roles/.yaml │ db.get_agent_role_readiness (the owner's stamp) │ admit_objectives() — only here, after the role file; any earlier │ return (no role, stopped, role-file error) spends nothing @@ -35,7 +42,7 @@ Info rail ──► PortalAgentRole.vue ──► GET /api/enterprise/client-por ▼ portal_objective / portal_metric / objectives_error / finding_codes ▼ - PortalRoleCard {role, seat, objectives[metrics], objectives_error, + PortalRoleCard {role, seat_source, objectives[metrics], objectives_error, finding_codes, readiness, walkthrough, relationship, can_flip_readiness} @@ -50,9 +57,17 @@ owner ──► Mark ready (ConfirmDialog) ──► POST …/role/readiness {st - **Never a second store.** Nothing about the role is cached or copied platform-side; every read goes through the agent's own server, so the card can never disagree with the file — and a failed read is *named* (`role.error`), never rendered as an empty - role. No `x-role` → no card at all: the panel is byte-identical for every agent that - has no role. -- **Author-controlled input is bounded.** `x-role.role` and `x-canon.clone_path` reach + role. No seat, no canon and no `x-role` → no card at all: the panel is byte-identical + for every agent that has none of them. +- **The seat on record, never the template (ent#811/#814, live check 2026-10-06).** The + card used to take its role from `x-role.role` and its "Seat ·" line from + `x-role.seat` (an email), so a shared companion's card said `sales-lead` while the + Access tab said Head of Sales. It now asks `resolve_served_seat(agent, viewer)` — the + same answer the agent's prompt gets: the seat it holds, else the viewer's own seat, + else its primary's. `seat_source` tells the viewer which, in words, never a person. + `x-role` survives only as the template's readiness claim and as a reason to show + the card at all (so a legacy companion keeps its readiness control). +- **Author-controlled input is bounded.** The seat id and `x-canon.clone_path` reach a file read, so both are validated before any path is built — the role id by `_ID_RE`, the canon path by the join's own `canon_root` (one validator, not a copy); text fields are capped. The objective bounds (scan 100 files, 20 objectives, 12 metrics each) are diff --git a/docs/memory/requirements/core-agent.md b/docs/memory/requirements/core-agent.md index 8a4bb5b62..760501112 100644 --- a/docs/memory/requirements/core-agent.md +++ b/docs/memory/requirements/core-agent.md @@ -3426,7 +3426,7 @@ to localStorage in the clear. - **Flow**: `docs/memory/feature-flows/workspace-drafts.md` ### 5.36 Workspace — the role card in Agent details: role, objectives with metric freshness, readiness (trinity-enterprise#527) -- **Status**: ✅ Implemented (2026-09-21) — the Role + Readiness half; the relationship line waits for ent#500. OSS-core (Workspace). +- **Status**: ✅ Implemented (2026-09-21); the role and the relationship line read the assignment record since 2026-10-07 (ent#811/#814 follow-up, the #3271 / ent#818 live check). OSS-core (Workspace). - **Requirement ID**: WORKSPACE_ROLE_CARD - **GitHub Issue**: abilityai/trinity-enterprise#527 (+ #663, the 1.0 gate on who flips readiness; + #676, 2026-09-30, the objectives cut over to the ent#666 join) - **Description**: When a companion has a role (Tandem, ent#497), the Info rail's @@ -3437,17 +3437,24 @@ to localStorage in the clear. - **Files are truth, read through the platform, never a second store.** Everything the card shows about the role comes from the agent's own container on each read (`client_portal/role_card.py`, via the agent client — the same door the Files tab - uses): `template.yaml → x-role: {role, status, seat?}` (written by the - `create-agent:role-companion` wizard, #511) and `x-canon.clone_path` (default - `canon`); the role file `/roles/.yaml` and `/objectives/*.yaml` + uses): `template.yaml → x-canon.clone_path` (default `canon`) and `x-role.status` + (the template's readiness claim, #511); the role file `/roles/.yaml` + where `` is **the seat on record** — the seat this agent serves for the + viewer (`assignment_provider.resolve_served_seat`: held → the viewer's own → + the primary's), never `x-role.role`. `seat_source` (holds / person / primary / + none) is rendered as words, never a person. No seat → `role.error = no_seat` + with the supported objectives still listed. **Your relationship** is the + viewer's assignment kind (`kinds_for` on the provider; primary / approver / + collaborator / viewer), "no assignment recorded" only when there is none. + The role file `/roles/.yaml` and `/objectives/*.yaml` (framework §3.4 grammar — `owner: role:` or `supporting_agents` names this agent). The role file is the card's own read; the objectives and their numbers are **not** — they come from the one objective ↔ metric join (`objective_join_service.read_objective_join`, §50 of `lifecycle-observability.md`), called in process with the template and the agent client the card already holds (trinity-enterprise#676). Nothing is cached or - copied platform-side. Every read is fail-soft and **named**: no `x-role` → no - card at all (the panel is unchanged, AC 5); a role file that cannot be read or + copied platform-side. Every read is fail-soft and **named**: no seat, no canon + and no `x-role` → no card at all (the panel is unchanged, AC 5); a role file that cannot be read or parsed → the card says so (`role.error`), never an empty role; a stopped agent → "the agent is stopped; the card reads its files when it runs"; objectives that could not be read → one line saying so (`objectives_error`), never an @@ -3519,11 +3526,16 @@ to localStorage in the clear. for the verdict: a held brief is a `skipped` execution whose reason names the fix ("Held: … runs once its owner marks it ready"), a `schedule_execution_skipped` event, and advanced run times — never a failure, a retry or an alert. **Every ambiguity fails open - and is logged** (verdict call failed, container not running, Docker or template.yaml - unreadable within 3 s). The template is read only to learn whether the agent is a - companion; `x-role.status` is agent-writable and is never trusted (#663). Honest limit: an - *unstamped* companion can take itself out of scope (drop `x-role`, or stall the read into - fail-open); an owner's stamp is the one thing it cannot touch. + and is logged** (verdict call failed, stamp unreadable). `x-role.status` is + agent-writable and is never trusted (#663). **Who is a companion (trinity-enterprise#813, + ruling 2026-10-06):** asked only for an unstamped agent, and answered by the seat on + record (`assignment_provider.resolve_seat`), not the template — a companion is an agent + whose primary is assigned and holds a seat. An agent holding a seat itself (an autonomous + player), no primary with a seat, no seat lookup or a lookup error all read as "not a + companion", so the brief fires; each decision is logged with its reason. The template is + no longer read, so an agent cannot take itself out of scope by editing its own files — + the seat is admin-written. A companion template with no assigned primary therefore sends + its brief until its primary is assigned. - **Rollout (ruled 2026-09-24, amended from option 1 at plan review)**: a one-time, database-only seed on both migration tracks stamps `ready` (`changed_by = rollout:ent#689`) for every live agent with autonomy on and an enabled, non-deleted diff --git a/docs/memory/requirements/infrastructure.md b/docs/memory/requirements/infrastructure.md index e031c6bb5..ff5e587ce 100644 --- a/docs/memory/requirements/infrastructure.md +++ b/docs/memory/requirements/infrastructure.md @@ -263,6 +263,9 @@ - **Display name, never an email.** The block reaches anonymous public-link and paid turns, so an address would be third-party PII in front of an outside audience — the answer contract has no email-shaped key at all - **Audience-scoped.** `triggered_by` is passed to the provider so it can suppress on an outside audience; a Workspace/portal turn is labelled `public`, so the outside set is `{public, paid}` with no third label. Fail-closed against a label nobody has thought of yet - **Consent is reported, not granted.** An assignment records who fills a role; permission to reach out lives on the separate `agent_sharing.allow_proactive` consent bit, so the line states the consent explicitly rather than leaving a bare name that reads as permission + - **Consent has three states** (trinity-enterprise#817, operator ruling 2026-10-06): `agent_sharing.allow_proactive` NULL = not asked, 0 = declined, 1 = consented. A new share is written NULL; the Access toggle writes 0/1; the legacy DEFAULT-0 rows were moved to NULL (data migration on both tracks — the default wrote 0 for everyone). Only 1 allows an unprompted message, before and after. Read back as `proactive_consent` on shares, the Access list and every assignment row + - **A shared companion serves the seat of the person each run is for** (trinity-enterprise#814, R26): `assignment_provider.resolve_served_seat(agent, email)` answers from that person's own assignment row, the agent's own seat when it holds one (holds wins), the primary's as the fallback — never raises. The prompt keeps its `Primary human` line and adds `Seat this run serves` only when the run serves a different seat (a chat user's `source_user_email`, or a scheduled brief's Workspace delivery address, which is never rendered); a seat decision (ent#638) is stamped with the served seat, and the agent's own `decided_by_role` is only the fallback. The one-primary rule, the primary display name and the `primary` address are unchanged + - **The seats a canon defines** are read by `GET /api/agents/{name}/canon/roles` (`services/canon_roles_service.py`, OSS-core): each `/roles/*.yaml` with id, `title` and `updated`, from the agent's container, on the objectives read budget, every empty answer named. Assignment drift is **checked on read** against it — the row records the file's `updated` stamp when a seat is set; the roster compares (current / changed / missing / unknown with a reason / no_role). No background loop - Degrades to nothing on: no provider, a provider that raises, and a provider answering a malformed shape — the last needs its own check, because a `str` where a list was promised iterates into single characters and renders the WRONG prompt without raising - The `trinity_execution_context_enabled` kill-switch covers these lines too (they are inside the block, not beside it) - **Flow**: `docs/memory/feature-flows/role-assignments.md`, `docs/memory/feature-flows/execution-context-injection.md` diff --git a/docs/memory/requirements/lifecycle-observability.md b/docs/memory/requirements/lifecycle-observability.md index 86ca0bed3..24382727c 100644 --- a/docs/memory/requirements/lifecycle-observability.md +++ b/docs/memory/requirements/lifecycle-observability.md @@ -1756,10 +1756,15 @@ file-level codes are the deliberate exception**: `objective_invalid` and carries nothing that says whose concern it is, and silence there is how a file disappears from a read that claims to show them all. -An objective reaches an agent two ways: `owner: role:` matching the -template's `x-role.role` (**owned**), or the agent's own name in -`supporting_agents` (**supporting**). An agent with no `x-role` can still -support. +An objective reaches an agent two ways: `owner: role:` matching a seat the +agent owns through (**owned**), or the agent's own name in `supporting_agents` +(**supporting**). Since trinity-enterprise#812 (operator ruling 2026-10-06) the +seat comes from Trinity's record through `assignment_provider.resolve_seat`, not +from the template: the seat the agent **holds** itself (that seat only), else +the seats its assigned people hold — primary first; a shared companion owns the +union. `x-role` is neither required nor read. An agent with no seat owns nothing +— a `no_seat` finding says so — and can still support. `supporting_agents` is +read as before but frozen: nothing new is built on agent names in canon. ### 50.2 The four sources, and which one owns what @@ -1876,7 +1881,7 @@ it. Findings appear twice — flat in `findings[]` with `objective_id` / `metric | `objective_id_invalid` | a file's `id:` is not a valid id | the **file name** is used instead and the finding says so — a *missing* `id` falls back silently, an id the author wrote and this read refused does not, because the ent#661 v3 project view keys objectives by id across agents | | `objective_file_skipped` | a `*.yaml` in `objectives/` whose NAME is not a plain path segment (a space, a non-ASCII character) | rename it; the file is never fetched, and `source.objectives_skipped` counts them so "not there" can be told from "there under a name this read will not open" | | `objectives_read_timeout` | the fan-out exceeded `OBJECTIVES_READ_BUDGET_SEC` | retry; the agent is answering, just too slowly — `source.objectives_dir: "timeout"`, no objective joined | -| `role_id_invalid` | `x-role.role` is not a valid id | fix `template.yaml`; no owned objective can match until then | +| `no_seat` | the agent neither holds a seat nor serves a person who holds one (trinity-enterprise#812; replaces `role_id_invalid`, which read `x-role`) | assign its primary with the seat they hold on the Access tab, or record the seat the agent holds; supporting objectives still show | | `canon_path_invalid` | `x-canon.clone_path` is not a plain path | fix it; **no file is read with that path** | Every code in this table above the file-level pair is published **only for the @@ -1937,7 +1942,7 @@ stopped is an *answer*, not an error. ``` {agent_name, generated_at, stale_rule: "2x cadence", - role: {id, path} | null, # from x-role; null → objectives can only be supporting + role: {id, path, case, seats} | null, # the seat on record (#812): case holds|serves|none canon_root: "canon" | null, unavailable: null | agent_stopped | agent_missing | agent_unreachable, source: {template, objectives_dir, objectives_listed, objectives_scanned, @@ -2030,10 +2035,10 @@ too (`isinstance(True, int)` is `True`, and `true` is not `1`). ### 50.9 Zero config -An agent with no `x-role` and no `x-canon` costs one container-state read and -one `template.yaml` read — **no store query at all** — and answers -`{objectives: [], message: "no x-role or x-canon in template.yaml — nothing to -join…"}`. Every empty state carries `message` naming the next action: the +An agent with no `x-canon` costs one container-state read and one +`template.yaml` read — **no store query at all** — and answers +`{objectives: [], message: "no x-canon in template.yaml — nothing to join…"}` +(trinity-enterprise#812: the canon declaration alone enables the join). Every empty state carries `message` naming the next action: the `objectives/` directory that was not found, the agent that is stopped, the fact that no active objective names this agent. diff --git a/src/backend/client_portal/models.py b/src/backend/client_portal/models.py index 091b7a2ba..a779e1465 100644 --- a/src/backend/client_portal/models.py +++ b/src/backend/client_portal/models.py @@ -688,7 +688,7 @@ class PortalRoleInfo(BaseModel): stale: bool = False path: Optional[str] = None # role_file_not_found | role_file_unreadable | role_file_invalid | - # role_id_invalid | canon_path_invalid — the card SAYS the file failed. + # role_id_invalid | canon_path_invalid | no_seat — the card SAYS why. error: Optional[str] = None @@ -704,7 +704,10 @@ class PortalRoleCard(BaseModel): `role` is None when the agent carries no `x-role` (no card is rendered).""" agent_name: str role: Optional[PortalRoleInfo] = None - seat: Optional[str] = None + # ent#814: where the seat on the card came from, for THIS viewer — + # holds (the agent's own) | person (the viewer's own) | primary | none. + # Never a person: the old `seat` carried x-role's email. + seat_source: Optional[Literal["holds", "person", "primary", "none"]] = None objectives: list[PortalRoleObjective] = Field(default_factory=list) # ent#676 — why `objectives` is empty when that is NOT simply true: # objectives_rate_limited | agent_unreachable | objectives_timeout | @@ -718,7 +721,8 @@ class PortalRoleCard(BaseModel): finding_codes: list[str] = Field(default_factory=list) readiness: Optional[PortalRoleReadiness] = None walkthrough: Optional[PortalRoleWalkthrough] = None - # ent#500's assignment kind, when it lands; None renders as "no assignment recorded". + # The viewer's assignment kind on the agent (`kinds_for`): primary | + # approver | collaborator | viewer. None renders as "no assignment recorded". relationship: Optional[str] = None can_flip_readiness: bool = False # ent#689: a live seat-delivery schedule is being held because readiness is diff --git a/src/backend/client_portal/role_card.py b/src/backend/client_portal/role_card.py index 9ae4dd611..f68feeb8a 100644 --- a/src/backend/client_portal/role_card.py +++ b/src/backend/client_portal/role_card.py @@ -9,10 +9,21 @@ from the agent's own container on each request, through the same agent door the Files tab uses — never copied platform-side: -* `template.yaml` → `x-role: {role, status, seat?}` (the wizard writes it, - #511) and `x-canon.clone_path` (default `canon`); +* `template.yaml` → `x-canon.clone_path` (default `canon`), and `x-role.status` + for the template's own readiness claim (the wizard writes it, #511); * `/roles/.yaml` (framework §3.4). +**Which role is the seat on record, not the template (ent#811/#814).** The +role is the seat this agent serves FOR THIS VIEWER +(`assignment_provider.resolve_served_seat`): the seat it holds itself, else the +viewer's own seat on it, else its primary's — the same answer the agent is +given in its prompt. `x-role.role` / `x-role.seat` are no longer read; a +template that still says `x-role: sales-lead` while the Access tab says Head +of Sales showed two truths on one screen. `seat_source` says where the seat +came from (holds | person | primary | none). No seat → `role.error = no_seat`, +and supporting objectives still show. The viewer's relationship is the +provider's `kinds_for` (ent#638's optional method). + **The objectives are not this module's to compute (ent#676).** What the agent is supposed to move, where each number is and whether to believe it is the one objective ↔ metric join (`services/objective_join_service.read_objective_join`, @@ -23,7 +34,7 @@ stay on the operator door (the #78 auth-path invariant), and a finding crosses as its code. -Every read is fail-soft and NAMED: no `x-role` → no card; an unreadable or +Every read is fail-soft and NAMED: no seat, no canon and no `x-role` → no card; an unreadable or unparseable role file → `role.error`, never an empty role; a stopped agent → `unavailable: agent_stopped`; objectives that could not be read → `objectives_error`, never an empty list dressed as "this agent has none". @@ -294,9 +305,8 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool, admit_objectives: Callable[[], bool]) -> dict: """Everything the Role card shows, or `{"role": None}` when the agent has no role. - The relationship line reads from ent#500's assignments when they land; - until then it is `None`, which the client renders as "no assignment - recorded" rather than blank. + The relationship line is the viewer's assignment kind on this agent + (`kinds_for`), or `None` — rendered as "no assignment recorded", never blank. `admit_objectives` is the router's check against the objective-read budget (`services/objectives_read_budget`), called here — once, and only when the @@ -314,6 +324,7 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool, from services import objective_join_service stamp = _readiness_stamp(agent_name) + relationship = _relationship(agent_name, email) try: state = await docker_utils.agent_container_state_async(agent_name) @@ -328,19 +339,22 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool, "role": None, "unavailable": "agent_stopped" if state else "agent_unreachable", "readiness": effective_readiness(None, stamp) if stamp else None, - "relationship": None, + "relationship": relationship, "can_flip_readiness": _is_owner(agent_name, email, is_platform), } client = get_agent_client(agent_name) template, terr = await _read_yaml(client, "template.yaml") xrole = template.get("x-role") if isinstance(template, dict) else None - if not isinstance(xrole, dict): - # No role → no card (AC 5). An unreadable template is the same answer: - # there is nothing to show and nothing to warn about. + xrole = xrole if isinstance(xrole, dict) else {} + has_canon = isinstance(template, dict) and isinstance(template.get("x-canon"), dict) + served = _served_seat(agent_name, email) + if not (served["role_id"] or has_canon or xrole): + # No seat, no canon, no role declared → no card (AC 5). An unreadable + # template with no seat on record is the same answer: nothing to show. return {"agent_name": agent_name, "role": None} - role_id = _safe_id(xrole.get("role")) + role_id = _safe_id(served["role_id"]) # The join's validator, not a copy of it — one rule for what may reach a # file read. root = objective_join_service.canon_root(template) @@ -352,7 +366,7 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool, "path": f"{root}/roles/{role_id}.yaml" if (root and role_id) else None, "error": None, }, - "seat": _text(xrole.get("seat"), 128), + "seat_source": served["source"], "objectives": [], "objectives_error": None, "objectives_partial": False, @@ -362,24 +376,29 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool, # brief nor see the schedules it comes from (the flip is platform-only too). "brief_held": is_platform and _brief_held(agent_name, stamp), "walkthrough": _walkthrough(agent_name, email, is_platform), - "relationship": None, + "relationship": relationship, "can_flip_readiness": _is_owner(agent_name, email, is_platform), } - if not role_id or not root: - card["role"]["error"] = "role_id_invalid" if not role_id else "canon_path_invalid" - return card - - role, rerr = await _read_yaml(client, card["role"]["path"]) - if rerr: - card["role"]["error"] = f"role_file_{rerr}" + if not root: + card["role"]["error"] = "canon_path_invalid" return card - card["role"].update({ - "title": _text(role.get("title"), 120) or role_id, - "mission": _text(role.get("mission")), - "status": _text(role.get("status"), 32), - "review_by": _text(role.get("review_by"), 32), - "stale": is_stale(_text(role.get("review_by"), 32)) if role.get("review_by") else False, - }) + if not role_id: + # No seat on record (or one that is not a valid id): say so on the role, + # and still read the objectives — supporting work shows without a seat + # (ent#812), and the join names the same `no_seat`. + card["role"]["error"] = "role_id_invalid" if served["role_id"] else "no_seat" + else: + role, rerr = await _read_yaml(client, card["role"]["path"]) + if rerr: + card["role"]["error"] = f"role_file_{rerr}" + return card + card["role"].update({ + "title": _text(role.get("title"), 120) or role_id, + "mission": _text(role.get("mission")), + "status": _text(role.get("status"), 32), + "review_by": _text(role.get("review_by"), 32), + "stale": is_stale(_text(role.get("review_by"), 32)) if role.get("review_by") else False, + }) if not admit_objectives(): card["objectives_error"] = "objectives_rate_limited" @@ -404,6 +423,31 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool, return card +def _served_seat(agent_name: str, email: str) -> dict: + """The seat this agent serves for this viewer — the seam's own never-raising + answer, with a belt for an import-time failure.""" + try: + from services import assignment_provider + return assignment_provider.resolve_served_seat(agent_name, email) + except Exception as e: # noqa: BLE001 + logger.warning("role card: seat read failed for %s: %s", agent_name, e) + return {"role_id": None, "source": "none"} + + +def _relationship(agent_name: str, email: str) -> Optional[str]: + """The viewer's assignment kind on this agent, or None. The reader gate's + own rule (`seat_decision_service.reader_kind`): a missing method, a raise or + an unknown kind all read as None.""" + if not email: + return None + try: + from services import seat_decision_service + return seat_decision_service.reader_kind(agent_name, email.strip().lower()) + except Exception as e: # noqa: BLE001 + logger.warning("role card: relationship read failed for %s: %s", agent_name, e) + return None + + def _brief_held(agent_name: str, stamp: Optional[dict]) -> bool: """Whether a proactive brief is being held (ent#689): the agent has a live seat-delivery schedule and its stamp is not `ready`. Reads the stamp the diff --git a/src/backend/db/agent_settings/sharing.py b/src/backend/db/agent_settings/sharing.py index 972c68d38..b87060a33 100644 --- a/src/backend/db/agent_settings/sharing.py +++ b/src/backend/db/agent_settings/sharing.py @@ -19,6 +19,18 @@ class SharingMixin: """Mixin for agent sharing operations. Requires self._user_ops and ownership methods.""" + @staticmethod + def proactive_consent_state(value) -> str: + """`agent_sharing.allow_proactive` as a statement (trinity-enterprise#817). + + NULL = never asked, 0 = declined, 1 = consented. Only `consented` lets + an agent send unprompted; the other two are equally "no", and they are + told apart only so a person can see whether anyone ever asked. + """ + if value is None: + return "not_asked" + return "consented" if int(value) == 1 else "declined" + @staticmethod def _row_to_agent_share(row) -> AgentShare: """Convert an agent_sharing row to an AgentShare model.""" @@ -29,7 +41,9 @@ def _row_to_agent_share(row) -> AgentShare: shared_by_id=row["shared_by_id"], shared_by_email=row["shared_by_email"] or "unknown", created_at=datetime.fromisoformat(row["created_at"]), - allow_proactive=bool(row["allow_proactive"]) if "allow_proactive" in row.keys() else False + allow_proactive=bool(row["allow_proactive"]) if "allow_proactive" in row.keys() else False, + proactive_consent=SharingMixin.proactive_consent_state( + row["allow_proactive"] if "allow_proactive" in row.keys() else None), ) def share_agent(self, agent_name: str, owner_username: str, share_with_email: str) -> Optional[AgentShare]: @@ -69,6 +83,9 @@ def share_agent(self, agent_name: str, owner_username: str, share_with_email: st shared_with_email=normalized_email, shared_by_id=owner["id"], created_at=now, + # ent#817: nobody has been asked yet — NULL, not the + # column's legacy DEFAULT 0, which reads as "declined". + allow_proactive=None, ) ) # #446: once the email is on the allow-list, any pending access @@ -178,6 +195,7 @@ def get_agent_operator_access(self, agent_name: str) -> List[dict]: "last_active": r["last_login"], "status": "active" if resolved else "pending", "allow_proactive": bool(r["allow_proactive"]), + "proactive_consent": self.proactive_consent_state(r["allow_proactive"]), }) return out diff --git a/src/backend/db/migrations.py b/src/backend/db/migrations.py index 10d224f4f..c49b251de 100644 --- a/src/backend/db/migrations.py +++ b/src/backend/db/migrations.py @@ -5260,6 +5260,22 @@ def _migrate_skill_gate_requests_table(cursor, conn): """ +def _migrate_proactive_consent_not_asked(cursor, conn): + """trinity-enterprise#817 — proactive consent says "not asked" apart from "no". + + `agent_sharing.allow_proactive` was 0 by DEFAULT for every share, so "nobody + asked this person" and "this person declined" were one value. From now on + NULL = not asked, 0 = declined, 1 = consented, and a new share is written + NULL. The existing 0s are moved to NULL: the default wrote 0 for everyone, + so almost none of them was ever an answer (operator ruling 2026-10-06). + + Data only — the column and its DDL are unchanged, so no table rebuild. Who + may be messaged is unchanged too: only 1 allows it, before and after. + Idempotent. Mirrored by Alembic 0098_proactive_consent_not_asked. + """ + cursor.execute("UPDATE agent_sharing SET allow_proactive = NULL WHERE allow_proactive = 0") + conn.commit() + def _migrate_supersede_queue_flood_backlog(cursor, conn): """#3130: collapse the flood-alert backlog to one pending alert per agent. @@ -5691,4 +5707,5 @@ def _migrate_agent_skill_gates(cursor, conn): ("portal_messages_execution_id", _migrate_portal_messages_execution_id), ("a2a_internal_scope", _migrate_a2a_internal_scope), ("operator_queue_replace", _migrate_operator_queue_replace), + ("proactive_consent_not_asked", _migrate_proactive_consent_not_asked), ] diff --git a/src/backend/db_models.py b/src/backend/db_models.py index 6ae79f777..fa1cb21d4 100644 --- a/src/backend/db_models.py +++ b/src/backend/db_models.py @@ -58,6 +58,9 @@ class AgentShare(BaseModel): shared_by_email: str # Email or username of who shared it created_at: datetime allow_proactive: bool = False # Can agent send proactive messages to this user + # trinity-enterprise#817: consented | declined | not_asked — `allow_proactive` + # alone cannot tell "never asked" from "said no". + proactive_consent: str = "not_asked" class AgentOperatorAccess(BaseModel): @@ -74,6 +77,7 @@ class AgentOperatorAccess(BaseModel): last_active: Optional[str] = None # ISO-Z last_login; None until first login status: str # 'active' | 'pending' allow_proactive: bool = False + proactive_consent: str = "not_asked" # ent#817: consented | declined | not_asked class AgentShareRequest(BaseModel): diff --git a/src/backend/migrations/versions/0098_proactive_consent_not_asked.py b/src/backend/migrations/versions/0098_proactive_consent_not_asked.py new file mode 100644 index 000000000..44b747195 --- /dev/null +++ b/src/backend/migrations/versions/0098_proactive_consent_not_asked.py @@ -0,0 +1,32 @@ +"""trinity-enterprise#817 — proactive consent says "not asked" apart from "no". + +`agent_sharing.allow_proactive` was 0 by default for every share, so "never +asked" and "declined" were one value. From now on NULL = not asked, +0 = declined, 1 = consented, and a new share is written NULL. The existing 0s +move to NULL: the default wrote 0 for everyone, so almost none was ever an +answer (operator ruling 2026-10-06). + +Data only; the column is unchanged. Who may be messaged is unchanged: only 1 +allows it. Idempotent. + +Mirrors the SQLite ``proactive_consent_not_asked`` migration. + +Revision ID: 0098_proactive_consent_not_asked +Revises: 0097_operator_queue_replace +""" +from alembic import op + + +revision = "0098_proactive_consent_not_asked" +down_revision = "0097_operator_queue_replace" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("UPDATE agent_sharing SET allow_proactive = NULL WHERE allow_proactive = 0") + + +def downgrade() -> None: + # The pre-#817 reading: everything that is not consent is 0. + op.execute("UPDATE agent_sharing SET allow_proactive = 0 WHERE allow_proactive IS NULL") diff --git a/src/backend/models.py b/src/backend/models.py index f6b46ad73..bc12d7167 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -5325,12 +5325,44 @@ class ObjectiveRead(BaseModel): metrics_truncated: bool = False +class CanonRoleRead(BaseModel): + """One seat the agent's canon defines — `/roles/.yaml` + (trinity-enterprise#817). `error` names why the file could not be read.""" + + id: str + title: Optional[str] = None + updated: Optional[str] = None + path: Optional[str] = None + error: Optional[str] = None + + +class CanonRolesRead(BaseModel): + """The seats an agent's canon defines (trinity-enterprise#817). An empty + list always carries `unavailable` or `reason` and a `message`, never a blank.""" + + agent_name: str + canon_root: Optional[str] = None + roles: List[CanonRoleRead] = [] + unavailable: Optional[str] = None + reason: Optional[str] = None + message: Optional[str] = None + truncated: bool = False + + class ObjectiveRoleRead(BaseModel): - """The role from `x-role`. `null` when the agent has none — it can still - support an objective by name.""" + """The seat the agent owns objectives through (trinity-enterprise#812) — + from Trinity's record, not from `x-role`. `null` when it has none; it can + still support an objective by name. + + `case`: `holds` (the agent holds the seat itself), `serves` (a companion — + its assigned people's seats, primary first) or `none`. `seats` is every + seat it owns through; `id`/`path` name the first. + """ id: Optional[str] = None path: Optional[str] = None + case: Optional[str] = None + seats: List[str] = [] class ObjectiveJoinSource(BaseModel): diff --git a/src/backend/routers/agent_files.py b/src/backend/routers/agent_files.py index bb9f074b6..8f4322479 100644 --- a/src/backend/routers/agent_files.py +++ b/src/backend/routers/agent_files.py @@ -13,6 +13,7 @@ from services.agent_auth import agent_httpx_client from services import ( agent_skills_listing, + canon_roles_service, metric_access_service, metric_read_service, objective_join_service, @@ -40,6 +41,7 @@ set_file_sharing_status_logic, ) from models import ( + CanonRolesRead, CreateFolderRequest, FileUpdateRequest, ObjectiveJoinRead, @@ -447,6 +449,31 @@ async def get_agent_metrics( ) +@router.get("/{agent_name}/canon/roles", response_model=CanonRolesRead) +async def get_agent_canon_roles( + agent_name: AuthorizedAgentByName, + current_user: User = Depends(get_current_user), +): + """The seats this agent's canon defines — `/roles/*.yaml` with each + file's id, `title` and `updated` stamp (trinity-enterprise#817). + + Read from the agent's own container through the same door as its objectives, + and drawing on the same per-agent budget (`objectives_read_budget`), because + it is the same kind of fan-out against the same container. An agent key reads + only its own canon. OSS-core (operator ruling 2026-10-06). + """ + if current_user.agent_name and current_user.agent_name != agent_name: + raise HTTPException( + status_code=403, + detail="Agent-scoped key may only read its own canon", + ) + objectives_read_budget.enforce( + agent_name, + detail="Canon read rate limit exceeded for this agent.", + ) + return await canon_roles_service.read_canon_roles(agent_name) + + @router.get("/{agent_name}/objectives", response_model=ObjectiveJoinRead) async def get_agent_objectives( agent_name: AuthorizedAgentByName, diff --git a/src/backend/routers/seat_decisions.py b/src/backend/routers/seat_decisions.py index 48cba2789..4bb1fa711 100644 --- a/src/backend/routers/seat_decisions.py +++ b/src/backend/routers/seat_decisions.py @@ -29,7 +29,7 @@ from dependencies import get_current_user, assert_agent_access from db_models import User from services import idempotency_service, rate_limiter, schedule_seat_memory, seat_decision_service -from services.assignment_provider import resolve_assignment +from services.assignment_provider import resolve_assignment, resolve_served_seat logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/agents", tags=["seat-decisions"]) @@ -102,7 +102,12 @@ async def record_seat_decision( raise HTTPException(status_code=409, detail="A record with this Idempotency-Key is in flight") return {**(idem.snapshot or {}), "replayed": True} - role = body.decided_by_role + # ent#814: the decision is stamped with the seat THIS run serves — the + # served person's own seat, the agent's own if it holds one, the primary's + # as the default. Resolved here, not taken from the agent, whose prompt + # used to name the primary's seat on every run; the agent's value is only + # the fallback when nobody's seat is on record. + role = resolve_served_seat(agent_name, seat_email)["role_id"] or body.decided_by_role if not role: assignment = resolve_assignment(agent_name, getattr(execution, "triggered_by", None)) role = (assignment or {}).get("role_id") or None diff --git a/src/backend/services/assignment_provider.py b/src/backend/services/assignment_provider.py index a5ce0ed29..fd5d9191b 100644 --- a/src/backend/services/assignment_provider.py +++ b/src/backend/services/assignment_provider.py @@ -84,6 +84,23 @@ def assignment_for( # the value addresses a person, it is never rendered into a prompt. # def people_for(self, agent_name: str, role: str) -> Optional[dict]: ... + # OPTIONAL (trinity-enterprise#811): which seat ``agent_name`` HOLDS itself or + # SERVES through its primary, asked with no triggering user — the readiness + # gate, the role card and the objective join ask on their own. Shaped as + # ``{"case": "holds"|"serves"|"none", "role_id": str|None, "seats": [str]}``; + # seat ids only, never a person. Read through ``getattr`` by + # :func:`resolve_seat`, which answers "no seat" for every degraded case. + # def seat_for(self, agent_name: str) -> Optional[dict]: ... + + # OPTIONAL (trinity-enterprise#814): the seat a run serves when it is + # addressed to ``person_email`` — a shared companion serves several people + # and each run serves the one it is for. Shaped as + # ``{"role_id": str|None, "source": "holds"|"person"|"primary"|"none"}``; + # a seat id, never a person. Read through ``getattr`` by + # :func:`resolve_served_seat`, which falls back to :func:`resolve_seat` + # (the primary's seat) for a provider that predates it. + # def served_seat_for(self, agent_name: str, person_email: str) -> Optional[dict]: ... + _provider: Optional[AssignmentProvider] = None @@ -238,3 +255,101 @@ def resolve_assignment( ) return None return _validated(answer, agent_name) + + +#: The answer :func:`resolve_seat` gives when nothing better is known. +SEAT_CASES = ("holds", "serves", "none") + + +def _no_seat(reason: str) -> Dict[str, Any]: + return {"case": "none", "role_id": None, "seats": [], "reason": reason} + + +def resolve_seat(agent_name: Optional[str]) -> Dict[str, Any]: + """Which seat ``agent_name`` holds or serves (trinity-enterprise#811). + + Always a dict, never ``None`` and never a raise: the callers (the readiness + gate above all) must treat ambiguity as "no seat", and a caller that had to + tell ``None`` from a dict would be one more place to get that wrong. + + ``{"case", "role_id", "seats", "reason"}``. ``reason`` is ``"provider"`` when + a registered provider answered, else why the answer is "no seat": + ``no_agent`` / ``no_provider`` / ``unsupported`` (a provider without + ``seat_for``) / ``no_answer`` / ``error`` / ``malformed``. + """ + if not agent_name: + return _no_seat("no_agent") + provider = _provider + if provider is None: + return _no_seat("no_provider") + seat_for = getattr(provider, "seat_for", None) + if seat_for is None: + return _no_seat("unsupported") + try: + answer = seat_for(agent_name) + except Exception: # noqa: BLE001 — a provider bug must never break a gate + logger.warning("[assignment_provider] provider.seat_for failed for %s; " + "answering no seat", agent_name, exc_info=True) + return _no_seat("error") + if answer is None: + return _no_seat("no_answer") + if not isinstance(answer, Mapping): + return _no_seat("malformed") + case = answer.get("case") + role_id = answer.get("role_id") + seats = answer.get("seats") + if (case not in SEAT_CASES + or (role_id is not None and not isinstance(role_id, str)) + or (case != "none" and not role_id) + or not isinstance(seats, (list, tuple)) + or not all(isinstance(v, str) for v in seats)): + logger.warning("[assignment_provider] provider.seat_for returned a malformed " + "answer for %s; answering no seat", agent_name) + return _no_seat("malformed") + if case == "none": + role_id = None + return {"case": case, "role_id": role_id, "seats": list(seats), "reason": "provider"} + + +#: Where a served seat came from (trinity-enterprise#814). +SERVED_SOURCES = ("holds", "person", "primary", "none") + + +def resolve_served_seat(agent_name: Optional[str], person_email: Optional[str]) -> Dict[str, Any]: + """The seat a run addressed to ``person_email`` serves (trinity-enterprise#814). + + Always ``{"role_id", "source"}``, never a raise. The person's own + assignment row decides; the agent's own seat (``holds``) wins over it, and + the primary's seat is the fallback — which is also the whole answer when + no person is known or the provider predates ``served_seat_for``. + """ + def _fallback() -> Dict[str, Any]: + seat = resolve_seat(agent_name) + if seat["case"] == "holds": + return {"role_id": seat["role_id"], "source": "holds"} + if seat["case"] == "serves": + return {"role_id": seat["role_id"], "source": "primary"} + return {"role_id": None, "source": "none"} + + email = (person_email or "").strip().lower() + provider = _provider + fn = getattr(provider, "served_seat_for", None) if provider is not None else None + if not agent_name or not email or fn is None: + return _fallback() + try: + answer = fn(agent_name, email) + except Exception: # noqa: BLE001 — a provider bug must never break a turn + logger.warning("[assignment_provider] provider.served_seat_for failed for %s; " + "falling back to the primary's seat", agent_name, exc_info=True) + return _fallback() + if answer is None: + return _fallback() + if (not isinstance(answer, Mapping) + or answer.get("source") not in SERVED_SOURCES + or (answer.get("role_id") is not None and not isinstance(answer.get("role_id"), str)) + or (answer.get("source") != "none" and not answer.get("role_id"))): + logger.warning("[assignment_provider] provider.served_seat_for returned a malformed " + "answer for %s; falling back to the primary's seat", agent_name) + return _fallback() + source = answer["source"] + return {"role_id": answer.get("role_id") if source != "none" else None, "source": source} diff --git a/src/backend/services/canon_roles_service.py b/src/backend/services/canon_roles_service.py new file mode 100644 index 000000000..135499924 --- /dev/null +++ b/src/backend/services/canon_roles_service.py @@ -0,0 +1,127 @@ +"""The seats an agent's canon defines — `/roles/*.yaml` (trinity-enterprise#817). + +Lists each role file's id (the file stem), `title` and `updated` stamp, read out +of the agent's own container through the agent door the objective join and the +role card already use. **Files are truth; this is a projection**: nothing is +copied platform-side. + +Consumers: the assignments section's seat picker and header (the title and the +`updated` stamp beside the seat), and the assignment drift check, which compares +a row's recorded stamp with the file's current one. + +OSS-core (operator ruling 2026-10-06), like the objective join beside it: the +read is edition-agnostic; only the gated assignments section uses it. + +Never a blank: every reason the list is empty is named — the agent is stopped, +the template declares no canon, there is no `roles/` directory, the listing could +not be read — so "no seats defined" is never confused with "could not look". +The fan-out draws on the objectives read budget, the container's one bucket. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Dict, List, Optional + +from services import objective_join_service as oj + +logger = logging.getLogger(__name__) + +#: A canon defines a handful of seats; a hundred is a misconfigured directory. +MAX_ROLES = 100 +#: The whole read, listing included — well inside a page load. +ROLES_READ_BUDGET_SEC = 15.0 +_CONCURRENCY = 5 + +UNAVAILABLE_COPY = { + "agent_stopped": "this agent is stopped — its role files live in its container; start it to read them", + "agent_missing": "no container for this agent — recreate it to read its role files", + "agent_unreachable": "the agent is not answering — its role files are read from its container", +} +REASON_COPY = { + "no_canon": "no `x-canon` in template.yaml — this agent declares no canon, so it defines no seats", + "canon_path_invalid": "`x-canon.clone_path` in template.yaml is not a plain path — no file was read with it", + "absent": "the canon has no `roles/` directory", + "unreadable": "the canon's `roles/` directory could not be listed", + "timeout": "the role files took too long to read — retry", + "template_unreadable": "template.yaml could not be read from this agent", +} + + +def _stamp(value: Any) -> Optional[str]: + """`updated:` as text. YAML reads an unquoted date as a date object.""" + if value is None: + return None + return oj._text(str(value), 64) + + +def _empty(agent_name: str, *, root=None, unavailable=None, reason=None) -> Dict[str, Any]: + message = UNAVAILABLE_COPY.get(unavailable) or REASON_COPY.get(reason) + return {"agent_name": agent_name, "canon_root": root, "roles": [], + "unavailable": unavailable, "reason": reason, "message": message, + "truncated": False} + + +async def read_canon_roles(agent_name: str, *, client=None) -> Dict[str, Any]: + """`{agent_name, canon_root, roles: [{id, title, updated, path, error}], + unavailable, reason, message, truncated}`. Never raises.""" + from services import docker_utils + from services.agent_client import get_agent_client + + try: + state = await docker_utils.agent_container_state_async(agent_name) + except Exception as e: # noqa: BLE001 + logger.warning("canon roles: container state for %s failed: %s", agent_name, e) + state = None + if state != "running": + unavailable = {"stopped": "agent_stopped", "missing": "agent_missing"}.get(state, "agent_unreachable") + return _empty(agent_name, unavailable=unavailable) + + client = client or get_agent_client(agent_name) + try: + return await asyncio.wait_for(_read(agent_name, client), ROLES_READ_BUDGET_SEC) + except asyncio.TimeoutError: + return _empty(agent_name, reason="timeout") + except oj._Unreachable: + return _empty(agent_name, unavailable="agent_unreachable") + except Exception as e: # noqa: BLE001 — a bad canon is never a 500 + logger.warning("canon roles: read for %s failed: %s", agent_name, e) + return _empty(agent_name, reason="unreadable") + + +async def _read(agent_name: str, client) -> Dict[str, Any]: + template, terr = await oj._read_yaml(client, "template.yaml") + if terr: + return _empty(agent_name, reason="template_unreadable") + if not isinstance(template, dict) or "x-canon" not in template: + return _empty(agent_name, reason="no_canon") + root = oj.canon_root(template) + if root is None: + return _empty(agent_name, reason="canon_path_invalid") + + names, _skipped, source = await oj._list_objective_files(client, root, "roles") + if source != "read": + return _empty(agent_name, root=root, reason=source) + + truncated = len(names) > MAX_ROLES + names = names[:MAX_ROLES] + sem = asyncio.Semaphore(_CONCURRENCY) + + async def one(name: str) -> Optional[Dict[str, Any]]: + role_id = oj._safe_id(name.rsplit(".", 1)[0]) + if not role_id: + return None + path = f"{root}/roles/{name}" + async with sem: + doc, err = await oj._read_yaml(client, path) + if err: + return {"id": role_id, "title": None, "updated": None, "path": path, "error": err} + return {"id": role_id, "title": oj._text(doc.get("title"), 120), + "updated": _stamp(doc.get("updated")), "path": path, "error": None} + + roles = [r for r in await asyncio.gather(*(one(n) for n in names)) if r] + roles.sort(key=lambda r: r["id"]) + return {"agent_name": agent_name, "canon_root": root, "roles": roles, + "unavailable": None, "reason": None if roles else "empty", + "message": None if roles else "the canon's `roles/` directory holds no role file", + "truncated": truncated} diff --git a/src/backend/services/objective_join_service.py b/src/backend/services/objective_join_service.py index 615d1f82e..b26799222 100644 --- a/src/backend/services/objective_join_service.py +++ b/src/backend/services/objective_join_service.py @@ -184,15 +184,37 @@ def canon_root(template: Any) -> Optional[str]: return raw -def objective_concerns(obj: dict, role_id: Optional[str], - agent_name: str) -> bool: - """An objective belongs to this agent when its role owns it or it supports it. +def owned_seat_ids(role_id: Any) -> Tuple[str, ...]: + """The seat ids an agent owns objectives through, as a tuple of valid ids. + + trinity-enterprise#812: an agent owns through the seat it HOLDS, or — a + companion — the seats its assigned people hold (a shared companion owns the + union). Callers pass one id, a sequence of ids, or None; invalid ids drop. + """ + if role_id is None: + return () + values = [role_id] if isinstance(role_id, str) else list(role_id) + out: List[str] = [] + for v in values: + sid = _safe_id(v) + if sid and sid not in out: + out.append(sid) + return tuple(out) + + +def objective_concerns(obj: dict, role_id: Any, agent_name: str) -> bool: + """An objective belongs to this agent when one of its seats owns it or it + supports it. Framework §3.4: `owner: role:`, `supporting_agents: []`. - An agent with no `x-role` has no owned objectives — it can still support. + `role_id` is one seat id or several (see `owned_seat_ids`). An agent with no + seat has no owned objectives — it can still support. + + `supporting_agents` is read as before but FROZEN (trinity-enterprise#812): + it puts Trinity agent names into canon, and nothing new is built on it. """ owner = _text(obj.get("owner"), 128) or "" - if role_id and owner == f"role:{role_id}": + if any(owner == f"role:{sid}" for sid in owned_seat_ids(role_id)): return True return agent_name in (obj.get("supporting_agents") or []) @@ -470,7 +492,7 @@ def parse_objective( def select_objectives( objectives: List[Dict[str, Any]], *, - role_id: Optional[str], + role_id: Any, agent_name: str, ) -> Tuple[List[Dict[str, Any]], bool]: """(the ones this agent answers for, truncated) — active only, capped last. @@ -510,7 +532,7 @@ def join_objectives( latest_by_name: Dict[str, Dict[str, Any]], *, agent_name: str, - role_id: Optional[str], + role_id: Any, served: Optional[Dict[str, Dict[str, Any]]] = None, ) -> Dict[str, Any]: """The pure join. Every unit test drives this; nothing here does I/O. @@ -530,6 +552,7 @@ def join_objectives( through here and there is no second one to disagree with it. """ by_name = {d["name"]: d for d in definitions if d.get("name")} + owned_owners = {f"role:{sid}" for sid in owned_seat_ids(role_id)} findings: List[Dict[str, Any]] = [] rows_out: List[Dict[str, Any]] = [] summary = _new_summary() @@ -547,7 +570,7 @@ def join_objectives( else: seen_ids[obj_id] = obj["path"] - owned = bool(role_id) and (obj.get("owner") or "") == f"role:{role_id}" + owned = (obj.get("owner") or "") in owned_owners supporting = agent_name in (obj.get("supporting_agents") or []) metrics_out = [] @@ -860,8 +883,9 @@ async def _read_yaml(client, path: str, *, async def _list_objective_files( - client, root: str) -> Tuple[List[str], List[str], str]: - """(file names, refused names, source code) for `/objectives`. + client, root: str, subdir: str = "objectives") -> Tuple[List[str], List[str], str]: + """(file names, refused names, source code) for `/` — + `objectives` here, `roles` for `canon_roles_service` (trinity-enterprise#817). The agent-server listing is RECURSIVE with no depth cap, so only top-level `type: file` entries are taken; nested folders are ignored. A 404 is @@ -875,7 +899,7 @@ async def _list_objective_files( fetch". """ response = await _agent_get( - client, f"/api/files?path=/home/developer/{root}/objectives") + client, f"/api/files?path=/home/developer/{root}/{subdir}") if response is None: return [], [], "unreadable" status = getattr(response, "status_code", 0) @@ -1213,30 +1237,43 @@ async def read_objective_join( agent_name, now, source=source, message=( "template.yaml could not be read from this agent " - f"({terr}) — it is where `x-role` and `x-canon` say which " - "role this agent fills and where its canon lives")) + f"({terr}) — it is where `x-canon` says where this agent's " + "canon lives")) else: source["template"] = "read" - xrole = template.get("x-role") if isinstance(template, dict) else None + # trinity-enterprise#812 (operator ruling 2026-10-06): the canon + # declaration alone enables the join. `x-role` is neither required nor read; + # the seat comes from Trinity's record — the one the agent HOLDS, or the + # seats its assigned people hold (a companion). has_canon = isinstance(template, dict) and "x-canon" in template findings: List[Dict[str, Any]] = [] - if not isinstance(xrole, dict) and not has_canon: - # Zero config: no role, no canon, nothing to join — and NO store read. + if not has_canon: + # Zero config: no canon, nothing to join — and NO store read. return _empty( agent_name, now, source=source, - message=("no `x-role` or `x-canon` in template.yaml — nothing to " - "join. An objective is a canon file that names a metric; " - "declare the role this agent fills to read them here.")) + message=("no `x-canon` in template.yaml — nothing to join. An " + "objective is a canon file that names a metric; declare " + "where this agent's canon lives to read them here.")) - role_id = _safe_id(xrole.get("role")) if isinstance(xrole, dict) else None - if isinstance(xrole, dict) and xrole.get("role") and not role_id: + from services.assignment_provider import resolve_seat + + seat = resolve_seat(agent_name) + if seat["case"] == "holds": + owned = owned_seat_ids(seat["role_id"]) + else: + owned = owned_seat_ids([seat["role_id"]] + list(seat["seats"]) + if seat["role_id"] else seat["seats"]) + role_id = owned[0] if owned else None + if not owned: findings.append(_finding( - "role_id_invalid", - f"`x-role.role` in template.yaml is not a valid id, so no owned " - f"objective can be matched for `{agent_name}` — ids are letters, " - "digits, dot, dash and underscore.", + "no_seat", + f"`{agent_name}` neither holds a seat nor serves a person who holds " + "one, so it owns no objective — assign its primary (with the seat " + "they hold) on the agent's Access tab, or record the seat the " + "agent holds itself. Objectives that name it in " + "`supporting_agents` still show.", )) root = canon_root(template) @@ -1249,17 +1286,19 @@ async def read_objective_join( )) return _empty( agent_name, now, source=source, findings=findings, - role={"id": role_id, "path": None}, + role={"id": role_id, "path": None, "case": seat["case"], "seats": list(owned)}, message=("`x-canon.clone_path` is not a readable path — fix it in " "template.yaml")) role = { "id": role_id, "path": f"{root}/roles/{role_id}.yaml" if role_id else None, + "case": seat["case"], + "seats": list(owned), } files = await read_objective_files( - client, root, agent_name=agent_name, role_id=role_id) + client, root, agent_name=agent_name, role_id=owned) source.update(files["source"]) findings.extend(files["findings"]) @@ -1305,7 +1344,7 @@ async def read_objective_join( joined = join_objectives( objectives, definitions, latest, - agent_name=agent_name, role_id=role_id, served=served) + agent_name=agent_name, role_id=owned, served=served) return { "agent_name": agent_name, diff --git a/src/backend/services/platform_prompt_service.py b/src/backend/services/platform_prompt_service.py index 9566ecc0e..f01c24bf6 100644 --- a/src/backend/services/platform_prompt_service.py +++ b/src/backend/services/platform_prompt_service.py @@ -748,6 +748,16 @@ class ExecutionContext: # are in all (the rest are named as a count). Auto-filled from the DB. pending_asks: Optional[List[dict]] = None pending_asks_total: Optional[int] = None + # trinity-enterprise#814: the person a scheduled seat run is addressed to + # (ent#637 — the Workspace delivery address on the execution row). NEVER + # rendered: it only picks which seat `role_id` names. A chat turn uses + # `source_user_email` for the same purpose. + served_person_email: Optional[str] = None + # trinity-enterprise#814: the seat THIS run serves when it is not the + # primary's (a shared companion working for someone else, or an agent that + # holds a seat itself). Rendered as its own line; the `Primary human` line + # and its role stay the primary's. + served_role_id: Optional[str] = None @staticmethod def derive_mode(triggered_by: Optional[str]) -> str: @@ -1005,6 +1015,9 @@ def build_execution_context(ctx: ExecutionContext) -> str: if assignment: lines.append(f"- **Primary human**: {assignment}") + served = _sanitize_field(ctx.served_role_id, max_len=MAX_ROLE_ID_LEN) + if served and served != _sanitize_field(ctx.role_id, max_len=MAX_ROLE_ID_LEN): + lines.append(f"- **Seat this run serves**: {served}") stakeholders = _render_stakeholders(ctx) if stakeholders: lines.append(f"- **Stakeholders**: {stakeholders}") @@ -1066,6 +1079,30 @@ def _resolve_assignment( return resolve_assignment(agent_name, triggered_by) or {} +def _served_role(ctx: ExecutionContext) -> Optional[str]: + """The seat THIS run serves, when it is not the primary's (trinity-enterprise#814). + + A shared companion serves several people; the provider's answer carries the + primary's seat. When the run is addressed to a person — the chat user, or + the Workspace a brief is delivered to — their own seat is the one served, + and the agent's own seat when it holds one. Asked only when the provider + already answered, so an audience it suppressed still renders nothing. + """ + person = ctx.served_person_email or ctx.source_user_email + if not person: + return None + try: + from services.assignment_provider import resolve_served_seat + + served = resolve_served_seat(ctx.agent_name, person) + except Exception as e: # noqa: BLE001 — the line degrades, never the turn + logger.debug(f"_served_role({ctx.agent_name}) failed: {e}") + return None + if served.get("source") in ("holds", "person"): + return served.get("role_id") + return None + + def _resolve_ended_asks(agent_name: Optional[str]) -> List[dict]: """This agent's asks that ended in the last day, newest first. Empty on any failure — the line is omitted, never the turn (trinity-enterprise#611).""" @@ -1180,6 +1217,9 @@ def compose_system_prompt( if ctx.pending_asks is not None else _resolve_pending_asks(ctx.agent_name) ) + served_role = ( + _served_role(ctx) if assignment and ctx.served_role_id is None else None + ) ctx = replace( ctx, collaborators=( @@ -1219,6 +1259,9 @@ def compose_system_prompt( ), pending_asks=pending, pending_asks_total=pending_total, + served_role_id=( + ctx.served_role_id if ctx.served_role_id is not None else served_role + ), ) block = build_execution_context(ctx) if block: diff --git a/src/backend/services/role_readiness_gate.py b/src/backend/services/role_readiness_gate.py index 406ae04a3..3816a1e89 100644 --- a/src/backend/services/role_readiness_gate.py +++ b/src/backend/services/role_readiness_gate.py @@ -8,14 +8,20 @@ Two properties are load-bearing: * **The owner's stamp is the only authority.** `x-role.status` in template.yaml - is written by the agent itself, so it is never read as a verdict. The template - is read only to learn whether the agent declares a role at all — a companion. - Honest limit: an UNSTAMPED agent can therefore take itself out of scope (drop - `x-role`, or stall the read into fail-open). A stamp it cannot touch: once an - owner has stamped `calibrating`, nothing the agent writes releases the brief. + is written by the agent itself, so it is never read as a verdict. Once an owner + has stamped `calibrating`, nothing the agent writes releases the brief. * **Every ambiguity fails open** (#1638: never mute working behaviour on an - ambiguous read). Stamp read failed, container not running, Docker unreadable, - template unreadable within the bound → the brief fires, and the reason is logged. + ambiguous read). Stamp read failed, no seat lookup, a lookup error → the brief + fires, and the reason is logged. + +**Who is a companion (trinity-enterprise#813, ruling 2026-10-06).** Asked only +for an UNSTAMPED agent: an agent whose primary is assigned and holds a seat — +`assignment_provider.resolve_seat` answers `serves`. The template is no longer +read for it (`x-role` stopped being truth), so an agent can no longer take itself +out of scope by editing its own files: the seat is admin-written. An agent that +holds a seat ITSELF is an autonomous player, not a companion; no primary, no +provider or a lookup error all read as "not a companion", so the brief fires as +it did before. Grandfathering is not here: a one-time data seed (`role_readiness_rollout_seed`, both migration tracks) stamped every agent with a live seat brief at deploy. @@ -33,10 +39,6 @@ #: the role card reads it and says "carried over", never "by ". ROLLOUT_CHANGED_BY = "rollout:ent#689" -#: The template read happens on the scheduler's dispatch path; the scheduler's -#: own call is bounded at 5 s, so this must finish well inside it. -TEMPLATE_READ_TIMEOUT_SECONDS = 3.0 - class AgentNotFound(Exception): """No ownership row for the agent — the route answers 404 (the scheduler fails open).""" @@ -57,8 +59,10 @@ def held_reason(agent_name: str) -> str: ) -def decide(agent_name: str, stamp: Optional[dict], companion: Optional[bool]) -> Verdict: - """The rule, pure. `companion` is None when it could not be established.""" +def decide(agent_name: str, stamp: Optional[dict], companion: Optional[bool], + why: str = "") -> Verdict: + """The rule, pure. `companion` is None when it could not be established; + `why` is the seat reason behind a "not a companion" (#813).""" if stamp and stamp.get("status"): if stamp["status"] == "ready": return Verdict(True, "stamped ready", "stamp") @@ -66,7 +70,7 @@ def decide(agent_name: str, stamp: Optional[dict], companion: Optional[bool]) -> if companion is None: return Verdict(True, "readiness could not be established — firing (fail open)", "fail_open") if not companion: - return Verdict(True, "not a companion (no x-role)", "not_companion") + return Verdict(True, f"not a companion ({why or 'no seat'})", "not_companion") return Verdict(False, held_reason(agent_name), "unstamped_companion") @@ -122,40 +126,24 @@ def briefs_held_for_list(agents: list, readiness_by_name: dict) -> set: } -async def _is_companion(agent_name: str) -> Optional[bool]: - """Whether template.yaml declares `x-role` — True / False, or None when that - cannot be read right now (not running, Docker unreadable, slow, unparsable).""" - from services import docker_utils - from services.agent_client import get_agent_client - from utils.safe_yaml import load_template_yaml +def companion_from_seat(agent_name: str) -> tuple: + """(is it a companion, why) from the seat on record (trinity-enterprise#813). - try: - state = await docker_utils.agent_container_state_async(agent_name) - except Exception as e: # noqa: BLE001 - logger.warning("[ent#689] container state for %s unreadable (%s) — fail open", agent_name, e) - return None - if state != "running": - return None - try: - res = await asyncio.wait_for( - get_agent_client(agent_name).read_file("template.yaml", timeout=TEMPLATE_READ_TIMEOUT_SECONDS), - TEMPLATE_READ_TIMEOUT_SECONDS + 0.5, - ) - except Exception as e: # noqa: BLE001 — TimeoutError and circuit-open included - logger.warning("[ent#689] template.yaml for %s unreadable (%r) — fail open", agent_name, e) - return None - if not res or not res.get("success"): - return None - if res.get("not_found") or res.get("content") is None: - return False # no template at all: not a companion - try: - data = load_template_yaml(res["content"]) - except Exception as e: # noqa: BLE001 - logger.warning("[ent#689] template.yaml for %s unparsable (%s) — fail open", agent_name, e) - return None - if not isinstance(data, dict): - return None - return isinstance(data.get("x-role"), dict) + A companion is an agent whose primary is assigned and holds a seat. Every + other answer — the agent holds a seat itself, no primary with a seat, no + seat lookup, a lookup error — is "not a companion", stated with its reason. + Never raises (`resolve_seat` never does). + """ + from services.assignment_provider import resolve_seat + + seat = resolve_seat(agent_name) + if seat["reason"] != "provider": + return False, f"seat lookup unavailable: {seat['reason']}" + if seat["case"] == "serves": + return True, f"its primary holds the seat {seat['role_id']}" + if seat["case"] == "holds": + return False, f"it holds the seat {seat['role_id']} itself" + return False, "no primary with a seat" async def brief_readiness(agent_name: str) -> Verdict: @@ -169,8 +157,12 @@ async def brief_readiness(agent_name: str) -> Verdict: except Exception as e: # noqa: BLE001 logger.warning("[ent#689] readiness stamp for %s unreadable (%s) — fail open", agent_name, e) return Verdict(True, "readiness could not be established — firing (fail open)", "fail_open") - companion = None if stamp else await _is_companion(agent_name) - verdict = decide(agent_name, stamp, companion) + companion, why = (None, "") if stamp else companion_from_seat(agent_name) + verdict = decide(agent_name, stamp, companion, why) if verdict.basis == "fail_open": logger.warning("[ent#689] %s: %s", agent_name, verdict.reason) + elif not stamp: + # #813: the companion decision, with the reason it took. + logger.info("[ent#813] %s: %s → %s", agent_name, why, + "held" if not verdict.fire else "fires") return verdict diff --git a/src/backend/services/seat_decision_service.py b/src/backend/services/seat_decision_service.py index 9db30d9b4..525b41afb 100644 --- a/src/backend/services/seat_decision_service.py +++ b/src/backend/services/seat_decision_service.py @@ -60,6 +60,11 @@ PROMPT_BLOCK_BYTES = 1500 _SLUG_RE = re.compile(r"\A[a-z0-9][a-z0-9_-]{0,63}\Z") +#: A canon role id, lowercased — `objective_join_service._ID_RE` (dots allowed: +#: `/roles/head.of.sales.yaml` is a valid seat). Since ent#814 the seat on +#: record wins over the agent's word here, so the check must accept every id the +#: Access tab can put on record, or a dotted seat refuses every decision with 422. +_ROLE_ID_RE = re.compile(r"\A[a-z0-9][a-z0-9._-]{0,63}\Z") _DATE_RE = re.compile(r"\A\d{4}-\d{2}-\d{2}\Z") CANON_PROPOSAL_HINT = ( @@ -192,8 +197,8 @@ def validate_record(payload: dict, *, today: Optional[date] = None) -> dict: if ask_class and not _SLUG_RE.match(ask_class): problems["ask_class"] = "ask_class must be a slug (a-z, 0-9, -, _; 64 max)" role = _clean(payload.get("decided_by_role")) or None - if role and not _SLUG_RE.match(role.lower()): - problems["decided_by_role"] = "decided_by_role must be a role id slug" + if role and not _ROLE_ID_RE.match(role.lower()): + problems["decided_by_role"] = "decided_by_role must be a role id (a-z, 0-9, ., -, _; 64 max)" raw_cites = payload.get("cites") or [] cites = [c for c in raw_cites if isinstance(c, str) and c.strip()] if isinstance(raw_cites, (list, tuple)) else [] diff --git a/src/backend/services/task_execution_service.py b/src/backend/services/task_execution_service.py index e0da13324..588c836f2 100644 --- a/src/backend/services/task_execution_service.py +++ b/src/backend/services/task_execution_service.py @@ -86,6 +86,21 @@ from db.write_params import ExecutionResult, TaskExecutionFields +def _served_person_for(execution_id: Optional[str], triggered_by: Optional[str]) -> Optional[str]: + """The person a scheduled seat run is addressed to (ent#637), or None — + picks the seat the prompt names (trinity-enterprise#814). One row read, and + only for a schedule fire; fail-soft.""" + from services import schedule_seat_memory + + if not execution_id or (triggered_by or "").lower() not in schedule_seat_memory.SCHEDULE_FIRE_TRIGGERS: + return None + try: + return schedule_seat_memory.seat_for_execution(db.get_execution(execution_id)) + except Exception as e: # noqa: BLE001 + logger.debug(f"_served_person_for({execution_id}) failed: {e}") + return None + + def _resolve_agent_runtime(agent_name: str) -> str: """Best-effort resolve an agent's runtime for the platform prompt (#1187). @@ -2305,6 +2320,7 @@ def _compose_effective_system_prompt( schedule_cron=(schedule_context or {}).get("cron"), schedule_next_run=(schedule_context or {}).get("next_run"), execution_id=execution_id, + served_person_email=_served_person_for(execution_id, triggered_by), ) effective_system_prompt = compose_system_prompt( execution_context=exec_ctx, diff --git a/src/frontend/src/components/AccessPanel.vue b/src/frontend/src/components/AccessPanel.vue index 8f472045d..14f8ee661 100644 --- a/src/frontend/src/components/AccessPanel.vue +++ b/src/frontend/src/components/AccessPanel.vue @@ -11,7 +11,13 @@

- + + + +