design platform assistant - sinrimin/harness-nexus GitHub Wiki

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.
⚠️ **GitHub.com Fallback** ⚠️