Skip to content

Commit 2fefb8c

Browse files
Fix non-terminating stash retrieve loop on explicit working-memory reads
The per-call tool-result cap (CapToolResultAsync) and the watermark trimmer (ToolResultTrimmer) re-stashed the result of an explicit GetFromWorkingMemory retrieval under the retrieval call's new id, then advertised that new key back to the model. The model fetched it, got a slightly larger reference, which was re-stashed again -- a retrieve->re-stash->retrieve loop that made no progress until the iteration/timeout budget killed it. Observed 2026-06-10: a communications-briefing subagent burned its full 15-minute budget this way after pulling a ~15k-char multi-account email payload. ChunkingAIFunction already exempted these working-memory read tools from re-chunking for the same reason. Centralize that exemption in a shared StashExemptTools set and honor it in all three paths (chunk, cap, trim) so an explicit retrieval is always returned in full and never re-stashed. Bump version to 0.12.30. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 449bc82 commit 2fefb8c

6 files changed

Lines changed: 160 additions & 10 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<!-- Version can be overridden from the command line: -p:Version=0.3.1
1010
AssemblyVersion and FileVersion are derived automatically by the SDK
1111
(prerelease suffixes like -beta001 are stripped for assembly versions). -->
12-
<Version>0.12.29</Version>
12+
<Version>0.12.30</Version>
1313
</PropertyGroup>
1414

1515
<!-- NuGet package metadata (shared across all packable projects) -->

src/RockBot.Host/AgentLoopRunner.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,6 +1664,14 @@ internal static async Task<string> CapToolResultAsync(
16641664
if (maxChars <= 0 || resultStr.Length <= maxChars)
16651665
return resultStr;
16661666

1667+
// Explicit working-memory retrievals (GetFromWorkingMemory and friends) must never
1668+
// be re-capped/re-stashed: doing so parks the retrieved content under the retrieval
1669+
// call's *new* id and tells the model to fetch that, producing a non-terminating
1670+
// retrieve→re-stash→retrieve loop. The agent asked for this content by name, so hand
1671+
// it back in full. See StashExemptTools.
1672+
if (StashExemptTools.Contains(toolName))
1673+
return resultStr;
1674+
16671675
// No callId or no stash state → head-only truncation. The model can't recover
16681676
// the elided content (nothing to register), so we don't promise it can.
16691677
if (string.IsNullOrEmpty(callId) || stashState is null)

src/RockBot.Host/ChunkingAIFunction.cs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,6 @@ public sealed class ChunkingAIFunction(
2727
private readonly int _chunkMaxLength = Math.Max(chunkingThreshold, 20_000);
2828
private static readonly TimeSpan ToolResultChunkTtl = TimeSpan.FromMinutes(20);
2929

30-
private static readonly HashSet<string> ChunkingExemptTools = new(StringComparer.OrdinalIgnoreCase)
31-
{
32-
"GetFromWorkingMemory",
33-
"SearchWorkingMemory",
34-
"ListWorkingMemory",
35-
};
36-
3730
public override string Name => inner.Name;
3831
public override string Description => inner.Description;
3932
public override JsonElement JsonSchema => inner.JsonSchema;
@@ -45,7 +38,7 @@ public sealed class ChunkingAIFunction(
4538
var result = await inner.InvokeAsync(arguments, cancellationToken);
4639
var resultStr = result?.ToString() ?? string.Empty;
4740

48-
if (ChunkingExemptTools.Contains(inner.Name))
41+
if (StashExemptTools.Contains(inner.Name))
4942
return result;
5043

5144
if (resultStr.Length <= chunkingThreshold)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
namespace RockBot.Host;
2+
3+
/// <summary>
4+
/// Working-memory read tools whose results are <i>explicit</i> retrievals of content the
5+
/// agent already chose to load (or enumerate). Their results must never be re-chunked,
6+
/// re-capped, or re-stashed.
7+
///
8+
/// <para><b>Why.</b> The chunk/cap/stash machinery replaces an oversized tool result with
9+
/// a head + elision marker + tail surface and parks the full original in working memory
10+
/// under a key derived from the <i>call id</i>, telling the model to fetch it via
11+
/// <c>GetFromWorkingMemory</c>. If that retrieval result is itself oversized and gets
12+
/// re-stashed, it lands under the retrieval call's <i>new</i> id — so the model, dutifully
13+
/// fetching the newly-advertised key, retrieves a slightly larger reference, which is
14+
/// re-stashed under yet another id, and so on. The result is a retrieve→re-stash→retrieve
15+
/// loop that makes no progress until the surrounding iteration/timeout budget kills it.
16+
/// (Observed 2026-06-10: a communications-briefing subagent burned its full 15-minute
17+
/// budget in exactly this loop after pulling a ~15k-char multi-account email payload.)</para>
18+
///
19+
/// <para>Exempting these tools means an explicit retrieval is honoured in full and left
20+
/// alone — matching the long-standing chunking exemption these same tools already had.</para>
21+
/// </summary>
22+
internal static class StashExemptTools
23+
{
24+
private static readonly HashSet<string> Names = new(StringComparer.OrdinalIgnoreCase)
25+
{
26+
"GetFromWorkingMemory",
27+
"SearchWorkingMemory",
28+
"ListWorkingMemory",
29+
};
30+
31+
/// <summary>
32+
/// True when <paramref name="toolName"/> is an explicit working-memory read whose
33+
/// result must not be re-chunked, re-capped, or re-stashed.
34+
/// </summary>
35+
public static bool Contains(string? toolName) =>
36+
!string.IsNullOrEmpty(toolName) && Names.Contains(toolName);
37+
}

src/RockBot.Host/ToolResultTrimmer.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,13 @@ public static async Task TrimAsync(
7474
if (messages[i].Contents[j] is FunctionResultContent frc)
7575
{
7676
var len = frc.Result?.ToString()?.Length ?? 0;
77-
if (len > bestLen) { bestMsg = i; bestContent = j; bestLen = len; }
77+
if (len <= bestLen) continue;
78+
// Never re-stash an explicit working-memory retrieval — re-stashing
79+
// mints a fresh key on every pass and loops the model forever
80+
// re-fetching its own growing reference. See StashExemptTools.
81+
if (StashExemptTools.Contains(ExtractToolNameForCallId(messages, frc.CallId)))
82+
continue;
83+
bestMsg = i; bestContent = j; bestLen = len;
7884
}
7985
}
8086
}

tests/RockBot.Host.Tests/AgentLoopRunnerTrimStashTests.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,112 @@ public void TruncateArgsSummary_WithinLimit_ReturnsAsIs()
267267
Assert.AreEqual(s, AgentLoopRunner.TruncateArgsSummary(s));
268268
}
269269

270+
[TestMethod]
271+
[Timeout(10_000)]
272+
public async Task Trim_OversizeRetrievalResult_IsNotReStashedAndDoesNotSpin()
273+
{
274+
// Regression for the 2026-06-10 communications-briefing runaway: an explicit
275+
// GetFromWorkingMemory retrieval returned an oversized result, which the trim
276+
// re-stashed under the *retrieval* call's id and advertised back to the model.
277+
// The model re-fetched the new key, got a larger reference, which was re-stashed
278+
// again — a retrieve→re-stash→retrieve loop that burned the whole subagent budget.
279+
// Explicit working-memory reads must be left intact.
280+
var wm = new TestWorkingMemory();
281+
var runner = NewRunner(wm);
282+
var stashState = new AgentLoopStashContext.State { SessionId = "sess-1" };
283+
284+
var bigRetrieval = new string('R', 4000);
285+
var messages = new List<ChatMessage>
286+
{
287+
new(ChatRole.System, "system prompt"),
288+
new(ChatRole.User, "do the thing"),
289+
BuildAssistantWithCall("GetFromWorkingMemory", "call-1"),
290+
new(ChatRole.Tool, [new FunctionResultContent("call-1", bigRetrieval)]),
291+
};
292+
293+
await runner.TrimLargeToolResultsAsync(messages, maxTokens: 200, "sess-1", stashState);
294+
295+
var frc = (FunctionResultContent)messages[3].Contents[0];
296+
Assert.AreEqual(bigRetrieval, frc.Result?.ToString(),
297+
"An explicit GetFromWorkingMemory retrieval must be left intact, not head+tail trimmed.");
298+
Assert.IsTrue(stashState.Registry.IsEmpty,
299+
"A retrieval result must never be re-stashed (that mints a fresh key and loops the model).");
300+
Assert.AreEqual(0, wm.WriteCount, "Nothing should be written to working memory for a retrieval result.");
301+
}
302+
303+
[TestMethod]
304+
[Timeout(10_000)]
305+
public async Task Trim_RetrievalAndNormalResult_TrimsNormalAndSkipsRetrieval()
306+
{
307+
// When both an exempt retrieval and a normal oversized result are over budget,
308+
// the trim must skip the retrieval and reclaim space from the normal result.
309+
var wm = new TestWorkingMemory();
310+
var runner = NewRunner(wm);
311+
var stashState = new AgentLoopStashContext.State { SessionId = "sess-1" };
312+
313+
var bigRetrieval = new string('R', 4000);
314+
var biggerNormal = new string('N', 5000) + "NORMAL-TAIL";
315+
var messages = new List<ChatMessage>
316+
{
317+
new(ChatRole.System, "system prompt"),
318+
new(ChatRole.User, "do the thing"),
319+
BuildAssistantWithCall("GetFromWorkingMemory", "call-ret"),
320+
new(ChatRole.Tool, [new FunctionResultContent("call-ret", bigRetrieval)]),
321+
BuildAssistantWithCall("fetch_url", "call-norm"),
322+
new(ChatRole.Tool, [new FunctionResultContent("call-norm", biggerNormal)]),
323+
};
324+
325+
await runner.TrimLargeToolResultsAsync(messages, maxTokens: 200, "sess-1", stashState);
326+
327+
var retrieval = (FunctionResultContent)messages[3].Contents[0];
328+
Assert.AreEqual(bigRetrieval, retrieval.Result?.ToString(),
329+
"The retrieval result must be untouched.");
330+
331+
var normal = (FunctionResultContent)messages[5].Contents[0];
332+
StringAssert.Contains(normal.Result?.ToString() ?? string.Empty, ElisionMarkerPrefix,
333+
"The normal result must be head+tail trimmed to reclaim space.");
334+
335+
Assert.AreEqual(1, stashState.Registry.Snapshot().Count,
336+
"Only the normal result should be stashed.");
337+
Assert.AreEqual("call-norm", stashState.Registry.Snapshot()[0].CallId);
338+
}
339+
340+
[TestMethod]
341+
public async Task CapToolResult_RetrievalTool_ReturnsUnchangedWithoutStashing()
342+
{
343+
var wm = new TestWorkingMemory();
344+
var stashState = new AgentLoopStashContext.State { SessionId = "sess-1" };
345+
var big = new string('R', 4000);
346+
347+
var capped = await AgentLoopRunner.CapToolResultAsync(
348+
big, callId: "call-1", toolName: "GetFromWorkingMemory",
349+
workingMemory: wm, stashState: stashState,
350+
maxChars: 1000, headRatio: 0.6, ttl: TimeSpan.FromMinutes(60),
351+
logger: NullLogger<AgentLoopRunner>.Instance);
352+
353+
Assert.AreEqual(big, capped, "An explicit retrieval must be returned in full, not capped.");
354+
Assert.IsTrue(stashState.Registry.IsEmpty, "A retrieval result must not be stashed.");
355+
Assert.AreEqual(0, wm.WriteCount, "A retrieval result must not be written back to working memory.");
356+
}
357+
358+
[TestMethod]
359+
public async Task CapToolResult_NormalTool_CapsAndStashes()
360+
{
361+
var wm = new TestWorkingMemory();
362+
var stashState = new AgentLoopStashContext.State { SessionId = "sess-1" };
363+
var big = new string('N', 4000);
364+
365+
var capped = await AgentLoopRunner.CapToolResultAsync(
366+
big, callId: "call-1", toolName: "fetch_url",
367+
workingMemory: wm, stashState: stashState,
368+
maxChars: 1000, headRatio: 0.6, ttl: TimeSpan.FromMinutes(60),
369+
logger: NullLogger<AgentLoopRunner>.Instance);
370+
371+
Assert.IsTrue(capped.Length < big.Length, "A normal oversized result must be capped.");
372+
StringAssert.Contains(capped, ElisionMarkerPrefix);
373+
Assert.AreEqual(1, stashState.Registry.Snapshot().Count, "A normal capped result must be stashed.");
374+
}
375+
270376
// ── Helpers ──────────────────────────────────────────────────────────────
271377

272378
private static AgentLoopRunner NewRunner(IWorkingMemory workingMemory)

0 commit comments

Comments
 (0)