@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Particle Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Prism Harness for TypeScript
|
|
2
|
+
|
|
3
|
+
Durable agent sessions — threads, session state and store drivers. The
|
|
4
|
+
TypeScript port of [`particle-academy/prism-harness`](https://github.com/Particle-Academy/prism-harness).
|
|
5
|
+
|
|
6
|
+
Zero runtime dependencies. Node 20+.
|
|
7
|
+
|
|
8
|
+
This package is private while coordinated parity work is in progress.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { FileSessionStore, MemorySessionStore, PrismHarness } from '@particle-academy/prism-harness';
|
|
12
|
+
|
|
13
|
+
const harness = new PrismHarness({
|
|
14
|
+
drivers: {
|
|
15
|
+
memory: () => new MemorySessionStore(),
|
|
16
|
+
files: () => new FileSessionStore('./storage/harness'),
|
|
17
|
+
},
|
|
18
|
+
stores: { ephemeral: 'memory', durable: 'files' },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const session = harness.for({ type: 'User', id: 7 }).session('support');
|
|
22
|
+
|
|
23
|
+
await session.usingMode('plan').then((s) => s.usingModel('claude-sonnet-4-5'));
|
|
24
|
+
|
|
25
|
+
await session.lock(async (live) => {
|
|
26
|
+
// whatever must not happen twice
|
|
27
|
+
await live.beginRun('run-1', 'plan', 'anthropic', 'claude-sonnet-4-5');
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Resolved, never held
|
|
32
|
+
|
|
33
|
+
A server handles a request and moves on, so a session cannot be an object kept
|
|
34
|
+
in memory the way a single-process agent's is. Every call rebuilds one from a
|
|
35
|
+
store, which is what makes a fresh worker see the same mode, model and
|
|
36
|
+
conversation as the request that set them.
|
|
37
|
+
|
|
38
|
+
## The two halves
|
|
39
|
+
|
|
40
|
+
State is split into named slots, because the halves have genuinely different
|
|
41
|
+
requirements:
|
|
42
|
+
|
|
43
|
+
| Slot | Holds | Losing it means |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `ephemeral` | active mode, selected model, run bookkeeping | falls back to a default |
|
|
46
|
+
| `durable` | threads, stored capabilities, agent task lists | work is gone |
|
|
47
|
+
|
|
48
|
+
**A store that reports itself volatile is REFUSED for the durable slot**, at the
|
|
49
|
+
moment a session is opened. That is the guard the package exists for: a cache is
|
|
50
|
+
disposable by definition, and the durable slot holds approvals a human has not
|
|
51
|
+
answered yet. Accepting it and finding out later is exactly the failure this
|
|
52
|
+
package was written to avoid.
|
|
53
|
+
|
|
54
|
+
Construct a `PrismHarness` with no drivers and it works — and then refuses
|
|
55
|
+
durable state, loudly, with a message that names the fix. That is deliberate. A
|
|
56
|
+
package that silently accepted an in-memory durable store would pass every test
|
|
57
|
+
on one process and lose a half-executed action the first time it ran on two.
|
|
58
|
+
|
|
59
|
+
## The same address as PHP
|
|
60
|
+
|
|
61
|
+
`Session.key()` is byte for byte what the reference builds: `session:` plus the
|
|
62
|
+
sha1 of the participant type truncated to 12, the participant id, and the scope.
|
|
63
|
+
Matching exactly is what lets a PHP app and a TypeScript agent **share one store
|
|
64
|
+
and resolve the same session**. A different digest would give two conversations
|
|
65
|
+
that look identical and never meet.
|
|
66
|
+
|
|
67
|
+
## Threads
|
|
68
|
+
|
|
69
|
+
`record()` assigns positions inside the store lock, reading the current length
|
|
70
|
+
and writing the new messages as one operation. Two turns landing concurrently
|
|
71
|
+
would otherwise both read position 4 and both write position 5, silently losing
|
|
72
|
+
a message — the race the reference tracks as prism-harness#2.
|
|
73
|
+
|
|
74
|
+
## Agent task lists
|
|
75
|
+
|
|
76
|
+
An agent given a goal has to keep working across many requests. It needs a list
|
|
77
|
+
of what remains, and that list has to survive the request, the worker, a crash
|
|
78
|
+
and a deploy.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const tasks = session.tasks();
|
|
82
|
+
|
|
83
|
+
await tasks.addMany(['read the issue', 'write the patch', 'run the gates']);
|
|
84
|
+
|
|
85
|
+
const task = await tasks.claim('worker-1'); // ONE atomic call
|
|
86
|
+
|
|
87
|
+
if (task !== null) {
|
|
88
|
+
// …do the work…
|
|
89
|
+
await tasks.release(task, 'worker-1', 'done');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await tasks.pending(); // a COUNT, which is what a loop needs to stop
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
There is **no task model, no schema and no migration**. Two contracts and
|
|
96
|
+
adapters: `AgentTaskSource` is where tasks come from — `claim`, `release`,
|
|
97
|
+
`pending`, `find` — and `AgentTask` is one unit of work. `find()` is on the
|
|
98
|
+
contract because `release()` takes a task while every external caller holds
|
|
99
|
+
only an id: a tool call carries `{"id": "t-1"}` and an HTTP route has
|
|
100
|
+
`/tasks/t-1`.
|
|
101
|
+
|
|
102
|
+
`StoreTaskSource` is the default, backed by the durable slot; a consumer with an
|
|
103
|
+
existing table wraps their own record with `asAgentTask()` or mixes
|
|
104
|
+
`withAgentTask()` into their class. Both satisfy one contract.
|
|
105
|
+
|
|
106
|
+
Four states — `todo`, `claimed`, `done`, `failed` — and the transitions are
|
|
107
|
+
pinned across all three languages:
|
|
108
|
+
|
|
109
|
+
| | |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `claim()` is one call | Read-then-mark is two operations with a window between them, and two workers arriving in that window get the same task. |
|
|
112
|
+
| A claim carries an owner AND an expiry | Five minutes by default. An expired claim returns the task to **`todo`**, never to `failed`: a worker dying is not the task failing, and conflating them burns a retry that never ran. |
|
|
113
|
+
| `claimed` is written before the work begins | So "started and died" is distinguishable from "never started". |
|
|
114
|
+
| `done` and `failed` are terminal | Re-releasing one is an error, not a silent no-op. |
|
|
115
|
+
| A worker may extend its own lease | Only while it still holds it, and bounded by the RUN. `RunLedger.exhaustion()` refuses outright — cancelled, out of steps, out of money or out of time — and `remainingSeconds()` bounds what is granted. There is no second timeout to set, or to forget to set. The new expiry is `now + granted` even when that shortens the lease: the grant is what the run can still afford. |
|
|
116
|
+
| A lease must be whole positive seconds | Zero or less is refused rather than clamped, and a fraction is refused rather than truncated — both are a value quietly becoming a different value, and `claimed_until` is an integer timestamp so a fractional lease could never have been honoured anyway. The check is `Number.isInteger`, which also refuses `NaN`, the infinities, and the string a JSON config would have delivered past the type annotation. |
|
|
117
|
+
| `release()` names the worker | Not in the spec's sketch, and added because without it a completion tool can close any task in the list — including one another worker is midway through. A release by anyone but the holder is refused, and the refusal does not say who the holder is. |
|
|
118
|
+
|
|
119
|
+
**A volatile store is refused**, the same way the durable slot is. A
|
|
120
|
+
half-finished task list that vanishes on a deploy is indistinguishable from a
|
|
121
|
+
finished one.
|
|
122
|
+
|
|
123
|
+
**An empty worker id or task id is refused, and nothing is trimmed first.**
|
|
124
|
+
`trim`, `strip` and `String.prototype.trim` each strip a different set of
|
|
125
|
+
codepoints, so trimming would have three ports disagreeing about whether the
|
|
126
|
+
same id is blank. A single space is a legal id. That is the `prism-human-plus`
|
|
127
|
+
G-36 lesson applied before it costs anything.
|
|
128
|
+
|
|
129
|
+
**An agent cannot mark its own task complete.** `release()` is the
|
|
130
|
+
application's call, made from evidence. A consumer who wants the agent to close
|
|
131
|
+
its own tasks builds `agentCompletionTool()` and authorizes it through the
|
|
132
|
+
existing `ToolAuthorizer` — which must be enabled, because an authorizer that is
|
|
133
|
+
off allows every call and would grant the authority while reading as a control.
|
|
134
|
+
If the model can declare its own work done, "run until the goal is met" becomes
|
|
135
|
+
"run until it decides it is met", and a run that has stalled ends by declaring
|
|
136
|
+
victory.
|
|
137
|
+
|
|
138
|
+
That tool **requires an explicit `outcome`** of `done` or `failed`. Anything
|
|
139
|
+
else — `"complete"`, `null`, or a missing argument — is refused rather than
|
|
140
|
+
guessed at, because `outcome` is the one value the model supplies and it decides
|
|
141
|
+
a terminal state. Guessing would let a malformed call produce the more
|
|
142
|
+
privileged result.
|
|
143
|
+
|
|
144
|
+
It also **checks the holder itself** rather than trusting the source to. It is
|
|
145
|
+
typed to the `AgentTaskSource` contract, and an interface cannot make an
|
|
146
|
+
implementation check anything — `release(task, worker, outcome)` reads like
|
|
147
|
+
"find it and set the state", which is what a third party will write. A task
|
|
148
|
+
whose holder cannot be established at all is refused too: `AgentTask` is three
|
|
149
|
+
methods, so a conforming source may expose no holder, and reading that silence
|
|
150
|
+
as permission is the same mistake as inferring `done` from a missing outcome.
|
|
151
|
+
|
|
152
|
+
Stopping is the existing `RunBudget` — cost, turns and wall-clock. Dependency
|
|
153
|
+
ordering is not here: drive [`fancy-flow`](https://github.com/Particle-Academy/fancy-flow)
|
|
154
|
+
for that. A task list that grows a scheduler has rebuilt a workflow engine
|
|
155
|
+
badly.
|
|
156
|
+
|
|
157
|
+
## Drivers
|
|
158
|
+
|
|
159
|
+
- **`MemorySessionStore`** — volatile, and says so. Right for the ephemeral slot
|
|
160
|
+
in a test or a single-process tool. Its lock is real but process-local.
|
|
161
|
+
- **`FileSessionStore`** — durable. Atomic writes (write-then-rename) and a
|
|
162
|
+
cross-process lock built on exclusive file creation, which is the one
|
|
163
|
+
primitive that is atomic on every filesystem worth supporting. Two workers on
|
|
164
|
+
one machine genuinely exclude each other; two machines over a network
|
|
165
|
+
filesystem do not, and no file lock can promise that — use a database there.
|
|
166
|
+
|
|
167
|
+
Its **lockfile format is shared with the PHP and Python ports**, because a
|
|
168
|
+
worker in any of the three may be pointed at one store directory: the expiry
|
|
169
|
+
in milliseconds, then a newline, written in a single write. The terminator is
|
|
170
|
+
load-bearing. Without it there is nothing to tell a complete expiry from the
|
|
171
|
+
first half of one, and every prefix of a timestamp is a smaller, *older*
|
|
172
|
+
timestamp — so a torn write reads as a lock that expired decades ago and gets
|
|
173
|
+
deleted out from under the worker holding it. Anything unterminated is treated
|
|
174
|
+
as held, bounded by the file's own age so a process that died mid-write cannot
|
|
175
|
+
wedge the key either.
|
|
176
|
+
|
|
177
|
+
Implement `SessionStore` for anything else. Declare your own `durability()`:
|
|
178
|
+
only you know whether your Redis is persistent or a disposable cache, and that
|
|
179
|
+
declaration is an assertion about your infrastructure, not a preference.
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ModeRegistry } from './modes.js';
|
|
2
|
+
import type { SessionStoreManager } from './stores/store-manager.js';
|
|
3
|
+
import type { ToolRegistry } from './tools.js';
|
|
4
|
+
import type { ToolAuthorizer } from './tools.js';
|
|
5
|
+
export interface DoctorFinding {
|
|
6
|
+
check: string;
|
|
7
|
+
ok: boolean;
|
|
8
|
+
detail: string;
|
|
9
|
+
}
|
|
10
|
+
export interface DoctorReport {
|
|
11
|
+
findings: readonly DoctorFinding[];
|
|
12
|
+
problems: number;
|
|
13
|
+
ok: boolean;
|
|
14
|
+
summary(): string;
|
|
15
|
+
}
|
|
16
|
+
export interface DoctorSubjects {
|
|
17
|
+
modes?: ModeRegistry;
|
|
18
|
+
tools?: ToolRegistry;
|
|
19
|
+
stores?: SessionStoreManager;
|
|
20
|
+
authorizer?: ToolAuthorizer;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Check the harness configuration BEFORE a run does it for you.
|
|
24
|
+
*
|
|
25
|
+
* Every check here corresponds to a failure this package already refuses at
|
|
26
|
+
* runtime. Those refusals are correct and they are also LATE: a mode nobody has
|
|
27
|
+
* entered yet keeps its broken subagent reference until someone switches to it,
|
|
28
|
+
* and the first person to find out is a user mid-conversation.
|
|
29
|
+
*
|
|
30
|
+
* So this resolves EVERY mode rather than the default one, and reports what a
|
|
31
|
+
* run would have thrown. A CLI is left to the consumer — this returns the
|
|
32
|
+
* report as data, which is also what makes it testable and what lets a health
|
|
33
|
+
* endpoint serve it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function diagnose(subjects: DoctorSubjects): DoctorReport;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check the harness configuration BEFORE a run does it for you.
|
|
3
|
+
*
|
|
4
|
+
* Every check here corresponds to a failure this package already refuses at
|
|
5
|
+
* runtime. Those refusals are correct and they are also LATE: a mode nobody has
|
|
6
|
+
* entered yet keeps its broken subagent reference until someone switches to it,
|
|
7
|
+
* and the first person to find out is a user mid-conversation.
|
|
8
|
+
*
|
|
9
|
+
* So this resolves EVERY mode rather than the default one, and reports what a
|
|
10
|
+
* run would have thrown. A CLI is left to the consumer — this returns the
|
|
11
|
+
* report as data, which is also what makes it testable and what lets a health
|
|
12
|
+
* endpoint serve it.
|
|
13
|
+
*/
|
|
14
|
+
export function diagnose(subjects) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
if (subjects.stores !== undefined) {
|
|
17
|
+
findings.push(checkStores(subjects.stores));
|
|
18
|
+
}
|
|
19
|
+
if (subjects.authorizer !== undefined) {
|
|
20
|
+
findings.push({
|
|
21
|
+
check: 'authorizer',
|
|
22
|
+
ok: true,
|
|
23
|
+
detail: subjects.authorizer.enabled
|
|
24
|
+
? 'enabled — tools are filtered per run and per call'
|
|
25
|
+
: 'DISABLED — every registered tool is offered to every run',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (subjects.modes !== undefined) {
|
|
29
|
+
findings.push(...checkModes(subjects.modes, subjects.tools));
|
|
30
|
+
}
|
|
31
|
+
const problems = findings.filter((finding) => !finding.ok).length;
|
|
32
|
+
return {
|
|
33
|
+
findings,
|
|
34
|
+
problems,
|
|
35
|
+
ok: problems === 0,
|
|
36
|
+
summary() {
|
|
37
|
+
const lines = findings.map((finding) => ` ${finding.ok ? 'ok ' : 'FAIL'} ${finding.check}: ${finding.detail}`);
|
|
38
|
+
lines.unshift(problems === 0
|
|
39
|
+
? 'Harness configuration is consistent.'
|
|
40
|
+
: `${problems} problem(s) found in the harness configuration.`);
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function checkStores(stores) {
|
|
46
|
+
try {
|
|
47
|
+
const durable = stores.durable();
|
|
48
|
+
stores.ephemeral();
|
|
49
|
+
return { check: 'stores', ok: true, detail: `durable slot is ${durable.durability()}` };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return { check: 'stores', ok: false, detail: message(error) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve EVERY mode, and check the tools each one names.
|
|
57
|
+
*
|
|
58
|
+
* A mode listing a tool the registry cannot produce fails only when a run
|
|
59
|
+
* reaches for it — which is to say, in front of whoever is talking to the
|
|
60
|
+
* agent. Checked here against the registry's static names; a tool that only a
|
|
61
|
+
* provider can supply needs a session, so its absence is reported as unknown
|
|
62
|
+
* rather than missing.
|
|
63
|
+
*/
|
|
64
|
+
function checkModes(modes, tools) {
|
|
65
|
+
const findings = [];
|
|
66
|
+
const names = modes.names();
|
|
67
|
+
if (names.length === 0) {
|
|
68
|
+
return [{ check: 'modes', ok: false, detail: 'no modes are configured' }];
|
|
69
|
+
}
|
|
70
|
+
const known = new Set(tools?.names() ?? []);
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
try {
|
|
73
|
+
const mode = modes.resolve(name);
|
|
74
|
+
const detail = [
|
|
75
|
+
`${mode.tools.length} tool(s)`,
|
|
76
|
+
`${mode.maxSteps} step(s)`,
|
|
77
|
+
`${Object.keys(mode.subagents).length} subagent(s)`,
|
|
78
|
+
];
|
|
79
|
+
if (mode.requiresApproval.length > 0) {
|
|
80
|
+
detail.push(`approval: ${mode.requiresApproval.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
const missing = tools === undefined || mode.tools.includes('*')
|
|
83
|
+
? []
|
|
84
|
+
: mode.tools.filter((tool) => !known.has(tool));
|
|
85
|
+
findings.push(missing.length > 0
|
|
86
|
+
? {
|
|
87
|
+
check: `mode:${name}`,
|
|
88
|
+
ok: false,
|
|
89
|
+
detail: `names ${missing.length} tool(s) the registry cannot produce: ${missing.join(', ')}`,
|
|
90
|
+
}
|
|
91
|
+
: { check: `mode:${name}`, ok: true, detail: detail.join(', ') });
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
findings.push({ check: `mode:${name}`, ok: false, detail: message(error) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// The default has to resolve, or every session that does not name a mode
|
|
98
|
+
// fails — and that is the ordinary path, not an edge.
|
|
99
|
+
try {
|
|
100
|
+
modes.resolve(null);
|
|
101
|
+
findings.push({ check: 'default mode', ok: true, detail: modes.default() });
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
findings.push({ check: 'default mode', ok: false, detail: message(error) });
|
|
105
|
+
}
|
|
106
|
+
return findings;
|
|
107
|
+
}
|
|
108
|
+
function message(error) {
|
|
109
|
+
return error instanceof Error ? error.message : String(error);
|
|
110
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failures carry a stable CODE.
|
|
3
|
+
*
|
|
4
|
+
* The PHP reference identifies a failure by its exception class and an English
|
|
5
|
+
* sentence. A class name does not survive a port and a sentence is not a
|
|
6
|
+
* contract, so the code is what a consumer branches on here: treat
|
|
7
|
+
* `HarnessError.code` as stable and `HarnessError.message` as free to change in
|
|
8
|
+
* any release. Same decision, and the same reasoning, as `prism-ts`.
|
|
9
|
+
*/
|
|
10
|
+
export type HarnessErrorCode =
|
|
11
|
+
/** A lock on a session key could not be acquired before the wait expired. */
|
|
12
|
+
'session_locked'
|
|
13
|
+
/** A store that reports itself volatile was configured for durable state. */
|
|
14
|
+
| 'unsafe_state_configuration'
|
|
15
|
+
/** A store slot names a driver nothing is registered under. */
|
|
16
|
+
| 'unknown_store_driver'
|
|
17
|
+
/** A tool was asked for that this session cannot reach. */
|
|
18
|
+
| 'tool_not_available'
|
|
19
|
+
/** A run was refused — by budget, by depth, or by a cancelled ledger. */
|
|
20
|
+
| 'run_not_permitted'
|
|
21
|
+
/** A stored payload could not be mapped back into a value object. */
|
|
22
|
+
| 'unmappable_content'
|
|
23
|
+
/** A session was asked to do something that needs a runtime it does not have. */
|
|
24
|
+
| 'no_agent_runtime'
|
|
25
|
+
/** A mode was asked for that the application has not configured. */
|
|
26
|
+
| 'mode_not_configured'
|
|
27
|
+
/** A configured mode is malformed, or names something that does not exist. */
|
|
28
|
+
| 'mode_malformed'
|
|
29
|
+
/** A tool policy is defined but the authorizer that would consult it is off. */
|
|
30
|
+
| 'unsafe_authorization_configuration'
|
|
31
|
+
/** A tool call was refused by the call-time policy. */
|
|
32
|
+
| 'call_not_authorized'
|
|
33
|
+
/** A skill file was asked for that would resolve outside its own skill. */
|
|
34
|
+
| 'skill_path_refused'
|
|
35
|
+
/** A task was added under an id the source already holds. */
|
|
36
|
+
| 'duplicate_task_id'
|
|
37
|
+
/** A worker id or a task id was the empty string. */
|
|
38
|
+
| 'task_identifier_blank'
|
|
39
|
+
/** A lease or extension that was not a whole positive number of seconds. */
|
|
40
|
+
| 'task_lease_invalid'
|
|
41
|
+
/** A task id was named that its source does not hold. */
|
|
42
|
+
| 'task_not_found'
|
|
43
|
+
/** A task that is already `done` or `failed` was released again. */
|
|
44
|
+
| 'task_already_terminal'
|
|
45
|
+
/**
|
|
46
|
+
* A worker acted on a task it is not holding.
|
|
47
|
+
*
|
|
48
|
+
* ONE code for every shape of "not yours": nobody holds it, someone else
|
|
49
|
+
* does, or your own lease has expired. Decision 0020 would rather see those
|
|
50
|
+
* split, and the first draft here did split them -- but decision 0004 pins
|
|
51
|
+
* codes ACROSS LANGUAGES, and the Python port settled on one. A consumer
|
|
52
|
+
* branching on a code that exists in one port and not another is worse than a
|
|
53
|
+
* coarse code that means the same thing everywhere. The sentence still says
|
|
54
|
+
* which case it was.
|
|
55
|
+
*/
|
|
56
|
+
| 'task_lease_not_held'
|
|
57
|
+
/**
|
|
58
|
+
* A completion tool was called with an outcome that is not `done` or `failed`.
|
|
59
|
+
*
|
|
60
|
+
* NOT in the set the Python port settled, and raised rather than dropped --
|
|
61
|
+
* see `taskOutcomeInvalid` for why coercing instead is a security defect.
|
|
62
|
+
*/
|
|
63
|
+
| 'task_outcome_invalid';
|
|
64
|
+
export interface HarnessErrorOptions {
|
|
65
|
+
cause?: unknown;
|
|
66
|
+
}
|
|
67
|
+
export declare class HarnessError extends Error {
|
|
68
|
+
readonly code: HarnessErrorCode;
|
|
69
|
+
constructor(code: HarnessErrorCode, message: string, options?: HarnessErrorOptions);
|
|
70
|
+
static sessionLocked(key: string, waitSeconds: number): HarnessError;
|
|
71
|
+
/**
|
|
72
|
+
* The guard this package exists for.
|
|
73
|
+
*
|
|
74
|
+
* A cache is disposable by definition, and the durable slot holds pending
|
|
75
|
+
* tool approvals — a half-executed action waiting on a human. Losing one is a
|
|
76
|
+
* correctness failure, not a cache miss, so a store that reports itself
|
|
77
|
+
* volatile is refused here rather than accepted and discovered later.
|
|
78
|
+
*/
|
|
79
|
+
static volatileDurableStore(slot: string, driver: string): HarnessError;
|
|
80
|
+
static unknownStoreDriver(slot: string, driver: string): HarnessError;
|
|
81
|
+
static toolNotAvailable(name: string, available: readonly string[]): HarnessError;
|
|
82
|
+
static runNotPermitted(reason: string): HarnessError;
|
|
83
|
+
static unmappableContent(description: string, options?: HarnessErrorOptions): HarnessError;
|
|
84
|
+
static modeNotConfigured(name: string): HarnessError;
|
|
85
|
+
static modeMalformed(name: string, detail: string): HarnessError;
|
|
86
|
+
/**
|
|
87
|
+
* Both at once is the one configuration not to leave in place.
|
|
88
|
+
*
|
|
89
|
+
* A defined policy that is never consulted looks like a control to every
|
|
90
|
+
* reader and is not one -- every registered tool is offered to every run
|
|
91
|
+
* while the code says otherwise.
|
|
92
|
+
*/
|
|
93
|
+
static policyDefinedButDisabled(): HarnessError;
|
|
94
|
+
/**
|
|
95
|
+
* Thrown rather than returned as a tool result.
|
|
96
|
+
*
|
|
97
|
+
* A refusal handed back as a result reads to the model as a failure it might
|
|
98
|
+
* retry differently, and a denied action being retried is the opposite of
|
|
99
|
+
* what a guard is for.
|
|
100
|
+
*/
|
|
101
|
+
static callNotAuthorized(tool: string): HarnessError;
|
|
102
|
+
/**
|
|
103
|
+
* A skill name or path that would leave the skill directory.
|
|
104
|
+
*
|
|
105
|
+
* Refused rather than sanitised. Silently rewriting a traversal to something
|
|
106
|
+
* safe teaches a caller — or a model — that the request was fine.
|
|
107
|
+
*/
|
|
108
|
+
static skillPathRefused(detail: string): HarnessError;
|
|
109
|
+
/**
|
|
110
|
+
* A task list pointed at a store whose contents can vanish.
|
|
111
|
+
*
|
|
112
|
+
* The SAME code as `volatileDurableStore`, deliberately. It is the same
|
|
113
|
+
* misconfiguration reaching the same conclusion, and a consumer branching on
|
|
114
|
+
* "durable state was pointed somewhere it cannot live" should not have to
|
|
115
|
+
* know which feature noticed. Prose is free per prism-parity decision 0004;
|
|
116
|
+
* the code is not.
|
|
117
|
+
*
|
|
118
|
+
* A half-finished task list that vanishes on a deploy is indistinguishable
|
|
119
|
+
* from a finished one, which is why this refuses at construction rather than
|
|
120
|
+
* degrading to a default.
|
|
121
|
+
*/
|
|
122
|
+
static volatileTaskSource(): HarnessError;
|
|
123
|
+
/**
|
|
124
|
+
* A lease that is not a whole positive number of seconds. REFUSED, not adjusted.
|
|
125
|
+
*
|
|
126
|
+
* Both halves were learned the hard way. Zero or less was CLAMPED to one
|
|
127
|
+
* second at first, because a clamp fails closed; a positive fraction was then
|
|
128
|
+
* still TRUNCATED. Both are a value quietly becoming a different value, and
|
|
129
|
+
* this repository has already shipped a configuration that did exactly that
|
|
130
|
+
* and stayed green throughout. A caller asking for a zero-second or
|
|
131
|
+
* fractional lease has a bug, and the useful thing to hand back is the bug.
|
|
132
|
+
*
|
|
133
|
+
* Takes `unknown` rather than `number` deliberately: the value that reaches
|
|
134
|
+
* this is whatever actually arrived, including a string out of a JSON config
|
|
135
|
+
* that the type annotation never saw.
|
|
136
|
+
*/
|
|
137
|
+
static taskLeaseInvalid(seconds: unknown): HarnessError;
|
|
138
|
+
static taskNotFound(id: string): HarnessError;
|
|
139
|
+
static duplicateTaskId(id: string): HarnessError;
|
|
140
|
+
/**
|
|
141
|
+
* An empty identifier, compared against `""` EXACTLY.
|
|
142
|
+
*
|
|
143
|
+
* DELIBERATELY NOT TRIMMED, in any of the three languages. PHP's `trim`,
|
|
144
|
+
* JavaScript's `String.prototype.trim` and Python's `str.strip` each strip a
|
|
145
|
+
* different set of codepoints, so trimming here would produce three different
|
|
146
|
+
* answers for the same input -- which is precisely how `prism-human-plus`
|
|
147
|
+
* G-36 happened. A worker id of one space is accepted, and that is the
|
|
148
|
+
* correct behaviour rather than an oversight.
|
|
149
|
+
*/
|
|
150
|
+
static taskIdentifierBlank(what: string): HarnessError;
|
|
151
|
+
/**
|
|
152
|
+
* An error, never a silent no-op.
|
|
153
|
+
*
|
|
154
|
+
* A second `release()` means two things believe they own the outcome of one
|
|
155
|
+
* task, and swallowing it leaves whichever wrote last as the answer with
|
|
156
|
+
* nothing recording that they disagreed.
|
|
157
|
+
*/
|
|
158
|
+
static taskAlreadyTerminal(id: string, state: string): HarnessError;
|
|
159
|
+
/**
|
|
160
|
+
* "You are not holding this" -- and NOT WHO IS.
|
|
161
|
+
*
|
|
162
|
+
* The holder is deliberately left out of the sentence. This error can be
|
|
163
|
+
* raised inside a tool call, and a tool's failure comes back to the model as
|
|
164
|
+
* readable text: naming the other worker would hand an agent the identity of
|
|
165
|
+
* a peer it has no business knowing, from a refusal. The caller that needs to
|
|
166
|
+
* know who holds a task can ask the source.
|
|
167
|
+
*
|
|
168
|
+
* Covers three situations -- nobody holds it, someone else does, and your own
|
|
169
|
+
* lease expired -- because all three have the same answer: claim it again.
|
|
170
|
+
*/
|
|
171
|
+
static taskLeaseNotHeld(id: string, worker: string, detail: string): HarnessError;
|
|
172
|
+
/**
|
|
173
|
+
* An outcome that is not `done` or `failed` was named.
|
|
174
|
+
*
|
|
175
|
+
* REFUSED, not coerced. The outcome decides a TERMINAL state; mapping
|
|
176
|
+
* `"complete"`, `"DONE"`, `null` or a MISSING argument onto `done` would mean
|
|
177
|
+
* a malformed call produces the MORE privileged result -- an agent declaring
|
|
178
|
+
* victory by typo. Failing closed here costs a retry with a valid value.
|
|
179
|
+
*
|
|
180
|
+
* Absent is covered by the same code deliberately. The argument for treating
|
|
181
|
+
* it as `done` is real -- calling a tool named `complete_task` looks like the
|
|
182
|
+
* declaration by itself -- and the reference settled against it.
|
|
183
|
+
*
|
|
184
|
+
* `where` names the door that refused rather than always a tool, because
|
|
185
|
+
* there are two: the completion tool a model can call, and
|
|
186
|
+
* `StoreTaskSource.release()`, which is reachable from any untyped caller.
|
|
187
|
+
*/
|
|
188
|
+
static taskOutcomeInvalid(where: string, given: unknown): HarnessError;
|
|
189
|
+
/**
|
|
190
|
+
* A self-completion tool built against an authorizer that is switched off.
|
|
191
|
+
*
|
|
192
|
+
* The same code, and the same argument, as `policyDefinedButDisabled`: a
|
|
193
|
+
* control that is never consulted reads as a control to every reader and is
|
|
194
|
+
* not one. An agent closing its own tasks is exactly the authority that must
|
|
195
|
+
* not be granted by accident.
|
|
196
|
+
*/
|
|
197
|
+
static completionToolWithoutAuthorizer(name: string): HarnessError;
|
|
198
|
+
static noAgentRuntime(action: string): HarnessError;
|
|
199
|
+
}
|