@particle-academy/prism-harness 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,58 @@ await session.lock(async (live) => {
30
30
  });
31
31
  ```
32
32
 
33
+ ## Named agents
34
+
35
+ Select a stable agent id in trusted application code, using
36
+ [a-z][a-z0-9_-]{0,63}. The existing calls without an agent retain their addresses.
37
+ Each named agent has its own tools, providers, modes, session state, threads and
38
+ default task list.
39
+
40
+ ```ts
41
+ import { AgentRuntime, ModeRegistry, ToolRegistry } from '@particle-academy/prism-harness';
42
+
43
+ const tools = new ToolRegistry(), modes = new ModeRegistry();
44
+ tools.forAgent('support').register({ name: 'lookup', handle: () => 'found' }, 'app.support');
45
+ modes.forAgent('support').register('reply', { tools: ['lookup'] }, 'app.support').setDefault('reply');
46
+ const runtime = new AgentRuntime({ tools, modes,
47
+ client: async () => ({ text: 'done', finishReason: 'stop' }),
48
+ }); // Replace the synthetic client when connecting a model.
49
+ const session = harness.forAgent('support').for({ type: 'User', id: 7 }).session('case:42');
50
+ const descriptors = await runtime.effectiveTools(session, 'reply');
51
+ ```
52
+
53
+ Import ToolRegistry, ModeRegistry and AgentRuntime from this package. When the
54
+ harness is constructed with runtime, its bound agent also exposes
55
+ effectiveTools('reply', participant, scope). Descriptors contain name, owner and
56
+ source and come from the model offer resolver. Inspection executes no handler,
57
+ creates no session, thread or index entry and makes no model/provider request
58
+ or logical state change. Reads may physically purge expired entries, exactly as
59
+ an ordinary store read does. Explicit state writes during resolution refuse.
60
+
61
+ For named agents, the mode list is the ceiling even when a caller supplies tool
62
+ names. Legacy sessions retain caller-list selection by default; set
63
+ `AgentRuntimeOptions.narrowRequestedTools` to `true` to apply the ceiling there.
64
+ Named agents inherit no legacy authority. Builtins require explicit registration and
65
+ mode selection. Named-agent duplicates are refused; use
66
+ tools.forAgent('support').replace(replacement, 'app.support', 'app.support.v2')
67
+ for an intentional replacement.
68
+
69
+ Legacy registries retain duplicate overwrite and existing cross-kind precedence.
70
+ Use `new ToolRegistry({ refuseDuplicates: true })` and
71
+ `new ModeRegistry({ refuseDuplicates: true })` to refuse legacy duplicates.
72
+ Named session addresses are immutable and validated; legacy participant/scope
73
+ values retain their existing mutable behavior.
74
+
75
+ AgentRuntime's agents map accepts provider/model, max_steps_ceiling,
76
+ max_output_tokens_per_call and lock_ttl/lock_wait overrides. Unset scalars fall
77
+ back to config; explicit null clears nullable defaults. Caps narrow the existing
78
+ shared RunContext ledger and output budget. ToolAuthorizer's agents map supplies
79
+ enabled/offer/call policy overrides, retaining invocation-time rechecks.
80
+ PrismHarness's agents map separately supplies ttlSeconds, defaultScope and
81
+ defaultTaskList. Typed ParticipantRef values require a ParticipantAdapters actor
82
+ resolver; a child keeps the already-bound actor. Existing plain participants
83
+ continue to work.
84
+
33
85
  ## Resolved, never held
34
86
 
35
87
  A server handles a request and moves on, so a session cannot be an object kept
@@ -202,6 +254,15 @@ reporting a constraint it did not actually check. The same rules as the PHP
202
254
  reference, message for message, so a document refused in one language is refused
203
255
  in the other for the same stated reason.
204
256
 
257
+ ## Failed model steps
258
+
259
+ A client response with `finishReason: 'error'` or `'unknown'` ends the run with
260
+ `HarnessError.code === 'model_step_failed'`. Its `finishReason` identifies which
261
+ of those two finishes occurred; the error message contains no model output.
262
+ The step, cost, and assistant response are recorded before the error is raised.
263
+ Tools requested by that response are refused, with no pending approval authority,
264
+ so approving or resuming later cannot execute them. A new turn can still be sent.
265
+
205
266
  ## Approvals
206
267
 
207
268
  A mode names the tools a person must approve:
@@ -325,21 +386,39 @@ badly.
325
386
 
326
387
  - **`MemorySessionStore`** — volatile, and says so. Right for the ephemeral slot
327
388
  in a test or a single-process tool. Its lock is real but process-local.
328
- - **`FileSessionStore`** — durable. Atomic writes (write-then-rename) and a
329
- cross-process lock built on exclusive file creation, which is the one
330
- primitive that is atomic on every filesystem worth supporting. Two workers on
331
- one machine genuinely exclude each other; two machines over a network
332
- filesystem do not, and no file lock can promise that — use a database there.
333
-
334
- Its **lockfile format is shared with the PHP and Python ports**, because a
335
- worker in any of the three may be pointed at one store directory: the expiry
336
- in milliseconds, then a newline, written in a single write. The terminator is
337
- load-bearing. Without it there is nothing to tell a complete expiry from the
338
- first half of one, and every prefix of a timestamp is a smaller, *older*
339
- timestamp — so a torn write reads as a lock that expired decades ago and gets
340
- deleted out from under the worker holding it. Anything unterminated is treated
341
- as held, bounded by the file's own age so a process that died mid-write cannot
342
- wedge the key either.
389
+ - **`FileSessionStore`** — durable. Payload writes remain atomic. Cross-process
390
+ local locks follow [decision 0022](https://github.com/Particle-Academy/prism-parity/blob/main/docs/decisions/0022-local-file-lock-ownership.md):
391
+ a complete nonce-bound owner directory is published atomically; TTL expiry
392
+ never proves that a live holder died. Recovery requires a matching established
393
+ native boot/PID realm and a proven dead or explicitly released owner. Local
394
+ filesystems on one host only; SMB/NFS are unsupported.
395
+
396
+ On Windows, the first file-lock operation makes one fixed, read-only metadata
397
+ query using the system Windows PowerShell and CIM module, with a fifteen-second
398
+ timeout. Established identities and detected container conditions are cached
399
+ for the process lifetime. Transient query failures are cached for sixty seconds,
400
+ then permit a new sample. With unknown identity a worker can acquire a free
401
+ lock and release its own owner record, but unknown owners and unknown probers
402
+ never authorize PID probes or recovery. A crashed unknown owner's lock stays
403
+ held for explicit operator inspection. `FileSessionStore.identityStatus()` is
404
+ an in-memory snapshot and performs no query or filesystem work. No runtime
405
+ compilation or extra dependency is used.
406
+
407
+ **BREAKING CHANGE: Stop all old workers before upgrading the file-lock
408
+ protocol.** Inspect and archive stale legacy timestamp lockfiles explicitly.
409
+ The library refuses them with `session_locked` and
410
+ `legacy_lock_requires_maintenance`, naming the canonical path and action
411
+ `stop_old_workers_then_inspect_and_archive`. On Windows, Node can replace a
412
+ legacy file created between preflight and rename; leaving old workers running
413
+ can break exclusion. No rolling upgrade is supported. The hard stop is a
414
+ correctness requirement in both ports.
415
+
416
+ A blocked Windows reader can delay release. The callback may already have
417
+ completed when release raises an error; the library does not replay it.
418
+ Pending cleanup is retried at the owning process's next file-lock operation.
419
+ The `file_lock_release_pending` warning includes only the key digest and nonce.
420
+ Read `store.pendingReleases()` for this directory's pending digest/nonce entries;
421
+ the snapshot's length is the count. Querying does not retry or write anything.
343
422
 
344
423
  **Threads are append-only logs** (`<digest>.jsonl` beside the payload file):
345
424
  one line per message, one write and an `fsync` per `record()`, so recording
@@ -0,0 +1,35 @@
1
+ import { SessionAddress } from './address.js';
2
+ import type { Participant } from './session.js';
3
+ import type { JsonObject } from './json.js';
4
+ import type { SessionStore } from './stores/session-store.js';
5
+ export declare class AddressLimitExceeded extends Error {
6
+ readonly code = "address_limit_exceeded";
7
+ constructor();
8
+ }
9
+ export declare class IndexLockScopeUnsupported extends Error {
10
+ readonly code = "index_lock_scope_unsupported";
11
+ constructor();
12
+ }
13
+ export declare function storeLockRank(store: SessionStore): number;
14
+ export declare class IndexRecoveryRequired extends Error {
15
+ readonly code = "index_recovery_required";
16
+ constructor();
17
+ }
18
+ export declare function addressLimit(value: number | null): number | null;
19
+ export interface AddressIndexPage {
20
+ entries: JsonObject[];
21
+ next_cursor: number | null;
22
+ }
23
+ /** Fixed pages and a small commit head; no participant-wide value or ceiling. */
24
+ export declare class StoreAddressIndex {
25
+ #private;
26
+ private readonly store;
27
+ private readonly maxAddressesPerParticipant;
28
+ static readonly PAGE_SIZE = 100;
29
+ constructor(store: SessionStore, maxAddressesPerParticipant?: number | null);
30
+ record(address: SessionAddress, slot: 'ephemeral' | 'durable', kind: 'state' | 'lock' | 'tasks' | 'thread', key: string): Promise<void>;
31
+ recover(participant: Participant | null): Promise<void>;
32
+ page(participant: Participant | null, cursor?: number, limit?: number): Promise<AddressIndexPage>;
33
+ }
34
+ /** Preserve append-log capabilities and existing transcript bytes while indexing writes. */
35
+ export declare function indexedStore(inner: SessionStore, index: StoreAddressIndex, address: SessionAddress, slot: 'ephemeral' | 'durable', kind?: 'state' | 'tasks' | 'thread'): SessionStore;
@@ -0,0 +1,170 @@
1
+ var _a;
2
+ import { createHash } from 'node:crypto';
3
+ import { isJsonObject } from './json.js';
4
+ import { supportsAppendLog } from './stores/session-store.js';
5
+ import { HarnessError } from './errors.js';
6
+ export class AddressLimitExceeded extends Error {
7
+ code = 'address_limit_exceeded';
8
+ constructor() { super('The configured max_addresses_per_participant limit was reached; no resource was written.'); this.name = 'AddressLimitExceeded'; }
9
+ }
10
+ export class IndexLockScopeUnsupported extends Error {
11
+ code = 'index_lock_scope_unsupported';
12
+ constructor() { super('Indexed namespaces require declared sharing and lock scopes, with the lock covering the sharing scope.'); this.name = 'IndexLockScopeUnsupported'; }
13
+ }
14
+ export function storeLockRank(store) {
15
+ const ranks = { process: 0, host: 1, distributed: 2 };
16
+ const sharing = store.sharingScope?.(), locking = store.lockScope?.();
17
+ if (!sharing || !locking || sharing === 'unsupported' || locking === 'unsupported'
18
+ || !Object.hasOwn(ranks, sharing) || !Object.hasOwn(ranks, locking) || ranks[locking] < ranks[sharing])
19
+ throw new IndexLockScopeUnsupported();
20
+ return ranks[locking];
21
+ }
22
+ export class IndexRecoveryRequired extends Error {
23
+ code = 'index_recovery_required';
24
+ constructor() { super('Recover the interrupted address append before reading a complete index or planning erasure.'); this.name = 'IndexRecoveryRequired'; }
25
+ }
26
+ export function addressLimit(value) {
27
+ if (value !== null && (!Number.isSafeInteger(value) || value < 1))
28
+ throw new TypeError('max_addresses_per_participant must be a positive integer or null.');
29
+ return value;
30
+ }
31
+ /** Fixed pages and a small commit head; no participant-wide value or ceiling. */
32
+ export class StoreAddressIndex {
33
+ store;
34
+ maxAddressesPerParticipant;
35
+ static PAGE_SIZE = 100;
36
+ constructor(store, maxAddressesPerParticipant = null) {
37
+ this.store = store;
38
+ this.maxAddressesPerParticipant = maxAddressesPerParticipant;
39
+ if (store.durability() !== 'durable')
40
+ throw new HarnessError('unsafe_state_configuration', 'The address index requires a durable store.');
41
+ storeLockRank(store);
42
+ addressLimit(maxAddressesPerParticipant);
43
+ }
44
+ #root(type, id) {
45
+ return `harness-address-index:v1:${createHash('sha256').update(JSON.stringify([type, id])).digest('hex')}`;
46
+ }
47
+ #head(value) {
48
+ if (value === null)
49
+ return { version: 1, count: 0, pages: 0, pending: null };
50
+ if (value.version !== 1 || typeof value.count !== 'number' || !Number.isSafeInteger(value.count) || value.count < 0
51
+ || value.pages !== Math.ceil(value.count / _a.PAGE_SIZE)
52
+ || (value.pending !== null && !isJsonObject(value.pending)))
53
+ throw new Error('Invalid harness address index head.');
54
+ return value;
55
+ }
56
+ async #pageIds(root, number) {
57
+ const page = await this.store.get(`${root}:page:${number}`);
58
+ if (page === null)
59
+ return [];
60
+ if (page.version !== 1 || !Array.isArray(page.ids) || page.ids.length > _a.PAGE_SIZE
61
+ || page.ids.some(id => typeof id !== 'string' || /^[a-f0-9]{64}$/.exec(id)?.[0] !== id))
62
+ throw new Error('Invalid harness address index page.');
63
+ return page.ids;
64
+ }
65
+ async #recover(root, head) {
66
+ if (head.pending === null)
67
+ return head;
68
+ const pending = head.pending;
69
+ if (!isJsonObject(pending) || typeof pending.hash !== 'string' || /^[a-f0-9]{64}$/.exec(pending.hash)?.[0] !== pending.hash
70
+ || pending.position !== head.count || !isJsonObject(pending.entry) || pending.entry.position !== head.count)
71
+ throw new Error('Invalid harness address append journal.');
72
+ const number = Math.floor(head.count / _a.PAGE_SIZE), offset = head.count % _a.PAGE_SIZE;
73
+ const ids = await this.#pageIds(root, number);
74
+ if (ids.length === offset) {
75
+ ids.push(pending.hash);
76
+ await this.store.put(`${root}:page:${number}`, { version: 1, ids });
77
+ }
78
+ else if (ids.length !== offset + 1 || ids[offset] !== pending.hash)
79
+ throw new Error('Conflicting harness address append journal.');
80
+ await this.store.put(`${root}:entry:${pending.hash}`, pending.entry);
81
+ const committed = { version: 1, count: head.count + 1, pages: number + 1, pending: null };
82
+ // This is the visibility/commit point. Resource writes happen only after it.
83
+ await this.store.put(`${root}:head`, committed);
84
+ return committed;
85
+ }
86
+ async record(address, slot, kind, key) {
87
+ const root = this.#root(address.participantType, address.participantId);
88
+ await this.store.withLock(root, async () => {
89
+ const head = await this.#recover(root, this.#head(await this.store.get(`${root}:head`)));
90
+ const hash = address.indexKey();
91
+ const identity = { agent: address.agent, participant_type: address.participantType, participant_id: address.participantId, scope: address.scope };
92
+ const existing = await this.store.get(`${root}:entry:${hash}`);
93
+ if (existing !== null && (!isJsonObject(existing.address) || Object.entries(identity).some(([key, value]) => existing.address[key] !== value)
94
+ || typeof existing.position !== 'number' || !Number.isSafeInteger(existing.position) || existing.position < 0 || existing.position >= head.count
95
+ || !Array.isArray(existing.resources)))
96
+ throw new Error('Invalid harness address index entry.');
97
+ if (existing !== null) {
98
+ const position = existing.position, ids = await this.#pageIds(root, Math.floor(position / _a.PAGE_SIZE));
99
+ if (ids[position % _a.PAGE_SIZE] !== hash)
100
+ throw new Error('Unlinked harness address index entry.');
101
+ }
102
+ const resources = isJsonObject(existing) && Array.isArray(existing.resources) ? [...existing.resources] : [];
103
+ const resource = { slot, kind, key };
104
+ if (resources.some(item => isJsonObject(item) && item.slot === slot && item.kind === kind && item.key === key))
105
+ return;
106
+ resources.push(resource);
107
+ const entry = { address: identity, resources, position: existing?.position ?? head.count };
108
+ if (existing !== null) {
109
+ await this.store.put(`${root}:entry:${hash}`, entry);
110
+ return;
111
+ }
112
+ if (this.maxAddressesPerParticipant !== null && head.count >= this.maxAddressesPerParticipant) {
113
+ throw new AddressLimitExceeded();
114
+ }
115
+ const prepared = { ...head, pending: { hash, position: head.count, entry } };
116
+ await this.store.put(`${root}:head`, prepared);
117
+ await this.#recover(root, prepared);
118
+ });
119
+ }
120
+ async recover(participant) {
121
+ const root = this.#root(participant?.type ?? null, participant === null ? null : String(participant.id));
122
+ await this.store.withLock(root, async () => { await this.#recover(root, this.#head(await this.store.get(`${root}:head`))); });
123
+ }
124
+ async page(participant, cursor = 0, limit = 100) {
125
+ if (!Number.isSafeInteger(cursor) || cursor < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100)
126
+ throw new TypeError('Invalid address index page cursor or limit.');
127
+ const root = this.#root(participant?.type ?? null, participant === null ? null : String(participant.id));
128
+ const head = this.#head(await this.store.get(`${root}:head`)), end = Math.min(cursor + limit, head.count), entries = [];
129
+ if (head.pending !== null)
130
+ throw new IndexRecoveryRequired();
131
+ let pageNumber = -1, ids = [];
132
+ for (let position = cursor; position < end; position++) {
133
+ const number = Math.floor(position / _a.PAGE_SIZE);
134
+ if (number !== pageNumber) {
135
+ ids = await this.#pageIds(root, number);
136
+ pageNumber = number;
137
+ }
138
+ const hash = ids[position % _a.PAGE_SIZE];
139
+ if (hash === undefined)
140
+ throw new Error('Missing committed harness address page member.');
141
+ const entry = await this.store.get(`${root}:entry:${hash}`);
142
+ if (entry === null || entry.position !== position)
143
+ throw new Error('Missing committed harness address index entry.');
144
+ entries.push({ address_key: hash, ...entry });
145
+ }
146
+ return { entries, next_cursor: end < head.count ? end : null };
147
+ }
148
+ }
149
+ _a = StoreAddressIndex;
150
+ /** Preserve append-log capabilities and existing transcript bytes while indexing writes. */
151
+ export function indexedStore(inner, index, address, slot, kind = 'state') {
152
+ const wrapper = {
153
+ lockScope: () => inner.lockScope?.() ?? 'unsupported',
154
+ sharingScope: () => inner.sharingScope?.() ?? 'unsupported',
155
+ get: key => inner.get(key),
156
+ put: async (key, payload, ttl) => { await index.record(address, slot, kind, key); await inner.put(key, payload, ttl); },
157
+ forget: key => inner.forget(key),
158
+ durability: () => inner.durability(),
159
+ withLock: async (key, callback, ttl, wait) => { await index.record(address, slot, 'lock', key); return inner.withLock(key, callback, ttl, wait); },
160
+ };
161
+ if (supportsAppendLog(inner)) {
162
+ return Object.assign(wrapper, {
163
+ readLog: (key) => inner.readLog(key),
164
+ logLength: (key) => inner.logLength(key),
165
+ clearLog: (key) => inner.clearLog(key),
166
+ appendLog: async (key, entries) => { await index.record(address, slot, kind, key); await inner.appendLog(key, entries); },
167
+ });
168
+ }
169
+ return wrapper;
170
+ }
@@ -0,0 +1,25 @@
1
+ import type { Participant } from './session.js';
2
+ export declare function validateAgent(agent: unknown): asserts agent is string | null;
3
+ /** An explicit host-defined identity; its actor comes from a registered adapter. */
4
+ export declare class ParticipantRef implements Participant {
5
+ readonly type: string;
6
+ readonly id: string;
7
+ constructor(type: string, id: string | number);
8
+ }
9
+ export declare class ParticipantAdapters {
10
+ #private;
11
+ register(type: string, actor: (participant: ParticipantRef) => object): void;
12
+ actor(participant: ParticipantRef, inheritedActor?: object): object;
13
+ }
14
+ /** Immutable address. Persona/display names are deliberately absent. */
15
+ export declare class SessionAddress {
16
+ #private;
17
+ readonly scope: string;
18
+ readonly agent: string | null;
19
+ readonly participantType: string | null;
20
+ readonly participantId: string | null;
21
+ constructor(participant: Participant | null, scope: string, agent?: string | null);
22
+ sessionKey(): string;
23
+ threadKey(): string;
24
+ indexKey(): string;
25
+ }
@@ -0,0 +1,83 @@
1
+ import { createHash } from 'node:crypto';
2
+ export function validateAgent(agent) {
3
+ if (agent !== null && (typeof agent !== 'string' || /^[a-z][a-z0-9_-]{0,63}$/.exec(agent)?.[0] !== agent)) {
4
+ throw new TypeError('Harness namespace must be a configured lowercase identifier of 1–64 ASCII bytes.');
5
+ }
6
+ }
7
+ function utf8(value) {
8
+ return typeof value === 'string' && Buffer.from(value, 'utf8').toString('utf8') === value;
9
+ }
10
+ /** An explicit host-defined identity; its actor comes from a registered adapter. */
11
+ export class ParticipantRef {
12
+ type;
13
+ id;
14
+ constructor(type, id) {
15
+ if (!utf8(type) || type.length === 0 || Buffer.byteLength(type) > 255
16
+ || (typeof id !== 'string' && (typeof id !== 'number' || !Number.isSafeInteger(id)))
17
+ || !utf8(String(id)) || String(id).length === 0 || Buffer.byteLength(String(id)) > 255) {
18
+ throw new TypeError('Participant references require non-empty UTF-8 type and id, at most 255 bytes each.');
19
+ }
20
+ this.type = type;
21
+ this.id = String(id);
22
+ Object.freeze(this);
23
+ }
24
+ }
25
+ export class ParticipantAdapters {
26
+ #actors = new Map();
27
+ register(type, actor) {
28
+ new ParticipantRef(type, 'validation');
29
+ if (this.#actors.has(type))
30
+ throw new Error('A participant adapter is already registered for this type.');
31
+ this.#actors.set(type, actor);
32
+ }
33
+ actor(participant, inheritedActor) {
34
+ const resolve = this.#actors.get(participant.type);
35
+ if (!resolve)
36
+ throw new Error('No participant actor adapter is registered for this type.');
37
+ const actor = inheritedActor ?? resolve(participant);
38
+ if (actor === null || typeof actor !== 'object')
39
+ throw new TypeError('Participant actor adapter must return an actor object.');
40
+ return actor;
41
+ }
42
+ }
43
+ /** Immutable address. Persona/display names are deliberately absent. */
44
+ export class SessionAddress {
45
+ scope;
46
+ agent;
47
+ participantType;
48
+ participantId;
49
+ constructor(participant, scope, agent = null) {
50
+ this.scope = scope;
51
+ this.agent = agent;
52
+ validateAgent(agent);
53
+ if ((agent !== null || participant instanceof ParticipantRef) && (!utf8(scope) || (participant !== null && (!utf8(participant.type)
54
+ || (typeof participant.id !== 'string' && (typeof participant.id !== 'number' || !Number.isSafeInteger(participant.id)))
55
+ || !utf8(String(participant.id))))))
56
+ throw new TypeError('Harness addresses require valid UTF-8 and integer or string ids.');
57
+ this.participantType = participant?.type ?? null;
58
+ this.participantId = participant === null ? null : String(participant.id);
59
+ Object.freeze(this);
60
+ }
61
+ sessionKey() {
62
+ if (this.participantType === null || this.participantId === null)
63
+ throw new Error('A session requires a participant.');
64
+ if (this.agent === null)
65
+ return `session:${digest('sha1', this.participantType).slice(0, 12)}:${this.participantId}:${this.scope}`;
66
+ return `session:${this.#digest('prism-harness.session.v1')}`;
67
+ }
68
+ threadKey() {
69
+ if (this.agent !== null)
70
+ return this.#digest('prism-harness.thread.v1');
71
+ // PHP's legacy json_encode escapes Unicode, slashes and DEL. Keep its bytes.
72
+ const json = JSON.stringify([this.participantType, this.participantId, this.scope])
73
+ .replace(/\//g, '\\/').replace(/[\u007f-\uffff]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
74
+ return digest('sha256', json);
75
+ }
76
+ indexKey() { return this.#digest('prism-harness.address.v1'); }
77
+ #digest(domain) {
78
+ return digest('sha256', JSON.stringify([domain, this.agent, this.participantType, this.participantId, this.scope]));
79
+ }
80
+ }
81
+ function digest(algorithm, value) {
82
+ return createHash(algorithm).update(value, 'utf8').digest('hex');
83
+ }
package/dist/errors.d.ts CHANGED
@@ -18,6 +18,8 @@ export type HarnessErrorCode =
18
18
  | 'tool_not_available'
19
19
  /** A run was refused — by budget, by depth, or by a cancelled ledger. */
20
20
  | 'run_not_permitted'
21
+ /** A model returned a terminal error/unknown finish; its tools must not run. */
22
+ | 'model_step_failed'
21
23
  /** A stored payload could not be mapped back into a value object. */
22
24
  | 'unmappable_content'
23
25
  /** A session was asked to do something that needs a runtime it does not have. */
@@ -75,6 +77,7 @@ export type HarnessErrorCode =
75
77
  | 'structured_schema_violation';
76
78
  export interface HarnessErrorOptions {
77
79
  cause?: unknown;
80
+ finishReason?: 'error' | 'unknown';
78
81
  /** The model's own text, on a structured failure. See `HarnessError.document`. */
79
82
  document?: string;
80
83
  /** Every way a document missed its schema, on a structured failure. */
@@ -82,6 +85,7 @@ export interface HarnessErrorOptions {
82
85
  }
83
86
  export declare class HarnessError extends Error {
84
87
  readonly code: HarnessErrorCode;
88
+ readonly finishReason?: 'error' | 'unknown';
85
89
  /**
86
90
  * The model's own text, on a structured failure.
87
91
  *
@@ -106,6 +110,7 @@ export declare class HarnessError extends Error {
106
110
  static unknownStoreDriver(slot: string, driver: string): HarnessError;
107
111
  static toolNotAvailable(name: string, available: readonly string[]): HarnessError;
108
112
  static runNotPermitted(reason: string): HarnessError;
113
+ static modelStepFailed(finishReason: 'error' | 'unknown'): HarnessError;
109
114
  static unmappableContent(description: string, options?: HarnessErrorOptions): HarnessError;
110
115
  static modeNotConfigured(name: string): HarnessError;
111
116
  static modeMalformed(name: string, detail: string): HarnessError;
package/dist/errors.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export class HarnessError extends Error {
2
2
  code;
3
+ finishReason;
3
4
  /**
4
5
  * The model's own text, on a structured failure.
5
6
  *
@@ -14,6 +15,10 @@ export class HarnessError extends Error {
14
15
  super(message, options);
15
16
  this.name = 'HarnessError';
16
17
  this.code = code;
18
+ if (options.finishReason !== undefined && options.finishReason !== 'error' && options.finishReason !== 'unknown') {
19
+ throw new TypeError('Model step finish reason must be error or unknown');
20
+ }
21
+ this.finishReason = options.finishReason;
17
22
  this.document = options.document;
18
23
  this.problems = options.problems ?? [];
19
24
  }
@@ -44,6 +49,12 @@ export class HarnessError extends Error {
44
49
  static runNotPermitted(reason) {
45
50
  return new HarnessError('run_not_permitted', reason);
46
51
  }
52
+ static modelStepFailed(finishReason) {
53
+ if (finishReason !== 'error' && finishReason !== 'unknown') {
54
+ throw new TypeError('Model step finish reason must be error or unknown');
55
+ }
56
+ return new HarnessError('model_step_failed', `Model step failed: ${finishReason}.`, { finishReason });
57
+ }
47
58
  static unmappableContent(description, options = {}) {
48
59
  return new HarnessError('unmappable_content', `Could not rebuild stored content: ${description}.`, options);
49
60
  }
package/dist/harness.d.ts CHANGED
@@ -3,9 +3,25 @@ import { Session } from './session.js';
3
3
  import type { SessionStore } from './stores/session-store.js';
4
4
  import type { StoreManagerOptions } from './stores/store-manager.js';
5
5
  import { SessionStoreManager } from './stores/store-manager.js';
6
+ import { ParticipantAdapters } from './address.js';
7
+ import { StoreTaskSource } from './store-task-source.js';
8
+ import { Thread } from './thread.js';
9
+ import type { AgentRuntime, EffectiveTool } from './runtime.js';
10
+ export interface AgentHarnessConfig {
11
+ ttlSeconds?: number | null;
12
+ defaultScope?: string;
13
+ defaultTaskList?: string;
14
+ }
6
15
  export interface HarnessOptions extends Partial<StoreManagerOptions> {
16
+ agents?: Record<string, AgentHarnessConfig>;
17
+ defaultScope?: string;
18
+ defaultTaskList?: string;
19
+ runtime?: AgentRuntime;
7
20
  /** How long ephemeral state lives. Null keeps it until forgotten. */
8
21
  ttlSeconds?: number | null;
22
+ participantAdapters?: ParticipantAdapters;
23
+ /** Null (the default) leaves named address growth unlimited. */
24
+ maxAddressesPerParticipant?: number | null;
9
25
  }
10
26
  /**
11
27
  * The entry point: `harness.for(participant).session('support')`.
@@ -32,6 +48,12 @@ export declare class PrismHarness {
32
48
  constructor(options?: HarnessOptions);
33
49
  /** Bind to a participant. Returns a builder, because the scope comes next. */
34
50
  for(participant: Participant): PendingSession;
51
+ forAgent(agent: string | null): AgentHarness;
52
+ effectiveTools(agent: string | null, mode: string, participant: Participant, scope?: string): Promise<EffectiveTool[]>;
53
+ pending(participant: Participant, agent: string | null, actor?: object): PendingSession;
54
+ tasks(list?: string, agent?: string | null): StoreTaskSource;
55
+ sharedThread(scope: string, agent?: string | null): Thread;
56
+ findShared(scope: string, agent?: string | null): Promise<Thread | null>;
35
57
  ephemeralStore(): SessionStore;
36
58
  durableStore(): SessionStore;
37
59
  }
@@ -39,7 +61,13 @@ export declare class PendingSession {
39
61
  private readonly participant;
40
62
  private readonly stores;
41
63
  private readonly ttlSeconds;
42
- constructor(participant: Participant, stores: SessionStoreManager, ttlSeconds: number | null);
64
+ private readonly agent;
65
+ private readonly actor?;
66
+ private readonly participants;
67
+ private readonly maxAddresses;
68
+ private readonly defaultScope;
69
+ constructor(participant: Participant, stores: SessionStoreManager, ttlSeconds: number | null, agent?: string | null, actor?: object | undefined, participants?: ParticipantAdapters, maxAddresses?: number | null, defaultScope?: string);
70
+ asActor(actor: object): PendingSession;
43
71
  /**
44
72
  * Resolve the session for a scope.
45
73
  *
@@ -47,5 +75,16 @@ export declare class PendingSession {
47
75
  * guard fire when a session is opened rather than at some later moment when
48
76
  * an approval needs saving.
49
77
  */
50
- session(scope: string): Session;
78
+ session(scope?: string): Session;
79
+ }
80
+ /** A namespace-bound view, sharing stores without a mutable current-agent flag. */
81
+ export declare class AgentHarness {
82
+ private readonly harness;
83
+ readonly agent: string | null;
84
+ constructor(harness: PrismHarness, agent: string | null);
85
+ for(participant: Participant): PendingSession;
86
+ effectiveTools(mode: string, participant: Participant, scope?: string): Promise<EffectiveTool[]>;
87
+ tasks(list?: string): StoreTaskSource;
88
+ sharedThread(scope: string): Thread;
89
+ findShared(scope: string): Promise<Thread | null>;
51
90
  }