totemlayer 0.1.14 → 0.1.15

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.
@@ -0,0 +1,71 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+
3
+ // Polling is independent of generation. Unrelated conversations can make
4
+ // progress while one provider turn is slow; one conversation stays ordered.
5
+ export async function runInboxQueue({
6
+ messages,
7
+ handle,
8
+ eligible = async () => true,
9
+ signal: ownerSignal,
10
+ pollMs,
11
+ concurrency = 2,
12
+ shouldContinue = () => true,
13
+ once = false,
14
+ }) {
15
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4)
16
+ throw new Error("Inbox concurrency must be between one and four.");
17
+ const stop = new AbortController();
18
+ const signal = AbortSignal.any([ownerSignal, stop.signal]);
19
+ const active = new Map();
20
+ const conversations = new Set();
21
+ const attempted = new Set();
22
+ let failure;
23
+ let pendingCount = 0;
24
+ try {
25
+ do {
26
+ const oldest = new Map();
27
+ for await (const message of messages(signal)) {
28
+ if (signal.aborted) break;
29
+ if (
30
+ active.has(message.id) ||
31
+ conversations.has(message.conversationId) ||
32
+ attempted.has(message.id)
33
+ )
34
+ continue;
35
+ if (!(await eligible(message))) continue;
36
+ // The server yields oldest first. Preserve that order within a chat.
37
+ if (!oldest.has(message.conversationId)) oldest.set(message.conversationId, message);
38
+ }
39
+ const pending = [...oldest.values()];
40
+ // Give both the backlog and recent conversations a slot, rather than
41
+ // draining yesterday's entire backlog before looking at today's inbox.
42
+ let recent = false;
43
+ while (pending.length && active.size < concurrency && !signal.aborted) {
44
+ const message = recent ? pending.pop() : pending.shift();
45
+ recent = !recent;
46
+ if (once) attempted.add(message.id);
47
+ conversations.add(message.conversationId);
48
+ const turn = Promise.resolve()
49
+ .then(() => handle(message, signal))
50
+ .catch((error) => {
51
+ failure ??= error;
52
+ stop.abort();
53
+ })
54
+ .finally(() => {
55
+ active.delete(message.id);
56
+ conversations.delete(message.conversationId);
57
+ });
58
+ active.set(message.id, turn);
59
+ }
60
+ pendingCount = pending.length;
61
+ if (!signal.aborted && (shouldContinue() || active.size || pendingCount))
62
+ await sleep(pollMs, undefined, { signal }).catch((error) => {
63
+ if (!signal.aborted) throw error;
64
+ });
65
+ } while (!signal.aborted && (shouldContinue() || active.size || pendingCount));
66
+ } finally {
67
+ stop.abort();
68
+ await Promise.allSettled(active.values());
69
+ }
70
+ if (failure) throw failure;
71
+ }
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { pause } from "./network.mjs";
6
6
  import { localModelEndpoint } from "./local-model-provider.mjs";
7
+ import { runInboxQueue } from "./inbox-queue.mjs";
7
8
  import { customerInspector, inspectNetworkMessage } from "./local-inspector.mjs";
8
9
  import {
9
10
  externalModelReply,
@@ -624,88 +625,95 @@ export async function runRuntime(settings) {
624
625
  });
625
626
  // The A2A queue must keep moving while a human command waits on another agent.
626
627
  // ask_agent reads durable conversation history, so acknowledgments cannot steal a reply.
627
- inbox = (async () => {
628
- do {
629
- for await (const message of client.messages.iterate(
630
- { direction: "inbound", unread: true, limit: 100 },
631
- { signal },
632
- )) {
633
- if (signal.aborted) break;
634
- if (await journal.read(`message-blocker:${message.id}`)) continue;
635
- try {
636
- await inspectNetworkMessage(settings.inspector, client, message, { signal });
637
- const continuation = autonomousReplyMetadata(message, settings, identity.address);
638
- if (message.inReplyToId && !continuation) {
639
- await client.messages.ack(message.id, { signal });
640
- continue;
641
- }
642
- const thread = [];
643
- let cursor;
644
- for (let page = 0; page < 5; page++) {
645
- const result = await client.conversations.messages(
646
- message.conversationId,
647
- { limit: 100, ...(cursor ? { cursor } : {}) },
648
- { signal },
649
- );
650
- thread.push(...result.data);
651
- if (thread.some((item) => item.id === message.id) || !result.nextCursor) break;
652
- cursor = result.nextCursor;
653
- }
654
- for (const item of thread)
655
- await inspectNetworkMessage(settings.inspector, client, item, { signal });
656
- const history = boundedHistory(
657
- thread.map((item) => ({
658
- id: item.id,
659
- actor: item.from === identity.address ? "agent" : "human",
660
- text:
661
- item.content.type === "text"
662
- ? item.content.text
663
- : JSON.stringify(item.content.data),
664
- })),
665
- message.id,
666
- );
667
- const text = await generate({
668
- client,
669
- identity,
670
- journal,
671
- key: `message:${message.id}`,
672
- text:
673
- message.content.type === "text"
674
- ? message.content.text
675
- : JSON.stringify(message.content.data),
676
- history,
677
- settings,
678
- signal,
679
- allowTools: false,
680
- });
681
- await settings.inspector.runAction(
682
- { id: message.id, text },
683
- (inspected) =>
684
- client.messages.reply(inspected.id, inspected.text, {
685
- idempotencyKey: operationKey(identity.id, "reply", message.id),
686
- ...(continuation ? { metadata: continuation } : {}),
687
- signal,
688
- }),
628
+ inbox = runInboxQueue({
629
+ signal,
630
+ pollMs: settings.pollMs,
631
+ concurrency: settings.messageConcurrency ?? 2,
632
+ once: settings.once,
633
+ shouldContinue: () => !settings.once || !humanDone,
634
+ messages: (signal) =>
635
+ client.messages.iterate({ direction: "inbound", unread: true, limit: 100 }, { signal }),
636
+ eligible: async (message) => !(await journal.read(`message-blocker:${message.id}`)),
637
+ handle: async (message, signal) => {
638
+ try {
639
+ await inspectNetworkMessage(settings.inspector, client, message, { signal });
640
+ const continuation = autonomousReplyMetadata(message, settings, identity.address);
641
+ if (message.inReplyToId && !continuation) {
642
+ await client.messages.ack(message.id, { signal });
643
+ return;
644
+ }
645
+ const thread = [];
646
+ let cursor;
647
+ for (let page = 0; page < 5; page++) {
648
+ const result = await client.conversations.messages(
649
+ message.conversationId,
650
+ { limit: 100, ...(cursor ? { cursor } : {}) },
689
651
  { signal },
690
652
  );
691
- await client.messages.ack(message.id, { signal });
692
- emit({
693
- type: "message_handled",
694
- data: { messageId: message.id, conversationId: message.conversationId },
695
- });
696
- } catch (error) {
697
- if (!signal.aborted) report(error, { messageId: message.id });
698
- if (error.status === 401) throw error;
699
- if (error.status === 429 || error.status >= 500) throw error;
700
- if (error.status >= 400 && error.status < 500)
701
- await journal.save(`message-blocker:${message.id}`, {
702
- code: error.code ?? "MESSAGE_BLOCKED",
703
- });
653
+ thread.push(...result.data);
654
+ if (thread.some((item) => item.id === message.id) || !result.nextCursor) break;
655
+ cursor = result.nextCursor;
704
656
  }
657
+ for (const item of thread)
658
+ await inspectNetworkMessage(settings.inspector, client, item, { signal });
659
+ const history = boundedHistory(
660
+ thread.map((item) => ({
661
+ id: item.id,
662
+ actor: item.from === identity.address ? "agent" : "human",
663
+ text:
664
+ item.content.type === "text"
665
+ ? item.content.text
666
+ : JSON.stringify(item.content.data),
667
+ })),
668
+ message.id,
669
+ );
670
+ const text = await generate({
671
+ client,
672
+ identity,
673
+ journal,
674
+ key: `message:${message.id}`,
675
+ text:
676
+ message.content.type === "text"
677
+ ? message.content.text
678
+ : JSON.stringify(message.content.data),
679
+ history,
680
+ settings: {
681
+ ...settings,
682
+ modelTimeoutMs:
683
+ settings.messageTimeoutMs ?? Math.min(settings.modelTimeoutMs ?? 900000, 120000),
684
+ },
685
+ signal,
686
+ allowTools: false,
687
+ });
688
+ await settings.inspector.runAction(
689
+ { id: message.id, text },
690
+ (inspected) =>
691
+ client.messages.reply(inspected.id, inspected.text, {
692
+ idempotencyKey: operationKey(identity.id, "reply", message.id),
693
+ ...(continuation ? { metadata: continuation } : {}),
694
+ signal,
695
+ }),
696
+ { signal },
697
+ );
698
+ await client.messages.ack(message.id, { signal });
699
+ emit({
700
+ type: "message_handled",
701
+ data: { messageId: message.id, conversationId: message.conversationId },
702
+ });
703
+ } catch (error) {
704
+ if (!signal.aborted) report(error, { messageId: message.id });
705
+ if (error.status === 401) throw error;
706
+ if (error.status === 429 || error.status >= 500) throw error;
707
+ if (
708
+ (error.status >= 400 && error.status < 500) ||
709
+ error.code === "AGENT_OUTCOME_UNCERTAIN"
710
+ )
711
+ await journal.save(`message-blocker:${message.id}`, {
712
+ code: error.code ?? "MESSAGE_BLOCKED",
713
+ });
705
714
  }
706
- if (!signal.aborted && (!settings.once || !humanDone)) await pause(settings.pollMs, signal);
707
- } while (!signal.aborted && (!settings.once || !humanDone));
708
- })().catch((error) => {
715
+ },
716
+ }).catch((error) => {
709
717
  if (!signal.aborted) {
710
718
  fatalError = error;
711
719
  shutdown.abort();
package/bin/totem.mjs CHANGED
@@ -4,7 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { createReadStream } from "node:fs";
5
5
  import { stripVTControlCharacters } from "node:util";
6
6
 
7
- const CLI_VERSION = "0.1.14";
7
+ const CLI_VERSION = "0.1.15";
8
8
  const DEFAULT_BASE_URL = "https://totemlayer.com";
9
9
  const API_KEY_PATTERN = /^at_(?:live|test)_[A-Za-z0-9_-]{20,}$/;
10
10
  const ACTION_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totemlayer",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Cross-platform CLI for TotemLayer agent identity and authorization",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,24 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import { mkdir, readdir, copyFile, rm } from "node:fs/promises";
3
- import { fileURLToPath } from "node:url";
4
-
5
- // Build from the sibling source every time a distributable is packed.
6
- const sdk = fileURLToPath(new URL("../../sdk/", import.meta.url));
7
- const destination = new URL("../bin/sdk/", import.meta.url);
8
- execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build", "--prefix", sdk], {
9
- stdio: "inherit",
10
- });
11
- await mkdir(destination, { recursive: true });
12
- // The CLI has no encrypted endpoint/key-store configuration yet. Keep the optional
13
- // HPKE entrypoints in the separately installed SDK, not a client without that dependency.
14
- const encryptedOnly = new Set(["e2ee-wire.js", "e2ee.js", "encrypted-client.js", "encrypted.js"]);
15
- for (const name of encryptedOnly) await rm(new URL(name, destination), { force: true });
16
- for (const name of await readdir(new URL("../../sdk/dist/", import.meta.url))) {
17
- if (name.endsWith(".js") && !encryptedOnly.has(name))
18
- await copyFile(new URL(`../../sdk/dist/${name}`, import.meta.url), new URL(name, destination));
19
- }
20
-
21
- const workerCore = new URL("../bin/worker-core/", import.meta.url);
22
- await mkdir(workerCore, { recursive: true });
23
- for (const name of ["adapters.mjs", "errors.mjs"])
24
- await copyFile(new URL(`../../agent-worker/${name}`, import.meta.url), new URL(name, workerCore));