totemlayer 0.1.15 → 0.1.17

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.
@@ -5,7 +5,9 @@ import { join } from "node:path";
5
5
  import { pause } from "./network.mjs";
6
6
  import { localModelEndpoint } from "./local-model-provider.mjs";
7
7
  import { runInboxQueue } from "./inbox-queue.mjs";
8
+ import { retryNetwork, transientNetworkError } from "./network-retry.mjs";
8
9
  import { customerInspector, inspectNetworkMessage } from "./local-inspector.mjs";
10
+ import { runtimeErrorCode } from "./runtime-diagnostics.mjs";
9
11
  import {
10
12
  externalModelReply,
11
13
  isExternalModelProvider,
@@ -405,23 +407,38 @@ async function generate({
405
407
  new Error(
406
408
  "A previous provider turn has an uncertain outcome. Inspect its private journal before retrying.",
407
409
  ),
408
- { code: "AGENT_OUTCOME_UNCERTAIN" },
410
+ {
411
+ code: state.externalFailure ?? "AGENT_OUTCOME_UNCERTAIN",
412
+ externalOutcomeUncertain: true,
413
+ },
409
414
  );
410
415
  state.externalPending = true;
411
416
  await journal.save(key, state);
412
417
  }
413
- const response = await modelReply({
414
- origin: settings.origin,
415
- provider: settings.provider,
416
- qwenUrl: settings.qwenUrl,
417
- nimUrl: settings.nimUrl,
418
- model: settings.model,
419
- messages: state.messages,
420
- tools,
421
- signal,
422
- timeoutMs: settings.modelTimeoutMs,
423
- adapterSettings: settings.adapterSettings,
424
- });
418
+ let response;
419
+ try {
420
+ response = await modelReply({
421
+ origin: settings.origin,
422
+ provider: settings.provider,
423
+ qwenUrl: settings.qwenUrl,
424
+ nimUrl: settings.nimUrl,
425
+ model: settings.model,
426
+ messages: state.messages,
427
+ tools,
428
+ signal,
429
+ timeoutMs: settings.modelTimeoutMs,
430
+ adapterSettings: settings.adapterSettings,
431
+ });
432
+ } catch (error) {
433
+ // Preserve a fixed diagnostic instead of hiding the original failure after restart.
434
+ // Keep externalPending: arbitrary external providers may have uncertain side effects.
435
+ if (isExternalModelProvider(settings.provider)) {
436
+ error.externalOutcomeUncertain = true;
437
+ state.externalFailure = runtimeErrorCode(error);
438
+ await journal.save(key, state);
439
+ }
440
+ throw error;
441
+ }
425
442
  await settings.inspector.assertAllowed("output", response, { task: text, signal });
426
443
  delete state.externalPending;
427
444
  state.messages.push(response);
@@ -572,7 +589,17 @@ export async function runRuntime(settings) {
572
589
  let inbox;
573
590
  let humanDone = false;
574
591
  let fatalError;
592
+ let diagnosticsSupported = false;
593
+ let diagnostics = { phase: "starting" };
594
+ const packageVersion = JSON.parse(
595
+ await readFile(new URL("../package.json", import.meta.url), "utf8"),
596
+ ).version;
575
597
  const report = (error, context = {}) => {
598
+ diagnostics = {
599
+ phase: "blocked",
600
+ errorCode: runtimeErrorCode(error),
601
+ ...(typeof context.messageId === "string" ? { messageId: context.messageId } : {}),
602
+ };
576
603
  if (settings.once) process.exitCode = 1;
577
604
  emit({
578
605
  type: "runtime_error",
@@ -591,11 +618,28 @@ export async function runRuntime(settings) {
591
618
  });
592
619
  };
593
620
  try {
594
- const beat = async () =>
595
- client.runtime.heartbeat(
596
- { label: `${settings.provider}:${settings.model}`.slice(0, 100) },
621
+ const beat = async () => {
622
+ const result = await client.runtime.heartbeat(
623
+ {
624
+ label: `${settings.provider}:${settings.model}`.slice(0, 100),
625
+ ...(diagnosticsSupported
626
+ ? {
627
+ diagnostics: {
628
+ ...diagnostics,
629
+ packageVersion,
630
+ provider: settings.provider,
631
+ platform: ["darwin", "linux", "win32"].includes(process.platform)
632
+ ? process.platform
633
+ : "other",
634
+ },
635
+ }
636
+ : {}),
637
+ },
597
638
  { signal },
598
639
  );
640
+ diagnosticsSupported = result?.diagnosticsSupported === true;
641
+ return result;
642
+ };
599
643
  await beat();
600
644
  heartbeat = (async () => {
601
645
  while (!signal.aborted) {
@@ -623,6 +667,7 @@ export async function runRuntime(settings) {
623
667
  inspection: settings.inspector.mode,
624
668
  },
625
669
  });
670
+ diagnostics = { phase: "waiting" };
626
671
  // The A2A queue must keep moving while a human command waits on another agent.
627
672
  // ask_agent reads durable conversation history, so acknowledgments cannot steal a reply.
628
673
  inbox = runInboxQueue({
@@ -630,16 +675,21 @@ export async function runRuntime(settings) {
630
675
  pollMs: settings.pollMs,
631
676
  concurrency: settings.messageConcurrency ?? 2,
632
677
  once: settings.once,
678
+ onPollError: (error) => report(error),
633
679
  shouldContinue: () => !settings.once || !humanDone,
634
680
  messages: (signal) =>
635
681
  client.messages.iterate({ direction: "inbound", unread: true, limit: 100 }, { signal }),
636
682
  eligible: async (message) => !(await journal.read(`message-blocker:${message.id}`)),
637
683
  handle: async (message, signal) => {
638
684
  try {
685
+ diagnostics = { phase: "working", messageId: message.id };
686
+ emit({ type: "message_started", data: { messageId: message.id } });
639
687
  await inspectNetworkMessage(settings.inspector, client, message, { signal });
640
688
  const continuation = autonomousReplyMetadata(message, settings, identity.address);
641
689
  if (message.inReplyToId && !continuation) {
642
690
  await client.messages.ack(message.id, { signal });
691
+ diagnostics = { phase: "waiting" };
692
+ emit({ type: "message_handled", data: { messageId: message.id } });
643
693
  return;
644
694
  }
645
695
  const thread = [];
@@ -696,6 +746,7 @@ export async function runRuntime(settings) {
696
746
  { signal },
697
747
  );
698
748
  await client.messages.ack(message.id, { signal });
749
+ diagnostics = { phase: "waiting" };
699
750
  emit({
700
751
  type: "message_handled",
701
752
  data: { messageId: message.id, conversationId: message.conversationId },
@@ -703,10 +754,12 @@ export async function runRuntime(settings) {
703
754
  } catch (error) {
704
755
  if (!signal.aborted) report(error, { messageId: message.id });
705
756
  if (error.status === 401) throw error;
706
- if (error.status === 429 || error.status >= 500) throw error;
757
+ // Retry this durable message on the next poll without stopping other turns.
758
+ if (transientNetworkError(error)) return;
707
759
  if (
708
760
  (error.status >= 400 && error.status < 500) ||
709
- error.code === "AGENT_OUTCOME_UNCERTAIN"
761
+ error.code === "AGENT_OUTCOME_UNCERTAIN" ||
762
+ error.externalOutcomeUncertain
710
763
  )
711
764
  await journal.save(`message-blocker:${message.id}`, {
712
765
  code: error.code ?? "MESSAGE_BLOCKED",
@@ -720,7 +773,20 @@ export async function runRuntime(settings) {
720
773
  }
721
774
  });
722
775
  do {
723
- const commands = await client.chatCommands.list({ limit: 20 }, { signal });
776
+ let commands;
777
+ try {
778
+ commands = await retryNetwork(() => client.chatCommands.list({ limit: 20 }, { signal }), {
779
+ signal,
780
+ delayMs: settings.pollMs,
781
+ onRetry: report,
782
+ });
783
+ } catch (error) {
784
+ if (signal.aborted) break;
785
+ if (!transientNetworkError(error)) throw error;
786
+ report(error);
787
+ await pause(Math.max(settings.pollMs, 100), signal);
788
+ continue;
789
+ }
724
790
  for (const command of commands.data) {
725
791
  if (signal.aborted) break;
726
792
  let claim;
@@ -728,6 +794,10 @@ export async function runRuntime(settings) {
728
794
  claim = await client.chatCommands.claim(command.id, { signal });
729
795
  } catch (error) {
730
796
  if (error.status === 409) continue;
797
+ if (transientNetworkError(error)) {
798
+ report(error);
799
+ continue;
800
+ }
731
801
  throw error;
732
802
  }
733
803
  const leaseStop = new AbortController();
@@ -1,4 +1,5 @@
1
- import { join } from "node:path";
1
+ import { join, dirname, isAbsolute, extname } from "node:path";
2
+ import { existsSync } from "node:fs";
2
3
 
3
4
  const INHERITED_ENVIRONMENT = new Set(
4
5
  [
@@ -71,9 +72,36 @@ export function powershellInvocation(script, environment = process.env) {
71
72
  // base64-encoded JSON argument array is interpolated into the script, so a
72
73
  // Windows path can never become shell syntax.
73
74
  export function installedClientInvocation(command, args, environment = process.env) {
75
+ // Native executables inherit the pipe directly. For npm clients, launch the
76
+ // installed JavaScript entrypoint with Node rather than a .cmd/PowerShell
77
+ // pipeline, which can leave stdin open indefinitely on Windows.
78
+ const clientPackage = {
79
+ codex: ["@openai", "codex", "bin", "codex.js"],
80
+ claude: ["@anthropic-ai", "claude-code", "cli.js"],
81
+ }[command];
82
+ const pathValue =
83
+ environment.PATH ??
84
+ environment.Path ??
85
+ Object.entries(environment).find(([key]) => key.toUpperCase() === "PATH")?.[1] ??
86
+ "";
87
+ const candidates = isAbsolute(command)
88
+ ? [command]
89
+ : pathValue
90
+ .split(";")
91
+ .filter(Boolean)
92
+ .flatMap((dir) => [join(dir, `${command}.exe`), join(dir, `${command}.cmd`)]);
93
+ for (const path of candidates) {
94
+ if (!existsSync(path)) continue;
95
+ if (extname(path).toLowerCase() === ".exe") return { command: path, args };
96
+ if (clientPackage) {
97
+ const entry = join(dirname(path), "node_modules", ...clientPackage);
98
+ if (existsSync(entry)) return { command: process.execPath, args: [entry, ...args] };
99
+ }
100
+ break;
101
+ }
74
102
  const payload = Buffer.from(JSON.stringify([command, ...args]), "utf8").toString("base64");
75
103
  return powershellInvocation(
76
- `$ErrorActionPreference = 'Stop'\n$items = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json\n$client = [string]$items[0]\n$clientArgs = @($items | Select-Object -Skip 1)\n& $client @clientArgs\nif ($null -eq $LASTEXITCODE) { exit 1 }\nexit $LASTEXITCODE`,
104
+ `$ErrorActionPreference = 'Stop'\n$items = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json\n$client = [string]$items[0]\n$clientArgs = @($items | Select-Object -Skip 1)\n$OutputEncoding = [Text.UTF8Encoding]::new($false)\n[Console]::OutputEncoding = $OutputEncoding\n$prompt = [Console]::In.ReadToEnd()\n$prompt | & $client @clientArgs\nif ($null -eq $LASTEXITCODE) { exit 1 }\nexit $LASTEXITCODE`,
77
105
  environment,
78
106
  );
79
107
  }
@@ -0,0 +1,38 @@
1
+ export const RUNTIME_ERROR_CODES = new Set([
2
+ "AGENT_START_FAILED",
3
+ "AGENT_EXIT_FAILED",
4
+ "AGENT_REPORTED_FAILURE",
5
+ "INVALID_AGENT_RESULT",
6
+ "AGENT_CLI_INCOMPATIBLE",
7
+ "AGENT_AUTH_REQUIRED",
8
+ "AGENT_QUOTA_EXCEEDED",
9
+ "AGENT_SANDBOX_UNAVAILABLE",
10
+ "AGENT_MODEL_UNAVAILABLE",
11
+ "AGENT_OUTCOME_UNCERTAIN",
12
+ "RUN_TIMEOUT",
13
+ "RUN_CANCELLED",
14
+ "OUTPUT_LIMIT_EXCEEDED",
15
+ "UNKNOWN_HTTP_OUTCOME",
16
+ "HTTP_AGENT_FAILED",
17
+ "INVALID_ADAPTER_CONFIG",
18
+ "INVALID_ADAPTER_ENV",
19
+ "INSPECTION_BLOCKED",
20
+ "INSPECTION_UNAVAILABLE",
21
+ "INSPECTION_UNSUPPORTED_FILE",
22
+ "AGENT_REPLY_TIMEOUT",
23
+ "INVALID_ASK",
24
+ "TOOL_NOT_ALLOWED",
25
+ "INVALID_PEER_REPLY",
26
+ "NETWORK_BUSY",
27
+ "NETWORK_ERROR",
28
+ "TIMEOUT",
29
+ "CANCELED",
30
+ "INVALID_NETWORK_CREDENTIAL",
31
+ "AGENT_IDENTITY_MISMATCH",
32
+ "MODEL_ERROR",
33
+ "MAX_STEPS",
34
+ "INTERNAL_ERROR",
35
+ ]);
36
+ export function runtimeErrorCode(error) {
37
+ return RUNTIME_ERROR_CODES.has(error?.code) ? error.code : "INTERNAL_ERROR";
38
+ }
package/bin/sdk/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { TotemError } from "./errors.js";
2
- import { createLocalInspector, InspectionError } from "./inspector.js";
3
2
  import { idempotencyKey, sleep, Transport } from "./transport.js";
4
3
  import { assertInputFields, assistantCheckOptions, signalOnlyOptions } from "./request-options.js";
5
4
  export * from "./types.js";
@@ -113,11 +112,6 @@ export class Totem {
113
112
  },
114
113
  idempotencyKey: options.idempotencyKey ?? `sdk:reply:${id}`,
115
114
  })).data,
116
- replyContent: async (id, content, options = {}) => (await this.#transport.request("POST", `/messages/${encodeURIComponent(id)}/reply`, {
117
- ...options,
118
- body: { content },
119
- idempotencyKey: options.idempotencyKey ?? idempotencyKey(),
120
- })).data,
121
115
  ack: async (id, options) => (await this.#transport.request("POST", `/messages/${encodeURIComponent(id)}/ack`, {
122
116
  ...options,
123
117
  body: {},
@@ -323,8 +317,6 @@ export class ConnectedAgent {
323
317
  if (!Number.isFinite(interval) || interval < 10)
324
318
  throw new TotemError("pollIntervalMs must be at least 10.", { code: "INVALID_CONFIG" });
325
319
  const controller = new AbortController();
326
- const inspector = options.inspector ?? createLocalInspector();
327
- const blocked = new Set();
328
320
  const report = (cause) => {
329
321
  this.#lastError = cause instanceof Error ? cause : new Error(String(cause));
330
322
  try {
@@ -340,42 +332,22 @@ export class ConnectedAgent {
340
332
  for await (const message of this.client.messages.iterate({ direction: "inbound", unread: true, limit: 100 }, { signal: controller.signal })) {
341
333
  if (controller.signal.aborted)
342
334
  break;
343
- if (blocked.has(message.id))
344
- continue;
335
+ const incoming = {
336
+ ...message,
337
+ text: message.content.type === "text"
338
+ ? message.content.text
339
+ : JSON.stringify(message.content.data),
340
+ reply: (text, writeOptions = {}) => this.client.messages.reply(message.id, text, {
341
+ ...writeOptions,
342
+ idempotencyKey: writeOptions.idempotencyKey ?? `reply:${message.id}:0`,
343
+ }),
344
+ ack: () => this.client.messages.ack(message.id),
345
+ };
345
346
  try {
346
- if (message.content.type === "encrypted")
347
- throw new TotemError("Use E2eeTotem and pinned endpoint keys to process encrypted messages.", { code: "E2EE_CLIENT_REQUIRED" });
348
- await inspector.assertAllowed("input", { content: message.content, metadata: message.metadata ?? {} }, { signal: controller.signal });
349
- const files = message.attachments ?? [];
350
- const ids = new Set(files.map((file) => file.id));
351
- if (message.content.type === "text" &&
352
- message.content.attachments?.some((id) => !ids.has(id)))
353
- throw new InspectionError("INSPECTION_UNSUPPORTED_FILE");
354
- for (const file of files) {
355
- const downloaded = await this.client.attachments.download(file.id, {
356
- signal: controller.signal,
357
- });
358
- await inspector.inspectFile(file.name, file.mediaType, downloaded.bytes, {
359
- signal: controller.signal,
360
- });
361
- }
362
- const incoming = {
363
- ...message,
364
- text: message.content.type === "text"
365
- ? message.content.text
366
- : JSON.stringify(message.content.data),
367
- reply: (text, writeOptions = {}) => inspector.runAction({ id: message.id, text }, (input) => this.client.messages.reply(input.id, input.text, {
368
- ...writeOptions,
369
- idempotencyKey: writeOptions.idempotencyKey ?? `reply:${message.id}:0`,
370
- }), { signal: controller.signal }),
371
- ack: () => this.client.messages.ack(message.id),
372
- };
373
347
  await handler(incoming);
374
348
  await incoming.ack();
375
349
  }
376
350
  catch (error) {
377
- if (error instanceof InspectionError && error.code !== "INSPECTION_UNAVAILABLE")
378
- blocked.add(message.id);
379
351
  report(error);
380
352
  }
381
353
  }
@@ -413,4 +385,3 @@ export class ConnectedAgent {
413
385
  /** Compatibility name for callers who prefer `new TotemAgent({ address, apiKey })`. */
414
386
  export class TotemAgent extends Totem {
415
387
  }
416
- export { createLocalInspector, InspectionError };
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.15";
7
+ const CLI_VERSION = "0.1.13";
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._:-]*$/;
@@ -51,7 +51,8 @@ export function createCommandAdapter(config) {
51
51
  !Array.isArray(config.args ?? []) ||
52
52
  !(config.args ?? []).every((arg) => typeof arg === "string" && !arg.includes("\0")) ||
53
53
  typeof config.cwd !== "string" ||
54
- !isAbsolute(config.cwd)
54
+ !isAbsolute(config.cwd) ||
55
+ (config.classifyFailure !== undefined && typeof config.classifyFailure !== "function")
55
56
  ) {
56
57
  throw new WorkerError("INVALID_ADAPTER_CONFIG");
57
58
  }
@@ -76,13 +77,25 @@ export function createCommandAdapter(config) {
76
77
  let child;
77
78
  let failure;
78
79
  let output = "";
80
+ let diagnosticOutput = "";
79
81
  let bytes = 0;
80
82
  let killTimer;
81
83
  // Also remove surviving descendants if the parent exits before escalation.
82
84
  const kill = (kind) => {
83
85
  try {
84
86
  if (process.platform !== "win32" && child?.pid) process.kill(-child.pid, kind);
85
- else child?.kill(kind);
87
+ else if (child?.pid) {
88
+ // A Windows shim can outlive its parent and keep stdio open.
89
+ const killer = spawn("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], {
90
+ windowsHide: true,
91
+ stdio: "ignore",
92
+ });
93
+ killer.on("error", () => {
94
+ try {
95
+ child.kill(kind);
96
+ } catch {}
97
+ });
98
+ }
86
99
  } catch {
87
100
  /* The process may already have exited. */
88
101
  }
@@ -122,6 +135,7 @@ export function createCommandAdapter(config) {
122
135
  child.stderr.on("data", (chunk) => {
123
136
  bytes += chunk.length;
124
137
  if (bytes > maxOutputBytes) stop("OUTPUT_LIMIT_EXCEEDED");
138
+ else if (config.classifyFailure) diagnosticOutput += chunk.toString("utf8");
125
139
  });
126
140
  child.stdin.on("error", () => {
127
141
  /* A failed child can close stdin early. */
@@ -137,7 +151,15 @@ export function createCommandAdapter(config) {
137
151
  kill("SIGKILL");
138
152
  return reject(failure);
139
153
  }
140
- if (code !== 0) return reject(new WorkerError("AGENT_EXIT_FAILED"));
154
+ if (code !== 0) {
155
+ try {
156
+ return reject(
157
+ new WorkerError(config.classifyFailure?.(diagnosticOutput) ?? "AGENT_EXIT_FAILED"),
158
+ );
159
+ } catch {
160
+ return reject(new WorkerError("AGENT_EXIT_FAILED"));
161
+ }
162
+ }
141
163
  try {
142
164
  resolve(validateResult(parseResult(output)));
143
165
  } catch (error) {
@@ -1,8 +1,4 @@
1
1
  const SAFE_CODES = new Set([
2
- "INSPECTION_BLOCKED",
3
- "INSPECTION_UNAVAILABLE",
4
- "INSPECTION_LIMIT",
5
- "INSPECTION_UNSUPPORTED_FILE",
6
2
  "INTERNAL_ERROR",
7
3
  "INVALID_AGENT_RESULT",
8
4
  "INVALID_ADAPTER_ENV",
@@ -30,6 +26,11 @@ const SAFE_CODES = new Set([
30
26
  "INVALID_PROMPT",
31
27
  "AGENT_START_FAILED",
32
28
  "AGENT_EXIT_FAILED",
29
+ "AGENT_CLI_INCOMPATIBLE",
30
+ "AGENT_AUTH_REQUIRED",
31
+ "AGENT_QUOTA_EXCEEDED",
32
+ "AGENT_SANDBOX_UNAVAILABLE",
33
+ "AGENT_MODEL_UNAVAILABLE",
33
34
  "AGENT_REPORTED_FAILURE",
34
35
  "UNKNOWN_HTTP_OUTCOME",
35
36
  "HTTP_AGENT_FAILED",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totemlayer",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Cross-platform CLI for TotemLayer agent identity and authorization",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,20 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdir, readdir, copyFile } 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
+ for (const name of await readdir(new URL("../../sdk/dist/", import.meta.url))) {
13
+ if (name.endsWith(".js"))
14
+ await copyFile(new URL(`../../sdk/dist/${name}`, import.meta.url), new URL(name, destination));
15
+ }
16
+
17
+ const workerCore = new URL("../bin/worker-core/", import.meta.url);
18
+ await mkdir(workerCore, { recursive: true });
19
+ for (const name of ["adapters.mjs", "errors.mjs"])
20
+ await copyFile(new URL(`../../agent-worker/${name}`, import.meta.url), new URL(name, workerCore));