@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/dist/doctor.d.ts +35 -0
  4. package/dist/doctor.js +110 -0
  5. package/dist/errors.d.ts +199 -0
  6. package/dist/errors.js +201 -0
  7. package/dist/events.d.ts +67 -0
  8. package/dist/events.js +40 -0
  9. package/dist/harness.d.ts +51 -0
  10. package/dist/harness.js +70 -0
  11. package/dist/index.d.ts +36 -0
  12. package/dist/index.js +20 -0
  13. package/dist/json.d.ts +15 -0
  14. package/dist/json.js +15 -0
  15. package/dist/modes.d.ts +63 -0
  16. package/dist/modes.js +109 -0
  17. package/dist/runtime.d.ts +104 -0
  18. package/dist/runtime.js +244 -0
  19. package/dist/session.d.ts +114 -0
  20. package/dist/session.js +198 -0
  21. package/dist/skills.d.ts +37 -0
  22. package/dist/skills.js +73 -0
  23. package/dist/store-task-source.d.ts +169 -0
  24. package/dist/store-task-source.js +513 -0
  25. package/dist/stores/file-session-store.d.ts +34 -0
  26. package/dist/stores/file-session-store.js +284 -0
  27. package/dist/stores/memory-session-store.d.ts +22 -0
  28. package/dist/stores/memory-session-store.js +97 -0
  29. package/dist/stores/session-store.d.ts +65 -0
  30. package/dist/stores/session-store.js +3 -0
  31. package/dist/stores/store-manager.d.ts +32 -0
  32. package/dist/stores/store-manager.js +57 -0
  33. package/dist/subagent-runner.d.ts +45 -0
  34. package/dist/subagent-runner.js +89 -0
  35. package/dist/subagents.d.ts +182 -0
  36. package/dist/subagents.js +286 -0
  37. package/dist/task-tools.d.ts +55 -0
  38. package/dist/task-tools.js +136 -0
  39. package/dist/tasks.d.ts +257 -0
  40. package/dist/tasks.js +162 -0
  41. package/dist/thread.d.ts +49 -0
  42. package/dist/thread.js +88 -0
  43. package/dist/tools.d.ts +89 -0
  44. package/dist/tools.js +148 -0
  45. 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
+ }
@@ -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
+ }