@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 +94 -15
- package/dist/address-index.d.ts +35 -0
- package/dist/address-index.js +170 -0
- package/dist/address.d.ts +25 -0
- package/dist/address.js +83 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -0
- package/dist/harness.d.ts +41 -2
- package/dist/harness.js +99 -4
- package/dist/index.d.ts +11 -5
- package/dist/index.js +4 -1
- package/dist/modes.d.ts +10 -1
- package/dist/modes.js +53 -1
- package/dist/registration.d.ts +6 -0
- package/dist/registration.js +15 -0
- package/dist/runtime.d.ts +21 -15
- package/dist/runtime.js +95 -8
- package/dist/session.d.ts +12 -0
- package/dist/session.js +66 -11
- package/dist/stores/file-lock-identity.d.ts +20 -0
- package/dist/stores/file-lock-identity.js +74 -0
- package/dist/stores/file-lock-windows-identity.d.ts +2 -0
- package/dist/stores/file-lock-windows-identity.js +48 -0
- package/dist/stores/file-owner-lock.d.ts +28 -0
- package/dist/stores/file-owner-lock.js +280 -0
- package/dist/stores/file-session-store.d.ts +13 -6
- package/dist/stores/file-session-store.js +16 -183
- package/dist/stores/memory-session-store.d.ts +2 -0
- package/dist/stores/memory-session-store.js +2 -0
- package/dist/stores/session-store.d.ts +4 -0
- package/dist/subagent-runner.js +10 -3
- package/dist/subagents.d.ts +2 -1
- package/dist/subagents.js +10 -3
- package/dist/task-tools.js +6 -1
- package/dist/thread.d.ts +2 -0
- package/dist/thread.js +6 -0
- package/dist/tools.d.ts +28 -5
- package/dist/tools.js +159 -21
- package/package.json +1 -1
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.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
}
|
package/dist/address.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|