trantor 0.18.23 → 0.18.24

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.24",
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();
@@ -483,14 +484,35 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
483
484
  const KICKOFF = process.env.CREW_KICKOFF ||
484
485
  `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
486
 
486
- let LESSONS = "";
487
+ let LESSONS_RAW = [];
487
488
  async function loadLessons() {
488
489
  try {
489
490
  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");
491
+ if (lessons?.length) LESSONS_RAW = lessons;
491
492
  } catch {}
492
493
  }
493
494
 
495
+ // card #5683: every section of a turn prompt is capped (bin/crew-payload.mjs) and the whole
496
+ // payload has ONE hard total cap. Codex burned 306k tokens into a remote-compact 404 crash-loop
497
+ // because a resumed session re-fed the full lessons block (22,298 of the 24,698 chars in its last
498
+ // turn file — 90%) plus an unbounded broadcast backlog on EVERY turn, redelivery after redelivery.
499
+ // Below the caps the composition is byte-identical to the old concatenation.
500
+ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "", tailText = "", rulesText = "", lessons = null }) {
501
+ const built = composePrompt([
502
+ { name: "base", text: base },
503
+ { name: "wake", text: wakeText, trim: "truncate", order: 4 },
504
+ { name: "ctx", text: ctxText, trim: "drop", order: 1 },
505
+ { name: "again", text: againText },
506
+ { name: "tail", text: tailText },
507
+ { name: "rules", text: rulesText, trim: "drop", order: 3 },
508
+ { name: "lessons", text: lessons?.text || "", trim: "drop", order: 2 },
509
+ ]);
510
+ const parts = built.sections.filter(s => s.chars).map(s => `${s.name} ${s.chars.toLocaleString("en-US")}c`).join(" · ");
511
+ const lessonsNote = lessons && lessons.total ? ` (lessons ${lessons.kept}/${lessons.total})` : "";
512
+ log(`payload: ${parts}${lessonsNote} → ${built.prompt.length.toLocaleString("en-US")}c${built.truncated ? ` \x1b[33mTRUNCATED — ${built.dropped.join("; ")}\x1b[0m` : ""}`);
513
+ return built.prompt;
514
+ }
515
+
494
516
  (async () => {
495
517
  await loadLessons();
496
518
  // start cursor at the CURRENT tip so we don't replay history
@@ -520,7 +542,7 @@ async function loadLessons() {
520
542
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
521
543
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
522
544
 
523
- const ec0 = runTurn(KICKOFF + LESSONS, true, "kickoff");
545
+ const ec0 = runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
524
546
  if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
525
547
  let lastTurnAt = Date.now();
526
548
  if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
@@ -530,7 +552,7 @@ async function loadLessons() {
530
552
  // pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
531
553
  // last turn, so a long turn doesn't stack an immediate pulse on top of itself.
532
554
  if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
533
- const ecp = runTurn(PULSE_PROMPT + "\n\n" + RULES + LESSONS, false, "pulse");
555
+ const ecp = runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
534
556
  if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
535
557
  lastTurnAt = Date.now();
536
558
  log("parked — waiting for the next message or pulse");
@@ -589,22 +611,33 @@ async function loadLessons() {
589
611
  // queued, on disk, with a backoff — which is the whole point of the change.
590
612
  async function deliverWake() {
591
613
  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");
614
+ const wakeCapped = capWake(wake);
615
+ const bcastCapped = capBcast(pendingBcast);
616
+ const wakeText = wakeCapped.text
617
+ ? `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
618
+ : "";
619
+ const ctxText = bcastCapped.text
620
+ ? `\nFYI broadcasts since your last turn (context only):\n${bcastCapped.text}\n`
621
+ : "";
594
622
  // Say plainly that this is a second look. Without it the model re-reads an old escalation as
595
623
  // brand new and can redo work it already half-did before the turn died.
596
- const again = deliveryFails
624
+ const againText = deliveryFails
597
625
  ? `\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
626
  : "";
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
627
  await loadLessons();
628
+ const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
601
629
  const trigger = wake.some(m => m.to === SESSION) ? "direct message" : "@mention";
602
630
  // Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
603
631
  const assigners = [];
604
632
  for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
605
633
  const asked = String(wake[0]?.text || "").replace(/\s+/g, " ").trim().slice(0, 90);
606
634
  const tStart = Date.now();
607
- const ec = runTurn(prompt + LESSONS, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
635
+ const prompt = composedTurn({
636
+ wakeText, ctxText, againText,
637
+ tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
638
+ rulesText: RULES, lessons,
639
+ });
640
+ const ec = runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
608
641
  const secs = Math.round((Date.now() - tStart) / 1000);
609
642
  if (ec) {
610
643
  deliveryFails++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.23",
3
+ "version": "0.18.24",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"