@ouro.bot/cli 0.1.0-alpha.807 → 0.1.0-alpha.809

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.
@@ -553,11 +553,6 @@ function messageContentText(content) {
553
553
  .filter(Boolean)
554
554
  .join("\n");
555
555
  }
556
- function isHarnessCorrectiveUserText(text) {
557
- return text.startsWith("no tool was called this turn. you must end every turn")
558
- || text.startsWith("private-return acknowledgement claimed work was queued, but no ponder packet was created this turn.")
559
- || text.startsWith("this exact request previously reached a tool that is advertised again now.");
560
- }
561
556
  function toolResultIndicatesFailure(content) {
562
557
  const normalized = content.trim().toLowerCase();
563
558
  return normalized.startsWith("error:")
@@ -598,7 +593,7 @@ function historicalFailedEffectsForExactRepeatedRequest(messages) {
598
593
  if (message?.role !== "user")
599
594
  continue;
600
595
  const text = messageContentText(message.content).replace(/\s+/g, " ").trim();
601
- if (!text || isHarnessCorrectiveUserText(text))
596
+ if (!text)
602
597
  continue;
603
598
  userRequests.push({ index, text });
604
599
  }
@@ -643,8 +638,6 @@ function latestUserMessageText(messages) {
643
638
  if (message?.role !== "user")
644
639
  continue;
645
640
  const text = messageContentText(message.content).trim();
646
- if (isHarnessCorrectiveUserText(text))
647
- continue;
648
641
  if (text.length > 0)
649
642
  return text;
650
643
  }
@@ -960,9 +953,15 @@ function buildAuthFailureGuidance(provider, model, agentName, detail) {
960
953
  }
961
954
  async function runAgent(messages, callbacks, channel, signal, options) {
962
955
  const generatedMessages = [];
956
+ let nextAttemptControls = [];
957
+ let rejectedAttempt = [];
963
958
  const pushGenerated = (...next) => {
964
959
  messages.push(...next);
965
960
  generatedMessages.push(...structuredClone(next));
961
+ if (rejectedAttempt.length > 0) {
962
+ rejectedAttempt = [];
963
+ providerRuntime.resetTurnState(messages);
964
+ }
966
965
  };
967
966
  const facing = (0, friends_1.channelToFacing)(channel);
968
967
  let providerRuntime = options?.providerRuntimeOverride ?? await getProviderRuntime(facing);
@@ -1127,10 +1126,14 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1127
1126
  let providerIterations = 0;
1128
1127
  const requiredToolCallNames = [...new Set(options?.requiredToolCalls?.names ?? [])];
1129
1128
  const dispatchedRequiredToolCalls = new Set();
1130
- const requiredCorrectionMarker = Symbol("requiredCorrection");
1131
- const messagesWithoutRequiredCorrections = () => messages.filter((message) => message[requiredCorrectionMarker] !== true);
1132
- const removeRequiredCorrections = () => {
1133
- messages.splice(0, messages.length, ...messagesWithoutRequiredCorrections());
1129
+ const rejectAttempt = (assistant, controls) => {
1130
+ rejectedAttempt = [structuredClone(assistant)];
1131
+ nextAttemptControls = controls;
1132
+ };
1133
+ const rejectToolBatch = (assistant, reasons) => {
1134
+ rejectAttempt(assistant, assistant.tool_calls.map((call, index) => ({
1135
+ role: "tool", tool_call_id: call.id, content: reasons[index],
1136
+ })));
1134
1137
  };
1135
1138
  const pendingRequiredToolCalls = () => {
1136
1139
  const missing = requiredToolCallNames.filter((name) => !dispatchedRequiredToolCalls.has(name));
@@ -1138,16 +1141,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1138
1141
  ? { missing, message: `${options.requiredToolCalls.retryMessage} Missing required tool calls: ${missing.join(", ")}.` }
1139
1142
  : null;
1140
1143
  };
1141
- const queueRequiredCorrection = (message, limitContext) => {
1144
+ const queueRequiredCorrection = (assistant, message, limitContext) => {
1142
1145
  if (providerIterations >= exports.MAX_PROVIDER_ITERATIONS)
1143
1146
  throw new Error(`provider iteration limit exhausted at response ${exports.MAX_PROVIDER_ITERATIONS} ${limitContext}`);
1144
- const correction = {
1145
- role: "user",
1146
- content: message,
1147
- [requiredCorrectionMarker]: true,
1148
- };
1149
- messages.push(correction);
1150
- providerRuntime.resetTurnState(messages);
1147
+ rejectAttempt(assistant, [{ role: "user", content: message }]);
1151
1148
  };
1152
1149
  const toolLoopState = (0, tool_loop_1.createToolLoopState)();
1153
1150
  const toolFrictionLedger = (0, tool_friction_1.createToolFrictionLedger)();
@@ -1180,8 +1177,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1180
1177
  summary: (0, error_classification_1.summarizeProviderError)(terminalError, terminalErrorClassification, providerRuntime.id, providerRuntime.model),
1181
1178
  },
1182
1179
  });
1183
- stripLastToolCalls(messages);
1184
- stripLastToolCalls(generatedMessages);
1180
+ // Only legacy input can still contain unaccepted calls. Generated output
1181
+ // has crossed acceptance; a later provider failure cannot revoke effects.
1182
+ if (generatedMessages.length === 0)
1183
+ stripLastToolCalls(messages);
1185
1184
  outcome = "errored";
1186
1185
  done = true;
1187
1186
  };
@@ -1304,6 +1303,23 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1304
1303
  break;
1305
1304
  }
1306
1305
  try {
1306
+ const budgetOptions = {
1307
+ requiredPromptEvidence: options?.requiredPromptEvidence,
1308
+ provider: providerRuntime.id,
1309
+ model: providerRuntime.model,
1310
+ contextWindowTokens: (0, config_1.getContextConfig)().maxTokens,
1311
+ };
1312
+ const canonicalBudget = (0, prompt_budget_1.applyPromptBudget)({ ...budgetOptions, messages });
1313
+ if (canonicalBudget.status !== "within_budget") {
1314
+ messages.splice(0, messages.length, ...canonicalBudget.messages);
1315
+ }
1316
+ // Preserve canonical evidence object identity while freezing the array
1317
+ // and the one attempt's private control/scratch across operational retries.
1318
+ const attemptMessages = rejectedAttempt.length > 0
1319
+ ? [...(0, prompt_budget_1.applyPromptBudget)({ ...budgetOptions, messages: [...messages, ...rejectedAttempt, ...nextAttemptControls] }).messages]
1320
+ : [...messages];
1321
+ if (rejectedAttempt.length > 0 || canonicalBudget.status !== "within_budget")
1322
+ providerRuntime.resetTurnState(attemptMessages);
1307
1323
  const turnCallbackBufferRef = { current: null };
1308
1324
  const callProviderTurn = async () => {
1309
1325
  callbacks.onModelStart();
@@ -1313,19 +1329,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1313
1329
  ? createFinalOnlyTextBuffer(callbacks)
1314
1330
  : null;
1315
1331
  try {
1316
- const promptBudget = (0, prompt_budget_1.applyPromptBudget)({
1317
- messages,
1318
- requiredPromptEvidence: options?.requiredPromptEvidence,
1319
- provider: providerRuntime.id,
1320
- model: providerRuntime.model,
1321
- contextWindowTokens: (0, config_1.getContextConfig)().maxTokens,
1322
- });
1323
- if (promptBudget.status !== "within_budget") {
1324
- messages.splice(0, messages.length, ...promptBudget.messages);
1325
- providerRuntime.resetTurnState(messages);
1326
- }
1327
1332
  return await providerRuntime.streamTurn({
1328
- messages,
1333
+ messages: attemptMessages,
1329
1334
  activeTools,
1330
1335
  callbacks: turnCallbackBufferRef.current?.callbacks ?? callbacks,
1331
1336
  signal,
@@ -1353,10 +1358,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1353
1358
  throw error;
1354
1359
  if (isContextOverflow(error) && !overflowRetried) {
1355
1360
  overflowRetried = true;
1356
- stripLastToolCalls(messages);
1357
- stripLastToolCalls(generatedMessages);
1361
+ const overflowMessages = messages.map((message) => message.role === "assistant" ? { ...message } : message);
1362
+ stripLastToolCalls(overflowMessages);
1358
1363
  const { maxTokens, contextMargin } = (0, config_1.getContextConfig)();
1359
- const trimmed = (0, context_1.trimMessages)(messages, maxTokens, contextMargin, maxTokens * 2);
1364
+ const trimmed = (0, context_1.trimMessages)(overflowMessages, maxTokens, contextMargin, maxTokens * 2);
1360
1365
  const requiredEvidence = options?.requiredPromptEvidence;
1361
1366
  const requiredMessages = new Set([
1362
1367
  ...(requiredEvidence?.verifiedPredecessorMessage ? [requiredEvidence.verifiedPredecessorMessage] : []),
@@ -1365,9 +1370,9 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1365
1370
  const trimmedMessages = new Set(trimmed);
1366
1371
  const overflowRetryMessages = requiredMessages.size === 0
1367
1372
  ? trimmed
1368
- : messages.filter((message) => trimmedMessages.has(message) || requiredMessages.has(message));
1373
+ : overflowMessages.filter((message) => trimmedMessages.has(message) || requiredMessages.has(message));
1369
1374
  messages.splice(0, messages.length, ...overflowRetryMessages);
1370
- providerRuntime.resetTurnState(messages);
1375
+ providerRuntime.resetTurnState(attemptMessages);
1371
1376
  callbacks.onError(new Error("context trimmed, retrying..."), "transient");
1372
1377
  return callProviderTurn();
1373
1378
  }
@@ -1397,7 +1402,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1397
1402
  });
1398
1403
  _providerRuntimeFactories[facing] = null;
1399
1404
  providerRuntime = await getProviderRuntime(facing);
1400
- providerRuntime.resetTurnState(messages);
1405
+ providerRuntime.resetTurnState(attemptMessages);
1401
1406
  }
1402
1407
  catch (refreshError) {
1403
1408
  (0, runtime_1.emitNervesEvent)({
@@ -1412,7 +1417,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1412
1417
  },
1413
1418
  sleep: async (delayMs) => {
1414
1419
  await waitForProviderRetry(delayMs, signal);
1415
- providerRuntime.resetTurnState(messages);
1420
+ providerRuntime.resetTurnState(attemptMessages);
1416
1421
  },
1417
1422
  });
1418
1423
  if (!attempt.ok) {
@@ -1420,6 +1425,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1420
1425
  continue;
1421
1426
  }
1422
1427
  const result = attempt.value;
1428
+ nextAttemptControls = [];
1423
1429
  providerIterations += 1;
1424
1430
  if (providerIterations === exports.MAX_PROVIDER_ITERATIONS && result.toolCalls.length > 0) {
1425
1431
  throw new Error(`provider iteration limit exhausted at response ${exports.MAX_PROVIDER_ITERATIONS} before tool execution`);
@@ -1526,8 +1532,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1526
1532
  message: "unresolved historical effect exhausted deterministic tool-call retries; failing closed",
1527
1533
  meta: { provider: providerRuntime.id, model: providerRuntime.model, toolNames: retryableHistoricalTools, cap: NO_TOOL_CALL_MAX_RETRIES },
1528
1534
  });
1529
- msg.content = blockedAnswer;
1530
- pushGenerated(msg);
1535
+ pushGenerated({ role: "assistant", content: blockedAnswer });
1531
1536
  callbacks.onTextChunk(blockedAnswer);
1532
1537
  completion = { answer: blockedAnswer, intent: "blocked" };
1533
1538
  outcome = "blocked";
@@ -1548,18 +1553,17 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1548
1553
  cap: NO_TOOL_CALL_MAX_RETRIES,
1549
1554
  },
1550
1555
  });
1551
- pushGenerated(msg);
1552
- messages.push({
1553
- role: "user",
1554
- content: `this exact request previously reached a tool that is advertised again now. Its unresolved historical effect is through ${retryableHistoricalTools.join(", ")}. Only that tool is available now. Read current state if needed, then retry the exact failed effect. Do not report completion until the effect succeeds.`,
1555
- });
1556
+ rejectAttempt(msg, [{
1557
+ role: "user",
1558
+ content: `this exact request previously reached a tool that is advertised again now. Its unresolved historical effect is through ${retryableHistoricalTools.join(", ")}. Only that tool is available now. Read current state if needed, then retry the exact failed effect. Do not report completion until the effect succeeds.`,
1559
+ }]);
1556
1560
  continue;
1557
1561
  }
1558
1562
  const requiredToolCallsGate = pendingRequiredToolCalls();
1559
1563
  if (requiredToolCallsGate) {
1560
1564
  streamCallbackBuffer?.discard();
1561
1565
  callbacks.onClearText?.();
1562
- queueRequiredCorrection(requiredToolCallsGate.message, "before required tool calls completed");
1566
+ queueRequiredCorrection(msg, requiredToolCallsGate.message, "before required tool calls completed");
1563
1567
  (0, runtime_1.emitNervesEvent)({
1564
1568
  level: "warn",
1565
1569
  component: "engine",
@@ -1573,7 +1577,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1573
1577
  if (requiredAnswerRejection) {
1574
1578
  streamCallbackBuffer?.discard();
1575
1579
  callbacks.onClearText?.();
1576
- queueRequiredCorrection(requiredAnswerRejection, "before required terminal answer validation completed");
1580
+ queueRequiredCorrection(msg, requiredAnswerRejection, "before required terminal answer validation completed");
1577
1581
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.required_tool_answer_rejected", message: "unsupported terminal answer rejected after required reads", meta: { answerLength: String(msg.content ?? "").length } });
1578
1582
  continue;
1579
1583
  }
@@ -1596,11 +1600,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1596
1600
  contentLength: result.content.length,
1597
1601
  },
1598
1602
  });
1599
- pushGenerated(msg);
1600
- messages.push({
1601
- role: "user",
1602
- content: `${privateReturnTextAckRetryError} Emit the ponder(action=create, ...) tool call now, or ask a blocking clarification without saying the private work is queued.`,
1603
- });
1603
+ rejectAttempt(msg, [{
1604
+ role: "user",
1605
+ content: `${privateReturnTextAckRetryError} Emit the ponder(action=create, ...) tool call now, or ask a blocking clarification without saying the private work is queued.`,
1606
+ }]);
1604
1607
  continue;
1605
1608
  }
1606
1609
  const blockedAnswer = "I could not start the private pass. No private-attention packet was created, so no return work was queued.";
@@ -1616,8 +1619,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1616
1619
  contentLength: result.content.length,
1617
1620
  },
1618
1621
  });
1619
- msg.content = blockedAnswer;
1620
- pushGenerated(msg);
1622
+ pushGenerated({ role: "assistant", content: blockedAnswer });
1621
1623
  callbacks.onTextChunk(blockedAnswer);
1622
1624
  completion = { answer: blockedAnswer, intent: "blocked" };
1623
1625
  outcome = "blocked";
@@ -1645,15 +1647,14 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1645
1647
  contentLength: result.content.length,
1646
1648
  },
1647
1649
  });
1648
- pushGenerated(msg);
1649
- messages.push({
1650
- role: "user",
1651
- content: isPrivateRuntimeChannel
1652
- ? augmentedToolContext?.noSend === true
1653
- ? "no tool was called this turn. this is an immutable no-send turn; call rest now without creating a continuation."
1654
- : "no tool was called this turn. you must end every turn by calling rest (or surface, ponder, observe). emit the tool call now."
1655
- : "no tool was called this turn. you must end every turn by calling settle with your answer (or ponder/observe). emit the tool call now.",
1656
- });
1650
+ rejectAttempt(msg, [{
1651
+ role: "user",
1652
+ content: isPrivateRuntimeChannel
1653
+ ? augmentedToolContext?.noSend === true
1654
+ ? "no tool was called this turn. this is an immutable no-send turn; call rest now without creating a continuation."
1655
+ : "no tool was called this turn. you must end every turn by calling rest (or surface, ponder, observe). emit the tool call now."
1656
+ : "no tool was called this turn. you must end every turn by calling settle with your answer (or ponder/observe). emit the tool call now.",
1657
+ }]);
1657
1658
  continue;
1658
1659
  }
1659
1660
  // Legitimate text-only response, or cap reached — accept as-is.
@@ -1664,20 +1665,19 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1664
1665
  else {
1665
1666
  // Reset the retry counter on any successful tool call.
1666
1667
  noToolCallRetries = 0;
1667
- const preCallMessages = structuredClone(messagesWithoutRequiredCorrections().filter((message) => message.role !== "system"));
1668
+ const preCallMessages = structuredClone(messages.filter((message) => message.role !== "system"));
1668
1669
  const validatedCalls = validateToolCallBatchAtProductionBoundary(result.toolCalls, activeTools);
1669
1670
  const invalidCall = validatedCalls.find((entry) => "error" in entry);
1670
1671
  if (invalidCall) {
1671
- await streamCallbackBuffer?.flush();
1672
- pushGenerated(msg);
1672
+ streamCallbackBuffer?.discard();
1673
1673
  const unadvertisedCall = validatedCalls.find((entry) => !activeToolNames.has(entry.call.name));
1674
+ const rejections = [];
1674
1675
  for (const entry of validatedCalls) {
1675
1676
  const detail = "error" in entry ? entry.error : "another call in this batch had invalid arguments";
1676
1677
  const rejection = unadvertisedCall
1677
1678
  ? `rejected: ${entry.call.name} was not advertised for this channel; no handler was executed.`
1678
1679
  : `invalid tool arguments: ${detail}`;
1679
- pushGenerated({ role: "tool", tool_call_id: entry.call.id, content: rejection });
1680
- providerRuntime.appendToolOutput(entry.call.id, rejection);
1680
+ rejections.push(rejection);
1681
1681
  options?.toolBoundaryObserver?.({
1682
1682
  name: entry.call.name,
1683
1683
  reason: activeToolNames.has(entry.call.name) ? "invalid_arguments" : "profile_excluded",
@@ -1686,6 +1686,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1686
1686
  sideEffect: false,
1687
1687
  });
1688
1688
  }
1689
+ rejectToolBatch(msg, rejections);
1689
1690
  if (unadvertisedCall) {
1690
1691
  (0, runtime_1.emitNervesEvent)({
1691
1692
  level: "warn",
@@ -1715,12 +1716,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1715
1716
  if (habitBlockReason) {
1716
1717
  streamCallbackBuffer?.discard();
1717
1718
  recordBlockedHabitSurfaceAttempts(habitSession, result.toolCalls, habitBlockReason);
1718
- pushGenerated(msg);
1719
1719
  const blockedOutput = `blocked: ${habitBlockReason}. No tool side effects from this assistant message were executed.`;
1720
- for (const call of result.toolCalls) {
1721
- pushGenerated({ role: "tool", tool_call_id: call.id, content: blockedOutput });
1722
- providerRuntime.appendToolOutput(call.id, blockedOutput);
1723
- }
1720
+ rejectToolBatch(msg, result.toolCalls.map(() => blockedOutput));
1724
1721
  (0, runtime_1.emitNervesEvent)({
1725
1722
  level: "warn",
1726
1723
  component: "engine",
@@ -1730,6 +1727,15 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1730
1727
  });
1731
1728
  continue;
1732
1729
  }
1730
+ const soleViolation = result.toolCalls.length > 1
1731
+ ? result.toolCalls.find((call) => SOLE_CALL_REJECTION[call.name] !== undefined || (0, tools_1.resolveToolDefinition)(call.name)?.terminalProjection?.requiresSoleCall === true)
1732
+ : undefined;
1733
+ if (soleViolation) {
1734
+ streamCallbackBuffer?.discard();
1735
+ const reason = SOLE_CALL_REJECTION[soleViolation.name] ?? `rejected: ${soleViolation.name} must be the only tool call.`;
1736
+ rejectToolBatch(msg, result.toolCalls.map(() => reason));
1737
+ continue;
1738
+ }
1733
1739
  const soleTerminalCall = result.toolCalls.length === 1
1734
1740
  ? result.toolCalls[0]
1735
1741
  : null;
@@ -1784,7 +1790,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1784
1790
  streamCallbackBuffer?.discard();
1785
1791
  callbacks.onToolEnd("settle", (0, tools_1.summarizeArgs)("settle", settleArgs), false);
1786
1792
  callbacks.onClearText?.();
1787
- queueRequiredCorrection(requiredToolCallsGate.message, "before required tool calls completed");
1793
+ queueRequiredCorrection(msg, requiredToolCallsGate.message, "before required tool calls completed");
1788
1794
  (0, runtime_1.emitNervesEvent)({
1789
1795
  level: "warn",
1790
1796
  component: "engine",
@@ -1800,10 +1806,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1800
1806
  streamCallbackBuffer?.discard();
1801
1807
  callbacks.onToolEnd("settle", (0, tools_1.summarizeArgs)("settle", settleArgs), false);
1802
1808
  callbacks.onClearText?.();
1803
- pushGenerated(msg);
1804
1809
  const gateMessage = "current held-work frame still has unsurfaced items — return each listed item with surface(delegationId=...) before you settle. Older transcript claims are historical; only the current held-work frame is the gate.";
1805
- pushGenerated({ role: "tool", tool_call_id: result.toolCalls[0].id, content: gateMessage });
1806
- providerRuntime.appendToolOutput(result.toolCalls[0].id, gateMessage);
1810
+ rejectToolBatch(msg, [gateMessage]);
1807
1811
  continue;
1808
1812
  }
1809
1813
  // Extract answer from the tool call arguments.
@@ -1814,7 +1818,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1814
1818
  streamCallbackBuffer?.discard();
1815
1819
  callbacks.onToolEnd("settle", (0, tools_1.summarizeArgs)("settle", settleArgs), false);
1816
1820
  callbacks.onClearText?.();
1817
- queueRequiredCorrection(requiredAnswerRejection, "before required terminal answer validation completed");
1821
+ queueRequiredCorrection(msg, requiredAnswerRejection, "before required terminal answer validation completed");
1818
1822
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.required_tool_answer_rejected", message: "unsupported settle answer rejected after required reads", meta: { answerLength: answer.length } });
1819
1823
  continue;
1820
1824
  }
@@ -1882,10 +1886,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1882
1886
  streamCallbackBuffer?.discard();
1883
1887
  callbacks.onToolEnd("settle", (0, tools_1.summarizeArgs)("settle", settleArgs), false);
1884
1888
  callbacks.onClearText?.();
1885
- pushGenerated(msg);
1886
- const toolRetryMessage = retryError;
1887
- pushGenerated({ role: "tool", tool_call_id: result.toolCalls[0].id, content: toolRetryMessage });
1888
- providerRuntime.appendToolOutput(result.toolCalls[0].id, toolRetryMessage);
1889
+ rejectToolBatch(msg, [retryError]);
1889
1890
  }
1890
1891
  continue;
1891
1892
  }
@@ -1923,19 +1924,15 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1923
1924
  const attentionQueue = augmentedToolContext?.delegatedOrigins;
1924
1925
  if (attentionQueue && attentionQueue.length > 0) {
1925
1926
  callbacks.onToolEnd("rest", (0, tools_1.summarizeArgs)("rest", restArgs), false);
1926
- pushGenerated(msg);
1927
1927
  const gateMessage = "current held-work frame still has unsurfaced items — return each listed item with surface(delegationId=...) before you rest. Older transcript claims are historical; only the current held-work frame is the gate.";
1928
- pushGenerated({ role: "tool", tool_call_id: result.toolCalls[0].id, content: gateMessage });
1929
- providerRuntime.appendToolOutput(result.toolCalls[0].id, gateMessage);
1928
+ rejectToolBatch(msg, [gateMessage]);
1930
1929
  continue;
1931
1930
  }
1932
1931
  if (hasFreshPendingWork(options) && !freshWorkGateFired) {
1933
1932
  freshWorkGateFired = true;
1934
1933
  callbacks.onToolEnd("rest", (0, tools_1.summarizeArgs)("rest", restArgs), false);
1935
- pushGenerated(msg);
1936
1934
  const gateMessage = "fresh work arrived for me this turn — inspect the pending messages above and take the next concrete action before you rest.";
1937
- pushGenerated({ role: "tool", tool_call_id: result.toolCalls[0].id, content: gateMessage });
1938
- providerRuntime.appendToolOutput(result.toolCalls[0].id, gateMessage);
1935
+ rejectToolBatch(msg, [gateMessage]);
1939
1936
  (0, runtime_1.emitNervesEvent)({
1940
1937
  level: "info",
1941
1938
  component: "engine",
@@ -1967,7 +1964,56 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1967
1964
  if (rejection)
1968
1965
  requiredDispatchRejections.set(entry.call.id, { name: entry.call.name, args: requiredArgs, message: rejection });
1969
1966
  }
1970
- const approvalCalls = await Promise.all(validCalls.filter((entry) => !requiredDispatchRejections.has(entry.call.id)).map(async (entry) => {
1967
+ if (requiredDispatchRejections.size > 0) {
1968
+ streamCallbackBuffer?.discard();
1969
+ for (const rejection of requiredDispatchRejections.values()) {
1970
+ callbacks.onToolStart(rejection.name, rejection.args);
1971
+ callbacks.onToolEnd(rejection.name, (0, tools_1.summarizeArgs)(rejection.name, rejection.args), false);
1972
+ options?.toolBoundaryObserver?.({
1973
+ name: rejection.name, reason: "dependency_rejected",
1974
+ globallyResolvable: typeof (0, tools_1.resolveToolDefinition)(rejection.name)?.handler === "function",
1975
+ invoked: false, sideEffect: false,
1976
+ });
1977
+ (0, runtime_1.emitNervesEvent)({
1978
+ level: "warn", component: "engine", event: "engine.required_tool_dispatch_rejected",
1979
+ message: "required tool dependency rejected before approval and handler dispatch",
1980
+ meta: { toolName: rejection.name },
1981
+ });
1982
+ }
1983
+ rejectToolBatch(msg, result.toolCalls.map((call) => requiredDispatchRejections.get(call.id)?.message
1984
+ ?? "rejected: another call in this batch has an unsatisfied required dependency; no handler was executed."));
1985
+ continue;
1986
+ }
1987
+ const executionRejections = new Map();
1988
+ for (const entry of validCalls) {
1989
+ const args = validatedCallArguments.get(entry.call);
1990
+ const fingerprint = effectFingerprint(entry.call.name, entry.call.arguments);
1991
+ if (forcingHistoricalEffect && fingerprint && !unresolvedHistoricalEffects.some((effect) => effect.fingerprint === fingerprint)) {
1992
+ executionRejections.set(entry.call.id, "rejected: this turn is retrying an unresolved historical effect, and these mutation arguments do not match it. Read current state or retry the exact failed effect.");
1993
+ }
1994
+ else if (entry.call.name === "send_message" && args.friendId === "self" && !isPrivateRuntimeChannel && looksLikePrivateReturnRequest(latestUserMessageText(messages))) {
1995
+ executionRejections.set(entry.call.id, "private-return requests must use ponder, not send_message(friendId=self). Create a typed ponder packet with the marker/source request preserved, then only acknowledge that the private pass is queued.");
1996
+ }
1997
+ else if (entry.call.name !== "speak" && entry.call.name !== "ponder") {
1998
+ const loop = (0, tool_loop_1.detectToolLoop)(toolLoopState, entry.call.name, args);
1999
+ if (loop.stuck)
2000
+ executionRejections.set(entry.call.id, `loop guard: ${loop.message}`);
2001
+ }
2002
+ }
2003
+ if (executionRejections.size > 0) {
2004
+ streamCallbackBuffer?.discard();
2005
+ for (const entry of validCalls) {
2006
+ if (!executionRejections.has(entry.call.id))
2007
+ continue;
2008
+ const args = validatedCallArguments.get(entry.call);
2009
+ callbacks.onToolStart(entry.call.name, args);
2010
+ callbacks.onToolEnd(entry.call.name, (0, tools_1.summarizeArgs)(entry.call.name, args), false);
2011
+ }
2012
+ rejectToolBatch(msg, result.toolCalls.map((call) => executionRejections.get(call.id)
2013
+ ?? "rejected: another call in this batch was inadmissible; no handler was executed."));
2014
+ continue;
2015
+ }
2016
+ const approvalCalls = await Promise.all(validCalls.map(async (entry) => {
1971
2017
  const classification = await (0, tools_1.classifyApprovalForInvocation)(entry.call.name, entry.validated.arguments, augmentedToolContext);
1972
2018
  return {
1973
2019
  ...entry,
@@ -1977,12 +2023,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1977
2023
  const protectedCall = approvalCalls.find((entry) => entry.policy.kind === "required");
1978
2024
  if (protectedCall && result.toolCalls.length !== 1) {
1979
2025
  streamCallbackBuffer?.discard();
1980
- pushGenerated(msg);
1981
- for (const call of result.toolCalls) {
1982
- const rejection = "rejected: approval-eligible tool must be the sole call; no call in this batch was executed.";
1983
- pushGenerated({ role: "tool", tool_call_id: call.id, content: rejection });
1984
- providerRuntime.appendToolOutput(call.id, rejection);
1985
- }
2026
+ rejectToolBatch(msg, result.toolCalls.map(() => "rejected: approval-eligible tool must be the sole call; no call in this batch was executed."));
1986
2027
  (0, runtime_1.emitNervesEvent)({
1987
2028
  level: "warn",
1988
2029
  component: "engine",
@@ -1995,10 +2036,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1995
2036
  if (protectedCall && protectedCall.policy.kind === "required") {
1996
2037
  if (!options?.approvalCoordinator) {
1997
2038
  streamCallbackBuffer?.discard();
1998
- pushGenerated(msg);
1999
2039
  const rejection = "rejected: this protected tool requires approval, but the approval coordinator is unavailable; the handler was not invoked.";
2000
- pushGenerated({ role: "tool", tool_call_id: protectedCall.call.id, content: rejection });
2001
- providerRuntime.appendToolOutput(protectedCall.call.id, rejection);
2040
+ rejectToolBatch(msg, [rejection]);
2002
2041
  (0, runtime_1.emitNervesEvent)({
2003
2042
  level: "warn",
2004
2043
  component: "engine",
@@ -2009,7 +2048,6 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2009
2048
  continue;
2010
2049
  }
2011
2050
  streamCallbackBuffer?.discard();
2012
- pushGenerated(msg);
2013
2051
  const toolDigest = (0, tool_arguments_1.digestJson)({
2014
2052
  name: protectedCall.call.name,
2015
2053
  schemaDigest: protectedCall.validated.schemaDigest,
@@ -2032,6 +2070,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2032
2070
  actionClass: protectedCall.policy.actionClass,
2033
2071
  liveToolContext: augmentedToolContext,
2034
2072
  });
2073
+ pushGenerated(msg);
2035
2074
  suspension = {
2036
2075
  approvalId: committed.approvalId,
2037
2076
  toolCallId: protectedCall.call.id,
@@ -2048,79 +2087,15 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2048
2087
  });
2049
2088
  continue;
2050
2089
  }
2051
- const containsSoleCallOnlyViolation = result.toolCalls.length > 1
2052
- && result.toolCalls.some((call) => {
2053
- const terminalProjection = (0, tools_1.resolveToolDefinition)(call.name)?.terminalProjection;
2054
- return SOLE_CALL_REJECTION[call.name] !== undefined
2055
- || terminalProjection?.requiresSoleCall === true;
2056
- });
2057
- if (callbacks.settleOutputMode === "final_only" && containsSoleCallOnlyViolation) {
2058
- streamCallbackBuffer?.discard();
2059
- }
2060
- else {
2061
- await streamCallbackBuffer?.flush();
2062
- }
2090
+ await streamCallbackBuffer?.flush();
2063
2091
  pushGenerated(msg);
2064
- // Execute tools (sole-call tools in mixed calls are rejected inline)
2092
+ // Every pre-dispatch gate accepted the complete batch.
2065
2093
  for (const tc of result.toolCalls) {
2066
2094
  if (signal?.aborted)
2067
2095
  break;
2068
- const requiredDispatchRejection = requiredDispatchRejections.get(tc.id);
2069
- if (requiredDispatchRejection) {
2070
- callbacks.onToolStart(tc.name, requiredDispatchRejection.args);
2071
- callbacks.onToolEnd(tc.name, (0, tools_1.summarizeArgs)(tc.name, requiredDispatchRejection.args), false);
2072
- pushGenerated({ role: "tool", tool_call_id: tc.id, content: requiredDispatchRejection.message });
2073
- providerRuntime.appendToolOutput(tc.id, requiredDispatchRejection.message);
2074
- options?.toolBoundaryObserver?.({
2075
- name: tc.name,
2076
- reason: "dependency_rejected",
2077
- globallyResolvable: typeof (0, tools_1.resolveToolDefinition)(tc.name)?.handler === "function",
2078
- invoked: false,
2079
- sideEffect: false,
2080
- });
2081
- (0, runtime_1.emitNervesEvent)({
2082
- level: "warn",
2083
- component: "engine",
2084
- event: "engine.required_tool_dispatch_rejected",
2085
- message: "required tool dependency rejected before approval and handler dispatch",
2086
- meta: { toolName: tc.name },
2087
- });
2088
- continue;
2089
- }
2090
- // Reject sole-call tools when mixed with other tool calls
2091
- const terminalProjection = (0, tools_1.resolveToolDefinition)(tc.name)?.terminalProjection;
2092
- const soleCallRejection = SOLE_CALL_REJECTION[tc.name]
2093
- ?? (terminalProjection?.requiresSoleCall
2094
- ? `rejected: ${tc.name} must be the only tool call.`
2095
- : undefined);
2096
- if (soleCallRejection) {
2097
- pushGenerated({ role: "tool", tool_call_id: tc.id, content: soleCallRejection });
2098
- providerRuntime.appendToolOutput(tc.id, soleCallRejection);
2099
- continue;
2100
- }
2101
2096
  const args = validatedCallArguments.get(tc);
2102
2097
  const currentEffectFingerprint = effectFingerprint(tc.name, tc.arguments);
2103
- if (forcingHistoricalEffect
2104
- && currentEffectFingerprint
2105
- && !unresolvedHistoricalEffects.some((effect) => effect.fingerprint === currentEffectFingerprint)) {
2106
- const rejection = "rejected: this turn is retrying an unresolved historical effect, and these mutation arguments do not match it. Read current state or retry the exact failed effect.";
2107
- callbacks.onToolStart(tc.name, args);
2108
- callbacks.onToolEnd(tc.name, (0, tools_1.summarizeArgs)(tc.name, args), false);
2109
- pushGenerated({ role: "tool", tool_call_id: tc.id, content: rejection });
2110
- providerRuntime.appendToolOutput(tc.id, rejection);
2111
- continue;
2112
- }
2113
2098
  if (tc.name === "send_message" && args.friendId === "self") {
2114
- const latestUserText = latestUserMessageText(messages);
2115
- if (!isPrivateRuntimeChannel && looksLikePrivateReturnRequest(latestUserText)) {
2116
- const argSummary = (0, tools_1.summarizeArgs)(tc.name, args);
2117
- const rejection = "private-return requests must use ponder, not send_message(friendId=self). Create a typed ponder packet with the marker/source request preserved, then only acknowledge that the private pass is queued.";
2118
- callbacks.onToolStart(tc.name, args);
2119
- callbacks.onToolEnd(tc.name, argSummary, false);
2120
- pushGenerated({ role: "tool", tool_call_id: tc.id, content: rejection });
2121
- providerRuntime.appendToolOutput(tc.id, rejection);
2122
- continue;
2123
- }
2124
2099
  sawSendMessageSelf = true;
2125
2100
  }
2126
2101
  if (tc.name === "speak") {
@@ -2342,16 +2317,6 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2342
2317
  /* v8 ignore next -- flag tested via truth-check integration tests @preserve */
2343
2318
  if (isExternalStateQuery(tc.name, args))
2344
2319
  sawExternalStateQuery = true;
2345
- const argSummary = (0, tools_1.summarizeArgs)(tc.name, args);
2346
- const toolLoop = (0, tool_loop_1.detectToolLoop)(toolLoopState, tc.name, args);
2347
- if (toolLoop.stuck) {
2348
- const rejection = `loop guard: ${toolLoop.message}`;
2349
- callbacks.onToolStart(tc.name, args);
2350
- callbacks.onToolEnd(tc.name, argSummary, false);
2351
- pushGenerated({ role: "tool", tool_call_id: tc.id, content: rejection });
2352
- providerRuntime.appendToolOutput(tc.id, rejection);
2353
- continue;
2354
- }
2355
2320
  callbacks.onToolStart(tc.name, args);
2356
2321
  let toolResult;
2357
2322
  let success;
@@ -2367,8 +2332,12 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2367
2332
  catch (e) {
2368
2333
  toolResult = `error: ${e}`;
2369
2334
  success = false;
2370
- augmentedToolContext?.habitSession?.recordError?.(toolResult);
2371
2335
  }
2336
+ const modelResult = (0, tool_friction_1.rewriteToolResultForModel)(tc.name, toolResult, toolFrictionLedger);
2337
+ pushGenerated({ role: "tool", tool_call_id: tc.id, content: modelResult });
2338
+ providerRuntime.appendToolOutput(tc.id, modelResult);
2339
+ if (!success)
2340
+ augmentedToolContext?.habitSession?.recordError?.(toolResult);
2372
2341
  const validatedRequiredResult = success && requiredToolCallNames.includes(tc.name) && options?.requiredToolCalls?.requireSuccessfulResults
2373
2342
  ? requiredToolResultSucceeded(tc.name, toolResult, args, options.requiredToolCalls.validateRequiredToolResult)
2374
2343
  : false;
@@ -2395,20 +2364,17 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2395
2364
  invoked: true,
2396
2365
  sideEffect: success && toolRiskProfile?.mutates !== "none",
2397
2366
  });
2398
- toolResult = (0, tool_friction_1.rewriteToolResultForModel)(tc.name, toolResult, toolFrictionLedger);
2399
- (0, tool_loop_1.recordToolOutcome)(toolLoopState, tc.name, args, toolResult, success);
2400
- callbacks.onToolEnd(tc.name, (0, tools_1.buildToolResultSummary)(tc.name, args, toolResult, success), success);
2401
- pushGenerated({ role: "tool", tool_call_id: tc.id, content: toolResult });
2402
- providerRuntime.appendToolOutput(tc.id, toolResult);
2403
- callbacks.onToolResult?.(messagesWithoutRequiredCorrections());
2367
+ (0, tool_loop_1.recordToolOutcome)(toolLoopState, tc.name, args, modelResult, success);
2368
+ callbacks.onToolEnd(tc.name, (0, tools_1.buildToolResultSummary)(tc.name, args, modelResult, success), success);
2369
+ callbacks.onToolResult?.(messages);
2404
2370
  }
2405
2371
  }
2406
2372
  }
2407
2373
  catch (e) {
2408
2374
  // Abort is not an error — just stop cleanly
2409
2375
  if (e instanceof provider_attempt_1.ProviderAttemptAbortError || signal?.aborted) {
2410
- stripLastToolCalls(messages);
2411
- stripLastToolCalls(generatedMessages);
2376
+ if (generatedMessages.length === 0)
2377
+ stripLastToolCalls(messages);
2412
2378
  outcome = "aborted";
2413
2379
  break;
2414
2380
  }
@@ -2426,7 +2392,9 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2426
2392
  }
2427
2393
  }
2428
2394
  finally {
2429
- removeRequiredCorrections();
2395
+ nextAttemptControls = [];
2396
+ rejectedAttempt = [];
2397
+ providerRuntime.resetTurnState(messages);
2430
2398
  }
2431
2399
  options?.captureGeneratedMessages?.(structuredClone(generatedMessages));
2432
2400
  (0, runtime_1.emitNervesEvent)({