@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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { HarnessError } from './errors.js';
|
|
2
|
+
import { authorizedTool } from './tools.js';
|
|
3
|
+
import { isTerminalState } from './tasks.js';
|
|
4
|
+
/**
|
|
5
|
+
* A tool that lets the AGENT close its own task. OFF UNLESS A CONSUMER BUILDS IT.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this is not the default
|
|
8
|
+
*
|
|
9
|
+
* If the model can set its own task to `done`, then "run until the goal is met"
|
|
10
|
+
* silently becomes "run until it decides it is met", and a run that has stalled
|
|
11
|
+
* ends by declaring victory. That is the same failure `prism-human-plus`
|
|
12
|
+
* addresses by reserving confirmation for the human, and the same reason the
|
|
13
|
+
* default here is off in all three languages. A port that ships it on has
|
|
14
|
+
* changed an observable decision, not a default.
|
|
15
|
+
*
|
|
16
|
+
* By default `release()` is called by the APPLICATION, from evidence. Nothing
|
|
17
|
+
* in this package registers this tool, and a `StoreTaskSource` on its own gives
|
|
18
|
+
* an agent no route to a terminal state.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the authorizer is checked
|
|
21
|
+
*
|
|
22
|
+
* Through the EXISTING `ToolAuthorizer` -- no new permission mechanism, because
|
|
23
|
+
* a second mechanism is a second place to grant something by accident.
|
|
24
|
+
*
|
|
25
|
+
* A disabled authorizer is refused rather than tolerated. `ToolAuthorizer`
|
|
26
|
+
* defaults to off, and off means every registered tool is offered to every run
|
|
27
|
+
* and every call is allowed; a consumer who wires this tool up against one has
|
|
28
|
+
* granted the agent completion authority while believing a control is in place.
|
|
29
|
+
* Same argument, and the same error code, as a policy that is defined and never
|
|
30
|
+
* consulted.
|
|
31
|
+
*
|
|
32
|
+
* The returned factory produces a tool wrapped by `authorizedTool`, so the call
|
|
33
|
+
* policy is asked again AT CALL TIME with the arguments -- an offer-time check
|
|
34
|
+
* alone can say "may complete tasks" and never "only this task, only once the
|
|
35
|
+
* tests pass".
|
|
36
|
+
*/
|
|
37
|
+
export function agentCompletionTool(options) {
|
|
38
|
+
const name = options.name ?? 'complete_task';
|
|
39
|
+
if (!options.authorizer.enabled) {
|
|
40
|
+
throw HarnessError.completionToolWithoutAuthorizer(name);
|
|
41
|
+
}
|
|
42
|
+
const { source, task, worker, authorizer } = options;
|
|
43
|
+
const tool = {
|
|
44
|
+
name,
|
|
45
|
+
description: `Record the outcome of the task [${task.id()}] as either "done" or "failed". Only call this ` +
|
|
46
|
+
'when the work is actually finished; "failed" is a legitimate answer and is not a worse one.',
|
|
47
|
+
async handle(args) {
|
|
48
|
+
const given = args.outcome;
|
|
49
|
+
// REQUIRED and VALIDATED. `outcome` is the one value the MODEL supplies
|
|
50
|
+
// and it decides a terminal state, so mapping anything else onto `done`
|
|
51
|
+
// -- `"complete"`, `"DONE"`, `null`, or nothing at all -- would let a
|
|
52
|
+
// malformed call produce the more privileged result: an agent declaring
|
|
53
|
+
// victory by typo.
|
|
54
|
+
//
|
|
55
|
+
// ABSENT is refused too, and that is a ruling rather than an obvious
|
|
56
|
+
// reading. The argument for allowing it is real: calling a tool named
|
|
57
|
+
// `complete_task` looks like the declaration by itself. The reference
|
|
58
|
+
// settled it the other way, and the same code covers both, so a model
|
|
59
|
+
// that omitted the argument is told to say which outcome it means rather
|
|
60
|
+
// than having one chosen for it.
|
|
61
|
+
if (given !== 'done' && given !== 'failed') {
|
|
62
|
+
throw HarnessError.taskOutcomeInvalid(name, given);
|
|
63
|
+
}
|
|
64
|
+
const outcome = given;
|
|
65
|
+
// CHECKED HERE TOO, and this is not belt-and-braces.
|
|
66
|
+
//
|
|
67
|
+
// `StoreTaskSource.release()` refuses a worker that is not the holder, so
|
|
68
|
+
// against that source this block changes nothing -- deleting it is
|
|
69
|
+
// invisible, and the mutation SURVIVES. That is precisely the argument
|
|
70
|
+
// for it: an interface cannot make an implementation check anything, and
|
|
71
|
+
// `release(task, worker, outcome)` reads to a third party like "find it
|
|
72
|
+
// and set the state", which is what they will write. This tool is the
|
|
73
|
+
// thing a MODEL can call, so it does not get to assume the source behind
|
|
74
|
+
// it is the careful one. A fixture source with no guard at all is in the
|
|
75
|
+
// suite for exactly this, because nothing else would notice.
|
|
76
|
+
const current = await source.find(task.id());
|
|
77
|
+
if (current === null) {
|
|
78
|
+
throw HarnessError.taskNotFound(task.id());
|
|
79
|
+
}
|
|
80
|
+
if (isTerminalState(current.state())) {
|
|
81
|
+
throw HarnessError.taskAlreadyTerminal(task.id(), current.state());
|
|
82
|
+
}
|
|
83
|
+
if (current.state() !== 'claimed') {
|
|
84
|
+
throw HarnessError.taskLeaseNotHeld(task.id(), worker, 'nothing is holding it');
|
|
85
|
+
}
|
|
86
|
+
// ONE comparison, and it must be `!==` against the worker rather than a
|
|
87
|
+
// hunt for a mismatch. The three ways this is not the worker's task --
|
|
88
|
+
// somebody else holds it, nobody holds it, or the holder CANNOT BE
|
|
89
|
+
// ESTABLISHED -- all have to land on refusal, and the third is the one
|
|
90
|
+
// that is easy to get wrong.
|
|
91
|
+
//
|
|
92
|
+
// `AgentTask` is `id`, `instruction`, `state` and nothing else, so a
|
|
93
|
+
// conforming source may legally return a task from which no holder can be
|
|
94
|
+
// read. The first version here treated that as "no mismatch, therefore
|
|
95
|
+
// allowed", which is READING SILENCE AS PERMISSION -- the same mistake as
|
|
96
|
+
// inferring `done` from an absent outcome, asked about a different field
|
|
97
|
+
// and one level down. An unknowable holder resolves to a sentinel that
|
|
98
|
+
// matches no worker, so it refuses.
|
|
99
|
+
if (holderOf(current) !== worker) {
|
|
100
|
+
throw HarnessError.taskLeaseNotHeld(task.id(), worker, 'it is not held by this worker');
|
|
101
|
+
}
|
|
102
|
+
await source.release(task, worker, outcome);
|
|
103
|
+
return { id: task.id(), state: outcome };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
return (session) => authorizedTool(tool, session, authorizer);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A holder that MATCHES NO WORKER, for a task whose holder cannot be read.
|
|
110
|
+
*
|
|
111
|
+
* A symbol rather than a string, so no worker id can ever equal it -- not the
|
|
112
|
+
* empty string, not `'unknown'`, not whatever a consumer happens to name a
|
|
113
|
+
* worker. "Impossible to collide with" is the entire job.
|
|
114
|
+
*/
|
|
115
|
+
const UNKNOWABLE_HOLDER = Symbol('the holder could not be established');
|
|
116
|
+
/**
|
|
117
|
+
* Who holds this task -- or the sentinel, when that cannot be established.
|
|
118
|
+
*
|
|
119
|
+
* `AgentTaskSource.find()` returns an `AgentTask`: three methods, no lease.
|
|
120
|
+
* That is deliberate, because a consumer adapting an existing table may have
|
|
121
|
+
* nowhere to put an owner, and it means a perfectly conforming source can hand
|
|
122
|
+
* back a task from which no holder is readable. `LeasedAgentTask` adds the
|
|
123
|
+
* lease, and everything this package produces is one.
|
|
124
|
+
*
|
|
125
|
+
* The unreadable case resolves to a sentinel rather than to `undefined`,
|
|
126
|
+
* because `undefined` invites the caller to skip the comparison -- which is
|
|
127
|
+
* what the first version of this did, and skipping the comparison is granting
|
|
128
|
+
* the permission.
|
|
129
|
+
*/
|
|
130
|
+
function holderOf(task) {
|
|
131
|
+
const candidate = task;
|
|
132
|
+
if (typeof candidate.claimedBy !== 'function') {
|
|
133
|
+
return UNKNOWABLE_HOLDER;
|
|
134
|
+
}
|
|
135
|
+
return candidate.claimedBy();
|
|
136
|
+
}
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { JsonObject } from './json.js';
|
|
2
|
+
/**
|
|
3
|
+
* What remains to be done, and how far along it is.
|
|
4
|
+
*
|
|
5
|
+
* FOUR STATES, and no others. This is identity, not spelling: a port that adds
|
|
6
|
+
* a fifth (`cancelled`, `retrying`, `skipped`) has changed what a stored record
|
|
7
|
+
* can say, and a consumer reading a list written by another language would meet
|
|
8
|
+
* a value it cannot map.
|
|
9
|
+
*
|
|
10
|
+
* The transitions are equally pinned:
|
|
11
|
+
*
|
|
12
|
+
* ```text
|
|
13
|
+
* claim() release(done)
|
|
14
|
+
* todo ---------> claimed -------------------------> done
|
|
15
|
+
* ^ | release(failed)
|
|
16
|
+
* | +-------------------------------> failed
|
|
17
|
+
* | |
|
|
18
|
+
* +-----------------+
|
|
19
|
+
* lease expires
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Note what is NOT there. An expired lease goes back to `todo`, never to
|
|
23
|
+
* `failed` -- a worker dying is not the task failing, and conflating the two
|
|
24
|
+
* burns a retry that never ran. And nothing leaves `done` or `failed`: a
|
|
25
|
+
* `failed` task is re-queued by the APPLICATION, because automatic retry needs
|
|
26
|
+
* backoff and attempt counts, and that is the scheduler this must not become.
|
|
27
|
+
*/
|
|
28
|
+
export type TaskState = 'todo' | 'claimed' | 'done' | 'failed';
|
|
29
|
+
/** Every state, in the order the machine visits them. */
|
|
30
|
+
export declare const TASK_STATES: readonly TaskState[];
|
|
31
|
+
/**
|
|
32
|
+
* What `release()` records. Both are TERMINAL.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately not a boolean. "Did it work" reads the same for a task that
|
|
35
|
+
* failed and a task that was never attempted, and those two are what this
|
|
36
|
+
* design exists to keep apart.
|
|
37
|
+
*/
|
|
38
|
+
export type TaskOutcome = Extract<TaskState, 'done' | 'failed'>;
|
|
39
|
+
/**
|
|
40
|
+
* How long a claim holds by default: FIVE MINUTES.
|
|
41
|
+
*
|
|
42
|
+
* Long enough for a model call plus tool work, short enough that a crashed
|
|
43
|
+
* worker does not wedge the list for an hour. The number matters less than it
|
|
44
|
+
* being the same number in all three languages, which is why it is a named
|
|
45
|
+
* constant rather than a literal at the call site.
|
|
46
|
+
*/
|
|
47
|
+
export declare const DEFAULT_LEASE_SECONDS = 300;
|
|
48
|
+
/**
|
|
49
|
+
* One unit of work.
|
|
50
|
+
*
|
|
51
|
+
* METHODS, not properties, and that is not decoration. An adapter over a
|
|
52
|
+
* consumer's own record has to read through to the live row -- a value
|
|
53
|
+
* snapshotted at construction would still report `claimed` after another
|
|
54
|
+
* worker's lease expired underneath it.
|
|
55
|
+
*/
|
|
56
|
+
export interface AgentTask {
|
|
57
|
+
/** Stable and unique WITHIN ITS SOURCE. Not globally unique. */
|
|
58
|
+
id(): string;
|
|
59
|
+
/** What the model is asked to do. */
|
|
60
|
+
instruction(): string;
|
|
61
|
+
state(): TaskState;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Where tasks come from.
|
|
65
|
+
*
|
|
66
|
+
* Four methods, and the first is the reason the interface exists.
|
|
67
|
+
*
|
|
68
|
+
* `claim()` is ONE call. "Read the next task" followed by "mark it mine" is two
|
|
69
|
+
* round trips with a window between them, and two workers arriving in that
|
|
70
|
+
* window both get the same task. An implementation must take the next available
|
|
71
|
+
* task and record the claim as a single atomic operation -- not for
|
|
72
|
+
* convenience, but because a source that cannot is not an implementation of
|
|
73
|
+
* this contract.
|
|
74
|
+
*/
|
|
75
|
+
export interface AgentTaskSource {
|
|
76
|
+
/**
|
|
77
|
+
* Atomically take the next available task, or null when there is none.
|
|
78
|
+
*
|
|
79
|
+
* Tasks come back in INSERTION ORDER unless the source defines otherwise. A
|
|
80
|
+
* source may expose an explicit position; it must not reorder implicitly.
|
|
81
|
+
* Nothing errors when ordering changes -- the agent simply does the work in a
|
|
82
|
+
* different sequence and produces a different result, which is the hardest
|
|
83
|
+
* kind of divergence to notice.
|
|
84
|
+
*/
|
|
85
|
+
claim(worker: string, leaseSeconds?: number): Promise<AgentTask | null>;
|
|
86
|
+
/**
|
|
87
|
+
* Record what happened, as the worker that is HOLDING the task.
|
|
88
|
+
*
|
|
89
|
+
* Called by the APPLICATION, from evidence -- not by the agent. See
|
|
90
|
+
* `agentCompletionTool` for the opt-in that changes that, and why it is off
|
|
91
|
+
* until a consumer turns it on.
|
|
92
|
+
*
|
|
93
|
+
* The `worker` argument is not in the spec's three-method sketch and is here
|
|
94
|
+
* on purpose. Without it, a completion tool handed a source can close ANY
|
|
95
|
+
* task in the list, including one another worker is midway through -- and the
|
|
96
|
+
* whole reason completion authority is gated is that an agent closing work it
|
|
97
|
+
* did not do is the failure being designed against. A release by anyone but
|
|
98
|
+
* the holder is refused.
|
|
99
|
+
*/
|
|
100
|
+
release(task: AgentTask, worker: string, outcome: TaskOutcome): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* How many tasks remain claimable.
|
|
103
|
+
*
|
|
104
|
+
* A COUNT, not a listing. It exists to terminate the loop and a count is
|
|
105
|
+
* enough for that; a listing invites the source to materialise every task on
|
|
106
|
+
* every pass, and a consumer that wants one already has its own query.
|
|
107
|
+
*/
|
|
108
|
+
pending(): Promise<number>;
|
|
109
|
+
/**
|
|
110
|
+
* The task with this id, as it stands now, or null.
|
|
111
|
+
*
|
|
112
|
+
* On the contract because `release()` takes a TASK and every external caller
|
|
113
|
+
* holds only an ID. A tool call carries `{"id": "t-1"}`; an HTTP route has
|
|
114
|
+
* `/tasks/t-1`; a worker resuming after a restart has whatever it wrote down.
|
|
115
|
+
* Without this the contract could be driven only by code that still had the
|
|
116
|
+
* object `claim()` returned, which is the one caller that does not need it.
|
|
117
|
+
*
|
|
118
|
+
* A FRESH read, with any expired lease already applied -- so a task whose
|
|
119
|
+
* lease ran out reports `todo` here even if the store has not been rewritten
|
|
120
|
+
* yet.
|
|
121
|
+
*/
|
|
122
|
+
find(id: string): Promise<AgentTask | null>;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The stored shape of a task. THE KEYS ARE THE CONTRACT.
|
|
126
|
+
*
|
|
127
|
+
* `claimed_by` and `claimed_until` are `| null` and NOT optional, and that
|
|
128
|
+
* distinction is why this type is spelled out rather than inferred. TypeScript
|
|
129
|
+
* has both `null` and `undefined` where PHP has one absent value; an optional
|
|
130
|
+
* property holding `undefined` is DROPPED by `JSON.stringify`; and a dropped
|
|
131
|
+
* key is different bytes -- an observable decision under prism-parity decision
|
|
132
|
+
* 0002, not a formatting detail.
|
|
133
|
+
*
|
|
134
|
+
* `claimed_until` is an integer Unix timestamp rather than a formatted date,
|
|
135
|
+
* because date formatting is exactly where three languages produce three
|
|
136
|
+
* strings from one instant.
|
|
137
|
+
*/
|
|
138
|
+
export interface AgentTaskRecord {
|
|
139
|
+
claimed_by: string | null;
|
|
140
|
+
claimed_until: number | null;
|
|
141
|
+
id: string;
|
|
142
|
+
instruction: string;
|
|
143
|
+
state: TaskState;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A task that also exposes its lease.
|
|
147
|
+
*
|
|
148
|
+
* Kept apart from `AgentTask` because the three-method contract is what an
|
|
149
|
+
* adapter over an existing table must satisfy, and such a table may have
|
|
150
|
+
* nowhere to put an owner and an expiry. Everything this package produces
|
|
151
|
+
* satisfies both.
|
|
152
|
+
*/
|
|
153
|
+
export interface LeasedAgentTask extends AgentTask {
|
|
154
|
+
/** Present-and-null when unclaimed. */
|
|
155
|
+
claimedBy(): string | null;
|
|
156
|
+
/** Integer Unix timestamp; present-and-null when unclaimed. */
|
|
157
|
+
claimedUntil(): number | null;
|
|
158
|
+
toRecord(): AgentTaskRecord;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The five attributes a consumer's own record has to expose.
|
|
162
|
+
*
|
|
163
|
+
* Camel-cased because this is the in-language shape; `AgentTaskRecord` is the
|
|
164
|
+
* stored one, and the two are converted rather than conflated. Only the stored
|
|
165
|
+
* one is pinned across languages.
|
|
166
|
+
*/
|
|
167
|
+
export interface AgentTaskAttributes {
|
|
168
|
+
id: string;
|
|
169
|
+
instruction: string;
|
|
170
|
+
state: TaskState;
|
|
171
|
+
/** PRESENT-AND-NULL when unclaimed. Never `undefined`; see `AgentTaskRecord`. */
|
|
172
|
+
claimedBy: string | null;
|
|
173
|
+
claimedUntil: number | null;
|
|
174
|
+
}
|
|
175
|
+
export declare function isTaskState(value: unknown): value is TaskState;
|
|
176
|
+
/** `done` and `failed`. Nothing leaves either. */
|
|
177
|
+
export declare function isTerminalState(state: TaskState): boolean;
|
|
178
|
+
/**
|
|
179
|
+
* Canonical JSON for one task record.
|
|
180
|
+
*
|
|
181
|
+
* ```json
|
|
182
|
+
* {"claimed_by":null,"claimed_until":null,"id":"t-1","instruction":"...","state":"todo"}
|
|
183
|
+
* ```
|
|
184
|
+
*
|
|
185
|
+
* Keys SORTED -- which for these five is also the order above, so one task has
|
|
186
|
+
* one byte sequence rather than two.
|
|
187
|
+
*
|
|
188
|
+
* `undefined` is coerced to `null` instead of being handed to `JSON.stringify`,
|
|
189
|
+
* which would silently drop the key. That coercion is a backstop for values
|
|
190
|
+
* arriving from a store or an untyped consumer; the types above already make it
|
|
191
|
+
* unreachable from typed code, and a test asserts both halves rather than
|
|
192
|
+
* trusting the type.
|
|
193
|
+
*
|
|
194
|
+
* No whitespace, no escaped slashes, no escaped non-ASCII: JavaScript's
|
|
195
|
+
* `JSON.stringify` already produces all three where PHP's and Python's defaults
|
|
196
|
+
* do not. See prism-parity decision 0005.
|
|
197
|
+
*/
|
|
198
|
+
export declare function canonicalTaskJson(record: AgentTaskRecord): string;
|
|
199
|
+
/**
|
|
200
|
+
* Rebuild a record from whatever a store handed back.
|
|
201
|
+
*
|
|
202
|
+
* A missing or `undefined` claim field becomes `null`, so there is exactly one
|
|
203
|
+
* representation of "unclaimed" once a row has been through here. A missing id,
|
|
204
|
+
* instruction or state is a CORRUPT row rather than a defaultable one, and is
|
|
205
|
+
* refused -- defaulting a missing state to `todo` would hand a half-finished
|
|
206
|
+
* task back out as if it had never been attempted.
|
|
207
|
+
*/
|
|
208
|
+
export declare function taskRecordFrom(value: JsonObject | Record<string, unknown>): AgentTaskRecord;
|
|
209
|
+
export declare function recordToAttributes(record: AgentTaskRecord): AgentTaskAttributes;
|
|
210
|
+
/**
|
|
211
|
+
* The stored record for a set of attributes.
|
|
212
|
+
*
|
|
213
|
+
* `?? null` on both claim fields is the load-bearing line in this file. An
|
|
214
|
+
* untyped consumer handing over `undefined` would otherwise produce a record
|
|
215
|
+
* whose keys vanish at serialisation time.
|
|
216
|
+
*/
|
|
217
|
+
export declare function attributesToRecord(attributes: AgentTaskAttributes): AgentTaskRecord;
|
|
218
|
+
/**
|
|
219
|
+
* A consumer's own record, as an `AgentTask`.
|
|
220
|
+
*
|
|
221
|
+
* This is the idiomatic equivalent of the reference's trait on an Eloquent
|
|
222
|
+
* model, and the reason it looks nothing like a trait is decision 0002. PHP
|
|
223
|
+
* needs a trait because a trait is how PHP shares behaviour into someone else's
|
|
224
|
+
* class. TypeScript's conformance is STRUCTURAL: a consumer whose record
|
|
225
|
+
* already carries the five attributes needs no inheritance at all, so the
|
|
226
|
+
* natural spelling is a function that wraps one. Contorting the language to
|
|
227
|
+
* imitate a trait would change the spelling and nothing observable.
|
|
228
|
+
*
|
|
229
|
+
* The attributes object is read at CALL TIME rather than copied, so a live
|
|
230
|
+
* record another worker's claim changed underneath reports the new state.
|
|
231
|
+
*/
|
|
232
|
+
export declare function asAgentTask(attributes: AgentTaskAttributes): LeasedAgentTask;
|
|
233
|
+
/**
|
|
234
|
+
* What the mixin needs from the class it is mixed into.
|
|
235
|
+
*
|
|
236
|
+
* ONE accessor rather than five fields, because a mixin cannot define a method
|
|
237
|
+
* called `id()` on a class that already has an `id` property -- the instance
|
|
238
|
+
* property shadows the prototype method and the method is never reached. That
|
|
239
|
+
* is a real constraint of the language, not a preference, and it is the reason
|
|
240
|
+
* the mixin asks for a mapping function instead of the attributes directly. A
|
|
241
|
+
* consumer maps their own column names here, exactly as a PHP trait would need
|
|
242
|
+
* them mapped.
|
|
243
|
+
*/
|
|
244
|
+
export interface AgentTaskAttributeSource {
|
|
245
|
+
taskAttributes(): AgentTaskAttributes;
|
|
246
|
+
}
|
|
247
|
+
type Constructor<T> = new (...args: any[]) => T;
|
|
248
|
+
/**
|
|
249
|
+
* The mixin: `class Todo extends withAgentTask(Row) {}`.
|
|
250
|
+
*
|
|
251
|
+
* Same behaviour as `asAgentTask`, reached the way a consumer who wants the
|
|
252
|
+
* methods ON their model reaches it. Both exist because TypeScript has two
|
|
253
|
+
* natural spellings of "share this behaviour into someone else's class" and
|
|
254
|
+
* neither is more correct than the other.
|
|
255
|
+
*/
|
|
256
|
+
export declare function withAgentTask<TBase extends Constructor<AgentTaskAttributeSource>>(Base: TBase): TBase & Constructor<LeasedAgentTask>;
|
|
257
|
+
export {};
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { HarnessError } from './errors.js';
|
|
2
|
+
/** Every state, in the order the machine visits them. */
|
|
3
|
+
export const TASK_STATES = ['todo', 'claimed', 'done', 'failed'];
|
|
4
|
+
/**
|
|
5
|
+
* How long a claim holds by default: FIVE MINUTES.
|
|
6
|
+
*
|
|
7
|
+
* Long enough for a model call plus tool work, short enough that a crashed
|
|
8
|
+
* worker does not wedge the list for an hour. The number matters less than it
|
|
9
|
+
* being the same number in all three languages, which is why it is a named
|
|
10
|
+
* constant rather than a literal at the call site.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_LEASE_SECONDS = 300;
|
|
13
|
+
export function isTaskState(value) {
|
|
14
|
+
return typeof value === 'string' && TASK_STATES.includes(value);
|
|
15
|
+
}
|
|
16
|
+
/** `done` and `failed`. Nothing leaves either. */
|
|
17
|
+
export function isTerminalState(state) {
|
|
18
|
+
return state === 'done' || state === 'failed';
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Canonical JSON for one task record.
|
|
22
|
+
*
|
|
23
|
+
* ```json
|
|
24
|
+
* {"claimed_by":null,"claimed_until":null,"id":"t-1","instruction":"...","state":"todo"}
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Keys SORTED -- which for these five is also the order above, so one task has
|
|
28
|
+
* one byte sequence rather than two.
|
|
29
|
+
*
|
|
30
|
+
* `undefined` is coerced to `null` instead of being handed to `JSON.stringify`,
|
|
31
|
+
* which would silently drop the key. That coercion is a backstop for values
|
|
32
|
+
* arriving from a store or an untyped consumer; the types above already make it
|
|
33
|
+
* unreachable from typed code, and a test asserts both halves rather than
|
|
34
|
+
* trusting the type.
|
|
35
|
+
*
|
|
36
|
+
* No whitespace, no escaped slashes, no escaped non-ASCII: JavaScript's
|
|
37
|
+
* `JSON.stringify` already produces all three where PHP's and Python's defaults
|
|
38
|
+
* do not. See prism-parity decision 0005.
|
|
39
|
+
*/
|
|
40
|
+
export function canonicalTaskJson(record) {
|
|
41
|
+
const loose = record;
|
|
42
|
+
const ordered = {};
|
|
43
|
+
for (const key of Object.keys(loose).sort()) {
|
|
44
|
+
const value = loose[key];
|
|
45
|
+
ordered[key] = value === undefined ? null : value;
|
|
46
|
+
}
|
|
47
|
+
return JSON.stringify(ordered);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Rebuild a record from whatever a store handed back.
|
|
51
|
+
*
|
|
52
|
+
* A missing or `undefined` claim field becomes `null`, so there is exactly one
|
|
53
|
+
* representation of "unclaimed" once a row has been through here. A missing id,
|
|
54
|
+
* instruction or state is a CORRUPT row rather than a defaultable one, and is
|
|
55
|
+
* refused -- defaulting a missing state to `todo` would hand a half-finished
|
|
56
|
+
* task back out as if it had never been attempted.
|
|
57
|
+
*/
|
|
58
|
+
export function taskRecordFrom(value) {
|
|
59
|
+
const loose = value;
|
|
60
|
+
const id = loose.id;
|
|
61
|
+
const instruction = loose.instruction;
|
|
62
|
+
const state = loose.state;
|
|
63
|
+
if (typeof id !== 'string' || id === '') {
|
|
64
|
+
throw HarnessError.unmappableContent('a stored task record has no id');
|
|
65
|
+
}
|
|
66
|
+
if (typeof instruction !== 'string') {
|
|
67
|
+
throw HarnessError.unmappableContent(`the stored task [${id}] has no instruction`);
|
|
68
|
+
}
|
|
69
|
+
if (!isTaskState(state)) {
|
|
70
|
+
throw HarnessError.unmappableContent(`the stored task [${id}] has the state [${String(state)}], which is not one of ${TASK_STATES.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
const claimedBy = loose.claimed_by;
|
|
73
|
+
const claimedUntil = loose.claimed_until;
|
|
74
|
+
return {
|
|
75
|
+
claimed_by: typeof claimedBy === 'string' ? claimedBy : null,
|
|
76
|
+
claimed_until: typeof claimedUntil === 'number' ? Math.trunc(claimedUntil) : null,
|
|
77
|
+
id,
|
|
78
|
+
instruction,
|
|
79
|
+
state,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function recordToAttributes(record) {
|
|
83
|
+
return {
|
|
84
|
+
id: record.id,
|
|
85
|
+
instruction: record.instruction,
|
|
86
|
+
state: record.state,
|
|
87
|
+
claimedBy: record.claimed_by,
|
|
88
|
+
claimedUntil: record.claimed_until,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The stored record for a set of attributes.
|
|
93
|
+
*
|
|
94
|
+
* `?? null` on both claim fields is the load-bearing line in this file. An
|
|
95
|
+
* untyped consumer handing over `undefined` would otherwise produce a record
|
|
96
|
+
* whose keys vanish at serialisation time.
|
|
97
|
+
*/
|
|
98
|
+
export function attributesToRecord(attributes) {
|
|
99
|
+
const until = attributes.claimedUntil;
|
|
100
|
+
return {
|
|
101
|
+
claimed_by: attributes.claimedBy ?? null,
|
|
102
|
+
claimed_until: until === null || until === undefined ? null : Math.trunc(until),
|
|
103
|
+
id: attributes.id,
|
|
104
|
+
instruction: attributes.instruction,
|
|
105
|
+
state: attributes.state,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A consumer's own record, as an `AgentTask`.
|
|
110
|
+
*
|
|
111
|
+
* This is the idiomatic equivalent of the reference's trait on an Eloquent
|
|
112
|
+
* model, and the reason it looks nothing like a trait is decision 0002. PHP
|
|
113
|
+
* needs a trait because a trait is how PHP shares behaviour into someone else's
|
|
114
|
+
* class. TypeScript's conformance is STRUCTURAL: a consumer whose record
|
|
115
|
+
* already carries the five attributes needs no inheritance at all, so the
|
|
116
|
+
* natural spelling is a function that wraps one. Contorting the language to
|
|
117
|
+
* imitate a trait would change the spelling and nothing observable.
|
|
118
|
+
*
|
|
119
|
+
* The attributes object is read at CALL TIME rather than copied, so a live
|
|
120
|
+
* record another worker's claim changed underneath reports the new state.
|
|
121
|
+
*/
|
|
122
|
+
export function asAgentTask(attributes) {
|
|
123
|
+
return {
|
|
124
|
+
id: () => attributes.id,
|
|
125
|
+
instruction: () => attributes.instruction,
|
|
126
|
+
state: () => attributes.state,
|
|
127
|
+
claimedBy: () => attributes.claimedBy ?? null,
|
|
128
|
+
claimedUntil: () => attributes.claimedUntil ?? null,
|
|
129
|
+
toRecord: () => attributesToRecord(attributes),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The mixin: `class Todo extends withAgentTask(Row) {}`.
|
|
134
|
+
*
|
|
135
|
+
* Same behaviour as `asAgentTask`, reached the way a consumer who wants the
|
|
136
|
+
* methods ON their model reaches it. Both exist because TypeScript has two
|
|
137
|
+
* natural spellings of "share this behaviour into someone else's class" and
|
|
138
|
+
* neither is more correct than the other.
|
|
139
|
+
*/
|
|
140
|
+
export function withAgentTask(Base) {
|
|
141
|
+
class AgentTaskRow extends Base {
|
|
142
|
+
id() {
|
|
143
|
+
return this.taskAttributes().id;
|
|
144
|
+
}
|
|
145
|
+
instruction() {
|
|
146
|
+
return this.taskAttributes().instruction;
|
|
147
|
+
}
|
|
148
|
+
state() {
|
|
149
|
+
return this.taskAttributes().state;
|
|
150
|
+
}
|
|
151
|
+
claimedBy() {
|
|
152
|
+
return this.taskAttributes().claimedBy ?? null;
|
|
153
|
+
}
|
|
154
|
+
claimedUntil() {
|
|
155
|
+
return this.taskAttributes().claimedUntil ?? null;
|
|
156
|
+
}
|
|
157
|
+
toRecord() {
|
|
158
|
+
return attributesToRecord(this.taskAttributes());
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return AgentTaskRow;
|
|
162
|
+
}
|
package/dist/thread.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { JsonObject } from './json.js';
|
|
2
|
+
import type { SessionStore } from './stores/session-store.js';
|
|
3
|
+
export interface ThreadMessage {
|
|
4
|
+
/** Position in the conversation, from 1. Assigned by the thread, never by the caller. */
|
|
5
|
+
position: number;
|
|
6
|
+
/** The serialized message — whatever `prism-ts`'s `messageFromObject` can rebuild. */
|
|
7
|
+
message: JsonObject;
|
|
8
|
+
/** The run that produced it, when there was one. */
|
|
9
|
+
runId: string | null;
|
|
10
|
+
recordedAt: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The stored conversation a session is bound to.
|
|
14
|
+
*
|
|
15
|
+
* DURABLE by construction: it lives in the durable slot, so it is the one thing
|
|
16
|
+
* here a flushed cache cannot take away. The PHP reference keeps it as Eloquent
|
|
17
|
+
* rows for the same reason.
|
|
18
|
+
*
|
|
19
|
+
* ## Position is assigned inside a lock
|
|
20
|
+
*
|
|
21
|
+
* `record()` takes the lock before reading the current length. Two turns
|
|
22
|
+
* landing concurrently would otherwise both read position 4 and both write
|
|
23
|
+
* position 5, and the conversation would silently lose a message — the same
|
|
24
|
+
* race the reference tracks as prism-harness#2. Doing the read and the write
|
|
25
|
+
* inside one lock is the fix, not a retry afterwards.
|
|
26
|
+
*/
|
|
27
|
+
export declare class Thread {
|
|
28
|
+
#private;
|
|
29
|
+
private readonly store;
|
|
30
|
+
private readonly key;
|
|
31
|
+
constructor(store: SessionStore, key: string);
|
|
32
|
+
messages(): Promise<ThreadMessage[]>;
|
|
33
|
+
/** How many messages the thread holds. */
|
|
34
|
+
count(): Promise<number>;
|
|
35
|
+
/**
|
|
36
|
+
* Append messages, in order, and return them with their assigned positions.
|
|
37
|
+
*
|
|
38
|
+
* Read-and-write inside ONE lock. See the class docblock.
|
|
39
|
+
*/
|
|
40
|
+
record(messages: readonly JsonObject[], runId?: string | null): Promise<ThreadMessage[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Forget the conversation.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately separate from `Session.forget()`, which drops only the
|
|
45
|
+
* ephemeral half. Losing a thread is not a cache miss and must never be a
|
|
46
|
+
* side effect of clearing session state.
|
|
47
|
+
*/
|
|
48
|
+
clear(): Promise<void>;
|
|
49
|
+
}
|