@ouro.bot/cli 0.1.0-alpha.491 → 0.1.0-alpha.492
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 +8 -0
- package/dist/heart/core.js +34 -3
- package/dist/heart/session-events.js +107 -3
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
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.492",
|
|
6
|
+
"changes": [
|
|
7
|
+
"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.",
|
|
8
|
+
"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.",
|
|
9
|
+
"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."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
4
12
|
{
|
|
5
13
|
"version": "0.1.0-alpha.491",
|
|
6
14
|
"changes": [
|
package/dist/heart/core.js
CHANGED
|
@@ -403,10 +403,13 @@ function repairOrphanedToolCalls(messages) {
|
|
|
403
403
|
}
|
|
404
404
|
const missing = asst.tool_calls.filter((tc) => !resultIds.has(tc.id));
|
|
405
405
|
if (missing.length > 0) {
|
|
406
|
+
// AX rule: the agent must see what happened. Don't say "interrupted"
|
|
407
|
+
// — that's vague. Tell them the result was lost, possible causes,
|
|
408
|
+
// and what to do next.
|
|
406
409
|
const syntheticResults = missing.map((tc) => ({
|
|
407
410
|
role: "tool",
|
|
408
411
|
tool_call_id: tc.id,
|
|
409
|
-
content: "error: tool call was
|
|
412
|
+
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
413
|
}));
|
|
411
414
|
let insertAt = i + 1;
|
|
412
415
|
while (insertAt < messages.length && messages[insertAt].role === "tool")
|
|
@@ -764,8 +767,36 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
764
767
|
const msg = {
|
|
765
768
|
role: "assistant",
|
|
766
769
|
};
|
|
767
|
-
|
|
768
|
-
|
|
770
|
+
// Persist assistant content WITHOUT inline <think>...</think> blocks.
|
|
771
|
+
// Reasoning content already routed through onReasoningChunk for live
|
|
772
|
+
// surfacing and persisted separately as `_reasoning_items` for
|
|
773
|
+
// providers that support a reasoning channel; saving it inline AND
|
|
774
|
+
// alongside tool_calls causes MiniMax to reject the replayed turn
|
|
775
|
+
// with "tool result's tool id not found" (error code 2013) because
|
|
776
|
+
// it can't reconcile reasoning-with-tools in the same assistant
|
|
777
|
+
// message. Strip aggressively at persist so the next replay is
|
|
778
|
+
// clean; preserve the original reasoning trace on the message via
|
|
779
|
+
// `_inline_reasoning` so debug/audit paths can still see it.
|
|
780
|
+
if (result.content) {
|
|
781
|
+
const stripped = stripThinkBlocksForViolationCheck(result.content);
|
|
782
|
+
if (stripped.length > 0)
|
|
783
|
+
msg.content = stripped;
|
|
784
|
+
if (stripped.length !== result.content.length) {
|
|
785
|
+
msg._inline_reasoning = result.content;
|
|
786
|
+
(0, runtime_1.emitNervesEvent)({
|
|
787
|
+
level: "info",
|
|
788
|
+
component: "engine",
|
|
789
|
+
event: "engine.inline_reasoning_stripped",
|
|
790
|
+
message: "stripped inline <think> blocks from persisted assistant message; preserved on _inline_reasoning",
|
|
791
|
+
meta: {
|
|
792
|
+
provider: providerRuntime.id,
|
|
793
|
+
model: providerRuntime.model,
|
|
794
|
+
originalLength: result.content.length,
|
|
795
|
+
strippedLength: stripped.length,
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
769
800
|
if (result.toolCalls.length)
|
|
770
801
|
msg.tool_calls = result.toolCalls.map((tc) => ({
|
|
771
802
|
id: tc.id,
|
|
@@ -334,7 +334,7 @@ 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
339
|
const validCallIds = new Set();
|
|
340
340
|
for (const msg of normalized) {
|
|
@@ -381,7 +381,7 @@ function repairToolCallSequences(messages) {
|
|
|
381
381
|
continue;
|
|
382
382
|
const syntheticResults = missing.map((toolCall) => ({
|
|
383
383
|
role: "tool",
|
|
384
|
-
content:
|
|
384
|
+
content: buildSyntheticToolResultMessage(toolCall.id, inlineReasoningStrippedCallIds),
|
|
385
385
|
name: null,
|
|
386
386
|
toolCallId: toolCall.id,
|
|
387
387
|
toolCalls: [],
|
|
@@ -460,6 +460,105 @@ function migrateToolNames(messages) {
|
|
|
460
460
|
}
|
|
461
461
|
return safeMessages.map(normalizeMessage).map(toProviderMessage);
|
|
462
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Strip inline `<think>...</think>` blocks from a string. Mirrors the
|
|
465
|
+
* helper at senses/shared-turn.ts (operator-facing) and core.ts
|
|
466
|
+
* (live-turn) — kept inline here because session-events.ts is the load-
|
|
467
|
+
* time repair path and needs its own copy to avoid sense/heart import
|
|
468
|
+
* cycles. If the close tag is missing, drops everything from the open
|
|
469
|
+
* tag onward.
|
|
470
|
+
*/
|
|
471
|
+
function stripInlineThinkBlocks(input) {
|
|
472
|
+
let out = input;
|
|
473
|
+
for (;;) {
|
|
474
|
+
const open = out.indexOf("<think>");
|
|
475
|
+
if (open === -1)
|
|
476
|
+
break;
|
|
477
|
+
const close = out.indexOf("</think>", open + "<think>".length);
|
|
478
|
+
if (close === -1) {
|
|
479
|
+
out = out.slice(0, open);
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
out = out.slice(0, open) + out.slice(close + "</think>".length);
|
|
483
|
+
}
|
|
484
|
+
return out.trim();
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Strip inline `<think>` content from any assistant message that ALSO has
|
|
488
|
+
* tool_calls. MiniMax-style models persist think-content + tool_calls on
|
|
489
|
+
* the same assistant turn; replaying that combination triggers MiniMax
|
|
490
|
+
* error 2013 ("tool result's tool id not found") and stalls the session.
|
|
491
|
+
*
|
|
492
|
+
* AX requirement: the agent MUST see that this happened. We don't silently
|
|
493
|
+
* paper over their previous turn — we strip for replay correctness AND
|
|
494
|
+
* collect the affected tool_call_ids in `inlineReasoningStrippedCallIds`
|
|
495
|
+
* so the downstream synthetic-tool-result repair can produce an
|
|
496
|
+
* explanatory message addressed to those specific calls. The agent sees:
|
|
497
|
+
* "your previous tool call's result was lost because the assistant message
|
|
498
|
+
* had inline reasoning blocks the provider couldn't replay — here's what
|
|
499
|
+
* happened, retry if needed." Full awareness, no silent corrections.
|
|
500
|
+
*
|
|
501
|
+
* This load-time repair self-heals existing sessions that were saved
|
|
502
|
+
* before the persist-time strip in core.ts landed.
|
|
503
|
+
*/
|
|
504
|
+
function repairInlineReasoningOnReplay(messages, inlineReasoningStrippedCallIds) {
|
|
505
|
+
let repaired = 0;
|
|
506
|
+
const result = messages.map((msg) => {
|
|
507
|
+
if (msg.role !== "assistant")
|
|
508
|
+
return msg;
|
|
509
|
+
const a = msg;
|
|
510
|
+
if (!a.tool_calls || a.tool_calls.length === 0)
|
|
511
|
+
return msg;
|
|
512
|
+
if (typeof a.content !== "string")
|
|
513
|
+
return msg;
|
|
514
|
+
if (!a.content.includes("<think>"))
|
|
515
|
+
return msg;
|
|
516
|
+
const stripped = stripInlineThinkBlocks(a.content);
|
|
517
|
+
repaired++;
|
|
518
|
+
for (const tc of a.tool_calls)
|
|
519
|
+
inlineReasoningStrippedCallIds.add(tc.id);
|
|
520
|
+
return { ...a, content: stripped.length > 0 ? stripped : null };
|
|
521
|
+
});
|
|
522
|
+
if (repaired > 0) {
|
|
523
|
+
(0, runtime_1.emitNervesEvent)({
|
|
524
|
+
level: "info",
|
|
525
|
+
event: "mind.session_inline_reasoning_repair",
|
|
526
|
+
component: "mind",
|
|
527
|
+
message: "stripped inline <think> blocks from assistant messages with tool_calls so replay is valid; agent will see explanatory tool-result messages",
|
|
528
|
+
meta: { repaired, affectedCallIds: inlineReasoningStrippedCallIds.size },
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Compose the synthetic tool-result message the agent sees when their
|
|
535
|
+
* previous turn's tool call has no matching tool result. The default
|
|
536
|
+
* message tells the agent what happened (turn ended early, result lost)
|
|
537
|
+
* and what to do (retry if the work isn't done). When the parent
|
|
538
|
+
* assistant message had inline `<think>` reasoning that the provider
|
|
539
|
+
* rejected, the message is more specific so the agent can adjust.
|
|
540
|
+
*
|
|
541
|
+
* AX rule: every repair must produce a message the agent can read and
|
|
542
|
+
* act on. Silent strips are never OK.
|
|
543
|
+
*/
|
|
544
|
+
function buildSyntheticToolResultMessage(toolCallId, inlineReasoningStrippedCallIds) {
|
|
545
|
+
if (inlineReasoningStrippedCallIds.has(toolCallId)) {
|
|
546
|
+
return [
|
|
547
|
+
"error: this tool call's result was lost.",
|
|
548
|
+
"your previous assistant turn included inline `<think>...</think>` reasoning alongside tool_calls,",
|
|
549
|
+
"and the provider (likely MiniMax) rejects that combination on replay (error 2013).",
|
|
550
|
+
"the harness has stripped the inline reasoning from the persisted content so the next replay is valid;",
|
|
551
|
+
"your reasoning trace itself is preserved out-of-band and not lost.",
|
|
552
|
+
"if the underlying work still needs to be done, retry the tool call now —",
|
|
553
|
+
"the call may not have run, or it ran but the result didn't reach you.",
|
|
554
|
+
].join(" ");
|
|
555
|
+
}
|
|
556
|
+
return [
|
|
557
|
+
"error: this tool call's result was lost — the previous turn ended before the tool finished",
|
|
558
|
+
"(provider rejection, daemon interrupt, or the tool itself errored).",
|
|
559
|
+
"if the work needs to be done, retry the tool call now.",
|
|
560
|
+
].join(" ");
|
|
561
|
+
}
|
|
463
562
|
function sanitizeProviderMessages(messages) {
|
|
464
563
|
const safeMessages = messages.filter((message) => Boolean(message) && typeof message === "object");
|
|
465
564
|
const normalized = safeMessages.map(normalizeMessage);
|
|
@@ -473,7 +572,12 @@ function sanitizeProviderMessages(messages) {
|
|
|
473
572
|
meta: { violations },
|
|
474
573
|
});
|
|
475
574
|
}
|
|
476
|
-
|
|
575
|
+
// Track which tool_call_ids belonged to assistant messages whose inline
|
|
576
|
+
// reasoning we just stripped. The synthetic-tool-result repair downstream
|
|
577
|
+
// uses this set to produce an explanatory message for those calls so the
|
|
578
|
+
// agent has full awareness of what happened.
|
|
579
|
+
const inlineReasoningStrippedCallIds = new Set();
|
|
580
|
+
return canonicalizeSystemMessageSequence(migrateToolNames(repairToolCallSequences(repairInlineReasoningOnReplay(repairSessionMessages(normalized.map(toProviderMessage)), inlineReasoningStrippedCallIds), inlineReasoningStrippedCallIds)));
|
|
477
581
|
}
|
|
478
582
|
function stampIngressTime(msg) {
|
|
479
583
|
msg._ingressAt = new Date().toISOString();
|