Skip to content

design platform assistant

sinrimin edited this page Oct 20, 2026 · 8 revisions

Design: platform assistant — a server-embedded agent that operates Harness Nexus as the user

Issue #52 (depends on #50 Node floor, #51 provider model registry). Status: M1 SHIPPED 2026-10-14, M2 + M3 SHIPPED 2026-10-15 — M1: the skeleton (data model, services, pi runtime with the v1 tool groups, assistant:* realtime, config page + Chat 端 integration). M2: the MCP bridge (config mcpServerIds → pooled tools as <server>__<tool> AgentTools via the registry, lazy seam; unavailable rows note honestly) and skills wiring (skillResourceIds → prompt index + read_skill; save-time visibility/kind/source rules, turn-time skip-with-note), plus the web pickers (client-dial greyed). M3: the write-tool approval gate (WRITE_TOOLS surface ACP permission_request cards; respond/timeout/ cancel all deny with a readable reason), the per-config daily token budget (UTC-day sum over its sessions' turns, refused honestly at turn start), and busy assistant turns as channel-bar tabs (× cancels the turn; the session row persists). The #52 feature is complete as scoped — no open follow-ups.

1. What it is

A platform-native AI assistant: users chat with it in the web console and it operates Harness Nexus itself — create/update skill resources, manage profiles, search the skill hub, inspect machines, call MCP tools — always acting AS the owning user, never with elevated permissions. Over time most platform operations should be reachable through it.

Runtime: @earendil-works/pi-agent-core (agent loop, tool execution, mid-conversation toolset/prompt changes) + @earendil-works/pi-ai (model protocol) embedded in the server, pinned exact versions. NOT the full pi-coding-agent SDK — it drags coding tools (bash/edit) and a resource loader; a server process runs neither.

New stance, stated explicitly (the one-line version for AGENTS.md): the "server never runs an agent loop" rule was always about MACHINE-side agents (ACP adapters belong to the daemon). The platform's OWN assistant is a platform feature and runs in-process — per-user, per-session, no machine needs to be online.

2. Identity — the actor is an explicit principal parameter (Plan B)

Settled 2026-10-14 by user decision: the platform gets a full service layer first (#53, design-service-layer.md); the assistant's tools call principal-carrying services directly. Two earlier sketches are DEAD and kept only as the historical record in research-platform-assistant.md: the AsyncLocalStorage context (disproven — light-my-request dispatches outside the caller's async context) and the boot-time internal credential + x-hnx-actor headers (superseded by this decision).

No tokens of any kind, no self-HTTP, no auth-hook internal branch. The tool executor calls the service with the session owner as an explicit principal:

// a tool's execute(), in full
const page = await svcResources.list({ uow }, { userId: session.ownerId, role }, {
  kind: params.kind, q: params.q,
});

The service layer is the single authorization enforcement point (#36 rules, 404 existence-hiding) — the assistant exercises exactly the same code the REST door does, with zero duplication and nothing to forge. The principal's role is re-read from the DB at turn start (and on agent rebind), so disable/demote takes effect on the next turn. beforeToolCall enforces the group allowlist + writes an audit row per call (assistant_tool_calls).

Why services and not app.inject() self-REST: consistency — one door for authorization truth, direct typed calls instead of a mock-HTTP hop, and the service layer has independent value (HTTP-free unit tests, future CLI/SDK reuse). Java analogy: @Service methods taking an Authentication parameter, controllers thin — rather than calling MockMvc from production code.

3. Data model (all owner-only rows)

  • assistant_configs — one row per named assistant, many per user: { id, ownerId, name (unique per owner), description?, providerId, defaultModel, systemPromptOverride?, enabledToolGroups[], mcpServerIds[], skillResourceIds[], createdAt, updatedAt }. SQLite migration N+1 + memory driver. Soft-delete: config vanishes from pickers, sessions stay readable.
  • assistant_sessions — { id, ownerId, assistantId, title, createdAt, updatedAt, lastMessageAt } + messages (assistant_messages: role-bearing JSON items — user / assistant / toolCall / toolResult / usage / note). Platform-owned session persistence is the explicit W7 counter-exception: W7's "the platform persists nothing session-shaped" is about machine agents; the assistant is platform data. The pi Agent state is rebuilt per turn from the DB — server restarts lose nothing.
  • assistant_tool_calls — audit: { id, sessionId, userId, tool, args (truncated), status, durationMs, createdAt }.

4. Model & provider

Rides #51: config references providerId + defaultModel; the sender's Model select lists that provider's type='chat' models. The pi-ai custom provider is built per turn from the resolved credential (findCredentialForOwner → decryptSecret; api mapping 1:1 as W16 verified: anthropic→anthropic-messages, openai-chat→openai-completions, openai-responses→openai-responses; baseUrl verbatim). temperature/topP/ serverTools/extraBody apply from the model's registry entry at request build (our own streamFn wrapper is the injection seam if pi-ai's options surface falls short — S0 spike verifies). Server→LLM outbound calls are outbound HTTP surface #3, governed by credential rules like provider-models.ts, not by the marketplace egress allowlist.

5. Tools

v1 groups (all read-heavy; no delete tools, no credential tools — destructive ops stay human):

group surface
resources list/get/create/update (incl. skill bodies)
profiles list/get/create/update
mcp list servers / status / list tools (read)
skills-hub multi-source search
machines machines / agent instances / jobs (read)
system info (read)

Defaults: reads on everywhere, writes on for resources/profiles. Tools are typebox-parameterized AgentTools (pi-native; the cross-package zod schemas stay the wire source — tool parameter schemas never leave the server).

6. MCP & skills

  • MCP: reuse the server-side McpRegistry pool. Config lists mcpServerIds the user can see; at session build each server's tools wrap as AgentTools named <server>__<tool>, executed via registry.callTool (credential resolution already lives there). Server-dialed rows only — client-dialed rows are honestly unavailable (greyed with a reason). Offline servers skip with a note (registry posture).
  • Skills: platform skill resources (inline / inline-bundle) → a skills INDEX section in the system prompt (name + description) + a read_skill tool for full bodies incl. bundle files. Progressive disclosure, Agent Skills-shaped, nothing lands on disk, single source of truth stays the resource store.

7. Streaming & transport

/app socket namespace, rooms chan:<assistantSessionId>, event family assistant:* (session.open → session.ready, message.send, abort, event {sessionId, event}, history). Event payloads REUSE the ChatStreamEvent shapes (message_delta, thought_delta, tool_call, usage, turn_result, queue_state, user_message) — pi-agent-core events map ~1:1, and the web's composer/tool cards/usage components come for free. Per-user busy gate + depth-1 queue (LiveSender semantics). assistant:config.set {kind:'option', configId:'agent'|'model', value} — opaque values, writable mid-turn, NEXT-TURN effect. Switching the agent mid-session is natively supported (pi diffs the toolset per request and announces changes in a system message; prompt patches ride system-message sections) — the transcript records a system note on rebind.

8. Web UI

  • Chat page integration (not a separate destination): the assistant is a pinned-first 端. Entry lands directly on the SESSION LIST of the default 端 (initial default: the assistant); the - All agents back-link position becomes a 端 dropdown (assistant pinned top, machine agents grouped with state lamps); a star next to the session-refresh button pins the default 端 (localStorage hnx.chat.defaultEnd — no server per-user pref store exists and none is created). The agent-cards screen retires (dropdown rows carry the state) — flagged as a reviewable call. AgentSession generalizes to an endpoint descriptor {type:'assistant'} | {type:'agent', agentInstanceId}: shared skeleton, branch points = socket family, new-session flow (no cwd), gates (no machine-online gate; per-user busy instead). Sender on the assistant 端 gains Agent + Model selects (machine 端 unchanged, W9).
  • Config page = new nav destination (one nav.ts declaration): DataTable + create button + shared FormDialog + two-stage delete. Dialog sections: basic (name/description), model (provider dropdown + registry models, default model), prompt (platform template prefilled, overridable) — tool groups and MCP/skills pickers grow in follow-ups. Empty assistant 端 → EmptyState + "create assistant" CTA.
  • i18n en/zh throughout; assistant → 助理; the chat-端 term is kept visually distinct from dial-site 拨号端 (the dropdown shows the 端's name).

9. Verification

S0 spike (done, packages/server/test/assistant-spike.test.ts): pi-ai custom provider shape against a gateway baseUrl; pi-agent-core custom tools + beforeToolCall + event mapping on the scripted fauxProvider; Node 22 static import health. (The spike's identity arm — ALS vs internal credential — is historical; Plan B removed both.) Then: shared schema tests, service unit tests (scope rules without HTTP), AssistantService tests with the faux stream — the network is never touched — plus authz tests through the service seam (404 hiding, admin gates), agent-browser acceptance over the full Chat-page path, quartet on the Node 22 floor before merge.

Post-ship: #54 — lazy session rows + the rich welcome (2026-10-15)

Two review-driven changes:

  • Lazy persistence. assistant:session.open (no rejoinId) no longer writes a session row — the fresh open lives only as an in-memory live channel (LiveChannel.pending). startTurn saves the pending row at the FIRST message (the queue-flush path included), so an open-then-abandon conversation leaves nothing behind a refresh. The snapshot (snapshotFor) hides legacy empty rows (lastMessageAt === null and no live channel) — pre-#54 empties can no longer be created. Tests: packages/server/test/assistant-lifecycle.test.ts (3).
  • Rich welcome (WelcomePane). With no session open, BOTH chat pages (machine agent + assistant) render a big greeting, the new-session action, and the most recent sessions as a vertical card list (title / workspace cwd for client agents / relative time) — desktop ~5 cards, phones ~3 (excess hidden below md). The sender stays HIDDEN in the welcome state; the button and cards are the only entries.

Post-ship: #58 — cross-provider model select, image attach (2026-10-16)

Four findings from the #56 verification pass, fixed together:

  • The model select spans providers (grouped). The config's provider+model were meant to be DEFAULTS, but the channel was locked to the config's provider — same-named models on different providers could not be switched in-session. Now sessionConfigEvent lists EVERY provider visible to the owner (personal own + global, the #36 set) grouped by provider name; the option value is the opaque compound providerId/modelId (base64url ids carry no /); a bare legacy id resolves against the channel's current provider. The channel carries providerId alongside modelId (LiveChannel), an agent rebind resets BOTH to the new config's defaults, config.set re-validates #36 scoping (a stranger's personal provider is refused even when spelled out), and the runner resolves the turn against the channel's provider (input.providerId ?? config.providerId). The closed select shows just the model name (ConfigSelect renders the item's name).
  • Image attach is wired, gated by the registry's vision flag. The server was image-capable end to end (PromptBlock images → pi content → history replay); the page stubbed imageSupported={false}. The 端's composer now rides the 9 W9 B path (browser downscale ≤1568px WebP/JPEG, GIF passthrough, ≤4 images / ≤6MB per turn) through paste / drop / the
    • menu; the + menu's file-reference item is hidden (no workspace on the assistant 端 — filePickerAvailable={false}). The gate derives from the CURRENT model entry's vision flag (page fetches the provider registry once; the compound model value locates the entry; switching models re-derives on the next config push). Unknown/absent = gated off.
  • Model-context image policy (IMAGE_CONTEXT_TURNS = 3). Persisted user rows keep their image blocks verbatim (the transcript replays them — the UI never degrades), but the LLM-facing rebuild (rowsToPiMessages/promptBlocksToPiContent) strips to [image omitted]: EVERY image when the resolved model entry lacks vision (a mid-session switch to a non-vision model must not feed blocks the wire would reject — rig-verified: the follow-up answered "看不到" and the reasoning said "the image was omitted"), and images older than the 3 most recent user turns even for vision models (re-uploading all history images every turn bloats the input bill and provider per-request image caps — the Claude Code precedent). Switching back to a vision model restores the images for free (rows are untouched).
  • Escape swallow for raw combobox listboxes. Radix's DismissableLayer listens on document CAPTURE — an input-level preventDefault can never arrive in time, so Escape over an open TagCombobox / model-id listbox closed the whole FormDialog. Fix: the listbox marks itself data-swallow-escape, and FormDialog's onEscapeKeyDown vetoes the dismiss while any such listbox exists (the listbox's own handler closes it). Found by agent-browser during acceptance; unit tests can't see it.

Verification: server 350 (new: cross-provider select + #36 refusal + legacy bare id; image policy ×4), web 107; agent-browser over the live rig (tui-verify): grouped same-name switch accepted server-side, image turn answered the image's color, vision=false grayed the menu item AND the follow-up ran stripped, hide-and-clear wiped stored tags on api switch (without saving), candidates (17) offered as dropdown-only suggestions.

Clone this wiki locally