@particle-academy/prism-harness 0.0.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/LICENSE +21 -0
- package/README.md +179 -0
- package/dist/doctor.d.ts +35 -0
- package/dist/doctor.js +110 -0
- package/dist/errors.d.ts +199 -0
- package/dist/errors.js +201 -0
- package/dist/events.d.ts +67 -0
- package/dist/events.js +40 -0
- package/dist/harness.d.ts +51 -0
- package/dist/harness.js +70 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +20 -0
- package/dist/json.d.ts +15 -0
- package/dist/json.js +15 -0
- package/dist/modes.d.ts +63 -0
- package/dist/modes.js +109 -0
- package/dist/runtime.d.ts +104 -0
- package/dist/runtime.js +244 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.js +198 -0
- package/dist/skills.d.ts +37 -0
- package/dist/skills.js +73 -0
- package/dist/store-task-source.d.ts +169 -0
- package/dist/store-task-source.js +513 -0
- package/dist/stores/file-session-store.d.ts +34 -0
- package/dist/stores/file-session-store.js +284 -0
- package/dist/stores/memory-session-store.d.ts +22 -0
- package/dist/stores/memory-session-store.js +97 -0
- package/dist/stores/session-store.d.ts +65 -0
- package/dist/stores/session-store.js +3 -0
- package/dist/stores/store-manager.d.ts +32 -0
- package/dist/stores/store-manager.js +57 -0
- package/dist/subagent-runner.d.ts +45 -0
- package/dist/subagent-runner.js +89 -0
- package/dist/subagents.d.ts +182 -0
- package/dist/subagents.js +286 -0
- package/dist/task-tools.d.ts +55 -0
- package/dist/task-tools.js +136 -0
- package/dist/tasks.d.ts +257 -0
- package/dist/tasks.js +162 -0
- package/dist/thread.d.ts +49 -0
- package/dist/thread.js +88 -0
- package/dist/tools.d.ts +89 -0
- package/dist/tools.js +148 -0
- package/package.json +34 -0
package/dist/thread.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { isJsonObject } from './json.js';
|
|
2
|
+
import { HarnessError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* The stored conversation a session is bound to.
|
|
5
|
+
*
|
|
6
|
+
* DURABLE by construction: it lives in the durable slot, so it is the one thing
|
|
7
|
+
* here a flushed cache cannot take away. The PHP reference keeps it as Eloquent
|
|
8
|
+
* rows for the same reason.
|
|
9
|
+
*
|
|
10
|
+
* ## Position is assigned inside a lock
|
|
11
|
+
*
|
|
12
|
+
* `record()` takes the lock before reading the current length. Two turns
|
|
13
|
+
* landing concurrently would otherwise both read position 4 and both write
|
|
14
|
+
* position 5, and the conversation would silently lose a message — the same
|
|
15
|
+
* race the reference tracks as prism-harness#2. Doing the read and the write
|
|
16
|
+
* inside one lock is the fix, not a retry afterwards.
|
|
17
|
+
*/
|
|
18
|
+
export class Thread {
|
|
19
|
+
store;
|
|
20
|
+
key;
|
|
21
|
+
constructor(store, key) {
|
|
22
|
+
this.store = store;
|
|
23
|
+
this.key = key;
|
|
24
|
+
}
|
|
25
|
+
async messages() {
|
|
26
|
+
const stored = await this.store.get(this.key);
|
|
27
|
+
if (stored === null)
|
|
28
|
+
return [];
|
|
29
|
+
const messages = stored.messages;
|
|
30
|
+
if (!Array.isArray(messages))
|
|
31
|
+
return [];
|
|
32
|
+
return messages.filter(isJsonObject).map((entry) => this.#toMessage(entry));
|
|
33
|
+
}
|
|
34
|
+
/** How many messages the thread holds. */
|
|
35
|
+
async count() {
|
|
36
|
+
return (await this.messages()).length;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Append messages, in order, and return them with their assigned positions.
|
|
40
|
+
*
|
|
41
|
+
* Read-and-write inside ONE lock. See the class docblock.
|
|
42
|
+
*/
|
|
43
|
+
async record(messages, runId = null) {
|
|
44
|
+
if (messages.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
return this.store.withLock(this.key, async () => {
|
|
47
|
+
const stored = (await this.store.get(this.key)) ?? {};
|
|
48
|
+
const existing = Array.isArray(stored.messages) ? stored.messages.filter(isJsonObject) : [];
|
|
49
|
+
const recordedAt = new Date().toISOString();
|
|
50
|
+
const appended = messages.map((message, index) => ({
|
|
51
|
+
position: existing.length + index + 1,
|
|
52
|
+
message,
|
|
53
|
+
run_id: runId,
|
|
54
|
+
recorded_at: recordedAt,
|
|
55
|
+
}));
|
|
56
|
+
await this.store.put(this.key, {
|
|
57
|
+
...stored,
|
|
58
|
+
messages: [...existing, ...appended],
|
|
59
|
+
});
|
|
60
|
+
return appended.map((entry) => this.#toMessage(entry));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Forget the conversation.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately separate from `Session.forget()`, which drops only the
|
|
67
|
+
* ephemeral half. Losing a thread is not a cache miss and must never be a
|
|
68
|
+
* side effect of clearing session state.
|
|
69
|
+
*/
|
|
70
|
+
async clear() {
|
|
71
|
+
await this.store.withLock(this.key, async () => {
|
|
72
|
+
const stored = (await this.store.get(this.key)) ?? {};
|
|
73
|
+
await this.store.put(this.key, { ...stored, messages: [] });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
#toMessage(entry) {
|
|
77
|
+
const message = entry.message;
|
|
78
|
+
if (!isJsonObject(message)) {
|
|
79
|
+
throw HarnessError.unmappableContent(`a stored thread entry has no message object (position ${String(entry.position)})`);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
position: typeof entry.position === 'number' ? entry.position : 0,
|
|
83
|
+
message,
|
|
84
|
+
runId: typeof entry.run_id === 'string' ? entry.run_id : null,
|
|
85
|
+
recordedAt: typeof entry.recorded_at === 'string' ? entry.recorded_at : '',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { JsonObject } from './json.js';
|
|
2
|
+
import type { Session } from './session.js';
|
|
3
|
+
/**
|
|
4
|
+
* The shape this package needs from a tool.
|
|
5
|
+
*
|
|
6
|
+
* STRUCTURAL, not an import. `prism-ts`'s `Tool` satisfies it, and so does
|
|
7
|
+
* anything else with a name and a handler — which keeps this package at zero
|
|
8
|
+
* dependencies and lets a consumer bring their own tool type. The reference
|
|
9
|
+
* imports `Prism\Prism\Tool` directly because it is already a dependency there.
|
|
10
|
+
*/
|
|
11
|
+
export interface HarnessTool {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly description?: string;
|
|
14
|
+
handle(args: JsonObject): unknown | Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
export type ToolFactory = (session: Session) => HarnessTool;
|
|
17
|
+
export type ToolProvider = (session: Session) => Iterable<HarnessTool> | Promise<Iterable<HarnessTool>>;
|
|
18
|
+
/**
|
|
19
|
+
* Which tools exist, and which a given run may be offered.
|
|
20
|
+
*
|
|
21
|
+
* Three ways in, because the three answer different questions: a tool that is
|
|
22
|
+
* always the same (`register`), one that needs the session to build
|
|
23
|
+
* (`registerFactory`), and a set discovered at resolve time — an MCP server's
|
|
24
|
+
* catalogue, say (`registerProvider`).
|
|
25
|
+
*/
|
|
26
|
+
export declare class ToolRegistry {
|
|
27
|
+
#private;
|
|
28
|
+
register(tool: HarnessTool): this;
|
|
29
|
+
registerMany(tools: Iterable<HarnessTool>): this;
|
|
30
|
+
registerFactory(name: string, factory: ToolFactory): this;
|
|
31
|
+
registerProvider(provider: ToolProvider): this;
|
|
32
|
+
/** Every name this registry can produce without a session. */
|
|
33
|
+
names(): string[];
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the named tools for a session.
|
|
36
|
+
*
|
|
37
|
+
* `'*'` means every tool this registry can produce. A name that resolves to
|
|
38
|
+
* nothing is an ERROR rather than a silent omission: a mode that lists a tool
|
|
39
|
+
* it cannot get is a misconfiguration, and dropping it quietly would leave a
|
|
40
|
+
* run wondering why the model never called it.
|
|
41
|
+
*/
|
|
42
|
+
resolve(names: readonly string[], session?: Session): Promise<Map<string, HarnessTool>>;
|
|
43
|
+
}
|
|
44
|
+
/** Whether a tool may be OFFERED to a run. */
|
|
45
|
+
export type OfferPolicy = (session: Session, tool: HarnessTool) => boolean | Promise<boolean>;
|
|
46
|
+
/** Whether THIS call, with THESE arguments, may proceed. */
|
|
47
|
+
export type CallPolicy = (session: Session, tool: HarnessTool, args: JsonObject) => boolean | Promise<boolean>;
|
|
48
|
+
export interface ToolAuthorizerOptions {
|
|
49
|
+
enabled?: boolean;
|
|
50
|
+
offer?: OfferPolicy;
|
|
51
|
+
call?: CallPolicy;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The two authorization questions, kept apart.
|
|
55
|
+
*
|
|
56
|
+
* Whether a tool may be OFFERED to a run, and whether THIS invocation of it —
|
|
57
|
+
* with these arguments, this many calls in — may proceed. At the moment the
|
|
58
|
+
* toolset is assembled the arguments do not exist yet, so an offer policy can
|
|
59
|
+
* say "may use delete_file" and never "only under /tmp".
|
|
60
|
+
*
|
|
61
|
+
* OFF BY DEFAULT, matching the reference. But a policy that is defined and
|
|
62
|
+
* never consulted is REFUSED at construction rather than tolerated: it looks
|
|
63
|
+
* like a control to every reader and is not one. That is the one configuration
|
|
64
|
+
* not to leave in place — both at once.
|
|
65
|
+
*/
|
|
66
|
+
export declare class ToolAuthorizer {
|
|
67
|
+
#private;
|
|
68
|
+
readonly enabled: boolean;
|
|
69
|
+
constructor(options?: ToolAuthorizerOptions);
|
|
70
|
+
/** The tools a run may be offered, each wrapped so the call policy is asked again. */
|
|
71
|
+
allowed(session: Session, tools: Map<string, HarnessTool>): Promise<HarnessTool[]>;
|
|
72
|
+
/**
|
|
73
|
+
* Whether this call may proceed.
|
|
74
|
+
*
|
|
75
|
+
* True when the authorizer is disabled, matching `allowed()` — the
|
|
76
|
+
* constructor has already refused the configuration where that silence would
|
|
77
|
+
* be mistaken for enforcement.
|
|
78
|
+
*/
|
|
79
|
+
allowsCall(session: Session, tool: HarnessTool, args: JsonObject): Promise<boolean>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A tool that asks the policy again, at call time, with the arguments.
|
|
83
|
+
*
|
|
84
|
+
* Refuses by THROWING rather than returning an error string: a refusal handed
|
|
85
|
+
* back as a tool result reads to the model as a failure it might retry
|
|
86
|
+
* differently, and a denied action being retried is the opposite of what a
|
|
87
|
+
* guard is for.
|
|
88
|
+
*/
|
|
89
|
+
export declare function authorizedTool(tool: HarnessTool, session: Session, authorizer: ToolAuthorizer): HarnessTool;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { HarnessError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which tools exist, and which a given run may be offered.
|
|
4
|
+
*
|
|
5
|
+
* Three ways in, because the three answer different questions: a tool that is
|
|
6
|
+
* always the same (`register`), one that needs the session to build
|
|
7
|
+
* (`registerFactory`), and a set discovered at resolve time — an MCP server's
|
|
8
|
+
* catalogue, say (`registerProvider`).
|
|
9
|
+
*/
|
|
10
|
+
export class ToolRegistry {
|
|
11
|
+
#tools = new Map();
|
|
12
|
+
#factories = new Map();
|
|
13
|
+
#providers = [];
|
|
14
|
+
register(tool) {
|
|
15
|
+
this.#tools.set(tool.name, tool);
|
|
16
|
+
return this;
|
|
17
|
+
}
|
|
18
|
+
registerMany(tools) {
|
|
19
|
+
for (const tool of tools)
|
|
20
|
+
this.register(tool);
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
registerFactory(name, factory) {
|
|
24
|
+
this.#factories.set(name, factory);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
registerProvider(provider) {
|
|
28
|
+
this.#providers.push(provider);
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
/** Every name this registry can produce without a session. */
|
|
32
|
+
names() {
|
|
33
|
+
return [...new Set([...this.#tools.keys(), ...this.#factories.keys()])].sort();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the named tools for a session.
|
|
37
|
+
*
|
|
38
|
+
* `'*'` means every tool this registry can produce. A name that resolves to
|
|
39
|
+
* nothing is an ERROR rather than a silent omission: a mode that lists a tool
|
|
40
|
+
* it cannot get is a misconfiguration, and dropping it quietly would leave a
|
|
41
|
+
* run wondering why the model never called it.
|
|
42
|
+
*/
|
|
43
|
+
async resolve(names, session) {
|
|
44
|
+
const provided = new Map();
|
|
45
|
+
if (session !== undefined) {
|
|
46
|
+
for (const provider of this.#providers) {
|
|
47
|
+
for (const tool of await provider(session))
|
|
48
|
+
provided.set(tool.name, tool);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const available = [
|
|
52
|
+
...new Set([...this.#tools.keys(), ...this.#factories.keys(), ...provided.keys()]),
|
|
53
|
+
];
|
|
54
|
+
const selected = names.includes('*') ? available : names;
|
|
55
|
+
const resolved = new Map();
|
|
56
|
+
for (const name of selected) {
|
|
57
|
+
const tool = this.#tools.get(name) ?? provided.get(name);
|
|
58
|
+
if (tool !== undefined) {
|
|
59
|
+
resolved.set(name, tool);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const factory = this.#factories.get(name);
|
|
63
|
+
if (factory === undefined) {
|
|
64
|
+
throw HarnessError.toolNotAvailable(name, available);
|
|
65
|
+
}
|
|
66
|
+
if (session === undefined) {
|
|
67
|
+
throw HarnessError.toolNotAvailable(`${name} (needs a session to build)`, available);
|
|
68
|
+
}
|
|
69
|
+
resolved.set(name, factory(session));
|
|
70
|
+
}
|
|
71
|
+
return resolved;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The two authorization questions, kept apart.
|
|
76
|
+
*
|
|
77
|
+
* Whether a tool may be OFFERED to a run, and whether THIS invocation of it —
|
|
78
|
+
* with these arguments, this many calls in — may proceed. At the moment the
|
|
79
|
+
* toolset is assembled the arguments do not exist yet, so an offer policy can
|
|
80
|
+
* say "may use delete_file" and never "only under /tmp".
|
|
81
|
+
*
|
|
82
|
+
* OFF BY DEFAULT, matching the reference. But a policy that is defined and
|
|
83
|
+
* never consulted is REFUSED at construction rather than tolerated: it looks
|
|
84
|
+
* like a control to every reader and is not one. That is the one configuration
|
|
85
|
+
* not to leave in place — both at once.
|
|
86
|
+
*/
|
|
87
|
+
export class ToolAuthorizer {
|
|
88
|
+
enabled;
|
|
89
|
+
#offer;
|
|
90
|
+
#call;
|
|
91
|
+
constructor(options = {}) {
|
|
92
|
+
this.enabled = options.enabled ?? false;
|
|
93
|
+
this.#offer = options.offer;
|
|
94
|
+
this.#call = options.call;
|
|
95
|
+
if (!this.enabled && (options.offer !== undefined || options.call !== undefined)) {
|
|
96
|
+
throw HarnessError.policyDefinedButDisabled();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** The tools a run may be offered, each wrapped so the call policy is asked again. */
|
|
100
|
+
async allowed(session, tools) {
|
|
101
|
+
if (!this.enabled) {
|
|
102
|
+
return [...tools.values()];
|
|
103
|
+
}
|
|
104
|
+
const allowed = [];
|
|
105
|
+
for (const tool of tools.values()) {
|
|
106
|
+
if (this.#offer === undefined || (await this.#offer(session, tool))) {
|
|
107
|
+
// Wrapped so the SAME policy is asked when the tool is actually called,
|
|
108
|
+
// with arguments. Offer-time filtering alone cannot bound how a tool is
|
|
109
|
+
// used, only whether it is present.
|
|
110
|
+
allowed.push(authorizedTool(tool, session, this));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return allowed;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether this call may proceed.
|
|
117
|
+
*
|
|
118
|
+
* True when the authorizer is disabled, matching `allowed()` — the
|
|
119
|
+
* constructor has already refused the configuration where that silence would
|
|
120
|
+
* be mistaken for enforcement.
|
|
121
|
+
*/
|
|
122
|
+
async allowsCall(session, tool, args) {
|
|
123
|
+
if (!this.enabled || this.#call === undefined) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
return this.#call(session, tool, args);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A tool that asks the policy again, at call time, with the arguments.
|
|
131
|
+
*
|
|
132
|
+
* Refuses by THROWING rather than returning an error string: a refusal handed
|
|
133
|
+
* back as a tool result reads to the model as a failure it might retry
|
|
134
|
+
* differently, and a denied action being retried is the opposite of what a
|
|
135
|
+
* guard is for.
|
|
136
|
+
*/
|
|
137
|
+
export function authorizedTool(tool, session, authorizer) {
|
|
138
|
+
return {
|
|
139
|
+
name: tool.name,
|
|
140
|
+
description: tool.description,
|
|
141
|
+
async handle(args) {
|
|
142
|
+
if (!(await authorizer.allowsCall(session, tool, args))) {
|
|
143
|
+
throw HarnessError.callNotAuthorized(tool.name);
|
|
144
|
+
}
|
|
145
|
+
return tool.handle(args);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@particle-academy/prism-harness",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Durable agent sessions for TypeScript — threads, session state and store drivers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^26.3.0",
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"vitest": "^4.1.11"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/Particle-Academy/prism-harness-ts.git"
|
|
33
|
+
}
|
|
34
|
+
}
|