@ouro.bot/cli 0.1.0-alpha.725 → 0.1.0-alpha.727

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.
@@ -39,6 +39,8 @@ exports.parseOrphanPidsFromPs = parseOrphanPidsFromPs;
39
39
  exports.filterPidfilePidsToActualOrphans = filterPidfilePidsToActualOrphans;
40
40
  exports.mergeUniqueOrphanPids = mergeUniqueOrphanPids;
41
41
  exports.waitForOrphanProcessesToSettle = waitForOrphanProcessesToSettle;
42
+ exports.assertOrphanCleanupComplete = assertOrphanCleanupComplete;
43
+ exports.drainOrphanProcessesBeforeStartup = drainOrphanProcessesBeforeStartup;
42
44
  exports.killOrphanProcesses = killOrphanProcesses;
43
45
  exports.writePidfile = writePidfile;
44
46
  exports.handleAgentSenseTurn = handleAgentSenseTurn;
@@ -124,7 +126,8 @@ function parseOrphanPidsFromPs(psOutput, selfPid) {
124
126
  && !line.includes("bluebubbles/entry.js")
125
127
  && !line.includes("mail-entry.js")
126
128
  && !line.includes("teams-entry.js")
127
- && !line.includes("a2a-entry.js"))
129
+ && !line.includes("a2a-entry.js")
130
+ && !line.includes("voice-entry.js"))
128
131
  continue;
129
132
  // Parse `<pid> <ppid> <command...>`. ps pads these with leading spaces.
130
133
  // Regex guarantees both groups are \d+ so parseInt can't produce NaN.
@@ -228,6 +231,41 @@ async function waitForOrphanProcessesToSettle(pids, deps = {}) {
228
231
  }
229
232
  return survivors;
230
233
  }
234
+ function assertOrphanCleanupComplete(survivingPids) {
235
+ if (survivingPids.length === 0)
236
+ return;
237
+ const errorReason = `refusing startup while ${survivingPids.length} orphaned Ouro processes remain: ${survivingPids.join(", ")}`;
238
+ (0, runtime_1.emitNervesEvent)({
239
+ level: "error",
240
+ component: "daemon",
241
+ event: "daemon.orphan_cleanup_incomplete",
242
+ message: errorReason,
243
+ meta: { survivingPids },
244
+ });
245
+ throw new Error(errorReason);
246
+ }
247
+ const ORPHAN_CLEANUP_MAX_SIGNAL_PASSES = 8;
248
+ async function drainOrphanProcessesBeforeStartup(socketPath = socket_client_1.DEFAULT_DAEMON_SOCKET_PATH, deps = {}) {
249
+ const signalOrphans = deps.signalOrphans ?? killOrphanProcesses;
250
+ const settleOrphans = deps.settleOrphans ?? waitForOrphanProcessesToSettle;
251
+ const maxSignalPasses = deps.maxSignalPasses ?? ORPHAN_CLEANUP_MAX_SIGNAL_PASSES;
252
+ for (let pass = 0; pass < maxSignalPasses; pass += 1) {
253
+ const signaledPids = signalOrphans(socketPath);
254
+ if (signaledPids.length === 0)
255
+ return;
256
+ const survivingPids = await settleOrphans(signaledPids);
257
+ assertOrphanCleanupComplete(survivingPids);
258
+ }
259
+ const errorReason = `orphan cleanup did not reach a fixed point after ${maxSignalPasses} signaling round(s)`;
260
+ (0, runtime_1.emitNervesEvent)({
261
+ level: "error",
262
+ component: "daemon",
263
+ event: "daemon.orphan_cleanup_not_converged",
264
+ message: errorReason,
265
+ meta: { maxSignalPasses },
266
+ });
267
+ throw new Error(errorReason);
268
+ }
231
269
  /* v8 ignore start -- shells out to ps; covered by filterPidfilePidsToActualOrphans unit tests via injected runner @preserve */
232
270
  function runPsCheck(pids) {
233
271
  try {
@@ -305,7 +343,10 @@ function killOrphanProcesses(socketPath = socket_client_1.DEFAULT_DAEMON_SOCKET_
305
343
  const result = (0, child_process_1.execSync)("ps -eo pid,ppid,command", { encoding: "utf-8", timeout: 5000 });
306
344
  scanOrphans = parseOrphanPidsFromPs(result, process.pid);
307
345
  }
308
- catch { /* ps failed — best effort */ }
346
+ catch (error) {
347
+ const detail = error instanceof Error ? error.message : String(error);
348
+ throw new Error(`could not discover orphaned Ouro processes: ${detail}`);
349
+ }
309
350
  const pidsToKill = mergeUniqueOrphanPids(pidfileOrphans, scanOrphans);
310
351
  if (pidsToKill.length > 0) {
311
352
  for (const pid of pidsToKill) {
@@ -325,13 +366,13 @@ function killOrphanProcesses(socketPath = socket_client_1.DEFAULT_DAEMON_SOCKET_
325
366
  }
326
367
  catch (error) {
327
368
  (0, runtime_1.emitNervesEvent)({
328
- level: "warn",
369
+ level: "error",
329
370
  component: "daemon",
330
371
  event: "daemon.orphan_cleanup_error",
331
372
  message: "failed to clean up orphaned ouro processes",
332
373
  meta: { error: error instanceof Error ? error.message : String(error) },
333
374
  });
334
- return [];
375
+ throw error instanceof Error ? error : new Error(String(error));
335
376
  }
336
377
  }
337
378
  /**
@@ -575,6 +616,7 @@ class OuroDaemon {
575
616
  onStopCommandComplete;
576
617
  externalEventRoot;
577
618
  rsvpHabitRunner;
619
+ orphanStartupDrain;
578
620
  constructor(options) {
579
621
  this.socketPath = options.socketPath;
580
622
  this.processManager = options.processManager;
@@ -589,6 +631,7 @@ class OuroDaemon {
589
631
  this.onStopCommandComplete = options.onStopCommandComplete ?? null;
590
632
  this.externalEventRoot = options.externalEventRoot ?? null;
591
633
  this.rsvpHabitRunner = options.rsvpHabitRunner;
634
+ this.orphanStartupDrain = options.orphanStartupDrain ?? drainOrphanProcessesBeforeStartup;
592
635
  }
593
636
  /* v8 ignore start -- default mailbox server wiring: production-only path, tests inject mailboxServerFactory stub instead. startMailboxHttpServer itself has full coverage in mailbox-http.test.ts @preserve */
594
637
  createDefaultMailboxServer() {
@@ -740,10 +783,7 @@ class OuroDaemon {
740
783
  }
741
784
  // MCP connections are lazily initialized per-agent during senseTurn
742
785
  // (daemon manages multiple agents; agent identity must be set before loading MCP config)
743
- /* v8 ignore start -- orphan cleanup + pidfile: calls process management functions @preserve */
744
- const killedOrphanPids = killOrphanProcesses(this.socketPath);
745
- await waitForOrphanProcessesToSettle(killedOrphanPids);
746
- /* v8 ignore stop */
786
+ await this.orphanStartupDrain(this.socketPath);
747
787
  await this.openCommandSocket();
748
788
  this.triggerAutoStartAgents();
749
789
  this.triggerAutoStartSensesWhenAgentsSettled();
@@ -1049,8 +1089,12 @@ class OuroDaemon {
1049
1089
  clearTimeout(this.senseAutostartTimer);
1050
1090
  this.senseAutostartTimer = null;
1051
1091
  }
1052
- await this.processManager.stopAll();
1053
- await this.senseManager?.stopAll();
1092
+ const workerStopTasks = [Promise.resolve().then(() => this.processManager.stopAll())];
1093
+ if (this.senseManager) {
1094
+ workerStopTasks.push(Promise.resolve().then(() => this.senseManager.stopAll()));
1095
+ }
1096
+ const workerStopResults = await Promise.allSettled(workerStopTasks);
1097
+ const workerStopFailures = workerStopResults.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
1054
1098
  if (this.server) {
1055
1099
  // DO NOT `await` server.close() here. server.close() resolves only
1056
1100
  // after every open connection has closed. When stop() is invoked
@@ -1113,6 +1157,19 @@ class OuroDaemon {
1113
1157
  });
1114
1158
  }
1115
1159
  this.socketIdentity = null;
1160
+ if (workerStopFailures.length > 0) {
1161
+ const errorReason = `daemon shutdown could not drain ${workerStopFailures.length} managed worker group(s)`;
1162
+ (0, runtime_1.emitNervesEvent)({
1163
+ level: "error",
1164
+ component: "daemon",
1165
+ event: "daemon.worker_shutdown_error",
1166
+ message: errorReason,
1167
+ meta: {
1168
+ failures: workerStopFailures.map((error) => error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error rejection @preserve */ String(error)),
1169
+ },
1170
+ });
1171
+ throw new AggregateError(workerStopFailures, errorReason);
1172
+ }
1116
1173
  }
1117
1174
  async handleRawPayload(raw) {
1118
1175
  try {
@@ -1477,8 +1534,12 @@ class OuroDaemon {
1477
1534
  await this.start();
1478
1535
  return { ok: true, message: "daemon started" };
1479
1536
  case "daemon.stop":
1480
- await this.stop();
1481
- this.onStopCommandComplete?.();
1537
+ try {
1538
+ await this.stop();
1539
+ }
1540
+ finally {
1541
+ this.onStopCommandComplete?.();
1542
+ }
1482
1543
  return { ok: true, message: "daemon stopped" };
1483
1544
  case "daemon.restart": {
1484
1545
  // Restart is "stop + let launchctl respawn." Under launchctl's KeepAlive
@@ -1494,8 +1555,12 @@ class OuroDaemon {
1494
1555
  requestedBy: command.requestedBy ?? null,
1495
1556
  },
1496
1557
  });
1497
- await this.stop();
1498
- this.onStopCommandComplete?.();
1558
+ try {
1559
+ await this.stop();
1560
+ }
1561
+ finally {
1562
+ this.onStopCommandComplete?.();
1563
+ }
1499
1564
  return {
1500
1565
  ok: true,
1501
1566
  message: "daemon restarting — launchctl will respawn",
@@ -56,6 +56,9 @@ exports.RESPAWN_GUARD_WINDOW_MS = 10 * 60_000;
56
56
  const MAX_PENDING_IPC_MESSAGES = 20;
57
57
  class DaemonProcessManager {
58
58
  agents = new Map();
59
+ /** `stopAll()` is terminal for a manager instance. Once set, it fences
60
+ * detached restart/start continuations until the owning daemon exits. */
61
+ shutdownRequested = false;
59
62
  maxRestartsPerHour;
60
63
  stabilityThresholdMs;
61
64
  initialBackoffMs;
@@ -67,6 +70,7 @@ class DaemonProcessManager {
67
70
  cooldownRecoveryMs;
68
71
  maxCooldownRetries;
69
72
  startupStaleAfterMs;
73
+ stopTimeoutMs;
70
74
  existsSyncFn;
71
75
  configCheckFn;
72
76
  statusWriterFn;
@@ -143,6 +147,7 @@ class DaemonProcessManager {
143
147
  this.cooldownRecoveryMs = options.cooldownRecoveryMs ?? 5 * 60 * 1_000;
144
148
  this.maxCooldownRetries = options.maxCooldownRetries ?? 3;
145
149
  this.startupStaleAfterMs = options.startupStaleAfterMs ?? 45_000;
150
+ this.stopTimeoutMs = options.stopTimeoutMs ?? 4_000;
146
151
  this.spawnFn = options.spawn ?? ((command, args, spawnOptions) => (0, child_process_1.spawn)(command, args, spawnOptions));
147
152
  this.now = options.now ?? (() => Date.now());
148
153
  this.setTimeoutFn = options.setTimeoutFn ?? ((cb, delay) => setTimeout(cb, delay));
@@ -169,6 +174,7 @@ class DaemonProcessManager {
169
174
  cooldownTimer: null,
170
175
  cooldownRetryCount: 0,
171
176
  fastCrashCount: 0,
177
+ stopFailureActive: false,
172
178
  snapshot: {
173
179
  name: agent.name,
174
180
  channel: agent.channel,
@@ -206,6 +212,8 @@ class DaemonProcessManager {
206
212
  }
207
213
  async startAgent(agent, options = {}) {
208
214
  const state = this.requireAgent(agent);
215
+ if (this.shutdownRequested)
216
+ return;
209
217
  if (state.process || state.startInFlight)
210
218
  return;
211
219
  const attemptId = state.startAttemptId + 1;
@@ -426,25 +434,87 @@ class DaemonProcessManager {
426
434
  return;
427
435
  }
428
436
  const child = state.process;
429
- state.process = null;
430
- state.snapshot.status = "stopped";
431
- state.snapshot.pid = null;
432
- try {
433
- child.kill("SIGTERM");
434
- }
435
- catch {
436
- (0, runtime_1.emitNervesEvent)({
437
- level: "warn",
438
- component: "daemon",
439
- event: "daemon.agent_stop_error",
440
- message: "failed to send SIGTERM to managed agent",
441
- meta: { agent },
442
- });
443
- }
444
- this.notifySnapshotChange(state.snapshot);
437
+ const childPid = child.pid ?? null;
438
+ const childPidLabel = childPid === null ? "unknown" : String(childPid);
439
+ await new Promise((resolve, reject) => {
440
+ let settled = false;
441
+ let timeout = null;
442
+ const finish = (error) => {
443
+ /* v8 ignore next -- defensive idempotence if an injected timer fires after cancellation @preserve */
444
+ if (settled)
445
+ return;
446
+ settled = true;
447
+ child.removeListener("exit", onExit);
448
+ if (timeout !== null)
449
+ this.clearTimeoutFn(timeout);
450
+ if (error)
451
+ reject(error);
452
+ else
453
+ resolve();
454
+ };
455
+ const onExit = () => finish();
456
+ child.once("exit", onExit);
457
+ let signalAccepted = false;
458
+ try {
459
+ signalAccepted = child.kill("SIGTERM");
460
+ }
461
+ catch {
462
+ const errorReason = "failed to send SIGTERM to managed agent; worker remains tracked";
463
+ state.stopFailureActive = true;
464
+ state.snapshot.errorReason = errorReason;
465
+ state.snapshot.fixHint = `Inspect managed worker PID ${childPidLabel} before retrying the stop or restart.`;
466
+ (0, runtime_1.emitNervesEvent)({
467
+ level: "error",
468
+ component: "daemon",
469
+ event: "daemon.agent_stop_error",
470
+ message: errorReason,
471
+ meta: { agent, pid: childPid },
472
+ });
473
+ this.notifySnapshotChange(state.snapshot);
474
+ finish(new Error(errorReason));
475
+ return;
476
+ }
477
+ if (!signalAccepted) {
478
+ const errorReason = "managed agent rejected SIGTERM; worker remains tracked";
479
+ state.stopFailureActive = true;
480
+ state.snapshot.errorReason = errorReason;
481
+ state.snapshot.fixHint = `Inspect managed worker PID ${childPidLabel} before retrying the stop or restart.`;
482
+ (0, runtime_1.emitNervesEvent)({
483
+ level: "error",
484
+ component: "daemon",
485
+ event: "daemon.agent_stop_error",
486
+ message: errorReason,
487
+ meta: { agent, pid: childPid },
488
+ });
489
+ this.notifySnapshotChange(state.snapshot);
490
+ finish(new Error(errorReason));
491
+ return;
492
+ }
493
+ if (settled)
494
+ return;
495
+ timeout = this.setTimeoutFn(() => {
496
+ if (settled)
497
+ return;
498
+ const errorReason = `managed agent did not exit within ${this.stopTimeoutMs}ms; replacement was not started`;
499
+ state.stopFailureActive = true;
500
+ state.snapshot.errorReason = errorReason;
501
+ state.snapshot.fixHint = `Inspect managed worker PID ${childPidLabel}; retry only after that worker has exited.`;
502
+ (0, runtime_1.emitNervesEvent)({
503
+ level: "error",
504
+ component: "daemon",
505
+ event: "daemon.agent_stop_timeout",
506
+ message: errorReason,
507
+ meta: { agent, pid: childPid, timeoutMs: this.stopTimeoutMs },
508
+ });
509
+ this.notifySnapshotChange(state.snapshot);
510
+ finish(new Error(errorReason));
511
+ }, this.stopTimeoutMs);
512
+ });
445
513
  }
446
514
  async restartAgent(agent, options = {}) {
447
515
  const state = this.requireAgent(agent);
516
+ if (this.shutdownRequested)
517
+ return;
448
518
  // Respawn-loop guard: prune timestamps outside the window, then check
449
519
  // whether we've already restarted this agent too many times in it.
450
520
  const now = this.now();
@@ -528,8 +598,14 @@ class DaemonProcessManager {
528
598
  await this.startAgent(agent, options);
529
599
  }
530
600
  async stopAll() {
531
- for (const state of this.agents.values()) {
532
- await this.stopAgent(state.config.name);
601
+ this.shutdownRequested = true;
602
+ const states = [...this.agents.values()];
603
+ const results = await Promise.allSettled(states.map((state) => this.stopAgent(state.config.name)));
604
+ const failures = results.flatMap((result, index) => result.status === "rejected"
605
+ ? [{ agent: states[index].config.name, reason: result.reason }]
606
+ : []);
607
+ if (failures.length > 0) {
608
+ throw new AggregateError(failures.map((failure) => failure.reason), `failed to stop ${failures.length} managed worker(s): ${failures.map((failure) => failure.agent).join(", ")}`);
533
609
  }
534
610
  }
535
611
  resetAgentFailureState(agent) {
@@ -582,6 +658,7 @@ class DaemonProcessManager {
582
658
  return [...this.agents.values()].map((state) => state.snapshot);
583
659
  }
584
660
  onExit(state, child, code, signal) {
661
+ /* v8 ignore next -- defensive: replacement cannot start before this child's one-shot exit listener drains @preserve */
585
662
  if (state.process !== child)
586
663
  return;
587
664
  state.process = null;
@@ -590,6 +667,11 @@ class DaemonProcessManager {
590
667
  state.snapshot.pid = null;
591
668
  state.snapshot.lastExitCode = code;
592
669
  state.snapshot.lastSignal = signal;
670
+ if (state.stopFailureActive) {
671
+ state.stopFailureActive = false;
672
+ state.snapshot.errorReason = null;
673
+ state.snapshot.fixHint = null;
674
+ }
593
675
  const crashed = !state.stopRequested && code !== 0;
594
676
  const now = this.currentTimeMs();
595
677
  const startedAt = state.snapshot.startedAt ? Date.parse(state.snapshot.startedAt) : now;
@@ -104,14 +104,13 @@ function parseToolCallInput(argumentsJson) {
104
104
  }
105
105
  }
106
106
  function toAnthropicMessages(messages) {
107
- let system;
107
+ const systemParts = [];
108
108
  const converted = [];
109
109
  for (const msg of messages) {
110
110
  if (msg.role === "system") {
111
- if (!system) {
112
- const text = toAnthropicTextContent(msg.content);
113
- system = text || undefined;
114
- }
111
+ const text = toAnthropicTextContent(msg.content).trim();
112
+ if (text)
113
+ systemParts.push(text);
115
114
  continue;
116
115
  }
117
116
  if (msg.role === "user") {
@@ -173,7 +172,7 @@ function toAnthropicMessages(messages) {
173
172
  });
174
173
  }
175
174
  }
176
- return { system, messages: converted };
175
+ return { system: systemParts.length > 0 ? systemParts.join("\n\n") : undefined, messages: converted };
177
176
  }
178
177
  function toAnthropicTools(tools) {
179
178
  return tools.map((tool) => ({
@@ -231,6 +230,11 @@ function isAnthropicAuthFailure(error) {
231
230
  }
232
231
  async function streamAnthropicMessages(client, model, request) {
233
232
  const { system, messages } = toAnthropicMessages(request.messages);
233
+ const additionalSystemParts = request.messages
234
+ .filter((message) => message.role === "system")
235
+ .slice(1)
236
+ .map((message) => toAnthropicTextContent(message.content).trim())
237
+ .filter((text) => text.length > 0);
234
238
  const anthropicTools = toAnthropicTools(request.activeTools);
235
239
  const modelCaps = (0, model_capabilities_1.getModelCapabilities)(model);
236
240
  const maxTokens = modelCaps.maxOutputTokens ?? 16384;
@@ -255,12 +259,13 @@ async function streamAnthropicMessages(client, model, request) {
255
259
  text: preambleText + "\n\n" + request.systemPrompt.stable,
256
260
  cache_control: { type: "ephemeral" },
257
261
  };
258
- if (request.systemPrompt.volatile) {
259
- params.system = [stableBlock, { type: "text", text: request.systemPrompt.volatile }];
260
- }
261
- else {
262
- params.system = [stableBlock];
263
- }
262
+ params.system = [
263
+ stableBlock,
264
+ ...(request.systemPrompt.volatile
265
+ ? [{ type: "text", text: request.systemPrompt.volatile }]
266
+ : []),
267
+ ...additionalSystemParts.map((text) => ({ type: "text", text })),
268
+ ];
264
269
  }
265
270
  else if (system) {
266
271
  // Fallback: no structured prompt, extract from messages (legacy path)
@@ -496,14 +496,14 @@ function toResponsesUserContent(content) {
496
496
  return parts.length > 0 ? parts : "";
497
497
  }
498
498
  function toResponsesInput(messages) {
499
- let instructions = "";
499
+ const instructionParts = [];
500
500
  const input = [];
501
501
  for (const msg of messages) {
502
502
  if (msg.role === "system") {
503
- if (!instructions) {
504
- const sys = msg;
505
- instructions = (typeof sys.content === "string" ? sys.content : "") || "";
506
- }
503
+ const sys = msg;
504
+ const content = (typeof sys.content === "string" ? sys.content : "").trim();
505
+ if (content)
506
+ instructionParts.push(content);
507
507
  continue;
508
508
  }
509
509
  if (msg.role === "user") {
@@ -551,7 +551,7 @@ function toResponsesInput(messages) {
551
551
  continue;
552
552
  }
553
553
  }
554
- return { instructions, input };
554
+ return { instructions: instructionParts.join("\n\n"), input };
555
555
  }
556
556
  function toResponsesTools(ccTools) {
557
557
  return ccTools.map((t) => ({
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PROMPT_BUDGET_FALLBACK_CONTEXT_WINDOW_TOKENS = exports.PROMPT_BUDGET_POLICY_VERSION = void 0;
4
4
  exports.estimatePromptBudgetTokensForMessage = estimatePromptBudgetTokensForMessage;
5
5
  exports.estimatePromptBudgetTokens = estimatePromptBudgetTokens;
6
+ exports.assessRequiredPromptEvidenceBudget = assessRequiredPromptEvidenceBudget;
6
7
  exports.applyPromptBudget = applyPromptBudget;
7
8
  const runtime_1 = require("../nerves/runtime");
8
9
  exports.PROMPT_BUDGET_POLICY_VERSION = "prompt-budget/v1";
@@ -89,6 +90,42 @@ function itemTokens(item) {
89
90
  function cloneMessage(message) {
90
91
  return { ...message };
91
92
  }
93
+ function requiredMessagesByIdentity(input) {
94
+ const evidence = input.requiredPromptEvidence;
95
+ if (!evidence)
96
+ return [];
97
+ const current = evidence.currentUserMessage;
98
+ if (current.role !== "user") {
99
+ throw new Error("required current user message must have role=user");
100
+ }
101
+ const currentMatches = input.messages.filter((message) => message === current);
102
+ if (currentMatches.length !== 1) {
103
+ throw new Error(currentMatches.length === 0
104
+ ? "required current user message is not present by identity"
105
+ : "required current user message must appear exactly once by identity");
106
+ }
107
+ const predecessor = evidence.verifiedPredecessorMessage;
108
+ if (predecessor) {
109
+ if (predecessor === current)
110
+ throw new Error("required predecessor and current user message must be distinct objects");
111
+ if (predecessor.role !== "system") {
112
+ throw new Error("required verified predecessor message must have role=system");
113
+ }
114
+ const predecessorMatches = input.messages.filter((message) => message === predecessor);
115
+ if (predecessorMatches.length !== 1) {
116
+ throw new Error(predecessorMatches.length === 0
117
+ ? "required verified predecessor message is not present by identity"
118
+ : "required verified predecessor message must appear exactly once by identity");
119
+ }
120
+ if (input.messages.indexOf(predecessor) !== input.messages.indexOf(current) - 1) {
121
+ throw new Error("required verified predecessor message must be immediately before the current user message");
122
+ }
123
+ }
124
+ return input.messages.filter((message) => message === current || message === predecessor);
125
+ }
126
+ function cloneUnlessRequired(message, requiredMessages) {
127
+ return requiredMessages.has(message) ? message : cloneMessage(message);
128
+ }
92
129
  function messageText(message) {
93
130
  return typeof message.content === "string" ? message.content : "";
94
131
  }
@@ -197,11 +234,35 @@ function nonSystemSourceFor(messages) {
197
234
  return "diagnostics";
198
235
  return "recent-session-tail";
199
236
  }
200
- function buildPromptBudgetItems(messages) {
237
+ function buildPromptBudgetItems(messages, requiredPromptEvidence) {
201
238
  const rawItems = [];
202
239
  let index = 0;
203
240
  while (index < messages.length) {
204
241
  const message = messages[index];
242
+ if (message === requiredPromptEvidence?.currentUserMessage) {
243
+ rawItems.push({
244
+ id: `required:${index}:current`,
245
+ priority: 1,
246
+ source: "current-user-message",
247
+ messages: [message],
248
+ originalMessageCount: 1,
249
+ required: "current-user",
250
+ });
251
+ index += 1;
252
+ continue;
253
+ }
254
+ if (message === requiredPromptEvidence?.verifiedPredecessorMessage) {
255
+ rawItems.push({
256
+ id: `required:${index}:predecessor`,
257
+ priority: 1,
258
+ source: "required-current-turn-state",
259
+ messages: [message],
260
+ originalMessageCount: 1,
261
+ required: "verified-predecessor",
262
+ });
263
+ index += 1;
264
+ continue;
265
+ }
205
266
  if (message.role === "system") {
206
267
  rawItems.push(...systemItems(message, index));
207
268
  index += 1;
@@ -236,6 +297,8 @@ function buildPromptBudgetItems(messages) {
236
297
  const lastUserItemIndex = rawItems.findLastIndex((item) => item.messages.some((message) => message.role === "user"));
237
298
  const latestToolItemIndex = rawItems.findLastIndex((item) => item.messages.some((message) => message.role === "tool" || assistantHasToolCalls(message)));
238
299
  return rawItems.map((item, itemIndex) => {
300
+ if (item.required)
301
+ return item;
239
302
  if (itemIndex === lastUserItemIndex) {
240
303
  return { ...item, priority: 1, source: "current-user-message" };
241
304
  }
@@ -254,7 +317,7 @@ function buildPromptBudgetItems(messages) {
254
317
  });
255
318
  }
256
319
  function flattenItems(items) {
257
- return items.flatMap((item) => item.messages.map(cloneMessage));
320
+ return items.flatMap((item) => item.messages.map((message) => item.required ? message : cloneMessage(message)));
258
321
  }
259
322
  function budgetFor(input) {
260
323
  const contextWindowTokens = typeof input.contextWindowTokens === "number" && Number.isFinite(input.contextWindowTokens) && input.contextWindowTokens > 0
@@ -273,6 +336,19 @@ function budgetFor(input) {
273
336
  inputTokenLimit: Math.max(0, contextWindowTokens - outputReserveTokens - protocolReserveTokens),
274
337
  };
275
338
  }
339
+ function assessRequiredPromptEvidenceBudget(input) {
340
+ const budget = budgetFor(input);
341
+ const messages = requiredMessagesByIdentity(input);
342
+ const estimatedTokens = estimatePromptBudgetTokens(messages);
343
+ return {
344
+ status: messages.length > 0 && estimatedTokens > budget.inputTokenLimit
345
+ ? "required_evidence_over_budget"
346
+ : "within_budget",
347
+ messages,
348
+ estimatedTokens,
349
+ budget,
350
+ };
351
+ }
276
352
  function totalItemTokens(items) {
277
353
  return estimatePromptBudgetTokens(flattenItems(items));
278
354
  }
@@ -304,15 +380,38 @@ function emitPromptBudgetEvent(result) {
304
380
  });
305
381
  }
306
382
  function applyPromptBudget(input) {
307
- const budget = budgetFor(input);
383
+ const requiredAssessment = assessRequiredPromptEvidenceBudget(input);
384
+ const budget = requiredAssessment.budget;
308
385
  const estimatedBeforeTokens = estimatePromptBudgetTokens(input.messages);
309
386
  const truncations = [];
387
+ const requiredMessages = requiredAssessment.messages;
388
+ const requiredMessageSet = new Set(requiredMessages);
310
389
  let droppedMessages = 0;
390
+ const requiredTokens = requiredAssessment.estimatedTokens;
391
+ if (requiredAssessment.status === "required_evidence_over_budget") {
392
+ droppedMessages = input.messages.length - requiredMessages.length;
393
+ const result = {
394
+ policyVersion: exports.PROMPT_BUDGET_POLICY_VERSION,
395
+ status: "required_evidence_over_budget",
396
+ messages: requiredMessages,
397
+ budget,
398
+ stats: {
399
+ estimatedBeforeTokens,
400
+ estimatedAfterTokens: requiredTokens,
401
+ originalMessages: input.messages.length,
402
+ finalMessages: requiredMessages.length,
403
+ droppedMessages,
404
+ truncations,
405
+ },
406
+ };
407
+ emitPromptBudgetEvent(result);
408
+ return result;
409
+ }
311
410
  if (estimatedBeforeTokens <= budget.inputTokenLimit) {
312
411
  const result = {
313
412
  policyVersion: exports.PROMPT_BUDGET_POLICY_VERSION,
314
413
  status: "within_budget",
315
- messages: input.messages.map(cloneMessage),
414
+ messages: input.messages.map((message) => cloneUnlessRequired(message, requiredMessageSet)),
316
415
  budget,
317
416
  stats: {
318
417
  estimatedBeforeTokens,
@@ -326,7 +425,7 @@ function applyPromptBudget(input) {
326
425
  emitPromptBudgetEvent(result);
327
426
  return result;
328
427
  }
329
- let items = buildPromptBudgetItems(input.messages);
428
+ let items = buildPromptBudgetItems(input.messages, input.requiredPromptEvidence);
330
429
  for (const priority of [8, 7, 6, 5, 4, 3]) {
331
430
  if (totalItemTokens(items) <= budget.inputTokenLimit)
332
431
  break;
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.surfaceToolDef = exports.speakTool = exports.restTool = exports.ponderTool = exports.observeTool = exports.settleTool = exports.tools = void 0;
4
4
  exports.resetMcpDefinitions = resetMcpDefinitions;
5
5
  exports.getToolsForChannel = getToolsForChannel;
6
- exports.getRestrictedReactionFeedbackTools = getRestrictedReactionFeedbackTools;
7
6
  exports.resolveToolDefinition = resolveToolDefinition;
8
7
  exports.shellRiskProfile = shellRiskProfile;
9
8
  exports.riskProfileForTool = riskProfileForTool;
@@ -127,15 +126,6 @@ function getToolsForChannel(capabilities, toolPreferences, _context, providerCap
127
126
  }
128
127
  return filterByCapability(result, providerCapabilities);
129
128
  }
130
- /**
131
- * The complete provider-facing tool surface for trusted reaction feedback.
132
- * Keep these schemas sourced from the normal registry so restricted turns
133
- * cannot drift onto lookalike definitions or inherit channel/MCP tools.
134
- */
135
- function getRestrictedReactionFeedbackTools() {
136
- const orientationGetTool = tools_base_1.baseToolDefinitions.find((definition) => definition.tool.function.name === "orientation_get").tool;
137
- return [tools_base_1.settleTool, tools_base_1.observeTool, orientationGetTool];
138
- }
139
129
  // Look up a tool definition from the live combined registry (native + MCP).
140
130
  // The base registry is intentionally consulted at call time so tests and
141
131
  // runtime extensions cannot leave execution metadata behind a stale snapshot.