Repository navigation
research platform assistant
Status: verified 2026-10-14 on
feat/52-platform-assistant(packages/server/test/assistant-spike.test.ts, offline, no network). Ground truth for the seams the M1 implementation rides on.Superseded by the Plan B decision (same day): the user chose the full service-layer refactor (#53) — assistant tools call principal-carrying services directly. §1 below (ALS disproven, internal credential proposed) is the HISTORICAL RECORD of why in-process identity could not ride ambient context or self-HTTP; the mechanism itself is no longer part of the design. §2–§4 (pi-ai/pi-agent-core seams) remain current.
The design's first sketch (ALS actor context opened around app.inject())
was disproven: light-my-request dispatches outside the caller's async
context — the ALS store is empty inside onRequest hooks. Verified in bare
Node (not just vitest):
const r = await als.run({ userId: 'u-1' }, () => app.inject({ url: '/w' }));
// hook sees: no store. (outside run: also no store — correct, but the
// inside-run case is the one that matters and it fails.)A real loopback socket would not carry ALS either (server-side handlers run from the event loop). Settled mechanism (test-verified):
-
boot-time random secret (
randomBytes(32).base64url), held in server memory only; restart rotates it; - tool executors call
app.inject()with headersx-hnx-internal-auth: <secret>+x-hnx-actor: <userId>; - the root auth hook's leading branch:
timingSafeEqualon the secret, then a FRESHfindUserById(disabled/demoted takes effect next call, matching the PAT path), thenreq.user+ aviaAssistantaudit marker; mismatch/absent → anonymous → the normal Bearer path.
Network clients cannot know the secret; header names are not the gate. Cryptographically equivalent to a minted JWT but per-process, no TTL juggling, nothing to revoke, and it survives any future transport change.
createProvider({id, name, baseUrl, auth, models, api}) builds a Provider
from parts (the same path models.json custom providers take). Verified shape
for our LlmProvider rows:
-
auth.apiKey.resolve: async () => ({ auth: { apiKey: decrypted }, source: 'credential' })— resolution is deferred per request (the decrypt happens here, not at build time); - registry models map to
ProviderModelentries:apimapped 1:1 (anthropic→anthropic-messages, openai-chat→openai-completions, openai-responses→openai-responses),name/contextWindow/maxTokensfrom the #51 registry;reasoning: falsedefault;input: ['text','image']whenvision; -
api:takes{stream, streamSimple}from the per-wire modules — import as@earendil-works/pi-ai/api/openai-completions(subpath; the ROOT index does NOT export the api factory functions; the.lazyvariants exportopenAICompletionsApi()-style factories that wrap the same thing).
-
fauxProvider({models, tokensPerSecond})+setResponses([...])scripts assistant messages (text/thinking/toolCall blocks viafauxAssistantMessage) with ZERO I/O — the AssistantService unit tests never touch the network (repo rule); -
new Agent({initialState: {systemPrompt, model, tools, messages}, streamFn: models.streamSimple.bind(models), beforeToolCall, afterToolCall})— verified end-to-end: scripted tool call →tool_execution_*→ scripted conclusion →turn_end×2 →agent_end; both audit hooks fire in order; tools are pi-native typebox schemas (piAi.Type.Object); -
Agent.state.messagesis the rebuild-from-DB seam: assign per turn, pi owns nothing durable (platform-owned session persistence holds).
@earendil-works/{pi-agent-core,pi-ai}@1.1.0 (pinned exact) import cleanly
on Node 22.22 (engines >=22.19; the #50 floor). pi-ai re-exports typebox
Type — tool schemas need no extra dependency.