@particle-academy/prism-harness 0.8.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
@@ -334,21 +386,39 @@ badly.
334
386
 
335
387
  - **`MemorySessionStore`** — volatile, and says so. Right for the ephemeral slot
336
388
  in a test or a single-process tool. Its lock is real but process-local.
337
- - **`FileSessionStore`** — durable. Atomic writes (write-then-rename) and a
338
- cross-process lock built on exclusive file creation, which is the one
339
- primitive that is atomic on every filesystem worth supporting. Two workers on
340
- one machine genuinely exclude each other; two machines over a network
341
- filesystem do not, and no file lock can promise that — use a database there.
342
-
343
- Its **lockfile format is shared with the PHP and Python ports**, because a
344
- worker in any of the three may be pointed at one store directory: the expiry
345
- in milliseconds, then a newline, written in a single write. The terminator is
346
- load-bearing. Without it there is nothing to tell a complete expiry from the
347
- first half of one, and every prefix of a timestamp is a smaller, *older*
348
- timestamp — so a torn write reads as a lock that expired decades ago and gets
349
- deleted out from under the worker holding it. Anything unterminated is treated
350
- as held, bounded by the file's own age so a process that died mid-write cannot
351
- 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.
352
422
 
353
423
  **Threads are append-only logs** (`<digest>.jsonl` beside the payload file):
354
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/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
  }
package/dist/harness.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { Session } from './session.js';
2
2
  import { MemorySessionStore } from './stores/memory-session-store.js';
3
3
  import { SessionStoreManager } from './stores/store-manager.js';
4
+ import { ParticipantAdapters, ParticipantRef, SessionAddress, validateAgent } from './address.js';
5
+ import { addressLimit, indexedStore, StoreAddressIndex } from './address-index.js';
6
+ import { StoreTaskSource } from './store-task-source.js';
7
+ import { Thread } from './thread.js';
4
8
  /**
5
9
  * The entry point: `harness.for(participant).session('support')`.
6
10
  *
@@ -24,8 +28,25 @@ import { SessionStoreManager } from './stores/store-manager.js';
24
28
  export class PrismHarness {
25
29
  #stores;
26
30
  #ttlSeconds;
31
+ #participants;
32
+ #maxAddresses;
33
+ #runtime;
34
+ #agents;
35
+ #defaultScope;
36
+ #defaultTaskList;
27
37
  constructor(options = {}) {
38
+ this.#runtime = options.runtime;
39
+ this.#defaultScope = options.defaultScope ?? 'default';
40
+ this.#defaultTaskList = options.defaultTaskList ?? 'default';
41
+ validateHarnessConfig({ defaultScope: this.#defaultScope, defaultTaskList: this.#defaultTaskList });
42
+ this.#agents = Object.fromEntries(Object.entries(options.agents ?? {}).map(([agent, config]) => {
43
+ validateAgent(agent);
44
+ validateHarnessConfig(config);
45
+ return [agent, { ...config }];
46
+ }));
28
47
  this.#ttlSeconds = options.ttlSeconds ?? null;
48
+ this.#participants = options.participantAdapters ?? new ParticipantAdapters();
49
+ this.#maxAddresses = addressLimit(options.maxAddressesPerParticipant ?? null);
29
50
  this.#stores = new SessionStoreManager({
30
51
  stores: options.stores,
31
52
  drivers: options.drivers ?? { default: () => new MemorySessionStore() },
@@ -33,7 +54,36 @@ export class PrismHarness {
33
54
  }
34
55
  /** Bind to a participant. Returns a builder, because the scope comes next. */
35
56
  for(participant) {
36
- return new PendingSession(participant, this.#stores, this.#ttlSeconds);
57
+ return this.pending(participant, null);
58
+ }
59
+ forAgent(agent) {
60
+ validateAgent(agent);
61
+ return new AgentHarness(this, agent);
62
+ }
63
+ effectiveTools(agent, mode, participant, scope) {
64
+ if (!this.#runtime)
65
+ throw new Error('Tool inspection requires a configured Harness runtime.');
66
+ return this.#runtime.effectiveTools(this.pending(participant, agent).session(scope), mode);
67
+ }
68
+ pending(participant, agent, actor) {
69
+ validateAgent(agent);
70
+ const config = agent === null ? undefined : this.#agents[agent];
71
+ const ttl = config?.ttlSeconds === undefined ? this.#ttlSeconds : config.ttlSeconds;
72
+ return new PendingSession(participant, this.#stores, ttl, agent, actor, this.#participants, this.#maxAddresses, config?.defaultScope ?? this.#defaultScope);
73
+ }
74
+ tasks(list, agent = null) {
75
+ list ??= (agent === null ? undefined : this.#agents[agent]?.defaultTaskList) ?? this.#defaultTaskList;
76
+ const address = new SessionAddress(null, `task-list:${list}`, agent);
77
+ const durable = this.#stores.durable();
78
+ return new StoreTaskSource(agent === null ? durable : indexedStore(durable, new StoreAddressIndex(durable, this.#maxAddresses), address, 'durable', 'tasks'), agent === null ? `${list}:tasks` : `agent-list:${address.indexKey()}:tasks`);
79
+ }
80
+ sharedThread(scope, agent = null) {
81
+ const address = new SessionAddress(null, scope, agent), durable = this.#stores.durable();
82
+ return new Thread(agent === null ? durable : indexedStore(durable, new StoreAddressIndex(durable, this.#maxAddresses), address, 'durable', 'thread'), `thread:${address.threadKey()}`);
83
+ }
84
+ async findShared(scope, agent = null) {
85
+ const thread = this.sharedThread(scope, agent);
86
+ return await thread.exists() ? thread : null;
37
87
  }
38
88
  ephemeralStore() {
39
89
  return this.#stores.ephemeral();
@@ -46,10 +96,25 @@ export class PendingSession {
46
96
  participant;
47
97
  stores;
48
98
  ttlSeconds;
49
- constructor(participant, stores, ttlSeconds) {
99
+ agent;
100
+ actor;
101
+ participants;
102
+ maxAddresses;
103
+ defaultScope;
104
+ constructor(participant, stores, ttlSeconds, agent = null, actor, participants = new ParticipantAdapters(), maxAddresses = null, defaultScope = 'default') {
50
105
  this.participant = participant;
51
106
  this.stores = stores;
52
107
  this.ttlSeconds = ttlSeconds;
108
+ this.agent = agent;
109
+ this.actor = actor;
110
+ this.participants = participants;
111
+ this.maxAddresses = maxAddresses;
112
+ this.defaultScope = defaultScope;
113
+ }
114
+ asActor(actor) {
115
+ if (this.participant instanceof ParticipantRef)
116
+ throw new Error('Typed participants require a registered actor adapter.');
117
+ return new PendingSession(this.participant, this.stores, this.ttlSeconds, this.agent, actor, this.participants, this.maxAddresses, this.defaultScope);
53
118
  }
54
119
  /**
55
120
  * Resolve the session for a scope.
@@ -58,13 +123,43 @@ export class PendingSession {
58
123
  * guard fire when a session is opened rather than at some later moment when
59
124
  * an approval needs saving.
60
125
  */
61
- session(scope) {
126
+ session(scope = this.defaultScope) {
127
+ const actor = this.participant instanceof ParticipantRef ? this.participants.actor(this.participant, this.actor) : (this.actor ?? this.participant);
128
+ const durable = this.stores.durable();
62
129
  return new Session({
63
130
  participant: this.participant,
64
131
  scope,
65
132
  ephemeral: this.stores.ephemeral(),
66
- durable: this.stores.durable(),
133
+ durable,
67
134
  ttlSeconds: this.ttlSeconds,
135
+ agent: this.agent,
136
+ actor,
137
+ addressIndex: this.agent !== null || this.participant instanceof ParticipantRef ? new StoreAddressIndex(durable, this.maxAddresses) : undefined,
68
138
  });
69
139
  }
70
140
  }
141
+ /** A namespace-bound view, sharing stores without a mutable current-agent flag. */
142
+ export class AgentHarness {
143
+ harness;
144
+ agent;
145
+ constructor(harness, agent) {
146
+ this.harness = harness;
147
+ this.agent = agent;
148
+ validateAgent(agent);
149
+ Object.defineProperty(this, 'agent', { writable: false, configurable: false });
150
+ }
151
+ for(participant) { return this.harness.pending(participant, this.agent); }
152
+ effectiveTools(mode, participant, scope) { return this.harness.effectiveTools(this.agent, mode, participant, scope); }
153
+ tasks(list) { return this.harness.tasks(list, this.agent); }
154
+ sharedThread(scope) { return this.harness.sharedThread(scope, this.agent); }
155
+ findShared(scope) { return this.harness.findShared(scope, this.agent); }
156
+ }
157
+ function validateHarnessConfig(config) {
158
+ if (config.ttlSeconds !== undefined && config.ttlSeconds !== null && (!Number.isSafeInteger(config.ttlSeconds) || config.ttlSeconds < 0))
159
+ throw new TypeError('Agent ephemeral TTL must be nonnegative whole seconds or null.');
160
+ for (const key of ['defaultScope', 'defaultTaskList']) {
161
+ const value = config[key];
162
+ if (value !== undefined && (typeof value !== 'string' || Buffer.from(value, 'utf8').toString('utf8') !== value || (key === 'defaultTaskList' && value.length === 0)))
163
+ throw new TypeError('Agent scope/list defaults must be valid strings.');
164
+ }
165
+ }