From 5b8af017dd6006b99793112db6ef94504ed9b7df Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Sat, 5 Sep 2026 11:23:17 +0200 Subject: [PATCH] feat(mcp): detect and withhold a tool-call-free reasoning-loop reply (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live run today (bar mode via a "thinking" model, opencode's kimi-k2.7-code) burned its whole step budget on a ~36,000-character reply that was pure self-narration ("OK. Let's go. Now. Done. I'll output now...") with zero real tool calls. bridge.outcome_of_steps returned that text as a normal, completed reply — a caller had no signal the run had degenerated rather than succeeded. Scoped this to lex-code rather than lex-llm: true mid-generation cancellation would need lex-llm to expose an abort, and would be moot for MCP specifically anyway — make_handler already calls iter.to_list on the whole run_loop before any Step is visible here, so the full (wasted) generation has already happened by the time detection could run. What's fully in scope on lex-code's side: never hand the raw ramble back as if it were a real answer. is_degenerate_ramble(steps) flags a turn with zero StepToolExec steps whose final StepDone text exceeds 8,000 characters — picked well below today's 36,000-character failure and comfortably above the longest genuine final answers seen live this session (a few thousand characters). make_handler now checks this before handing off to bridge.outcome_of_steps, replacing a flagged reply with an explicit "[degenerate response detected...]" message naming the character count and pointing at LEX_CODE_PROVIDER_ as the fix. Verified with constructed Step lists covering the three cases that matter: a long zero-tool-call reply (flagged), a short zero-tool-call reply — e.g. plain chat with no work to do — (not flagged, avoiding a false positive on legitimate short answers), and a long reply that DID call a tool (not flagged, since tool use is itself evidence of real work regardless of final text length). Also live-tested a normal short MCP request end to end afterward to confirm an ordinary reply still passes through unchanged. Fixes #114 Co-Authored-By: Claude Sonnet 5 --- src/server/mcp_main.lex | 68 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/src/server/mcp_main.lex b/src/server/mcp_main.lex index d8ecc37..175dc88 100644 --- a/src/server/mcp_main.lex +++ b/src/server/mcp_main.lex @@ -28,6 +28,8 @@ import "std.list" as list import "std.str" as str +import "std.int" as int + import "std.io" as io import "std.env" as env @@ -38,10 +40,14 @@ import "lex-llm/src/agent" as ag import "lex-llm/src/message" as lmsg +import "lex-llm/src/delta" as d + import "lex-agent/src/server" as srv import "lex-agent/src/message" as amsg +import "lex-agent/src/task" as tk + import "lex-agent/src/agent_card" as card import "lex-spec/capability" as cap @@ -176,13 +182,73 @@ fn recall_ctx() -> [sql, fs_read, fs_write] Str { # synthetic id (MCP has no client-tracked session to attribute them to) # and re-recalls before running the loop, so a fact remembered on one # call is real memory — attested in the trail — by the next one. +# #114: a live run today (bar mode via a "thinking" model) burned its +# whole step budget on a ~36,000-character reply that was pure +# self-narration — "OK. Let's go. Now. Done. I'll output now..." — and +# zero real tool calls. bridge.outcome_of_steps returned that text as a +# normal, completed reply; a caller (human or automated) had no signal +# the run had degenerated rather than succeeded. +# +# True mid-generation cancellation would need lex-llm to expose an +# abort — out of scope here, and moot for MCP specifically anyway, +# since make_handler already calls iter.to_list on the whole run_loop +# before this function ever sees a Step, so the full (wasted) generation +# already happened by the time detection could run. What IS in scope, +# entirely on lex-code's side: never hand the raw ramble back as if it +# were a real answer. 8,000 characters is picked well below today's +# 36,000-character failure and comfortably above a normal verbose-but- +# real final answer (the longest genuine replies seen live this session +# were a few thousand characters). +fn degenerate_char_threshold() -> Int { + 8000 +} + +fn has_tool_call(steps :: List[d.Step]) -> Bool { + match list.head(list.filter(steps, fn (s :: d.Step) -> Bool { + match s { + StepToolExec(_, _) => true, + _ => false, + } + })) { + Some(_) => true, + None => false, + } +} + +fn final_text_of(steps :: List[d.Step]) -> Str { + list.fold(steps, "", fn (acc :: Str, s :: d.Step) -> Str { + match s { + StepDone(msg) => lmsg.content(msg), + _ => acc, + } + }) +} + +fn is_degenerate_ramble(steps :: List[d.Step]) -> Bool { + if has_tool_call(steps) { + false + } else { + str.len(final_text_of(steps)) > degenerate_char_threshold() + } +} + +fn degenerate_outcome(steps :: List[d.Step]) -> srv.HandlerOutcome { + let n := str.len(final_text_of(steps)) + { next_state: TSCompleted, reply: Some(amsg.agent_text(str.join(["[degenerate response detected: ", int.to_str(n), " characters with zero tool calls — this looks like a reasoning loop, not a real answer. The raw text is being withheld rather than returned as if it were a completed reply. Retry, or try a different model/provider for this mode (see LEX_CODE_PROVIDER_)."], ""))), artifacts: [] } +} + fn make_handler(brains :: Brains) -> (amsg.Message) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc, approval] srv.HandlerOutcome { fn (m :: amsg.Message) -> [io, time, crypto, random, sql, fs_read, fs_write, net, concurrent, llm, proc, approval] srv.HandlerOutcome { let a := extract(m.parts) let request_id := crypto.random_str_hex(16) let __consolidated := consolidate.run(request_id) let brain := sess.with_memory(brain_for(brains, a.mode), recall_ctx()) - bridge.outcome_of_steps(iter.to_list(ag.run_loop(brain, [lmsg.user(a.task)]))) + let steps := iter.to_list(ag.run_loop(brain, [lmsg.user(a.task)])) + if is_degenerate_ramble(steps) { + degenerate_outcome(steps) + } else { + bridge.outcome_of_steps(steps) + } } }