harness-pi: a session reattached in-process keeps the previous request's createPi settings, and there is no way to inject credentials or opt out of reattach
Verified against @ai-sdk/[email protected] (npm latest) and main, with @ai-sdk/[email protected]
and the pi packages harness-pi resolves: @earendil-works/[email protected],
@earendil-works/[email protected].
Setup
A multi-replica server (Next.js route handlers behind a load balancer, any replica may serve any
request). Every request builds a fresh HarnessAgent with
createPi({ agentDir }), where agentDir is a temporary directory materialized for that request
(auth.json with the user's api_key credential for a custom provider, models.json declaring
that provider, settings.json), and deleted when the request ends. Lifecycle state is persisted in
a database, so the next request can land on any replica.
Bug: resuming a paused turn in the same process fails, in a fresh process it works
- Request 1: the model calls a host tool with no
execute(awaiting a tool result — a question for the user). The route callssession.suspendTurn()and persists thecontinue-turnstate. - Request 2 (the tool result) builds a new agent with a new
agentDirand callsagent.createSession({ sessionId, continueFrom })+agent.continueStream(...).
- Request 2 on another process: completes normally.
- Request 2 on the same process: the turn fails with
Error: Provider is not configured: <custom provider id>(surfaced fromharness-pi/dist/index.js:2777).
Reproduced deterministically on a two-replica setup, same prompt both times: same-process answer fails, cross-process answer succeeds.
Cause (source-verified): doSuspendTurn stores the live session in the module-level
parkedPiSessions map, and createPiSession returns that parked session whenever isResume is set
(if (parkedSession) { parkedPiSessions.delete(...); return { ...parkedSession, isResume: true } }).
The reattached session keeps the ModelRuntime / ModelRegistry / auth storage built from request
1's createPi({ agentDir }); request 2's settings are never applied. With a per-request
agentDir those paths no longer exist. (We have not pinned the exact call that empties the
provider's credential afterwards; the observable is the error above, plus pi's file auth backend
re-creating the deleted directory with an empty {} auth.json.)
Related: the same reattach also keeps request 1's sandbox session handle, which we already work around by handing the provider a delegator.
Bug: a parked session goes stale when the turn finishes in another process
- Request 1 on process A pauses (awaiting a tool result) — A parks the session.
- Request 2 on process B completes the turn and persists a
resume-sessionstate. - Request 3 (a new user message) lands on process A: A reattaches its stale parked session and the
turn fails with
Error: Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.(atAgentSession.prompt←harness-pi/dist/index.js:2774).
createPiSession does not check the parked session against the incoming lifecycle state, and
process B's progress never reaches A's map, so A's entry stays stale until the process restarts.
Gaps
- Reattach ignores the new request's settings and state. A reattached session should apply the
current
createPisettings (at leastagentDir/ providers / credentials) and be validated against the incoming lifecycle state — or not be reattached. The lifecycle contract already says a host-resident adapter "cannot keep its turn alive … the in-flight tail is recomputed on continue" (harness/dist/index.d.ts,doSuspendTurn), which is the path that works in a fresh process. - No opt-out of in-process reattach. For stateless multi-replica deployments the in-memory
parked session is a per-process cache whose behaviour differs from the fresh-process path.
session.destroy()is a no-op aftersuspendTurn()(the handle is alreadydetached), andstop()/detach()on an unfinished turn route to the same suspension. A setting such ascreatePi({ reattachInProcess: false })would let every replica take the same, persisted-state path. - No credential injection. For non-environment auth,
createPiSessioncallscreatePiModelRuntime({ auth, authPath: path.join(nativeAgentDir ?? hostAgentDir, 'auth.json'), modelsPath })(packages/harness-pi/src/pi-session.ts~L439), which passes the path toModelRuntime.create({ authPath, modelsPath })(packages/harness-pi/src/pi-auth.ts~L117-130).PiHarnessSettingsexposesauth,providers,thinkingLevel,agentDir,mcpServers,extensionFactories— nothing reachesModelRuntime.create({ credentials }), although pi-ai'sCredentialStoreis designed for app-injected persistent stores (refresh runs insidemodify) and pi-coding-agent documentsModelRuntime.create({ credentials }). harness-pi already builds an internal credential store for environment auth (createIsolatedPiCredentialStore). Injecting a store would remove the per-request credential files entirely.
Proposal
createPi({ credentials?: CredentialStore })forwarded toModelRuntime.create(or accept a pre-builtmodelRuntime).- Apply the current request's settings and validate the lifecycle state on reattach, or offer
reattachInProcess: false.
Related
Not the same as #18147 (custom provider dispatch with auth.customEnv).
Source: vercel/ai