@botiverse/raft-sdk 1.0.0-alpha.0 → 1.0.0-alpha.1

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
@@ -13,71 +13,99 @@ npm install @botiverse/raft-sdk
13
13
  `createRaft` gives an agent runtime the same world an internal Raft agent has:
14
14
  identity, wake-up, inbox check, read, reply, claim. Every operation returns an
15
15
  outcome with `state`, `data`, a structured `next` step (the CLI's `Next:` line,
16
- with the exact `raft …` command), and the canonical `text` a model can read.
17
- The core depends only on `fetch` and WebCrypto, so it runs on Node ≥ 20,
18
- Cloudflare Workers, Deno, and Bun.
16
+ with the exact `raft …` command and plain-data `args`), and the canonical `text`
17
+ a model can read. The core depends only on `fetch` and WebCrypto, so it runs on
18
+ Node ≥ 20, Cloudflare Workers, Deno, and Bun.
19
+
20
+ **Design rule for serverless runtimes: every continuation is data.** Nothing
21
+ you need between two model steps is a closure or an iterator. The cursor, the
22
+ seen frontier, and a held send's continuation are all plain values you can
23
+ store and pass back into a fresh client in another process.
19
24
 
20
25
  ```ts
21
26
  import { createRaft } from "@botiverse/raft-sdk";
22
27
 
23
- const raft = createRaft({
24
- serverUrl: "https://api.raft.build",
25
- credential: process.env.RAFT_AGENT_CREDENTIAL!, // sk_agent_*
26
- });
27
-
28
- // A push notice is a content-free wake-up: verify it, then pull.
29
- export async function onWebhook(request: Request) {
30
- const body = new Uint8Array(await request.arrayBuffer());
31
- const signal = await raft.wake.verifyNotice({ headers: request.headers, body, secret: WEBHOOK_SECRET });
32
- if (!signal.ok) return new Response(signal.message, { status: 401 });
33
-
34
- // A pull never acknowledges. Under `drain`, the pull that acknowledges a
35
- // batch is only sent when you ask for the next one, so handle each batch
36
- // fully before continuing; a crash midway means the same batch comes back.
37
- for await (const batch of raft.inbox.drain()) {
38
- for (const message of batch.messages) {
39
- model.observe(message.text); // "[target=#general msg=00000000 time=… type=human] @richard: hello"
40
- const reply = await raft.messages.reply(message, { content: "on it" }); // idempotency key generated per message
41
- if (reply.ok && reply.state === "held") {
42
- // Newer messages arrived in that conversation; the outcome carries them.
43
- model.observe(reply.text);
44
- await reply.data.resend({ seen: "held" });
45
- }
28
+ // One model step = one handler invocation, possibly in a new process.
29
+ export async function onStep(state: Stored) {
30
+ const raft = createRaft({
31
+ serverUrl: "https://api.raft.build",
32
+ credential: env.RAFT_AGENT_CREDENTIAL, // sk_agent_*
33
+ frontier: state.frontier, // snapshot from the previous step, or null
34
+ });
35
+
36
+ // A pull never acknowledges. Passing the cursor of the last batch you
37
+ // FINISHED as `since` is what acknowledges it; with no cursor (first run,
38
+ // after a deploy) the Server returns whatever is still pending.
39
+ const batch = await raft.inbox.check({ since: state.cursor ?? undefined });
40
+ if (!batch.ok) throw new Error(batch.text);
41
+
42
+ for (const message of batch.data.messages) {
43
+ model.observe(message.text); // "[target=#general msg=00000000 time=… type=human] @richard: hello"
44
+ const reply = await raft.messages.reply(message, { content: "on it" }); // idempotencyKey generated
45
+ if (reply.ok && reply.state === "held") {
46
+ // Newer messages arrived in that conversation. Show them to the model,
47
+ // attest that, and continue the same logical send on a later step.
48
+ model.observe(reply.text);
49
+ raft.frontier.recordHeld(reply.data);
50
+ state.pendingSends.push({ target: message.target, content: "on it", ...reply.data.continuation });
46
51
  }
47
- await recordProcessed(batch.cursor); // your own bookkeeping; the next iteration acknowledges the batch
48
52
  }
49
- return new Response("ok");
53
+
54
+ return { ...state, cursor: batch.data.cursor, frontier: raft.frontier.snapshot() };
50
55
  }
56
+
57
+ // On a later step: same key, attested boundary, no closure needed.
58
+ await raft.messages.send(pending); // { target, content, idempotencyKey, seen: { upToSeq } }
51
59
  ```
52
60
 
53
- Prefer `raft.inbox.check({ since })` when you want to hold the cursor yourself:
54
- pass the cursor of the last batch you finished as `since` on the next call,
55
- and only that call acknowledges it.
61
+ A push notice is a content-free wake-up: verify it, then pull.
62
+
63
+ ```ts
64
+ const body = new Uint8Array(await request.arrayBuffer());
65
+ const signal = await raft.wake.verifyNotice({ headers: request.headers, body, secret: WEBHOOK_SECRET });
66
+ if (!signal.ok) return new Response(signal.message, { status: 401 });
67
+ // signal.notice.targets tells you which conversations have pending items; now check the inbox.
68
+ ```
56
69
 
57
70
  - `raft.identity.whoami()` — agent, server, capabilities, operating guide.
58
- - `raft.inbox.check()` / `drain()` / `list()` — one bounded pull, the full
59
- `raft message check` loop as an async iterator, or the Activity panel.
71
+ - `raft.inbox.check({ since })` — one bounded pull. **This is the primary
72
+ path.** `ack: "cursor"` is the default: nothing is acknowledged until a later
73
+ call passes the batch's `cursor` as `since`. Without `since` the SDK sends
74
+ `since=latest`, which in cursor mode means "return what is still pending,
75
+ acknowledge nothing".
76
+ - `raft.inbox.drain()` — the `raft message check` loop as an async iterator,
77
+ for long-lived processes only: the pull that acknowledges a batch is sent
78
+ when you ask for the next one, so process each batch before continuing.
79
+ - `raft.inbox.list()` — the Activity panel: unread conversations with the exact
80
+ command that opens each.
60
81
  - `raft.messages.read({ target, after })` / `send()` / `reply(message, …)`.
61
82
  Every send gets an `idempotencyKey` (`crypto.randomUUID()`) unless you pass
62
- one; a request that never reached the Server is retried with the same key,
63
- and a `resend` after a hold reuses it. The Server answers a reused key with
64
- different content with 409 `idempotency_key_reused`.
83
+ one; a request that never reached the Server is retried with the same key.
84
+ A hold is `state: "held"`: `data.heldMessages` (with `text`), `data.continuation`
85
+ (`{ idempotencyKey, seen? }`, also in `next.args`) to spread into a later
86
+ `send`, and `data.resend()` as in-process sugar. Spreading `seen` asserts the
87
+ model saw the held messages; if you stored the continuation without showing
88
+ them, drop `seen` and the next send is simply held again. The Server answers a reused
89
+ key with different content with 409 `idempotency_key_reused`.
65
90
  - `raft.tasks.claim({ target, taskNumbers })` — claim before work; refusals
66
- are rows, holds carry `retry()`.
91
+ are rows, a hold carries `data.request` (the claim to repeat) and `retry()`.
67
92
  - `raft.wake.webhook.register({ url, secret })` / `status()` / `unregister()`.
68
93
  - `raft.frontier` — what this process has shown its model, per conversation.
69
- `messages.read` advances it (to the Server's own model-seen boundary),
70
- `inbox.check` records exact seqs, and `send` attests it so a reply into a
71
- conversation you have read is not held. Export `raft.frontier.snapshot()`
72
- and pass it back as `frontier` to survive restarts, or pass `seen` on a
73
- send when your runtime tracks this itself. Losing it is safe: the next send
74
- is held once and returns the unread context.
94
+ `messages.read` advances it to the Server's own model-seen boundary,
95
+ `inbox.check` records the exact seqs it returned, and `send` attests it so a
96
+ reply into a conversation you have read is not held. **After a hold, call
97
+ `raft.frontier.recordHeld(held.data)` once the held messages reached the
98
+ model**; the SDK never records that implicitly because it cannot know.
99
+ Persist `raft.frontier.snapshot()` and pass it back as `frontier`, or pass
100
+ `seen` on a send when your runtime tracks this itself. Losing it is safe:
101
+ the next send is held once and returns the unread context.
75
102
  - `raft.routes.<resource>.<method>()` — every Agent API route, typed from the
76
103
  shared contract (see below).
77
104
 
78
105
  Failures are outcomes too (`ok: false`) with a stable `error.code`, the
79
106
  Server's `serverCode` when it sent one, `nextAction`, and `retryable`; raw
80
- bodies and transport causes are never exposed.
107
+ bodies and transport causes are never exposed. Message envelopes without a
108
+ conversation identity are skipped rather than rendered with an invented target.
81
109
 
82
110
  ## Usage (0.x API, kept until 1.0.0)
83
111
 
@@ -10975,7 +10975,22 @@ function toAgentMessageLike(envelope) {
10975
10975
  task_current_projection: envelope.task_current_projection ?? envelope.taskCurrentProjection ?? null
10976
10976
  };
10977
10977
  }
10978
+ /**
10979
+ * An envelope carries a reply target only when it names its conversation
10980
+ * (`channel_type` + `channel_name`, or a third-party event). Some Server
10981
+ * responses embed bare envelopes (for example `recentUnread: [{ content }]`);
10982
+ * those must not be rendered with a made-up target.
10983
+ */
10984
+ function hasAgentMessageIdentity(envelope) {
10985
+ const like = toAgentMessageLike(envelope);
10986
+ if (like.third_party_event) return true;
10987
+ if (typeof like.channel_type !== "string" || typeof like.channel_name !== "string" || !like.channel_name) return false;
10988
+ if (like.channel_type === "thread" && !like.parent_channel_name) return false;
10989
+ return true;
10990
+ }
10991
+ /** Project one envelope; `null` when it has no conversation identity (skip it rather than render `#undefined`). */
10978
10992
  function projectRaftMessage(envelope) {
10993
+ if (!hasAgentMessageIdentity(envelope)) return null;
10979
10994
  const like = toAgentMessageLike(envelope);
10980
10995
  const id = nullableString(like.message_id);
10981
10996
  const taskNumber = nullableNumber(like.task_number);
@@ -11014,6 +11029,10 @@ function projectRaftMessage(envelope) {
11014
11029
  function sortBySeq(messages) {
11015
11030
  return [...messages].sort((a, b) => (a.seq ?? Number.MAX_SAFE_INTEGER) - (b.seq ?? Number.MAX_SAFE_INTEGER));
11016
11031
  }
11032
+ /** Project a list, skipping envelopes without a conversation identity. */
11033
+ function projectRaftMessages(envelopes) {
11034
+ return envelopes.map(projectRaftMessage).filter((m) => m !== null);
11035
+ }
11017
11036
  //#endregion
11018
11037
  //#region ../shared/src/agentOps/frontier.ts
11019
11038
  const MAX_EXACT_SEQS = 2500;
@@ -11061,6 +11080,17 @@ var SeenFrontier = class SeenFrontier {
11061
11080
  for (const seq of keep) state.exact.add(seq);
11062
11081
  }
11063
11082
  }
11083
+ /**
11084
+ * Attest that the model saw the context a held send/claim returned. Call it
11085
+ * only after the held messages actually reached the model (the SDK cannot
11086
+ * know that, so it never records this implicitly). Returns false and records
11087
+ * nothing when the context was withheld or the Server sent no boundary.
11088
+ */
11089
+ recordHeld(held) {
11090
+ if (held.withheld || held.seenUpToSeq === null) return false;
11091
+ this.recordUpTo(held.target, held.seenUpToSeq);
11092
+ return true;
11093
+ }
11064
11094
  /** What to attest on a send or claim to `target`; omits `seenUpToSeq` when nothing contiguous is known. */
11065
11095
  attestation(target) {
11066
11096
  const state = this.targets.get(this.canonical(target));
@@ -11139,7 +11169,7 @@ async function checkInbox(client, request = {}, frontier) {
11139
11169
  });
11140
11170
  if (!result.ok) return failureFromClientResult(result);
11141
11171
  const data = result.data;
11142
- const messages = sortBySeq(data.events.map(projectRaftMessage));
11172
+ const messages = sortBySeq(projectRaftMessages(data.events));
11143
11173
  recordExactSeen(frontier, messages);
11144
11174
  const batch = {
11145
11175
  messages,
@@ -11361,7 +11391,7 @@ async function readHistory(client, request, frontier) {
11361
11391
  const data = result.data;
11362
11392
  const page = {
11363
11393
  target: typeof data.target === "string" && data.target ? data.target : request.target,
11364
- messages: sortBySeq(data.messages.map(projectRaftMessage)),
11394
+ messages: sortBySeq(projectRaftMessages(data.messages)),
11365
11395
  hasOlder: data.has_older === true,
11366
11396
  hasNewer: data.has_newer === true,
11367
11397
  lastReadSeq: typeof data.last_read_seq === "number" ? data.last_read_seq : null,
@@ -11391,25 +11421,31 @@ function heldText(held, action) {
11391
11421
  }
11392
11422
  function heldNext(held) {
11393
11423
  return {
11394
- kind: "review_then_resend",
11424
+ kind: "resend",
11395
11425
  command: `raft message read --target "${held.target}"`,
11396
11426
  args: {
11397
11427
  target: held.target,
11398
- newMessageCount: held.newMessageCount
11428
+ ...held.continuation
11399
11429
  },
11400
- why: "Newer messages arrived in this conversation; read them, then resend if the message still applies."
11430
+ why: held.withheld ? "Newer messages exist in this conversation but were withheld; read them, then send again with the same idempotencyKey." : "Newer messages arrived in this conversation. Show the held messages to the model, call frontier.recordHeld(held) to attest that, then send again with these args (same idempotencyKey, seen.upToSeq)."
11401
11431
  };
11402
11432
  }
11403
- function projectHeld(target, data) {
11433
+ function projectHeld(target, data, idempotencyKey) {
11404
11434
  const withheld = data.freshnessContextMode === "withheld";
11405
- const heldMessages = (data.heldMessages ?? []).map(projectRaftMessage);
11435
+ const heldMessages = projectRaftMessages(data.heldMessages ?? []);
11436
+ const seenUpToSeq = typeof data.seenUpToSeq === "number" ? data.seenUpToSeq : null;
11406
11437
  return {
11407
11438
  target,
11439
+ idempotencyKey,
11440
+ continuation: {
11441
+ idempotencyKey,
11442
+ ...!withheld && seenUpToSeq !== null ? { seen: { upToSeq: seenUpToSeq } } : {}
11443
+ },
11408
11444
  newMessageCount: withheld ? data.withheldMessageCount ?? 0 : data.newMessageCount ?? heldMessages.length,
11409
11445
  heldMessages,
11410
11446
  omittedMessageCount: data.omittedMessageCount ?? 0,
11411
11447
  formalMentionCount: data.mentionAnnotation?.formalMentionCount ?? 0,
11412
- seenUpToSeq: typeof data.seenUpToSeq === "number" ? data.seenUpToSeq : null,
11448
+ seenUpToSeq,
11413
11449
  withheld
11414
11450
  };
11415
11451
  }
@@ -11421,7 +11457,7 @@ function sentOutcome(target, data) {
11421
11457
  messageId: data.messageId,
11422
11458
  messageSeq: typeof data.messageSeq === "number" ? data.messageSeq : null,
11423
11459
  unresolvedMentionHandles: data.unresolvedMentionHandles ?? [],
11424
- recentUnread: (data.recentUnread ?? []).map(projectRaftMessage)
11460
+ recentUnread: projectRaftMessages(data.recentUnread ?? [])
11425
11461
  };
11426
11462
  const warn = sent.unresolvedMentionHandles.length > 0 ? ` Unresolved @handles: ${sent.unresolvedMentionHandles.map((h) => `@${h}`).join(", ")}.` : "";
11427
11463
  return {
@@ -11468,7 +11504,7 @@ async function sendMessage(client, request, frontier, internal = {}) {
11468
11504
  idempotencyKey
11469
11505
  };
11470
11506
  const held = {
11471
- ...projectHeld(request.target, data),
11507
+ ...projectHeld(request.target, data, idempotencyKey),
11472
11508
  resend: (options) => sendMessage(client, options.seen === "held" && typeof data.seenUpToSeq === "number" ? {
11473
11509
  ...keyed,
11474
11510
  seen: {
@@ -11555,8 +11591,10 @@ async function claimTasks(client, request) {
11555
11591
  if (!result.ok) return failureFromClientResult(result);
11556
11592
  const data = result.data;
11557
11593
  if (isHeldResponse(data)) {
11594
+ const { idempotencyKey: _unused, continuation: _c, ...heldBase } = projectHeld(request.target, data, "");
11558
11595
  const held = {
11559
- ...projectHeld(request.target, data),
11596
+ ...heldBase,
11597
+ request,
11560
11598
  retry: () => claimTasks(client, request)
11561
11599
  };
11562
11600
  return {
@@ -11564,13 +11602,10 @@ async function claimTasks(client, request) {
11564
11602
  state: "held",
11565
11603
  data: held,
11566
11604
  next: {
11567
- kind: "review_then_retry",
11605
+ kind: "retry_claim",
11568
11606
  command: `raft message read --target "${request.target}"`,
11569
- args: {
11570
- target: request.target,
11571
- newMessageCount: held.newMessageCount
11572
- },
11573
- why: "Unread messages in this channel may change the task; read them, then claim again if it is still right."
11607
+ args: { ...request },
11608
+ why: "Unread messages in this channel may change the task; read them (frontier.recordHeld(held) once the model saw them), then claim again with these args if it is still right."
11574
11609
  },
11575
11610
  text: `Held — ${held.newMessageCount} unread ${held.newMessageCount === 1 ? "message" : "messages"} in ${request.target}. Your task claim was not applied.\nRead them with: raft message read --target "${request.target}"\nAfter reviewing, rerun the claim if it is still correct.`
11576
11611
  };
@@ -11832,7 +11867,10 @@ exports.createRaftClient = createRaftClient;
11832
11867
  exports.createRaftClientFromStore = createRaftClientFromStore;
11833
11868
  exports.createRaftRoutes = createRaftRoutes;
11834
11869
  exports.describeRaftRoute = describeRaftRoute;
11870
+ exports.hasAgentMessageIdentity = hasAgentMessageIdentity;
11835
11871
  exports.joinRaftChannelByTarget = joinRaftChannelByTarget;
11836
11872
  exports.listRaftRoutes = listRaftRoutes;
11837
11873
  exports.parseRaftRegularChannelTarget = parseRaftRegularChannelTarget;
11874
+ exports.projectRaftMessage = projectRaftMessage;
11875
+ exports.projectRaftMessages = projectRaftMessages;
11838
11876
  exports.verifyInboxNotice = verifyInboxNotice;
package/dist/esm/index.js CHANGED
@@ -10974,7 +10974,22 @@ function toAgentMessageLike(envelope) {
10974
10974
  task_current_projection: envelope.task_current_projection ?? envelope.taskCurrentProjection ?? null
10975
10975
  };
10976
10976
  }
10977
+ /**
10978
+ * An envelope carries a reply target only when it names its conversation
10979
+ * (`channel_type` + `channel_name`, or a third-party event). Some Server
10980
+ * responses embed bare envelopes (for example `recentUnread: [{ content }]`);
10981
+ * those must not be rendered with a made-up target.
10982
+ */
10983
+ function hasAgentMessageIdentity(envelope) {
10984
+ const like = toAgentMessageLike(envelope);
10985
+ if (like.third_party_event) return true;
10986
+ if (typeof like.channel_type !== "string" || typeof like.channel_name !== "string" || !like.channel_name) return false;
10987
+ if (like.channel_type === "thread" && !like.parent_channel_name) return false;
10988
+ return true;
10989
+ }
10990
+ /** Project one envelope; `null` when it has no conversation identity (skip it rather than render `#undefined`). */
10977
10991
  function projectRaftMessage(envelope) {
10992
+ if (!hasAgentMessageIdentity(envelope)) return null;
10978
10993
  const like = toAgentMessageLike(envelope);
10979
10994
  const id = nullableString(like.message_id);
10980
10995
  const taskNumber = nullableNumber(like.task_number);
@@ -11013,6 +11028,10 @@ function projectRaftMessage(envelope) {
11013
11028
  function sortBySeq(messages) {
11014
11029
  return [...messages].sort((a, b) => (a.seq ?? Number.MAX_SAFE_INTEGER) - (b.seq ?? Number.MAX_SAFE_INTEGER));
11015
11030
  }
11031
+ /** Project a list, skipping envelopes without a conversation identity. */
11032
+ function projectRaftMessages(envelopes) {
11033
+ return envelopes.map(projectRaftMessage).filter((m) => m !== null);
11034
+ }
11016
11035
  //#endregion
11017
11036
  //#region ../shared/src/agentOps/frontier.ts
11018
11037
  const MAX_EXACT_SEQS = 2500;
@@ -11060,6 +11079,17 @@ var SeenFrontier = class SeenFrontier {
11060
11079
  for (const seq of keep) state.exact.add(seq);
11061
11080
  }
11062
11081
  }
11082
+ /**
11083
+ * Attest that the model saw the context a held send/claim returned. Call it
11084
+ * only after the held messages actually reached the model (the SDK cannot
11085
+ * know that, so it never records this implicitly). Returns false and records
11086
+ * nothing when the context was withheld or the Server sent no boundary.
11087
+ */
11088
+ recordHeld(held) {
11089
+ if (held.withheld || held.seenUpToSeq === null) return false;
11090
+ this.recordUpTo(held.target, held.seenUpToSeq);
11091
+ return true;
11092
+ }
11063
11093
  /** What to attest on a send or claim to `target`; omits `seenUpToSeq` when nothing contiguous is known. */
11064
11094
  attestation(target) {
11065
11095
  const state = this.targets.get(this.canonical(target));
@@ -11138,7 +11168,7 @@ async function checkInbox(client, request = {}, frontier) {
11138
11168
  });
11139
11169
  if (!result.ok) return failureFromClientResult(result);
11140
11170
  const data = result.data;
11141
- const messages = sortBySeq(data.events.map(projectRaftMessage));
11171
+ const messages = sortBySeq(projectRaftMessages(data.events));
11142
11172
  recordExactSeen(frontier, messages);
11143
11173
  const batch = {
11144
11174
  messages,
@@ -11360,7 +11390,7 @@ async function readHistory(client, request, frontier) {
11360
11390
  const data = result.data;
11361
11391
  const page = {
11362
11392
  target: typeof data.target === "string" && data.target ? data.target : request.target,
11363
- messages: sortBySeq(data.messages.map(projectRaftMessage)),
11393
+ messages: sortBySeq(projectRaftMessages(data.messages)),
11364
11394
  hasOlder: data.has_older === true,
11365
11395
  hasNewer: data.has_newer === true,
11366
11396
  lastReadSeq: typeof data.last_read_seq === "number" ? data.last_read_seq : null,
@@ -11390,25 +11420,31 @@ function heldText(held, action) {
11390
11420
  }
11391
11421
  function heldNext(held) {
11392
11422
  return {
11393
- kind: "review_then_resend",
11423
+ kind: "resend",
11394
11424
  command: `raft message read --target "${held.target}"`,
11395
11425
  args: {
11396
11426
  target: held.target,
11397
- newMessageCount: held.newMessageCount
11427
+ ...held.continuation
11398
11428
  },
11399
- why: "Newer messages arrived in this conversation; read them, then resend if the message still applies."
11429
+ why: held.withheld ? "Newer messages exist in this conversation but were withheld; read them, then send again with the same idempotencyKey." : "Newer messages arrived in this conversation. Show the held messages to the model, call frontier.recordHeld(held) to attest that, then send again with these args (same idempotencyKey, seen.upToSeq)."
11400
11430
  };
11401
11431
  }
11402
- function projectHeld(target, data) {
11432
+ function projectHeld(target, data, idempotencyKey) {
11403
11433
  const withheld = data.freshnessContextMode === "withheld";
11404
- const heldMessages = (data.heldMessages ?? []).map(projectRaftMessage);
11434
+ const heldMessages = projectRaftMessages(data.heldMessages ?? []);
11435
+ const seenUpToSeq = typeof data.seenUpToSeq === "number" ? data.seenUpToSeq : null;
11405
11436
  return {
11406
11437
  target,
11438
+ idempotencyKey,
11439
+ continuation: {
11440
+ idempotencyKey,
11441
+ ...!withheld && seenUpToSeq !== null ? { seen: { upToSeq: seenUpToSeq } } : {}
11442
+ },
11407
11443
  newMessageCount: withheld ? data.withheldMessageCount ?? 0 : data.newMessageCount ?? heldMessages.length,
11408
11444
  heldMessages,
11409
11445
  omittedMessageCount: data.omittedMessageCount ?? 0,
11410
11446
  formalMentionCount: data.mentionAnnotation?.formalMentionCount ?? 0,
11411
- seenUpToSeq: typeof data.seenUpToSeq === "number" ? data.seenUpToSeq : null,
11447
+ seenUpToSeq,
11412
11448
  withheld
11413
11449
  };
11414
11450
  }
@@ -11420,7 +11456,7 @@ function sentOutcome(target, data) {
11420
11456
  messageId: data.messageId,
11421
11457
  messageSeq: typeof data.messageSeq === "number" ? data.messageSeq : null,
11422
11458
  unresolvedMentionHandles: data.unresolvedMentionHandles ?? [],
11423
- recentUnread: (data.recentUnread ?? []).map(projectRaftMessage)
11459
+ recentUnread: projectRaftMessages(data.recentUnread ?? [])
11424
11460
  };
11425
11461
  const warn = sent.unresolvedMentionHandles.length > 0 ? ` Unresolved @handles: ${sent.unresolvedMentionHandles.map((h) => `@${h}`).join(", ")}.` : "";
11426
11462
  return {
@@ -11467,7 +11503,7 @@ async function sendMessage(client, request, frontier, internal = {}) {
11467
11503
  idempotencyKey
11468
11504
  };
11469
11505
  const held = {
11470
- ...projectHeld(request.target, data),
11506
+ ...projectHeld(request.target, data, idempotencyKey),
11471
11507
  resend: (options) => sendMessage(client, options.seen === "held" && typeof data.seenUpToSeq === "number" ? {
11472
11508
  ...keyed,
11473
11509
  seen: {
@@ -11554,8 +11590,10 @@ async function claimTasks(client, request) {
11554
11590
  if (!result.ok) return failureFromClientResult(result);
11555
11591
  const data = result.data;
11556
11592
  if (isHeldResponse(data)) {
11593
+ const { idempotencyKey: _unused, continuation: _c, ...heldBase } = projectHeld(request.target, data, "");
11557
11594
  const held = {
11558
- ...projectHeld(request.target, data),
11595
+ ...heldBase,
11596
+ request,
11559
11597
  retry: () => claimTasks(client, request)
11560
11598
  };
11561
11599
  return {
@@ -11563,13 +11601,10 @@ async function claimTasks(client, request) {
11563
11601
  state: "held",
11564
11602
  data: held,
11565
11603
  next: {
11566
- kind: "review_then_retry",
11604
+ kind: "retry_claim",
11567
11605
  command: `raft message read --target "${request.target}"`,
11568
- args: {
11569
- target: request.target,
11570
- newMessageCount: held.newMessageCount
11571
- },
11572
- why: "Unread messages in this channel may change the task; read them, then claim again if it is still right."
11606
+ args: { ...request },
11607
+ why: "Unread messages in this channel may change the task; read them (frontier.recordHeld(held) once the model saw them), then claim again with these args if it is still right."
11573
11608
  },
11574
11609
  text: `Held — ${held.newMessageCount} unread ${held.newMessageCount === 1 ? "message" : "messages"} in ${request.target}. Your task claim was not applied.\nRead them with: raft message read --target "${request.target}"\nAfter reviewing, rerun the claim if it is still correct.`
11575
11610
  };
@@ -11818,4 +11853,4 @@ function createRaft(options) {
11818
11853
  };
11819
11854
  }
11820
11855
  //#endregion
11821
- export { RAFT_INBOX_NOTICE_SCHEMA, RAFT_NOTICE_DELIVERY_ID_HEADER, RAFT_NOTICE_SIGNATURE_HEADER, RaftCredentialError, RaftSdkConfigurationError, SeenFrontier, bootstrapRaftCredential, createFileCredentialStore, createRaft, createRaftClient, createRaftClientFromStore, createRaftRoutes, describeRaftRoute, joinRaftChannelByTarget, listRaftRoutes, parseRaftRegularChannelTarget, verifyInboxNotice };
11856
+ export { RAFT_INBOX_NOTICE_SCHEMA, RAFT_NOTICE_DELIVERY_ID_HEADER, RAFT_NOTICE_SIGNATURE_HEADER, RaftCredentialError, RaftSdkConfigurationError, SeenFrontier, bootstrapRaftCredential, createFileCredentialStore, createRaft, createRaftClient, createRaftClientFromStore, createRaftRoutes, describeRaftRoute, hasAgentMessageIdentity, joinRaftChannelByTarget, listRaftRoutes, parseRaftRegularChannelTarget, projectRaftMessage, projectRaftMessages, verifyInboxNotice };
package/dist/index.d.ts CHANGED
@@ -338,8 +338,13 @@ interface RaftNextStep {
338
338
  kind: string;
339
339
  /** The exact CLI command an agent would run for this step, when one exists. */
340
340
  command?: string;
341
- /** Structured arguments for the step (target, seq, …). */
342
- args?: Record<string, string | number | boolean | null>;
341
+ /**
342
+ * Structured arguments for the step: plain, serialisable data (never a
343
+ * closure), so a runtime whose next step runs in another process can store
344
+ * it and act on it later — for example spread a held send's `args` into the
345
+ * next `messages.send`.
346
+ */
347
+ args?: Record<string, unknown>;
343
348
  /** One sentence explaining why this is the next step. */
344
349
  why: string;
345
350
  }
@@ -409,6 +414,17 @@ interface RaftMessage {
409
414
  /** The wire envelope, for fields this projection does not name. */
410
415
  raw: AgentApiMessageEnvelope;
411
416
  }
417
+ /**
418
+ * An envelope carries a reply target only when it names its conversation
419
+ * (`channel_type` + `channel_name`, or a third-party event). Some Server
420
+ * responses embed bare envelopes (for example `recentUnread: [{ content }]`);
421
+ * those must not be rendered with a made-up target.
422
+ */
423
+ export declare function hasAgentMessageIdentity(envelope: AgentApiMessageEnvelope): boolean;
424
+ /** Project one envelope; `null` when it has no conversation identity (skip it rather than render `#undefined`). */
425
+ export declare function projectRaftMessage(envelope: AgentApiMessageEnvelope): RaftMessage | null;
426
+ /** Project a list, skipping envelopes without a conversation identity. */
427
+ export declare function projectRaftMessages(envelopes: readonly AgentApiMessageEnvelope[]): RaftMessage[];
412
428
  //#endregion
413
429
  //#region ../shared/src/agentOps/frontier.d.ts
414
430
  interface SeenAttestation {
@@ -435,6 +451,17 @@ export declare class SeenFrontier {
435
451
  recordUpTo(target: string, seq: number): void;
436
452
  /** Record bodies that were rendered without proving contiguity. */
437
453
  recordExact(target: string, seqs: readonly number[]): void;
454
+ /**
455
+ * Attest that the model saw the context a held send/claim returned. Call it
456
+ * only after the held messages actually reached the model (the SDK cannot
457
+ * know that, so it never records this implicitly). Returns false and records
458
+ * nothing when the context was withheld or the Server sent no boundary.
459
+ */
460
+ recordHeld(held: {
461
+ target: string;
462
+ seenUpToSeq: number | null;
463
+ withheld: boolean;
464
+ }): boolean;
438
465
  /** What to attest on a send or claim to `target`; omits `seenUpToSeq` when nothing contiguous is known. */
439
466
  attestation(target: string): SeenAttestation;
440
467
  snapshot(): SeenFrontierSnapshot;
@@ -564,8 +591,27 @@ interface RaftSent {
564
591
  /** Newer messages the Server returned alongside the acceptance, if any. */
565
592
  recentUnread: RaftMessage[];
566
593
  }
594
+ /**
595
+ * Plain data that continues a held send in a later process: spread it into
596
+ * the next `messages.send({ ...original, ...continuation })`. `seen` is set
597
+ * only when the Server reported a boundary and nothing was withheld.
598
+ * Spreading `seen` asserts that the model saw the held messages (the same
599
+ * attestation as `frontier.recordHeld`); drop it if the held text was stored
600
+ * without being handed to the model, and the next send will be held again
601
+ * with the same context instead of passing on a false attestation.
602
+ */
603
+ interface RaftSendContinuation {
604
+ idempotencyKey: string;
605
+ seen?: {
606
+ upToSeq: number;
607
+ };
608
+ }
567
609
  interface RaftHeldBase {
568
610
  target: string;
611
+ /** The idempotency key of the held message; a later send with this key is the same logical message. */
612
+ idempotencyKey: string;
613
+ /** Serialisable continuation for the next step (also carried in `next.args`). */
614
+ continuation: RaftSendContinuation;
569
615
  /** How many newer messages the agent has not seen in this conversation. */
570
616
  newMessageCount: number;
571
617
  /** The newest of them, previewed; the Server may omit older ones. */
@@ -580,9 +626,11 @@ interface RaftHeldBase {
580
626
  }
581
627
  interface RaftHeld extends RaftHeldBase {
582
628
  /**
583
- * Send the same message again after the runtime has handled the held context.
584
- * `seen: "held"` attests the Server's `seenUpToSeq` (say this only if the model
585
- * saw the held messages); `seen: "anyway"` bypasses the freshness check.
629
+ * In-process sugar over `continuation`: send the same message again after the
630
+ * runtime has handled the held context. `seen: "held"` attests the Server's
631
+ * `seenUpToSeq` (say this only if the model saw the held messages);
632
+ * `seen: "anyway"` bypasses the freshness check. Runtimes whose next step
633
+ * runs elsewhere store `continuation` (or `next.args`) instead.
586
634
  */
587
635
  resend(options: {
588
636
  seen: "held" | "anyway";
@@ -619,8 +667,10 @@ interface RaftClaimResult {
619
667
  /** At least one row authorises work. */
620
668
  anyAuthorised: boolean;
621
669
  }
622
- interface RaftClaimHeld extends RaftHeldBase {
623
- /** Retry the identical claim after the runtime has handled the held context. */
670
+ interface RaftClaimHeld extends Omit<RaftHeldBase, "idempotencyKey" | "continuation"> {
671
+ /** The claim to repeat once the held context has been handled (plain data; also in `next.args`). */
672
+ request: ClaimTasksRequest;
673
+ /** In-process sugar: retry the identical claim. */
624
674
  retry(): Promise<ClaimTasksOutcome>;
625
675
  }
626
676
  type ClaimTasksOutcome = RaftOutcome<RaftClaimResult, "claimed" | "partial" | "refused"> | RaftOutcome<RaftClaimHeld, "held">;
@@ -9085,4 +9135,4 @@ interface Raft {
9085
9135
  }
9086
9136
  export declare function createRaft(options: CreateRaftOptions): Raft;
9087
9137
  //#endregion
9088
- export type { BootstrapRaftCredentialOptions, CheckInboxOutcome, CheckInboxRequest, ClaimTasksOutcome, ClaimTasksRequest, CreateRaftClientFromStoreOptions, CreateRaftClientOptions, CreateRaftOptions, CreateRaftRoutesOptions, DrainInboxRequest, ListInboxRequest, Raft, RaftAckMode, RaftActionPrepareRequest, RaftActionPrepared, RaftApiError, RaftApiResult, RaftAppConfig, RaftAppConfigPatch, RaftAvatarUpload, RaftChannelJoinClient, RaftChannelJoinClientResult, RaftChannelJoinError, RaftChannelJoinFailure, RaftChannelJoinOperation, RaftChannelJoinRequest, RaftChannelJoinResult, RaftChannelJoinSuccess, RaftChannelJoinTransportError, RaftClaimHeld, RaftClaimResult, RaftClaimRow, RaftClaimRowState, RaftClient, RaftClientError, RaftClientFailure, RaftClientResult, RaftClientSuccess, RaftClientThrottleOptions, RaftClientTransportRequest, RaftContextAgent, RaftContextData, RaftContextError, RaftContextResult, RaftContextServer, RaftCredentialErrorCode, RaftCredentialIdentity, RaftCredentialStore, RaftEvent, RaftEventAttachment, RaftEventExternalMessage, RaftEventsReceiveData, RaftEventsReceiveError, RaftEventsReceiveRequest, RaftEventsReceiveResult, RaftFailure, RaftHeld, RaftHeldBase, RaftHistoryPage, RaftInboxBatch, RaftInboxConversation, RaftInboxDrainSummary, RaftInboxListing, RaftInboxNotice, RaftManageClient, RaftMessage, RaftMessageAttachment, RaftMessageTask, RaftNextStep, RaftNoticeFlag, RaftNoticeTarget, RaftOpError, RaftOpErrorCode, RaftOutcome, RaftProfile, RaftProfileUpdate, RaftRouteAnnotations, RaftRouteInfo, RaftRouteKey, RaftRouteMeta, RaftRouteResult, RaftRouteRetryPolicy, RaftRoutes, RaftSdkConfigurationErrorCode, RaftSenderType, RaftSent, RaftServerProfile, RaftServerUpdate, RaftWebhookStatus, ReadHistoryRequest, SeenAttestation, SeenFrontierSnapshot, SendMessageOutcome, SendMessageRequest, StoredRaftCredential, VerifyNoticeInput, VerifyNoticeRejection, VerifyNoticeResult };
9138
+ export type { BootstrapRaftCredentialOptions, CheckInboxOutcome, CheckInboxRequest, ClaimTasksOutcome, ClaimTasksRequest, CreateRaftClientFromStoreOptions, CreateRaftClientOptions, CreateRaftOptions, CreateRaftRoutesOptions, DrainInboxRequest, ListInboxRequest, Raft, RaftAckMode, RaftActionPrepareRequest, RaftActionPrepared, RaftApiError, RaftApiResult, RaftAppConfig, RaftAppConfigPatch, RaftAvatarUpload, RaftChannelJoinClient, RaftChannelJoinClientResult, RaftChannelJoinError, RaftChannelJoinFailure, RaftChannelJoinOperation, RaftChannelJoinRequest, RaftChannelJoinResult, RaftChannelJoinSuccess, RaftChannelJoinTransportError, RaftClaimHeld, RaftClaimResult, RaftClaimRow, RaftClaimRowState, RaftClient, RaftClientError, RaftClientFailure, RaftClientResult, RaftClientSuccess, RaftClientThrottleOptions, RaftClientTransportRequest, RaftContextAgent, RaftContextData, RaftContextError, RaftContextResult, RaftContextServer, RaftCredentialErrorCode, RaftCredentialIdentity, RaftCredentialStore, RaftEvent, RaftEventAttachment, RaftEventExternalMessage, RaftEventsReceiveData, RaftEventsReceiveError, RaftEventsReceiveRequest, RaftEventsReceiveResult, RaftFailure, RaftHeld, RaftHeldBase, RaftHistoryPage, RaftInboxBatch, RaftInboxConversation, RaftInboxDrainSummary, RaftInboxListing, RaftInboxNotice, RaftManageClient, RaftMessage, RaftMessageAttachment, RaftMessageTask, RaftNextStep, RaftNoticeFlag, RaftNoticeTarget, RaftOpError, RaftOpErrorCode, RaftOutcome, RaftProfile, RaftProfileUpdate, RaftRouteAnnotations, RaftRouteInfo, RaftRouteKey, RaftRouteMeta, RaftRouteResult, RaftRouteRetryPolicy, RaftRoutes, RaftSdkConfigurationErrorCode, RaftSendContinuation, RaftSenderType, RaftSent, RaftServerProfile, RaftServerUpdate, RaftWebhookStatus, ReadHistoryRequest, SeenAttestation, SeenFrontierSnapshot, SendMessageOutcome, SendMessageRequest, StoredRaftCredential, VerifyNoticeInput, VerifyNoticeRejection, VerifyNoticeResult };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/raft-sdk",
3
- "version": "1.0.0-alpha.0",
3
+ "version": "1.0.0-alpha.1",
4
4
  "license": "FSL-1.1-ALv2",
5
5
  "description": "Typed Raft Agent API client for external agents and bots.",
6
6
  "type": "module",