@ouro.bot/cli 0.1.0-alpha.491 → 0.1.0-alpha.493

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.
package/changelog.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.493",
6
+ "changes": [
7
+ "Position-aware orphan-tool-result detection in `repairToolCallSequences`. Slugger's session was STILL hitting MiniMax error 2013 even after the alpha.492 inline-reasoning strip landed because the orphan check was global (a tool result was kept if its tool_call_id appeared in ANY assistant message in the conversation, regardless of order). After session pruning, a synthetic tool-result for a long-pruned tool_call ended up at sequence 86 referencing `call_function_utqogadgqp5h_1` while the assistant message that defined that id lived at sequence 88 — AFTER the tool result. MiniMax requires tool results to follow their matching assistant. The fix walks the conversation in order, tracking tool_call_ids only as they're encountered in assistant messages; tool results referencing ids that haven't been defined yet are removed. Regression test reproduces the exact misordered shape and asserts the misplaced tool result is dropped while the correctly-ordered one survives. This is the third and final layer of the empty-reply chain (#611 stripped the operator surface, #612 stripped the persisted content + load-time repair, #493 fixes orphan-detection ordering)."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.492",
12
+ "changes": [
13
+ "Engine-level fix for the actual root cause of Slugger's empty-reply MCP bug (PR #611's strip+retry was the right shape, but missed the deepest layer). MiniMax-M2.7 occasionally emits an assistant message with BOTH inline `<think>...</think>` reasoning AND tool_calls. When that combination is replayed in a subsequent turn, MiniMax rejects with error 2013 ('tool result's tool id not found') and stalls the entire session — every subsequent turn fails the same way, the failover layer fires repeatedly suggesting a provider switch, and the agent's own answer never reaches the operator. Slugger's session was stuck for 11 unanswered user messages because of this exact loop.",
14
+ "The fix has two halves and an AX rule: (1) **Persist-time strip** — runAgent now strips `<think>` blocks from the assistant message's persisted `content` before saving, while preserving the original reasoning trace on `_inline_reasoning` for audit. New `engine.inline_reasoning_stripped` info-level nerve event fires when this happens. (2) **Load-time repair** — `sanitizeProviderMessages` self-heals existing sessions that were saved before (1) by stripping the same blocks at load time. (3) **AX rule: full agent awareness, no silent fixes**. When the load-time repair runs, the synthetic tool-result that fills in for the missing tool result is an **explanatory** one — it tells the agent specifically: \"your previous tool call's result was lost because the assistant message had inline reasoning blocks the provider rejected; the harness has stripped them; your reasoning trace is preserved out-of-band; if the work needs to be done, retry the tool call now.\" Tool calls whose parent didn't have stripped reasoning still get a generic-but-improved \"this tool call's result was lost — possible causes [...]; retry if needed\" message instead of the old vague \"interrupted (previous turn timed out)\" line. The agent always sees what happened and what to do next.",
15
+ "Also: the no-tool-call retry path (added in #611's last commit) now uses the same shared `stripThinkBlocksForViolationCheck` helper so the violation-detection logic is consistent. Three regression tests cover the full path: persist-time strip preserves `_inline_reasoning`, load-time repair produces the explanatory tool-result message, generic orphans get the generic message, and unclosed `<think>` tags drop everything from the open tag onward."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.491",
6
20
  "changes": [
@@ -361,27 +361,39 @@ const TOOL_SCAN_BOUNDARY_ROLES = new Set(["assistant", "user"]);
361
361
  // 1. If an assistant message has tool_calls but missing tool results, inject synthetic error results.
362
362
  // 2. If a tool result's tool_call_id doesn't match any tool_calls in a preceding assistant message, remove it.
363
363
  // This prevents 400 errors from the API after an aborted turn.
364
+ //
365
+ // Position-aware: a tool result is orphaned when its tool_call_id hasn't been
366
+ // defined by an assistant message AT THIS POSITION yet. MiniMax-M2.7 reuses
367
+ // canonical tool_call_ids across turns, so the global-set check that this
368
+ // function used previously kept misordered tool results that MiniMax then
369
+ // rejected with error 2013 ("tool result's tool id not found"). Walking
370
+ // in order matches what MiniMax actually enforces.
364
371
  function repairOrphanedToolCalls(messages) {
365
- // Pass 1: collect all valid tool_call IDs from assistant messages
366
- const validCallIds = new Set();
367
- for (const msg of messages) {
372
+ // Pass 1: walk in order, accumulate seen tool_call_ids per-position, and
373
+ // mark tool results for removal if their id hasn't been defined yet.
374
+ const seenCallIds = new Set();
375
+ const removeIndices = [];
376
+ for (let i = 0; i < messages.length; i++) {
377
+ const msg = messages[i];
368
378
  if (msg.role === "assistant") {
369
379
  const asst = msg;
370
380
  if (asst.tool_calls) {
371
381
  for (const tc of asst.tool_calls)
372
- validCallIds.add(tc.id);
382
+ seenCallIds.add(tc.id);
373
383
  }
384
+ continue;
374
385
  }
375
- }
376
- // Pass 2: remove orphaned tool results (tool_call_id not in any assistant's tool_calls)
377
- for (let i = messages.length - 1; i >= 0; i--) {
378
- if (messages[i].role === "tool") {
379
- const toolMsg = messages[i];
380
- if (!validCallIds.has(toolMsg.tool_call_id)) {
381
- messages.splice(i, 1);
386
+ if (msg.role === "tool") {
387
+ const toolMsg = msg;
388
+ if (!seenCallIds.has(toolMsg.tool_call_id)) {
389
+ removeIndices.push(i);
382
390
  }
383
391
  }
384
392
  }
393
+ // Splice from the end so earlier indices stay valid.
394
+ for (let i = removeIndices.length - 1; i >= 0; i--) {
395
+ messages.splice(removeIndices[i], 1);
396
+ }
385
397
  // Pass 3: inject synthetic results for tool_calls missing their tool results
386
398
  for (let i = 0; i < messages.length; i++) {
387
399
  const msg = messages[i];
@@ -403,10 +415,13 @@ function repairOrphanedToolCalls(messages) {
403
415
  }
404
416
  const missing = asst.tool_calls.filter((tc) => !resultIds.has(tc.id));
405
417
  if (missing.length > 0) {
418
+ // AX rule: the agent must see what happened. Don't say "interrupted"
419
+ // — that's vague. Tell them the result was lost, possible causes,
420
+ // and what to do next.
406
421
  const syntheticResults = missing.map((tc) => ({
407
422
  role: "tool",
408
423
  tool_call_id: tc.id,
409
- content: "error: tool call was interrupted (previous turn timed out or was aborted)",
424
+ content: "error: this tool call's result was lost — the previous turn ended before the tool finished (provider rejection, daemon interrupt, or the tool itself errored). if the work needs to be done, retry the tool call now.",
410
425
  }));
411
426
  let insertAt = i + 1;
412
427
  while (insertAt < messages.length && messages[insertAt].role === "tool")
@@ -764,8 +779,36 @@ async function runAgent(messages, callbacks, channel, signal, options) {
764
779
  const msg = {
765
780
  role: "assistant",
766
781
  };
767
- if (result.content)
768
- msg.content = result.content;
782
+ // Persist assistant content WITHOUT inline <think>...</think> blocks.
783
+ // Reasoning content already routed through onReasoningChunk for live
784
+ // surfacing and persisted separately as `_reasoning_items` for
785
+ // providers that support a reasoning channel; saving it inline AND
786
+ // alongside tool_calls causes MiniMax to reject the replayed turn
787
+ // with "tool result's tool id not found" (error code 2013) because
788
+ // it can't reconcile reasoning-with-tools in the same assistant
789
+ // message. Strip aggressively at persist so the next replay is
790
+ // clean; preserve the original reasoning trace on the message via
791
+ // `_inline_reasoning` so debug/audit paths can still see it.
792
+ if (result.content) {
793
+ const stripped = stripThinkBlocksForViolationCheck(result.content);
794
+ if (stripped.length > 0)
795
+ msg.content = stripped;
796
+ if (stripped.length !== result.content.length) {
797
+ msg._inline_reasoning = result.content;
798
+ (0, runtime_1.emitNervesEvent)({
799
+ level: "info",
800
+ component: "engine",
801
+ event: "engine.inline_reasoning_stripped",
802
+ message: "stripped inline <think> blocks from persisted assistant message; preserved on _inline_reasoning",
803
+ meta: {
804
+ provider: providerRuntime.id,
805
+ model: providerRuntime.model,
806
+ originalLength: result.content.length,
807
+ strippedLength: stripped.length,
808
+ },
809
+ });
810
+ }
811
+ }
769
812
  if (result.toolCalls.length)
770
813
  msg.tool_calls = result.toolCalls.map((tc) => ({
771
814
  id: tc.id,
@@ -334,20 +334,24 @@ function repairSessionMessages(messages) {
334
334
  });
335
335
  return result.map(toProviderMessage);
336
336
  }
337
- function repairToolCallSequences(messages) {
337
+ function repairToolCallSequences(messages, inlineReasoningStrippedCallIds = new Set()) {
338
338
  const normalized = messages.map(normalizeMessage);
339
- const validCallIds = new Set();
340
- for (const msg of normalized) {
341
- if (msg.role !== "assistant")
342
- continue;
343
- for (const toolCall of msg.toolCalls)
344
- validCallIds.add(toolCall.id);
345
- }
339
+ // Position-aware orphan detection. A tool result is orphaned if there is
340
+ // no preceding assistant message in the array whose tool_calls contain the
341
+ // matching id. (The previous logic checked all assistant messages
342
+ // globally, which kept tool results that appeared BEFORE their matching
343
+ // assistant invalid order — and triggered MiniMax error 2013 on replay.)
346
344
  let removed = 0;
345
+ const seenCallIds = new Set();
347
346
  const repaired = normalized.filter((msg) => {
347
+ if (msg.role === "assistant") {
348
+ for (const tc of msg.toolCalls)
349
+ seenCallIds.add(tc.id);
350
+ return true;
351
+ }
348
352
  if (msg.role !== "tool")
349
353
  return true;
350
- const keep = msg.toolCallId !== null && validCallIds.has(msg.toolCallId);
354
+ const keep = msg.toolCallId !== null && seenCallIds.has(msg.toolCallId);
351
355
  if (!keep)
352
356
  removed++;
353
357
  return keep;
@@ -381,7 +385,7 @@ function repairToolCallSequences(messages) {
381
385
  continue;
382
386
  const syntheticResults = missing.map((toolCall) => ({
383
387
  role: "tool",
384
- content: "error: tool call was interrupted (previous turn timed out or was aborted)",
388
+ content: buildSyntheticToolResultMessage(toolCall.id, inlineReasoningStrippedCallIds),
385
389
  name: null,
386
390
  toolCallId: toolCall.id,
387
391
  toolCalls: [],
@@ -460,6 +464,105 @@ function migrateToolNames(messages) {
460
464
  }
461
465
  return safeMessages.map(normalizeMessage).map(toProviderMessage);
462
466
  }
467
+ /**
468
+ * Strip inline `<think>...</think>` blocks from a string. Mirrors the
469
+ * helper at senses/shared-turn.ts (operator-facing) and core.ts
470
+ * (live-turn) — kept inline here because session-events.ts is the load-
471
+ * time repair path and needs its own copy to avoid sense/heart import
472
+ * cycles. If the close tag is missing, drops everything from the open
473
+ * tag onward.
474
+ */
475
+ function stripInlineThinkBlocks(input) {
476
+ let out = input;
477
+ for (;;) {
478
+ const open = out.indexOf("<think>");
479
+ if (open === -1)
480
+ break;
481
+ const close = out.indexOf("</think>", open + "<think>".length);
482
+ if (close === -1) {
483
+ out = out.slice(0, open);
484
+ break;
485
+ }
486
+ out = out.slice(0, open) + out.slice(close + "</think>".length);
487
+ }
488
+ return out.trim();
489
+ }
490
+ /**
491
+ * Strip inline `<think>` content from any assistant message that ALSO has
492
+ * tool_calls. MiniMax-style models persist think-content + tool_calls on
493
+ * the same assistant turn; replaying that combination triggers MiniMax
494
+ * error 2013 ("tool result's tool id not found") and stalls the session.
495
+ *
496
+ * AX requirement: the agent MUST see that this happened. We don't silently
497
+ * paper over their previous turn — we strip for replay correctness AND
498
+ * collect the affected tool_call_ids in `inlineReasoningStrippedCallIds`
499
+ * so the downstream synthetic-tool-result repair can produce an
500
+ * explanatory message addressed to those specific calls. The agent sees:
501
+ * "your previous tool call's result was lost because the assistant message
502
+ * had inline reasoning blocks the provider couldn't replay — here's what
503
+ * happened, retry if needed." Full awareness, no silent corrections.
504
+ *
505
+ * This load-time repair self-heals existing sessions that were saved
506
+ * before the persist-time strip in core.ts landed.
507
+ */
508
+ function repairInlineReasoningOnReplay(messages, inlineReasoningStrippedCallIds) {
509
+ let repaired = 0;
510
+ const result = messages.map((msg) => {
511
+ if (msg.role !== "assistant")
512
+ return msg;
513
+ const a = msg;
514
+ if (!a.tool_calls || a.tool_calls.length === 0)
515
+ return msg;
516
+ if (typeof a.content !== "string")
517
+ return msg;
518
+ if (!a.content.includes("<think>"))
519
+ return msg;
520
+ const stripped = stripInlineThinkBlocks(a.content);
521
+ repaired++;
522
+ for (const tc of a.tool_calls)
523
+ inlineReasoningStrippedCallIds.add(tc.id);
524
+ return { ...a, content: stripped.length > 0 ? stripped : null };
525
+ });
526
+ if (repaired > 0) {
527
+ (0, runtime_1.emitNervesEvent)({
528
+ level: "info",
529
+ event: "mind.session_inline_reasoning_repair",
530
+ component: "mind",
531
+ message: "stripped inline <think> blocks from assistant messages with tool_calls so replay is valid; agent will see explanatory tool-result messages",
532
+ meta: { repaired, affectedCallIds: inlineReasoningStrippedCallIds.size },
533
+ });
534
+ }
535
+ return result;
536
+ }
537
+ /**
538
+ * Compose the synthetic tool-result message the agent sees when their
539
+ * previous turn's tool call has no matching tool result. The default
540
+ * message tells the agent what happened (turn ended early, result lost)
541
+ * and what to do (retry if the work isn't done). When the parent
542
+ * assistant message had inline `<think>` reasoning that the provider
543
+ * rejected, the message is more specific so the agent can adjust.
544
+ *
545
+ * AX rule: every repair must produce a message the agent can read and
546
+ * act on. Silent strips are never OK.
547
+ */
548
+ function buildSyntheticToolResultMessage(toolCallId, inlineReasoningStrippedCallIds) {
549
+ if (inlineReasoningStrippedCallIds.has(toolCallId)) {
550
+ return [
551
+ "error: this tool call's result was lost.",
552
+ "your previous assistant turn included inline `<think>...</think>` reasoning alongside tool_calls,",
553
+ "and the provider (likely MiniMax) rejects that combination on replay (error 2013).",
554
+ "the harness has stripped the inline reasoning from the persisted content so the next replay is valid;",
555
+ "your reasoning trace itself is preserved out-of-band and not lost.",
556
+ "if the underlying work still needs to be done, retry the tool call now —",
557
+ "the call may not have run, or it ran but the result didn't reach you.",
558
+ ].join(" ");
559
+ }
560
+ return [
561
+ "error: this tool call's result was lost — the previous turn ended before the tool finished",
562
+ "(provider rejection, daemon interrupt, or the tool itself errored).",
563
+ "if the work needs to be done, retry the tool call now.",
564
+ ].join(" ");
565
+ }
463
566
  function sanitizeProviderMessages(messages) {
464
567
  const safeMessages = messages.filter((message) => Boolean(message) && typeof message === "object");
465
568
  const normalized = safeMessages.map(normalizeMessage);
@@ -473,7 +576,12 @@ function sanitizeProviderMessages(messages) {
473
576
  meta: { violations },
474
577
  });
475
578
  }
476
- return canonicalizeSystemMessageSequence(migrateToolNames(repairToolCallSequences(repairSessionMessages(normalized.map(toProviderMessage)))));
579
+ // Track which tool_call_ids belonged to assistant messages whose inline
580
+ // reasoning we just stripped. The synthetic-tool-result repair downstream
581
+ // uses this set to produce an explanatory message for those calls so the
582
+ // agent has full awareness of what happened.
583
+ const inlineReasoningStrippedCallIds = new Set();
584
+ return canonicalizeSystemMessageSequence(migrateToolNames(repairToolCallSequences(repairInlineReasoningOnReplay(repairSessionMessages(normalized.map(toProviderMessage)), inlineReasoningStrippedCallIds), inlineReasoningStrippedCallIds)));
477
585
  }
478
586
  function stampIngressTime(msg) {
479
587
  msg._ingressAt = new Date().toISOString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.491",
3
+ "version": "0.1.0-alpha.493",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",