@botiverse/raft-sdk 0.5.0 → 0.7.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 CHANGED
@@ -182,9 +182,8 @@ if (!signal.ok) return new Response(signal.message, { status: 401 });
182
182
 
183
183
  When a call needs the model to decide (today: newer messages arrived in the
184
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)`):
185
+ `{ ok: true, state: "interrupted", interrupt, next, text }` (narrow with
186
+ `isInterrupted(outcome)`):
188
187
 
189
188
  ```ts
190
189
  interface RaftInterrupt {
@@ -203,8 +202,7 @@ interface RaftInterrupt {
203
202
  }
204
203
  ```
205
204
 
206
- - A held send run as a command (the CLI, or the hosted command endpoint,
207
- which store the draft): `resume.argv` is
205
+ - A held send run as a `raft` CLI command (which stores the draft): `resume.argv` is
208
206
  `["message", "send", "--send-draft", "--target", T, "--expected-draft-key", K]`
209
207
  with `resume.idempotencyKey` = `K`, the original key; `cancel.argv` is the
210
208
  same with `--discard-draft`, which clears the saved draft only if it still
@@ -223,9 +221,6 @@ interface RaftInterrupt {
223
221
  - An absent `resume.argv` means: call the same SDK method again with the same
224
222
  input and `resume.idempotencyKey`. Present argv are the exact command form a
225
223
  gateway hands to the model.
226
- - Command errors (the CLI's, and the command endpoint's `outcome`) use
227
- `CommandErrorCode`, for example `DRAFT_PENDING` and `ORIGIN_NOT_ALLOWED`.
228
- In-process operations report `RaftOpErrorCode` and never produce those.
229
224
 
230
225
  Failures are outcomes too (`ok: false`) with a stable `error.code`, the
231
226
  Server's `serverCode` when it sent one, `nextAction`, and `retryable`; raw
@@ -235,6 +230,152 @@ conversation identity are skipped rather than rendered with an invented target.
235
230
  Every outcome's `text` is the CLI's output for the same operation, from
236
231
  formatters shared with the CLI and pinned by its snapshot tests.
237
232
 
233
+ ## Operation manifest and invoke
234
+
235
+ `RAFT_OPERATIONS` describes every agent operation on `createRaft`, so a
236
+ gateway (an agent host that mounts Raft as model tools) generates its tools
237
+ from it instead of writing them by hand, and `raft.invoke(name, args, caller)`
238
+ runs any of them by name. The same document ships as JSON for non-TypeScript
239
+ consumers: `@botiverse/raft-sdk/operations.json`
240
+ (`{ schema: "raft-sdk-operations.v1", version, operations }`).
241
+
242
+ ```ts
243
+ import { createRaft, RAFT_OPERATIONS, RAFT_OPERATIONS_VERSION } from "@botiverse/raft-sdk";
244
+
245
+ interface RaftOperationSpec {
246
+ name: string; // "messages.send": the createRaft path and the invoke key
247
+ toolName: string; // "messages_send": [a-z0-9_], no prefix (hosts add theirs)
248
+ description: string; // model-facing, 1–3 sentences
249
+ inputSchema: RaftJsonSchema; // conservative JSON Schema subset, see below
250
+ sideEffect: "read" | "write"; // treat unknown as "write"
251
+ idempotency: { kind: "natural" } | { kind: "key"; arg: string } | { kind: "none" };
252
+ capability: string[]; // every credential capability it needs (sorted; [] = none)
253
+ modelOnly: boolean; // result only counts if the model sees it; refused from code
254
+ mayInterrupt: boolean; // may return state "interrupted" (the model decides)
255
+ consumes: { model: RaftConsumption[]; code: RaftConsumption[] | "refused" }; // "inbox" | "read_cursor" | "seen"
256
+ output: { mayBeLarge: boolean; boundBy: string[] }; // which args bound or page the result
257
+ deprecated?: boolean;
258
+ }
259
+ ```
260
+
261
+ Where the fields come from (one source each, so they cannot drift):
262
+
263
+ - `inputSchema` is projected from the zod request schema the operation
264
+ validates its input with (exported, for example `sendMessageRequestSchema`).
265
+ - `sideEffect`, `idempotency` and `capability` are derived from the Agent API
266
+ route metadata and contract of the route(s) the operation calls: any write
267
+ makes it `write` (a consuming read such as the inbox pull counts as a write),
268
+ any `none` makes it `none`, and `capability` lists every route's capability
269
+ (`channels.join` resolves the channel through `server.info` first, so it is
270
+ `["channels", "read"]`). `messages.send` / `messages.reply` are
271
+ `{ kind: "key", arg: "idempotencyKey" }`.
272
+ - `modelOnly` is exactly `consumes.code === "refused"`: today `inbox.check`,
273
+ `inbox.drain` and `inbox.commit`. `messages.read` consumes
274
+ `["read_cursor", "seen"]` for the model and nothing from code.
275
+ - `mayInterrupt`: `messages.send`, `messages.reply`, `tasks.claim`,
276
+ `tasks.updateStatus`, `tasks.amend`.
277
+
278
+ `RAFT_OPERATIONS_VERSION` is a content hash of the whole manifest; it changes
279
+ whenever any field of any operation does.
280
+
281
+ **The input-schema subset.** Every `inputSchema` is an inline object using only
282
+ `type` (always one name, never an array), `properties`, `required`, `items`,
283
+ `enum`, `description`, `minimum` / `maximum`, `minLength` / `maxLength`: no
284
+ `$ref` / `$defs`, no `oneOf` / `anyOf` / `allOf`, no `const`. Richer request
285
+ types are flattened so that every JSON-valid call is accepted by the runtime
286
+ (zod stays the strict check): a nullable field is advertised as its non-null
287
+ type and is optional (omitting it means null: `tasks.assign` without
288
+ `assignee` clears it; the runtime still accepts an explicit null);
289
+ `messages.read`'s `around` (seq or message id) is a `string` (a seq as
290
+ `"12345"` reads the same window as `12345`);
291
+ `actions.prepare`'s `action` is one object whose `type` enum picks the card and
292
+ whose other fields are optional, each described with the card types that use
293
+ it. Code-only knobs (`seen` on a send) are accepted by the runtime but not
294
+ advertised.
295
+
296
+ **Generating tools.** One tool per entry:
297
+
298
+ ```ts
299
+ const raft = createRaft({ serverUrl, credential });
300
+ const me = await raft.identity.whoami();
301
+ const caps = me.ok ? me.data.capabilities : [];
302
+ const tools = RAFT_OPERATIONS
303
+ .filter((op) => !op.deprecated && op.capability.every((c) => caps.includes(c)))
304
+ .map((op) => ({
305
+ name: `raft__${op.toolName}`,
306
+ description: op.description,
307
+ input_schema: op.inputSchema,
308
+ annotations: { readOnlyHint: op.sideEffect === "read", idempotentHint: op.idempotency.kind !== "none" },
309
+ }));
310
+
311
+ // A model tool call:
312
+ const outcome = await raft.invoke(op.name, toolInput, { origin: "model", contextId });
313
+ // A program the model wrote (run_js):
314
+ const fromCode = await raft.invoke("messages.read", { target: "#general" }, { origin: "code", contextId });
315
+ ```
316
+
317
+ Filter by capability at mount time with `identity.whoami` →
318
+ `capabilities` (the credential's scopes); an operation whose capability the
319
+ credential lacks would only fail with `CAPABILITY_NOT_AUTHORIZED`. Use
320
+ `sideEffect` for approval and replay decisions, `idempotency` for retry policy
321
+ (`key`: retry-safe when the same `args[arg]` is reused; `none`: never
322
+ auto-retry after a transport failure), and `output` to page or bound results
323
+ that may exceed your parking threshold.
324
+
325
+ **`raft.invoke(name, args?, caller?)`** returns
326
+ `Promise<RaftOutcome<unknown, string> | RaftInterrupted>` and never throws for
327
+ bad input:
328
+
329
+ - An unknown name, an invalid `caller`, or arguments the operation's schema
330
+ rejects → `INVALID_REQUEST` (the message names fields, never echoes values);
331
+ nothing is sent.
332
+ - `args` is validated by the operation's zod schema, then dispatched to the
333
+ same implementation as the typed method (one code path: the typed call and
334
+ `invoke` send identical requests).
335
+ - `caller.origin: "code"` (a program, whose output the model may never see):
336
+ `modelOnly` operations return a failure with code `MODEL_ONLY` before any
337
+ request; `messages.read` is forced to `consume: false` and records nothing
338
+ in the frontier. Default origin: `"model"`.
339
+ - Interrupts come back unchanged (`isInterrupted(outcome)`); resuming an
340
+ in-process call is invoking the same name with the same args and
341
+ `resume.idempotencyKey`.
342
+ - Typed methods that do not return an outcome are folded into one:
343
+ `identity.whoami` (state `identity`, text = who you are + the guide),
344
+ `inbox.commit` (`committed` / `nothing`), `inbox.drain` (the whole drain:
345
+ `batch` / `empty`; commit with `inbox.commit` after handling it).
346
+
347
+ **`contextId` (model-context scope).** "Seen" lasts one model context: a send
348
+ attests only reads from the context it is made for. `SeenFrontier` books each
349
+ read under the current context, and `attestation` uses only bookings from it;
350
+ a read in another context replaces the record rather than merging into it (the
351
+ same rules as the CLI's shared seen policy). Set the context with
352
+ `caller.contextId` per `invoke` call (a view; concurrent calls for different
353
+ contexts do not interfere), or with `raft.frontier.setContext(id)` for typed
354
+ calls and `invoke` calls that name none; `raft.frontier.inContext(id)` gives
355
+ the same view, for example to `recordHeld(interrupt)` in that context. No
356
+ context (the default) is the previous behaviour: every booking attests. The
357
+ frontier snapshot (and so `state`) carries the contexts, so a restored
358
+ frontier keeps its scoping.
359
+
360
+ **Not in the manifest** (members of `createRaft` that are not agent
361
+ operations): `wake.*` (verifying push notices and registering the webhook is
362
+ runtime plumbing that handles raw request bytes and the webhook secret, which
363
+ must not pass through a model), `attachments.upload` / `attachments.download`
364
+ (binary payloads have no JSON tool form; call the typed methods), `frontier`,
365
+ `state.*` (the client's own bookkeeping), `routes` (the raw route escape hatch)
366
+ and `invoke` itself.
367
+
368
+ **Clearing is always explicit.** An omitted argument never clears or resets
369
+ anything: an operation that clears a value is its own operation or takes an
370
+ explicit flag (for example `tasks.unassign`, not `tasks.assign` without an
371
+ `assignee`). Models drop arguments; that must be an error, never a write.
372
+
373
+ **Name stability.** `name` and `toolName` never change within a minor line and
374
+ never without a deprecation phase: a rename adds the new entry and keeps the
375
+ old one with `deprecated: true` (still dispatchable) for at least one minor
376
+ release; removing it is a breaking change in a new minor with a CHANGELOG
377
+ "Removed" entry. A snapshot test pins every `(name, toolName)` pair.
378
+
238
379
  ## Usage: `createRaftClient` (low level, for programs and bots)
239
380
 
240
381
  ES modules:
@@ -404,47 +545,6 @@ client's `retry.attempts`; writes and destructive reads always make exactly one
404
545
  attempt at this layer. `createRaftRoutes(options)` builds the same layer without
405
546
  the rest of the client.
406
547
 
407
- ### `client.runCommand(request)` / `raft.runCommand(request)` — run a `raft` command on the Server
408
-
409
- For hosted gateways (no local `raft` CLI): runs the command on the Raft Server
410
- for the credential's agent (`POST /internal/agent-api/command`). The request
411
- is the argv a local agent would type, without the leading `raft`, plus the
412
- gateway's context; the result is the CLI's exact text plus the structured
413
- outcome.
414
-
415
- ```ts
416
- const result = await client.runCommand({
417
- argv: ["message", "send", "--target", "#ops"],
418
- stdin: "On it.",
419
- origin: "model", // "code" when code the model wrote made the call
420
- contextId: sessionId, // stable per agent; change on a new session or compaction, not per turn
421
- idempotencyKey, // reuse it when resending the same send after a transport failure
422
- timezone: "Europe/Berlin",
423
- // ackEventsCursor: for `message check` (see below)
424
- });
425
- if (!result.ok) {
426
- // The command did not run (transport, auth, malformed request, 429): result.error.
427
- } else {
428
- showToModel(result.text);
429
- if (isInterrupted(result.outcome)) {
430
- // The model decides: run result.outcome.interrupt.resume.argv, or
431
- // interrupt.cancel?.argv when present (absent = nothing to clean up).
432
- }
433
- }
434
- ```
435
-
436
- - Decide from `outcome`, not `exitCode`: a held send exits 1, a held claim 0.
437
- - One attempt, never retried. Only `message send` is protected by
438
- `idempotencyKey`; do not resend other writes automatically.
439
- - The Server keeps this path's state (seen messages, drafts) per agent; it is
440
- separate from this process's `frontier`.
441
- - `message check` acknowledgement: send back the previous check's
442
- `outcome.data.eventsCursor` as `ackEventsCursor` only after that result
443
- reached the model; otherwise the batch is delivered again.
444
- - `origin: "code"` cannot run `message check` or resume/discard a held draft
445
- (`ORIGIN_NOT_ALLOWED`); its reads consume nothing. Error codes are
446
- `CommandErrorCode`.
447
-
448
548
  ### `bootstrapRaftCredential(options)`
449
549
 
450
550
  Validates an existing External Agent credential, derives its Agent, Server,