Repository navigation
Releases: Particle-Academy/prism-harness-ts
Release list
v0.9.2
FileSessionStore writes survive a concurrent reader on Windows
- Fixed: on Windows, saving session state failed with
EPERMwhenever another process had the state file open, because
Windows will not replace an open file. A write now retries the replacement only on Windows permission errors, for up
to 750 ms, and then raises the original error. On failure the previous state file stays intact and the temporary file
is removed; nothing is written partially or dropped silently. State reads hold the file only for the read itself. - Other platforms are unchanged.
No breaking changes.
v0.9.1
narrowRequestedTools now applies to legacy sessions
- Fixed: in 0.9.0,
AgentRuntimeOptions.narrowRequestedTools: truehad no effect on legacy (no-agent) sessions, so a
caller-supplied tool list could still offer tools beyond the mode's list. With the option on, a legacy turn now
offers only tools in the mode's list. A requested tool outside it raisestool_not_available,'*'means the mode's
list, and an empty list offers no tools. - With the option off (the default), legacy behaviour is unchanged from 0.8.0. Named agents were already narrow-only.
No breaking changes.
v0.9.0
Agent-scoped harness, an owner-proof file lock, and a Session.lock that runs your callback once
Agents can now share one application without colliding. Existing applications keep their current behaviour, except
for the file lock format described under BREAKING CHANGE.
PrismHarness.forAgent('support')selects an isolated agent namespace for session keys, threads, task lists, tools,
modes and authorization. Choose a stable id; never derive it from a display name. Per-agent options are set through
HarnessOptions.agents,AgentRuntimeOptions.agentsandToolAuthorizeragents.- In a named namespace, duplicate tool and mode names throw
RegistrationCollision(registration_collision),
naming both owners. Replacement is explicit (replace/replaceFactory/replaceProvider). A caller-supplied tool
list can only narrow the mode, and built-ins are offered only when a mode lists them. AgentHarness.effectiveTools(mode, participant, scope)andAgentRuntime.effectiveTools(session, mode)return the
exact offered tool set, with each tool's owner and source. They run no handler, send no model request and create no
session or index entry. Expired entries may be purged, as any read does.ParticipantRefandParticipantAdaptersidentify non-user participants, andPendingSession.asActor()binds a host
actor. A named subagent tool accepts onlytask, and a child cannot switch actor.- Opt-in hardening for the legacy namespace, off by default:
RegistryOptions.refuseDuplicatesrefuses duplicate legacy registrations instead of overwriting them;AgentRuntimeOptions.narrowRequestedToolsstops a caller-supplied tool list from adding tools beyond the mode's
list.
WithoutnarrowRequestedTools, a caller-supplied list can still widen a legacy mode, as it did before; enabling it is
recommended.
maxAddressesPerParticipant(default unlimited) caps indexed addresses withAddressLimitExceeded
(address_limit_exceeded), raised before any write. Indexed namespaces require a store whose lock covers its sharing
scope (index_lock_scope_unsupported). The memory store and the file store both qualify.- Fixed:
Session.lock/FileSessionStore.withLockre-ran a callback that threwEEXISTand then reported
session_locked, which said the callback never ran. The callback now runs at most once, and its errors reach you
unchanged. A standalone process waiting for a lock no longer exits before the wait ends. FileSessionStore.pendingReleases()andidentityStatus()report a delayed lock release or unavailable host
identity, without doing any I/O.
BREAKING CHANGE: FileSessionStore locks are now owner-proof directories instead of timestamp files. A lock is
reclaimed only when its owner is provably dead on the same host, boot and process space, never just because its TTL
passed. STOP ALL WORKERS running 0.8.x or earlier (TypeScript or Python) before upgrading: no rolling upgrade is
supported, and on Windows a running old worker's lock can be replaced. A leftover old lock file is refused with
session_locked and the detail legacy_lock_requires_maintenance, naming the path to inspect and archive. When host
identity cannot be established, locking still works but crash recovery waits for maintenance. On Windows the first lock
in a process starts one fixed, read-only PowerShell metadata query. Local filesystems only: SMB and NFS are unsupported.
v0.8.0
A failed model step is terminal
Before this release, when a model response finished with 'error' or 'unknown', the runtime still invoked the tool calls returned with it and took another model step.
- A response that finishes with 'error' or 'unknown' is recorded (assistant message, one step, its cost) and the run then fails with the new error code
model_step_failed, carrying the boundedfinishReason('error' | 'unknown'). The message never includes model text. - No tool call from the failed response is invoked, and a later approval resume cannot make those calls dispatchable; they are recorded as refused.
- Valid stop and tool-call continuation is unchanged.
BREAKING CHANGE: a run whose model step failed now fails with HarnessErrorCode model_step_failed instead of continuing or invoking that step's tools. Callers that relied on tools running after an error response will no longer see them run.
v0.7.0
@particle-academy/prism-harness 0.7.0
No breaking changes.
Threads are append-only logs, so recording a message costs its own size
instead of a rewrite of the whole conversation.
- FileSessionStore keeps each thread as one JSON line per message
(.jsonl beside the payload), appended in one write and fsynced.
Before this, every Thread.record() re-read, re-parsed and rewrote the
whole thread: quadratic over a session, measured at 9 MB per row in
Genie 2. - A thread stored by 0.6.0 is read as before and moved into the log the
first time it grows. Nothing to migrate by hand. - A line cut off by a crash is dropped and the next append starts after
the last complete line; a damaged line anywhere else is refused with
unmappable_content. - New optional capability for custom stores: AppendLogStore, with
supportsAppendLog(). A store without it keeps working through
get/put as before. - messages() and count() are unchanged for callers.
The thread log is not yet read by the PHP and Python ports, so a worker
in another language pointed at the same store directory does not see it.
v0.6.0
v0.6.0 - Node 22 is the floor
Breaking changes: the declared Node floor moves from 20 to 22. Node 20
reached end of life on 2026-04-30, and engines >=20 told consumers that running
there was sanctioned. Be exact about what this does: npm checks engines and
emits EBADENGINE, but it is a WARNING and the install still proceeds. It is a
refusal only where you set engine-strict=true; pnpm refuses by default. No
source changed, so the code would most likely still run on Node 20 -- what
changed is that it is no longer claimed to.
Tested on Node 22, 24 and 26, on Linux and Windows.
This release also corrects the README, which said the package was
private while coordinated parity work was in progress. It is published, and
has been. The README ships inside the tarball and is the npm page, so that
sentence was being served to everyone who looked.
It also fixes four README examples that could not run. ModeRegistry,
HarnessError and recordApproval were used with no import line -- all three are
real exports, so a reader copying one of those blocks got a ReferenceError on a
name this package has. HarnessError was the worst: without it the instanceof
does not narrow, so three further lines of that example were errors too. The
fourth was a bare guarded: { ... }, object property, which is not compilable
TypeScript at all; a parse failure there had been masking the other three.
Every TypeScript example in this README is now typechecked against the
package's own source.
v0.5.0
v0.5.0 - Structured turns
A turn whose answer is a document rather than prose.
const response = await runtime.sendStructured(session, 'Plan the release', schema);
response.structured; // { title: 'Ship it', steps: ['write', 'test'] }
response.text; // the document as the model wrote itIt is the same run as send() — the mode's system prompt, its tools, the step
budget, the events, the approvals. Your client receives the schema as
request.schema and returns what it parsed as response.structured.
The assistant message is the raw document, and the parsed object rides along in
the row's additional_content under structured. A later turn replays the
conversation as messages and reads the text, so a thread holding a structured
answer reads like any other — and thr-0011..0013 in prism-parity's
harness-thread-rows corpus pin that shape across PHP, TypeScript and Python.
structured_unreadable— the text held no document at allstructured_schema_violation— a document with the wrong shape, carrying
problems: every way it missed, not the first
Nothing is coerced and nothing is trimmed to the fields that fit. An empty plan
settles a batch as done, which reads exactly like a considered answer of
"nothing to propose". The refused exchange is still recorded and the run marked
failed.
schemaProblems() is exported if you want the check directly. It reads the JSON
Schema — declared types, required keys, enum members, array items, and keys
nobody declared where a schema closes itself — and passes $ref, allOf,
oneOf and the numeric and string facets rather than half-implementing them.
The same rules as the PHP reference, message for message.
An error carrying a document now records its CODE as the run's failure, not its
message. A violation message names the values that missed, so the old behaviour
would have written pieces of the model's document into the run row and into
every listener's run.failed event — in a package that keeps tool arguments out
of events on purpose.
v0.4.0
v0.4.0 - Runs stored in the reference's shape; approvals resume where they stopped
Approvals
A pending approval now works with a real provider.
- A step that calls a gated tool gives each such call an approval id (
apr_...) and records it on the assistant row. The calls that need nobody run straight away, instead of the whole step waiting. recordApproval()records the decision. On the nextsend(), recorded decisions are acted on before the model is asked anything:- approved calls run once;
- denied calls return their reason;
- a pending call with no decision is refused with "No approval response provided".
- Before, the model was asked again. A real provider issues a repeated call under a new id, so a decision never matched it.
- A call that has a result never runs again.
- Resolution holds the session lock, so two workers resuming the same session run an approved call once.
- An approved call whose tool is no longer offered to the run, or that the authorizer now refuses, is recorded as not run. Before, it failed every later turn.
Record every decision, then resume:
for (const pending of response.pendingApprovals) {
await recordApproval(session, pending.id, true);
}
await runtime.send(session, '');Rows
The thread now holds what prism-harness (PHP) stores:
- an
assistantrow with each tool call'sarguments,result_id,reasoning_idandreasoning_summary, itsadditional_contentand itstool_approval_requests; - one
tool_resultrow per step, withtool_resultsandtool_approval_responses.
Your client receives the thread through threadView(). Consecutive tool result rows reach it as one, so each output goes to the provider once. LlmToolCall takes optional resultId, reasoningId and reasoningSummary. prism-parity's harness-thread-rows corpus pins these rows against the reference and the Python port.
assistantRow, toolResultRow, toolResultEntry and threadView are exported.
Breaking
PendingApproval.idis the approval id, not the tool call id.toolCallIdis new.- A client reading
request.messagesgetstool_resultson a tool result row, not one row per call. A failed tool's result has nofailedflag. - A caller that holds the session lock around
send()getsSessionLockedwhen the resume runs approved calls.
Rows recorded by 0.3.0, including a pending approval, still read and resume.
v0.3.0
v0.3.0 - Each step records what the next one has to send back
Tool arguments and provider state are recorded
Each step of a run recorded the assistant's tool calls as an id and a name only. The next step's request is built from the thread, so a client had no input to send back for a tool_use and no arguments for a function_call.
The assistant row now carries each call's arguments, and an additional_content map from a new optional field on LlmResponse:
const client: LlmClient = async (request) => {
const response = await callYourProvider(request);
return {
text: response.text,
toolCalls: response.toolCalls,
finishReason: response.finishReason,
// Provider state the next request must send back, e.g. Anthropic's
// thinking and thinking_signature.
additionalContent: response.additionalContent,
};
};additional_content uses prism's AssistantMessage key, so a client built on @particle-academy/prism 0.2.0 can pass response.additionalContent straight through and replay it from request.messages.
Rows recorded before this release read the same as before. Run events and the run record still carry tool names only.
Also
- The README's provider options example uses
{ thinking: { type: 'adaptive' } }.{ thinking: { enabled: true } }is not a shape Anthropic accepts. - The publish check waits up to fifteen minutes for npm to serve a release.
v0.2.0
v0.2.0 - Turns carry attachments; modes set provider options
Attachments on a turn
runtime.send() takes an optional trailing list of media to send with the prompt: prism-ts media objects, or media already serialized.
await runtime.send(session, 'What is wrong with this layout?', undefined, undefined, [
Image.fromBase64(screenshot, 'image/png'),
]);Attachments are stored with the turn in the shape UserMessage.fromObject() rebuilds. A turn without attachments is stored exactly as before.
Refused before a run starts, with a HarnessError code:
| Code | When |
|---|---|
attachment_by_reference |
built from a URL (including "", and a URL whose content was already fetched), or read from a file |
attachment_not_media |
anything other than an image, document, audio or video |
attachment_empty |
no bytes (base64 that decodes to nothing counts as none), no file id, no chunks |
attachment_without_prompt |
attachments with an empty prompt |
A media object is asked isUrl() and isFile() before it is serialized. Media read from a local path serializes as bytes with no path, so the serialized form alone would admit it.
The same rules and codes apply in the PHP reference (prism-harness v0.10.0) and the Python port, pinned by prism-parity's harness-turn-attachments corpus.
Provider options per mode
A mode can declare provider_options. They reach the model client unchanged as request.providerOptions, on every step. A value that is not a map is refused with mode_malformed.
LlmRequest now always carries providerOptions (an empty map when the mode declares none). A client that ignores the field is unaffected.