design platform assistant - sinrimin/harness-nexus GitHub Wiki
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.