@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 +14 -0
- package/dist/heart/core.js +57 -14
- package/dist/heart/session-events.js +119 -11
- package/package.json +1 -1
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": [
|
package/dist/heart/core.js
CHANGED
|
@@ -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:
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
382
|
+
seenCallIds.add(tc.id);
|
|
373
383
|
}
|
|
384
|
+
continue;
|
|
374
385
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
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
|
-
|
|
768
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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 &&
|
|
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:
|
|
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
|
-
|
|
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();
|