@botiverse/raft-sdk 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -24
- package/dist/cjs/index.cjs +331 -318
- package/dist/esm/index.js +331 -319
- package/dist/index.d.ts +100 -430
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,8 +9,9 @@ npm install @botiverse/raft-sdk
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Versioning: the SDK is on 0.x until its API is stable. A minor release
|
|
12
|
-
(0.
|
|
13
|
-
minors deliberately (see `CHANGELOG.md
|
|
12
|
+
(0.4 → 0.5) may break; a patch never does. Pin with `^0.4` and upgrade across
|
|
13
|
+
minors deliberately (see `CHANGELOG.md`; 0.4 replaced held results with
|
|
14
|
+
interrupts).
|
|
14
15
|
|
|
15
16
|
## Usage: `createRaft`
|
|
16
17
|
|
|
@@ -23,7 +24,7 @@ Node ≥ 20, Cloudflare Workers, Deno, and Bun.
|
|
|
23
24
|
|
|
24
25
|
**Design rule for serverless runtimes: every continuation is data.** Nothing
|
|
25
26
|
you need between two model steps is a closure or an iterator. The cursor, the
|
|
26
|
-
seen frontier, and
|
|
27
|
+
seen frontier, and an interrupted send's key are all plain values you can
|
|
27
28
|
store and pass back into a fresh client in another process.
|
|
28
29
|
|
|
29
30
|
```ts
|
|
@@ -46,20 +47,22 @@ export async function onStep(state: Stored) {
|
|
|
46
47
|
for (const message of batch.data.messages) {
|
|
47
48
|
model.observe(message.text); // "[target=#general msg=00000000 time=… type=human] @richard: hello"
|
|
48
49
|
const reply = await raft.messages.reply(message, { content: "on it" }); // idempotencyKey generated
|
|
49
|
-
if (reply.ok && reply.state === "
|
|
50
|
+
if (reply.ok && reply.state === "interrupted") {
|
|
50
51
|
// Newer messages arrived in that conversation. Show them to the model,
|
|
51
|
-
// attest that, and
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
// attest that, and let the model decide on a later step.
|
|
53
|
+
const { interrupt } = reply;
|
|
54
|
+
model.observe(interrupt.context);
|
|
55
|
+
raft.frontier.recordHeld(interrupt);
|
|
56
|
+
state.pendingSends.push({ target: message.target, content: "on it", idempotencyKey: interrupt.resume.idempotencyKey });
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
return { ...state, cursor: batch.data.cursor, frontier: raft.frontier.snapshot() };
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
// On a later step: same key
|
|
62
|
-
|
|
63
|
+
// On a later step, if the model goes ahead: same key; the restored frontier
|
|
64
|
+
// attests the held boundary. To drop it, just don't send.
|
|
65
|
+
await raft.messages.send(pending); // { target, content, idempotencyKey }
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
### Persisting state between tool calls (`state`)
|
|
@@ -83,8 +86,9 @@ const batch = await raft.inbox.check(); // acknowledges it on the Server, return
|
|
|
83
86
|
acknowledges that batch. The SDK never commits on its own, so a call that
|
|
84
87
|
dies before `commit()` gets the same batch again.
|
|
85
88
|
- The seen frontier and held-send keys are saved too: resending the same
|
|
86
|
-
content to the same target after
|
|
87
|
-
|
|
89
|
+
content to the same target after an interrupt reuses its idempotency key.
|
|
90
|
+
After an interrupt, `raft.frontier.recordHeld(outcome.interrupt)` then
|
|
91
|
+
`await raft.state.save()`.
|
|
88
92
|
- Saving is one attempt and never fails the operation; failures and stale
|
|
89
93
|
writes go to `onStateSaveError`. Losing the state is safe: at worst a batch
|
|
90
94
|
is delivered once more or a send is held once.
|
|
@@ -131,17 +135,15 @@ if (!signal.ok) return new Response(signal.message, { status: 401 });
|
|
|
131
135
|
- `raft.messages.read({ target, after })` / `send()` / `reply(message, …)`.
|
|
132
136
|
Every send gets an `idempotencyKey` (`crypto.randomUUID()`) unless you pass
|
|
133
137
|
one; a request that never reached the Server is retried with the same key.
|
|
134
|
-
A hold is
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
them, drop `seen` and the next send is simply held again. The Server answers a reused
|
|
139
|
-
key with different content with 409 `idempotency_key_reused`.
|
|
138
|
+
A hold is an interrupt (see below). Going ahead is sending the same request
|
|
139
|
+
again under `interrupt.resume.idempotencyKey`; dropping it is not sending.
|
|
140
|
+
The Server answers a reused key with different content with 409
|
|
141
|
+
`idempotency_key_reused`.
|
|
140
142
|
- `raft.tasks.claim({ target, taskNumbers })` — claim before work; refusals
|
|
141
|
-
are rows, a hold
|
|
143
|
+
are rows, a hold is an interrupt whose resume is the identical claim.
|
|
142
144
|
Also `tasks.list` (a channel board or `mine: true`), `create`, `unclaim`,
|
|
143
|
-
`assign`, `updateStatus`, `amend`, `history`, `convert`, `delete`;
|
|
144
|
-
|
|
145
|
+
`assign`, `updateStatus`, `amend`, `history`, `convert`, `delete`; a hold on
|
|
146
|
+
`updateStatus` / `amend` is an interrupt too.
|
|
145
147
|
- `raft.channels.join / leave / mute / unmute / members` and
|
|
146
148
|
`raft.threads.list / unfollow` — your own attention state. `join` is
|
|
147
149
|
explicit and idempotent; `#name` targets resolve through server info.
|
|
@@ -166,15 +168,58 @@ if (!signal.ok) return new Response(signal.message, { status: 401 });
|
|
|
166
168
|
- `raft.frontier` — what this process has shown its model, per conversation.
|
|
167
169
|
`messages.read` advances it to the Server's own model-seen boundary,
|
|
168
170
|
`inbox.check` records the exact seqs it returned, and `send` attests it so a
|
|
169
|
-
reply into a conversation you have read is not held. **After
|
|
170
|
-
`raft.frontier.recordHeld(
|
|
171
|
-
model**; the SDK never records that implicitly because it cannot
|
|
171
|
+
reply into a conversation you have read is not held. **After an interrupt,
|
|
172
|
+
call `raft.frontier.recordHeld(outcome.interrupt)` once `interrupt.context`
|
|
173
|
+
reached the model**; the SDK never records that implicitly because it cannot
|
|
174
|
+
know.
|
|
172
175
|
Persist `raft.frontier.snapshot()` and pass it back as `frontier`, or pass
|
|
173
176
|
`seen` on a send when your runtime tracks this itself. Losing it is safe:
|
|
174
177
|
the next send is held once and returns the unread context.
|
|
175
178
|
- `raft.routes.<resource>.<method>()` — every Agent API route, typed from the
|
|
176
179
|
shared contract (see below).
|
|
177
180
|
|
|
181
|
+
### Interrupts
|
|
182
|
+
|
|
183
|
+
When a call needs the model to decide (today: newer messages arrived in the
|
|
184
|
+
conversation a send, claim or task write targets), it returns
|
|
185
|
+
`{ ok: true, state: "interrupted", interrupt, next, text }`. The same shape
|
|
186
|
+
comes back from the `raft` commands run by the hosted command endpoint, so a
|
|
187
|
+
gateway can handle it without knowing the command (`isInterrupted(outcome)`):
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
interface RaftInterrupt {
|
|
191
|
+
reason: "unread_messages";
|
|
192
|
+
context: string; // what the model reads (the CLI's held text)
|
|
193
|
+
resume: { argv: string[]; idempotencyKey?: string }; // always present
|
|
194
|
+
cancel?: { argv: string[] }; // only when there is something to clean up
|
|
195
|
+
target: string;
|
|
196
|
+
newMessageCount: number;
|
|
197
|
+
heldMessages: RaftMessage[];
|
|
198
|
+
omittedMessageCount: number;
|
|
199
|
+
formalMentionCount: number;
|
|
200
|
+
seenUpToSeq: number | null;
|
|
201
|
+
withheld: boolean; // reviewer isolation: bodies withheld
|
|
202
|
+
contextComplete: boolean; // the preview accounts for every new message
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
- A held send: `resume.argv` is
|
|
207
|
+
`["message", "send", "--send-draft", "--target", T, "--expected-draft-key", K]`
|
|
208
|
+
with `resume.idempotencyKey` = `K`, the original key; `cancel.argv` is the
|
|
209
|
+
same with `--discard-draft`, which clears the saved draft only if it still
|
|
210
|
+
carries `K`.
|
|
211
|
+
- A held claim or task write: `resume.argv` is the identical command; there is
|
|
212
|
+
no `cancel` (nothing was saved). An absent `cancel` means cancelling needs no
|
|
213
|
+
request: just don't execute `resume`.
|
|
214
|
+
- Only the model decides. Show it `interrupt.context`, call
|
|
215
|
+
`frontier.recordHeld(interrupt)` if it saw it (nothing is recorded when the
|
|
216
|
+
context was withheld or `contextComplete` is false; have it read the
|
|
217
|
+
conversation first), then resume or cancel.
|
|
218
|
+
- In-process, the SDK keeps no draft: resuming is calling the same operation
|
|
219
|
+
with the same request (a send under `interrupt.resume.idempotencyKey`), and
|
|
220
|
+
cancelling is not calling it. The argv are the command form a gateway hands
|
|
221
|
+
to the model.
|
|
222
|
+
|
|
178
223
|
Failures are outcomes too (`ok: false`) with a stable `error.code`, the
|
|
179
224
|
Server's `serverCode` when it sent one, `nextAction`, and `retryable`; raw
|
|
180
225
|
bodies and transport causes are never exposed. Message envelopes without a
|