Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions docs/memory/architecture/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,32 @@ The seat's active decisions ride the shared memory block into every turn
reusable and `cites` β€” the health metric β€” non-zero. `PortalAgentDecisions.vue` in
Agent details. Requirement Β§5.37 of `core-agent.md`; flow in `workspace-seat-decisions.md`.

## Suggestions β€” what you can do with this agent, and what is waiting (ent#465)

`client_portal/suggestions/` (router β†’ service β†’ db, the `work/` shape) answers
`GET /api/enterprise/client-portal/agents/{name}/suggestions` for ONE viewer and
ONE agent. `service.build` is pure over an injected `now`: signals in (my pending
asks, my seat's decisions past `review_by`, the agent's schedules + one windowed
query of recent runs, overdue reminders, my last Workspace message, the playbooks
I have started, the exposed playbooks from the ONE briefing path), ranked
`(Suggestion, fingerprint)` pairs out. **Door, per class:** the router 404s a
portal token before any read (ent#78 auth-path invariant); `configure` classes
reach only owner or admin β€” `PortalPrincipal.is_admin` exists for this ONE
decision (role `admin` AND scope in `ADMIN_GATE_SCOPES`); everywhere else a
non-owner admin stays a viewer (ent#358). **Fingerprint = the identity of the
state, never a count** (the first failure of a streak, the set of held schedules),
so a dismissal outlives more of the same and ends when the state changes. The
feedback write (`POST …/suggestions/feedback`, key in the body) recomputes and 404s
a key not currently emitted β€” writes are bounded to real items and the fingerprint
is the server's. `workspace_suggestion_feedback` is named generically with a
`surface` column so the post-action next-step tier (design-system p27) shares one
dismissal model; CASCADE on the agent. The briefing is fetched only when the agent
can answer (#2196 availability first) and cached 60 s. Frontend:
`PortalSuggestions.vue` in the Info tab (full) and on the empty chat (compact, top
3, no chrome when empty); the shell loads the slice for the active 1:1 agent so
Info's rail dot (`updated` + a `note`, "2 suggestions") lights mid-conversation.
Requirement Β§5.39 of `core-agent.md`; flow in `workspace-suggestions.md`.

## Agents at the centre β€” Main, Reset, and the one page (ent#523, ent#524)

Clicking an agent opens the **conversation** you were last in. `/workspace/a/:agentName`
Expand Down Expand Up @@ -958,11 +984,13 @@ exactly ONE participant, never "at least one". Two properties are load-bearing:
leave Nβˆ’1 in a **permanent loading skeleton**. Grouping is unblocked by keying that store
per agent β€” a store change, not a rail change.

Info is the registry's first **static** tab: `signal: RAIL_SIGNAL_NONE` and `empty: null`,
declared rather than filled, because an agent always has a name, a health state and a chat
list (no empty state to teach) and nothing writes an `info` signal (no dot that can light).
`signalFor` already answers `emptySignal()` for an unmentioned tab, so the static form needs
no read-side special case. It is also absent from `feedsFor` by design β€” its body owns its
Info declares `empty: null` β€” an agent always has a name, a health state and a chat list, so
there is no empty state to teach. It shipped as the registry's first **static** tab
(`signal: RAIL_SIGNAL_NONE`, ent#547); **ent#465 amended that**: Info now carries
`RAIL_SIGNAL_UPDATED`, and the shell writes an `info` entry β€” with a `note`, "N suggestions",
that the tooltip and mobile strip read instead of "updated" β€” while suggestions are waiting
in it. A signalling tab without an empty state is therefore a legal registry shape;
`RAIL_SIGNAL_NONE` stays for a tab with neither. It is also absent from `feedsFor` by design β€” its body owns its
own two reads, the one docked tab not fed by the shell. Both `<PortalRail>` mounts (the
column and the mobile sheet) receive `#tab-info`; one alone leaves the phone on the generic
empty state. Mobile is a **gain**: the old panel was `hidden sm:flex`, so the header button
Expand Down
2 changes: 1 addition & 1 deletion docs/memory/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ The behavioral half of the standard (the visual half is Β§1–6). Confirmed in t
24. Identity is encoded in form as well as color β€” classes and states are distinguishable by shape or icon, never by hue alone.
25. Meaningful errors everywhere: every failure surface says what happened, what it means, and what to do β€” in user vocabulary; stack traces and HTTP codes go behind a details disclosure, never the headline.
26. Expectation setting: non-instant actions state what will happen and roughly how long; multi-step operations show staged progress; say where the result will appear.
27. Post-action next steps: after a completed action, offer the natural next move via static per-flow sequencing. (An adaptive, system-wide suggestion tier is deliberately deferred.)
27. Post-action next steps: after a completed action, offer the natural next move via static per-flow sequencing. (An adaptive, system-wide suggestion tier is deliberately deferred.) The per-agent, per-viewer suggestions list (ent#465, `PortalSuggestions.vue`) is not that tier β€” it is computed from state, not triggered by the action just taken β€” but it owns the dismissal model that tier should reuse (`workspace_suggestion_feedback.surface`).
28. Unbounded data is contained: any surface that can render a large or unbounded set (tables, logs, activity streams, execution lists) gets a bounded viewport β€” max-height with internal scroll, pagination, or virtualization β€” and never grows the page itself without limit. Sticky headers stay visible while scrolling; the surface states the total ("412 executions Β· latest 50 shown") so what's beyond the fold is known, not hidden. Companion of 7 (horizontal) and 4 (stable footprint).

## 8. Do / don't β€” the shape of a violation
Expand Down
1 change: 1 addition & 0 deletions docs/memory/feature-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
| Schedule β†’ Workspace Delivery | [schedule-workspace-delivery.md](feature-flows/schedule-workspace-delivery.md) | A schedule names one Workspace user; its output lands as a brief in their Main chat (ent#498) |
| Workspace Role Card | [workspace-role-card.md](feature-flows/workspace-role-card.md) | The Info rail's role card β€” a projection of the agent's canon files, plus the owner's readiness stamp (ent#527, #663) |
| Workspace Seat Decisions | [workspace-seat-decisions.md](feature-flows/workspace-seat-decisions.md) | The seat-level decision record β€” why a thing was approved, deferred or killed; recorded by the companion over MCP or the person in Agent details; the evidence base for the autonomy dial (ent#638, R25) |
| Workspace Suggestions | [workspace-suggestions.md](feature-flows/workspace-suggestions.md) | Per-viewer, per-agent suggestions β€” waiting asks and decisions, schedule health for owners, dormancy, playbooks you have not run; Accept prefills / opens / deep-links, Dismiss holds until the state changes (ent#465) |

### Dashboard & Monitoring

Expand Down
7 changes: 5 additions & 2 deletions docs/memory/feature-flows/workspace-rail.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ mounted panels therefore leave Nβˆ’1 stuck in a **permanent loading skeleton**
absence, because it claims to be fetching something it has already abandoned. Keying that
store per agent unblocks the grouping; it is a store change, not a rail change.

**The third registry shape: a STATIC tab.** Info declares `signal: RAIL_SIGNAL_NONE` and
`empty: null`. Both absences are declared rather than filled, because an agent always has
**The third registry shape: a STATIC tab** (ent#547 β€” **amended by ent#465**: Info now
declares `signal: RAIL_SIGNAL_UPDATED`, lit by the shell with a `note` β€” "N suggestions" β€”
while suggestions wait in it; see `workspace-suggestions.md`. `empty: null` stands, and
`RAIL_SIGNAL_NONE` remains the shape for a tab with neither.) As first shipped, Info declared
`signal: RAIL_SIGNAL_NONE` and `empty: null`. Both absences are declared rather than filled, because an agent always has
a name, a health state and a chat list β€” the body always renders, so the rail's generic
empty branch is unreachable β€” and nothing ever writes an `info` signal, so a borrowed
`updated` would light no dot while documenting a rule that does not exist, diluting the
Expand Down
92 changes: 92 additions & 0 deletions docs/memory/feature-flows/workspace-suggestions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Workspace β€” suggestions: what you can do with this agent, and what is waiting (trinity-enterprise#465)

## Overview

A platform user who opens an agent in the Workspace gets a short list computed for
**them and that agent**: questions the agent is waiting on them for, decisions on their
seat past review, the health of the agent's schedules (owner/admin only), how long since
they last talked to it, and playbooks it exposes that they have never run. Each item
shows its evidence (`signal`) and has two verbs: **Accept** β€” prefill the composer, open
a section, focus the chat, or deep-link to the operator schedules tab β€” and **Dismiss**.
Nothing is sent on the person's behalf, and an agent with nothing to say says so.

Requirement: `docs/memory/requirements/core-agent.md` Β§5.39. Journey: J13 (skeleton).

## Flow

```
Portal.vue (shell)
watch(active 1:1 agent, platform session) β†’ store.loadAgentSuggestions(name) [60 s reuse]
#empty slot β†’ PortalSuggestions compact limit=3 ─┐
Info tab β†’ PortalAgentDetails β†’ PortalSuggestions (full) ── both read one store slice
railSignals.info = {updated, note: "N suggestions"} β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β†’ Info's rail dot

GET /api/enterprise/client-portal/agents/{name}/suggestions
suggestions/router._gate: not is_platform β†’ 404 Β· agent_on_roster(…, True) β†’ 404 Β· rate limit 60/min/viewer
suggestions/service.get_suggestions
can_configure = principal.is_admin OR portal_owns_agent(email, name, True)
asyncio.gather(
to_thread(_gather) β†’ list_asks Β· list_seat_decisions+effective_status Β· list_schedules
Β· last_user_message_at Β· slash_texts_by_viewer Β· dismissed_fingerprints
Β· [owner/admin] autonomy Β· recent_runs_by_schedule (ROW_NUMBER ≀10) Β· overdue reminders
_playbooks(name) β†’ _availability_map β†’ _bounded_briefing (only if it can answer) β†’ playbooks | None
)
build(signals, now) β†’ ranked (Suggestion, fingerprint) pairs [PURE]
shape(pairs, dismissed) β†’ hide where fingerprint == dismissed one; cap 5; total; capabilities; basis

POST …/suggestions/feedback {key, action: accept|dismiss}
same gate β†’ key class allowlisted (else 422) β†’ recompute β†’ key not emitted β†’ 404 (nothing written)
β†’ db.record_feedback: dismissed_at + dismissed_fingerprint | accepted_at + accept_count+1
```

## Why it is shaped this way

- **Two kinds, per the engine-bay ruling (decision 15, option 1).** `invoke` items act
inside the Workspace; `configure` items only deep-link to the operator page. The
Workspace still never configures (Β§5.11).
- **Prefill, never send.** The ent#138 rule every hint follows. `open_chat` focuses the
composer; `link` opens a new tab and only for an `/agents/…` path.
- **One `autonomy_held` item, not one per schedule.** Autonomy is off by default, so a
per-schedule item would fill every slot, and nothing records when autonomy was turned
off β€” "held N days" would be invented. It cites the last real run instead, and
suppresses "never fired" (a held schedule cannot fire).
- **"Haven't run /name", not "never used".** Usage is explicit slash invocations in the
viewer's own Workspace messages and executions attributed to them; plain-language
requests are undetectable. A playbook an enabled schedule already runs is not offered.
- **Fingerprints are state identities, never counts.** Otherwise a failing schedule
returns with every new failure β€” nagging, not "the signal materially changed".
- **Bounded, unforgeable writes.** The key travels in the body (it can carry an
agent-authored playbook name); the server recomputes and writes only for an item it
would show this viewer now, with its own fingerprint.
- **Info's dot, not a number.** The rail has two signal shapes in one hue (principle
24); Info borrows `updated` and its words ("Info Β· 2 suggestions") come from `note`.
The shell loads the slice itself so the dot works with the rail collapsed.
- **No LLM.** A handful of indexed queries, one windowed runs query, and at most one
bounded briefing call (cached 60 s, skipped for a stopped agent).

## Files

| Layer | Files |
|---|---|
| Router | `src/backend/client_portal/suggestions/router.py` (registered in `main.py`) |
| Service | `src/backend/client_portal/suggestions/service.py` (`build`, `shape`, thresholds) |
| DB | `src/backend/client_portal/suggestions/db.py`; table `workspace_suggestion_feedback` in `db/schema.py`, `db/tables.py`, `db/migrations.py`, Alembic `0078_workspace_suggestion_feedback`; CASCADE in `db/agent_cleanup.py` |
| Auth | `client_portal/portal_auth.py` β€” `PortalPrincipal.is_admin`, `_is_admin_principal` |
| Frontend | `components/portal/PortalSuggestions.vue`, `PortalAgentDetails.vue`, `views/Portal.vue`, `stores/clientPortal.js` (suggestions slice), `components/portal/portalRail.js` (Info signal + `note`) |
| Tests | `tests/unit/test_ent465_suggestions.py`, `src/frontend/tests/unit/portalSuggestions.spec.js`, `portalRail.spec.js`, `tests/journeys/test_j13_suggestions_journey.py` |

## Known limits (review, 2026-09-24)

- "Show decisions" accepted from the **empty chat** opens the Info tab at its top; only the
Info-tab placement scrolls to the Decisions section.
- Two concurrent feedback writes from one viewer on SQLite can meet `SQLITE_BUSY` (a deferred
transaction reads, then writes); the client restores the item with its inline error and
nothing is lost. Concurrent new keys can leave a viewer one row over the 1,000-row cap.

## Deferred

- A suggestion that consults another agent (2026-09-21 amendment β†’ ent#698) and
suggestions derived from the current conversation (2026-09-22 β†’ ent#699).
- Role, project and objective-gap inputs (#500, #661, #477–#479); the ent#178 curated
skill set replaces the briefing ladder as the capability source when it lands.
- The Inbox placement (#610).
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2026-09-24 β€” pitfall β€” A second Trinity stack on the same Docker daemon reads every other stack's agent volumes as orphans
**Context**: trinity-enterprise#465's local demo. `cleanup_service._sweep_orphan_agent_volumes` (#1581) reclaims an `agent-*` volume that (1) no ownership row claims, (2) no container mounts, (3) for three consecutive cycles, past a 1h creation grace β€” up to 100 per cycle, force-removed. All three conditions are evaluated against the stack's OWN database and the daemon's WHOLE volume list. A stack booted with a fresh database (a demo, a `--keep` verify run, a second worktree's dev stack) owns no rows, so every labelled agent volume on the host that nothing currently mounts is an orphan to it; on the developer machine where this surfaced that was 207 of 238. There is no setting that turns the sweep off, and `/verify-local`'s overlay does not neutralise it β€” a run that stays up past ~15 minutes is inside the window.
**Lesson**: Docker volumes are host-global state; a sweep that decides "unowned" from a per-stack database is only safe when that database is the only one on the daemon. Before booting any second stack on a shared daemon, count `docker volume ls --filter label=trinity.agent-name` minus the attached ones β€” if it is non-zero, disable that sweep for the extra stack (a demo-only `sitecustomize` import hook that no-ops the method works without touching the branch) or scope the sweep to volumes this stack created. The structural fix is a stack-identity label on every agent volume, checked by the sweep before it reclaims anything.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2026-09-24 β€” pitfall β€” Content placed ABOVE a chat pane's empty state is scrolled out of view by stick-to-bottom
**Context**: trinity-enterprise#465. The empty-chat suggestions were mounted in `PortalConversation`'s `#empty` slot above `PortalBriefing`; the transcript scroller sticks to the bottom, so on a normal viewport the new list sat above the fold, cut off at the top edge, with only its third item partly visible. Every unit and mounted test passed β€” jsdom has no layout β€” and the defect was found only by the live screenshot pass.
**Lesson**: anything added to the empty-chat state goes BETWEEN the agent's identity and its hints (`PortalBriefing`'s `before-hints` slot), sized to fit one viewport with the hints β€” never prepended to the slot. For any new Workspace surface inside the transcript scroller, the screenshot at a real viewport is the test; plan it, don't treat it as polish.
Loading
Loading