trantor 0.18.23 → 0.18.25

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.23",
3
+ "version": "0.18.25",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
@@ -0,0 +1,115 @@
1
+ // Payload composition for crew-runner turn prompts — pure, unit-testable (card #5683).
2
+ //
3
+ // A fresh codex seat burned 306k tokens and crash-looped into a remote-compact 404. The runner-side
4
+ // part of that: every turn re-feeds the FULL lessons block (22,298 of the 24,698 chars in codex's
5
+ // last turn file — 90%), plus an unbounded FYI-broadcast backlog that grows across a failure streak
6
+ // and is replayed on every redelivery, on a RESUMED session where it all stacks. So every section
7
+ // here is capped, and the assembled prompt has ONE hard total cap with a visible truncation notice.
8
+ // Below the caps the output is byte-identical to the old string concatenation.
9
+
10
+ export const PAYLOAD_CAPS = Object.freeze({
11
+ wakeCount: 10, // direct/@mention messages: keep the last ~10
12
+ wakeMsgChars: 2000, // per-message body cap (the hub caps card notes at 2000 too)
13
+ bcastCount: 10, // FYI broadcast context: keep the last ~10
14
+ bcastMsgChars: 1000,
15
+ lessonsCount: 15, // top ~15 lessons, ranked by relevance to this turn's trigger
16
+ lessonsChars: 16000,
17
+ totalChars: 40000, // ONE hard total cap for the whole prompt
18
+ });
19
+
20
+ const WORD_RE = /[a-z0-9]{4,}/g;
21
+ const n = v => Number(v).toLocaleString("en-US");
22
+
23
+ // ---- wake messages (the task): keep the last `wakeCount`, cap each body ----
24
+ export function capWake(wake, caps = PAYLOAD_CAPS) {
25
+ const list = Array.isArray(wake) ? wake : [];
26
+ const kept = list.slice(-caps.wakeCount);
27
+ const lines = kept.map(m => {
28
+ let body = String(m?.text ?? "");
29
+ if (body.length > caps.wakeMsgChars)
30
+ body = body.slice(0, caps.wakeMsgChars) + ` …[+${n(body.length - caps.wakeMsgChars)} chars of this message dropped]`;
31
+ return `[${m?.from}${m?.to === "all" ? " -> all (mentions you)" : ""}]: ${body}`;
32
+ });
33
+ return { text: lines.join("\n"), kept: kept.length, total: list.length };
34
+ }
35
+
36
+ // ---- FYI broadcast context (context only): keep the last `bcastCount`, cap each body ----
37
+ export function capBcast(bcast, caps = PAYLOAD_CAPS) {
38
+ const list = Array.isArray(bcast) ? bcast : [];
39
+ const kept = list.slice(-caps.bcastCount);
40
+ const lines = kept.map(m => {
41
+ let body = String(m?.text ?? "");
42
+ if (body.length > caps.bcastMsgChars)
43
+ body = body.slice(0, caps.bcastMsgChars) + ` …[+${n(body.length - caps.bcastMsgChars)} chars dropped]`;
44
+ return `[${m?.from} -> all]: ${body}`;
45
+ });
46
+ return { text: lines.join("\n"), kept: kept.length, total: list.length };
47
+ }
48
+
49
+ // ---- lessons: top `lessonsCount` ranked by word overlap with this turn's trigger, then char-capped.
50
+ // No trigger (kickoff/pulse) → no signal to rank on, so original order stands.
51
+ export function pickLessons(lessons, trigger = "", caps = PAYLOAD_CAPS) {
52
+ const list = Array.isArray(lessons) ? lessons.filter(Boolean) : [];
53
+ if (!list.length) return { text: "", kept: 0, total: 0 };
54
+ const tw = new Set(String(trigger).toLowerCase().match(WORD_RE) || []);
55
+ const ranked = list
56
+ .map((l, i) => {
57
+ const words = String(l?.text || "").toLowerCase().match(WORD_RE) || [];
58
+ let score = 0;
59
+ for (const w of words) if (tw.has(w)) score++;
60
+ return { l, i, score };
61
+ })
62
+ .sort((a, b) => b.score - a.score || a.i - b.i)
63
+ .slice(0, caps.lessonsCount)
64
+ .sort((a, b) => a.i - b.i);
65
+ let budget = caps.lessonsChars;
66
+ const lines = [];
67
+ for (const { l } of ranked) {
68
+ const line = `- [${l?.scope}] ${l?.text}`;
69
+ if (line.length > budget) continue; // doesn't fit — a shorter one may
70
+ budget -= line.length + 1;
71
+ lines.push(line);
72
+ }
73
+ if (!lines.length) return { text: "", kept: 0, total: list.length };
74
+ return {
75
+ text: "\n\nLESSONS from previous crews (hard-won — follow them):\n" + lines.join("\n"),
76
+ kept: lines.length,
77
+ total: list.length,
78
+ };
79
+ }
80
+
81
+ // ---- one composer, one hard total cap ----
82
+ // sections: [{ name, text, trim?, order? }] joined in order. `trim: "drop"` removes the whole
83
+ // section when the total is over `totalChars` (lowest `order` dropped first); `trim: "truncate"`
84
+ // cuts the section to the remaining budget. Sections without `trim` are never touched — they are
85
+ // the runner-authored frame. The payload carries a visible notice naming every trim.
86
+ export function composePrompt(sections, caps = PAYLOAD_CAPS) {
87
+ const secs = sections.map(s => ({ ...s, text: String(s?.text ?? "") }));
88
+ let total = secs.reduce((a, s) => a + s.text.length, 0);
89
+ const dropped = [];
90
+ const trimmable = secs.filter(s => s.trim).sort((a, b) => a.order - b.order);
91
+ for (const s of trimmable) {
92
+ if (total <= caps.totalChars) break;
93
+ if (s.trim === "drop") {
94
+ dropped.push(`${s.name} (${n(s.text.length)} chars dropped)`);
95
+ total -= s.text.length;
96
+ s.text = "";
97
+ } else {
98
+ const rest = total - s.text.length;
99
+ const budget = Math.max(0, caps.totalChars - rest);
100
+ dropped.push(`${s.name} (${n(s.text.length)} → ${n(budget)} chars)`);
101
+ s.text = s.text.slice(0, budget) + " …[truncated — payload hit the hard cap]";
102
+ total = rest + s.text.length;
103
+ }
104
+ }
105
+ const prompt = secs.map(s => s.text).join("")
106
+ + (dropped.length
107
+ ? `\n\n[PAYLOAD TRUNCATED: hard cap ${n(caps.totalChars)} chars — ${dropped.join("; ")}.]`
108
+ : "");
109
+ return {
110
+ prompt,
111
+ sections: secs.map(s => ({ name: s.name, chars: s.text.length })),
112
+ truncated: dropped.length > 0,
113
+ dropped,
114
+ };
115
+ }
@@ -16,6 +16,7 @@ import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project
16
16
  import { loadOrCreate } from "../lib/identity.mjs";
17
17
  import { signedHeaders } from "../lib/signed-fetch.mjs";
18
18
  import { ensureEnrolled } from "../lib/enroll.mjs";
19
+ import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
19
20
 
20
21
  const AGENT = process.argv[2];
21
22
  const DIR = process.argv[3] || process.cwd();
@@ -265,6 +266,10 @@ let consecFails = 0;
265
266
  // every live seat, so two working agents spent the evening reading the same sentence.
266
267
  let announced = "";
267
268
  let lastErrText = "";
269
+ // #5481: the turn exited 0 with a NULL/empty transcript — the Inception/Mercury trap. The provider
270
+ // burned its whole max_tokens budget on internal reasoning and returned a null completion; the
271
+ // runner used to read that silence as a clean turn while nothing was produced.
272
+ let lastEmptyOutput = false;
268
273
  const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
269
274
 
270
275
  // ---- undelivered wake messages (the runner owns delivery, not the hub) ----
@@ -311,7 +316,9 @@ function loadPending() {
311
316
  // classifyFailure's set (no bare /expired/) so a healthy transcript never trips it.
312
317
  const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
313
318
 
314
- function classifyFailure(exit, errText) {
319
+ function classifyFailure(exit, errText, emptyOutput = false) {
320
+ // #5481: silence with a clean exit is a failure shape, not success — see lastEmptyOutput.
321
+ if (emptyOutput) return "empty-output";
315
322
  const t = (errText || "").toLowerCase();
316
323
  if (exit === 127) return "missing-cli";
317
324
  // #5684: a provider BACKEND failure is not quota — it wants retry/swap, not a window wait.
@@ -328,14 +335,20 @@ function classifyFailure(exit, errText) {
328
335
 
329
336
  async function reportFailure(exit, trigger, undelivered = 0) {
330
337
  consecFails++;
331
- const reason = classifyFailure(exit, lastErrText);
338
+ const reason = classifyFailure(exit, lastErrText, lastEmptyOutput);
332
339
  const down = consecFails >= 2;
333
340
  const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
334
341
  await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
335
342
  const hint = reason === "exhausted" ? " — needs `trantor swap`"
336
343
  : reason === "auth" ? " — check credentials"
337
344
  : reason === "backend-error" ? " — provider backend error (NOT quota): retry, or `trantor swap` to another provider"
338
- : reason === "missing-cli" ? " — CLI not on PATH" : "";
345
+ : reason === "missing-cli" ? " — CLI not on PATH"
346
+ // #5481: name the suspected trap, not just the symptom — the dial lives in the provider's
347
+ // opencode model config (limit.output), not in the runner.
348
+ : reason === "empty-output" ? (AGENT === "inception"
349
+ ? " — inception: raise max_tokens — diffusion burns budget on reasoning"
350
+ : " — exit 0 with NULL output: raise the provider's max_tokens (reasoning may be eating the budget)")
351
+ : "";
339
352
  // The count of messages this seat is HOLDING is the operator-actionable half of a failure: a
340
353
  // crashed pulse costs nothing, a crashed turn sitting on three escalations is someone waiting.
341
354
  const held = undelivered ? ` · holding ${undelivered} undelivered message${undelivered > 1 ? "s" : ""} (will retry)` : "";
@@ -423,7 +436,8 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
423
436
  cmuxStatus("building", "#4a90d9", "hammer", { priority: 50 }); herdrAgent("working");
424
437
  // inherit stdio so the window shows the agent working live; also capture for sid-parsing.
425
438
  // Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
426
- try { appendFileSync(ERRF, "", { flag: "w" }); } catch {}
439
+ try { appendFileSync(ERRF, "", { flag: "w" }); } catch {} // truncate
440
+ lastEmptyOutput = false;
427
441
  // pipefail: without it the sid-capture `| tee` makes a FAILED turn exit 0 (tee's status),
428
442
  // so the failure reporter never fires and a dead seat heartbeats green on the bus.
429
443
  // A CLI's own explanation for quitting often goes to STDOUT, not stderr — Claude's usage-limit
@@ -473,8 +487,20 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
473
487
  effExit = 1;
474
488
  log("\x1b[31mexit 0 but turn output shows an auth failure — treating as FAILED (auth)\x1b[0m");
475
489
  }
476
- telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit });
477
- log(`turn ended (exit ${realExit}${effExit !== realExit ? ` effective ${effExit} (auth)` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
490
+ // #5481: the Inception/Mercury trap exit 0 with a NULL completion. ERRF is the TOTAL output
491
+ // capture, not just stderr: every seat's stdout is tee'd into it (`| tee -a ERRF` for the
492
+ // opencode family, `| tee /dev/stderr` + the stderr tee for sid seats — line ~448). So an
493
+ // empty ERRF on a clean exit means the turn produced nothing on EITHER stream — and every
494
+ // real CLI prints something on success (drill C pins that), so silence is the trap, not a
495
+ // quiet victory. (Integration note: this was nearly "fixed" into stdout-only detection that
496
+ // never fired — the tee topology is the load-bearing fact; keep this comment with it.)
497
+ if (realExit === 0 && effExit === 0 && !lastErrText.trim()) {
498
+ effExit = 1;
499
+ lastEmptyOutput = true;
500
+ log("\x1b[31mexit 0 but the turn produced NO output — treating as FAILED (empty-output)\x1b[0m");
501
+ }
502
+ telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput });
503
+ log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
478
504
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
479
505
  return effExit;
480
506
  }
@@ -483,14 +509,35 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
483
509
  const KICKOFF = process.env.CREW_KICKOFF ||
484
510
  `You just joined (your arrival was already announced on the bus). 1) relay_inbox — if a contract for you is already waiting, do it now per the Rules. 2) End your turn.\n\n${RULES}`;
485
511
 
486
- let LESSONS = "";
512
+ let LESSONS_RAW = [];
487
513
  async function loadLessons() {
488
514
  try {
489
515
  const { lessons } = await api(`/lessons?agent=${encodeURIComponent(AGENT)}`);
490
- if (lessons?.length) LESSONS = "\n\nLESSONS from previous crews (hard-won — follow them):\n" + lessons.map(l => `- [${l.scope}] ${l.text}`).join("\n");
516
+ if (lessons?.length) LESSONS_RAW = lessons;
491
517
  } catch {}
492
518
  }
493
519
 
520
+ // card #5683: every section of a turn prompt is capped (bin/crew-payload.mjs) and the whole
521
+ // payload has ONE hard total cap. Codex burned 306k tokens into a remote-compact 404 crash-loop
522
+ // because a resumed session re-fed the full lessons block (22,298 of the 24,698 chars in its last
523
+ // turn file — 90%) plus an unbounded broadcast backlog on EVERY turn, redelivery after redelivery.
524
+ // Below the caps the composition is byte-identical to the old concatenation.
525
+ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "", tailText = "", rulesText = "", lessons = null }) {
526
+ const built = composePrompt([
527
+ { name: "base", text: base },
528
+ { name: "wake", text: wakeText, trim: "truncate", order: 4 },
529
+ { name: "ctx", text: ctxText, trim: "drop", order: 1 },
530
+ { name: "again", text: againText },
531
+ { name: "tail", text: tailText },
532
+ { name: "rules", text: rulesText, trim: "drop", order: 3 },
533
+ { name: "lessons", text: lessons?.text || "", trim: "drop", order: 2 },
534
+ ]);
535
+ const parts = built.sections.filter(s => s.chars).map(s => `${s.name} ${s.chars.toLocaleString("en-US")}c`).join(" · ");
536
+ const lessonsNote = lessons && lessons.total ? ` (lessons ${lessons.kept}/${lessons.total})` : "";
537
+ log(`payload: ${parts}${lessonsNote} → ${built.prompt.length.toLocaleString("en-US")}c${built.truncated ? ` \x1b[33mTRUNCATED — ${built.dropped.join("; ")}\x1b[0m` : ""}`);
538
+ return built.prompt;
539
+ }
540
+
494
541
  (async () => {
495
542
  await loadLessons();
496
543
  // start cursor at the CURRENT tip so we don't replay history
@@ -520,7 +567,7 @@ async function loadLessons() {
520
567
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
521
568
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
522
569
 
523
- const ec0 = runTurn(KICKOFF + LESSONS, true, "kickoff");
570
+ const ec0 = runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
524
571
  if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
525
572
  let lastTurnAt = Date.now();
526
573
  if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
@@ -530,7 +577,7 @@ async function loadLessons() {
530
577
  // pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
531
578
  // last turn, so a long turn doesn't stack an immediate pulse on top of itself.
532
579
  if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
533
- const ecp = runTurn(PULSE_PROMPT + "\n\n" + RULES + LESSONS, false, "pulse");
580
+ const ecp = runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
534
581
  if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
535
582
  lastTurnAt = Date.now();
536
583
  log("parked — waiting for the next message or pulse");
@@ -589,22 +636,33 @@ async function loadLessons() {
589
636
  // queued, on disk, with a backoff — which is the whole point of the change.
590
637
  async function deliverWake() {
591
638
  const wake = pendingWake;
592
- const ctx = pendingBcast.length ? `\nFYI broadcasts since your last turn (context only):\n${pendingBcast.map(m => `[${m.from} -> all]: ${m.text}`).join("\n")}\n` : "";
593
- const lines = wake.map(m => `[${m.from}${m.to === "all" ? " -> all (mentions you)" : ""}]: ${m.text}`).join("\n");
639
+ const wakeCapped = capWake(wake);
640
+ const bcastCapped = capBcast(pendingBcast);
641
+ const wakeText = wakeCapped.text
642
+ ? `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
643
+ : "";
644
+ const ctxText = bcastCapped.text
645
+ ? `\nFYI broadcasts since your last turn (context only):\n${bcastCapped.text}\n`
646
+ : "";
594
647
  // Say plainly that this is a second look. Without it the model re-reads an old escalation as
595
648
  // brand new and can redo work it already half-did before the turn died.
596
- const again = deliveryFails
649
+ const againText = deliveryFails
597
650
  ? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wake.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
598
651
  : "";
599
- const prompt = `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${lines}\n${ctx}${again}\nAct on what's addressed to you, then end your turn.\n\n${RULES}`;
600
652
  await loadLessons();
653
+ const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
601
654
  const trigger = wake.some(m => m.to === SESSION) ? "direct message" : "@mention";
602
655
  // Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
603
656
  const assigners = [];
604
657
  for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
605
658
  const asked = String(wake[0]?.text || "").replace(/\s+/g, " ").trim().slice(0, 90);
606
659
  const tStart = Date.now();
607
- const ec = runTurn(prompt + LESSONS, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
660
+ const prompt = composedTurn({
661
+ wakeText, ctxText, againText,
662
+ tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
663
+ rulesText: RULES, lessons,
664
+ });
665
+ const ec = runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
608
666
  const secs = Math.round((Date.now() - tStart) / 1000);
609
667
  if (ec) {
610
668
  deliveryFails++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.23",
3
+ "version": "0.18.25",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"