Repository navigation
design platform assistant
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 (configmcpServerIds→ 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_TOOLSsurface ACPpermission_requestcards; 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.
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.
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 inresearch-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-actorheaders (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.
-
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 piAgentstate is rebuilt per turn from the DB — server restarts lose nothing. -
assistant_tool_calls— audit:{ id, sessionId, userId, tool, args (truncated), status, durationMs, createdAt }.
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.
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).
-
MCP: reuse the server-side
McpRegistrypool. Config listsmcpServerIdsthe user can see; at session build each server's tools wrap asAgentTools named<server>__<tool>, executed viaregistry.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_skilltool for full bodies incl. bundle files. Progressive disclosure, Agent Skills-shaped, nothing lands on disk, single source of truth stays the resource store.
/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.
-
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 agentsback-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.AgentSessiongeneralizes 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.tsdeclaration):DataTable+ create button + sharedFormDialog+ 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).
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.
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).startTurnsaves 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 === nulland 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.
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
sessionConfigEventlists EVERY provider visible to the owner (personal own + global, the #36 set) grouped by provider name; the option value is the opaque compoundproviderId/modelId(base64url ids carry no/); a bare legacy id resolves against the channel's current provider. The channel carriesproviderIdalongsidemodelId(LiveChannel), an agent rebind resets BOTH to the new config's defaults,config.setre-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'sname). -
Image attach is wired, gated by the registry's
visionflag. The server was image-capable end to end (PromptBlock images → pi content → history replay); the page stubbedimageSupported={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'svisionflag (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.
- menu; the + menu's file-reference item is hidden (no workspace on the
assistant 端 —
-
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 lacksvision(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, andFormDialog'sonEscapeKeyDownvetoes 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.