You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
chat.rs carries a hand-written agent loop: open the stream, forward text deltas, assemble tool calls at the end of the stream, validate them (check_call), dispatch, push the exchange back, count to MAX_ROUNDS, order an answer when the budget runs out, fall back to one-shot RAG when round 0 fails, replay the previous turn's tool exchange and the entities already identified. About 1,100 lines, and every policy question about it lands as another branch in that loop. #509 is the latest: a text-only turn is the loop's definition of "done", so a promise to search ships as the answer, and the guard added in #543 (ask once more, accept DONE) is a heuristic on top of a heuristic. Fifteen live turns against DeepSeek-V3 showed why: the guard fired four times and the model answered DONE every time, including after "请稍等,我将调用相关工具". A model asked to grade its own turn passes itself.
The same fifteen turns turned up two more loop defects:
Any error on round 0 is taken for "this model has no tool calling" and the turn degrades to one-shot RAG. A transient network failure to SiliconFlow produced exactly that: a warning saying tool-calling is unavailable, then the fallback failing on the same network error.
The "entities already identified" block is injected as a user message. Asked "你搜了吗", the model replied to that block as if the user had written it: "看起来你已经获得了OpenAI的相关实体ID,可以继续使用这些ID…".
The shape to move to
The tools are already a schema shared with MCP (tools_schema). Give them to a runner that has the loop's decisions as first-class hooks instead of branches. Both Rust candidates were read at source:
rig (rig-core 0.42, rig-agent; last release 2026-08). Its runner is our loop with a hook at every joint: on_completion_call returning a RequestPatch (where the entity block goes, as a patch on the request rather than a fake user message), ToolCallAction::{Run, Rewrite} and an InvalidToolCall hook (check_call), on_model_turn_finished returning ModelTurnAction::{Continue, Retry(Repeat | Feedback(text)), Stop} (the The chat loop believes the model when it says it will search #509 policy as one hook), tool_choice on the builder (structured termination: every turn must call a tool, with answer and no_evidence_needed as tools), max_turns, and streaming items TextDelta / ToolCall / ToolResult / ReasoningDelta (our delta and step frames). on_model_select is where One model does every job in every base #470's per-task model goes. Its default termination is the same as ours, a text-only turn ends the run, so the framework alone does not fix The chat loop believes the model when it says it will search #509; it makes the fix a hook rather than a branch.
swiftide-agents has a stop tool and a stop_on_assistant = false mode (the agent runs until it calls stop) plus hooks, but its last release is 2025-11 and it is built around its own context and history model. Not this one.
Keep LlmClient. It holds behaviour paid for with incidents: the read timeout (a 7,459-chunk ingest died on chunk 55 with no error), error bodies forwarded (#538), out-of-credit versus rate-limit classification, cached-token logging. rig's provider client would drop all of that. rig's model is a trait; wrap LlmClient as its CompletionModel so the loop and hooks are rig's and the wire is ours.
What must survive, and is the acceptance
The SSE contract unchanged: conversation, step (with the UTF-16 at offset), sources, delta, done, error, in the same order the frontend consumes today. Reconnect through live unchanged.
Persistence unchanged: steps, sources, resolved, tool_exchange on the stored turn; the next turn replays them.
The record axis (as_of) reaches the tools as it does now.
The budget-exhausted answer, the rate-limit retry, and a degradation path that fires only on "the provider rejected tool calling", not on any error.
The chat loop believes the model when it says it will search #509 closed structurally: a turn cannot end on a promise. Either tool_choice forces a tool every turn with answer / no_evidence_needed as the terminals, or the turn-finished hook retries with feedback; whichever is chosen, "请稍等,我将调用工具" must not be a final state, and the fifteen-turn harness from A promise to search is not an answer #543's testing (relation ×3, board, timeline ×2, "说短一点", "你搜了吗", "啥情况", "你是谁" ×2, "你好") runs green with the same or better answers and no invented searches.
check_call's rules pinned as they are today (its tests move with it).
A decision record: why a runner, why rig, why the client stays ours, and what the termination contract is.
Order
Spike on a branch: LlmClient behind rig's model trait, the eight tools registered, the two hooks, the SSE frames reproduced. Run the harness. Report what fit and what did not.
If it holds: replace the loop, delete the A promise to search is not an answer #543 guard, land the record. If not: write down why, and the fallback is the structured-termination change inside the hand-written loop (tool_choice: required plus answer / no_evidence_needed tools).
Not this: text matching on the model's prose ("please wait", "我将搜索"); more prompt wording; a second nudge.
chat.rscarries a hand-written agent loop: open the stream, forward text deltas, assemble tool calls at the end of the stream, validate them (check_call), dispatch, push the exchange back, count toMAX_ROUNDS, order an answer when the budget runs out, fall back to one-shot RAG when round 0 fails, replay the previous turn's tool exchange and the entities already identified. About 1,100 lines, and every policy question about it lands as another branch in that loop. #509 is the latest: a text-only turn is the loop's definition of "done", so a promise to search ships as the answer, and the guard added in #543 (ask once more, acceptDONE) is a heuristic on top of a heuristic. Fifteen live turns against DeepSeek-V3 showed why: the guard fired four times and the model answeredDONEevery time, including after "请稍等,我将调用相关工具". A model asked to grade its own turn passes itself.The same fifteen turns turned up two more loop defects:
usermessage. Asked "你搜了吗", the model replied to that block as if the user had written it: "看起来你已经获得了OpenAI的相关实体ID,可以继续使用这些ID…".The shape to move to
The tools are already a schema shared with MCP (
tools_schema). Give them to a runner that has the loop's decisions as first-class hooks instead of branches. Both Rust candidates were read at source:rig-core0.42,rig-agent; last release 2026-08). Its runner is our loop with a hook at every joint:on_completion_callreturning aRequestPatch(where the entity block goes, as a patch on the request rather than a fake user message),ToolCallAction::{Run, Rewrite}and anInvalidToolCallhook (check_call),on_model_turn_finishedreturningModelTurnAction::{Continue, Retry(Repeat | Feedback(text)), Stop}(the The chat loop believes the model when it says it will search #509 policy as one hook),tool_choiceon the builder (structured termination: every turn must call a tool, withanswerandno_evidence_neededas tools),max_turns, and streaming itemsTextDelta/ToolCall/ToolResult/ReasoningDelta(ourdeltaandstepframes).on_model_selectis where One model does every job in every base #470's per-task model goes. Its default termination is the same as ours, a text-only turn ends the run, so the framework alone does not fix The chat loop believes the model when it says it will search #509; it makes the fix a hook rather than a branch.stoptool and astop_on_assistant = falsemode (the agent runs until it callsstop) plus hooks, but its last release is 2025-11 and it is built around its own context and history model. Not this one.Keep
LlmClient. It holds behaviour paid for with incidents: the read timeout (a 7,459-chunk ingest died on chunk 55 with no error), error bodies forwarded (#538), out-of-credit versus rate-limit classification, cached-token logging. rig's provider client would drop all of that. rig's model is a trait; wrapLlmClientas itsCompletionModelso the loop and hooks are rig's and the wire is ours.What must survive, and is the acceptance
conversation,step(with the UTF-16atoffset),sources,delta,done,error, in the same order the frontend consumes today. Reconnect throughliveunchanged.steps,sources,resolved,tool_exchangeon the stored turn; the next turn replays them.as_of) reaches the tools as it does now.tool_choiceforces a tool every turn withanswer/no_evidence_neededas the terminals, or the turn-finished hook retries with feedback; whichever is chosen, "请稍等,我将调用工具" must not be a final state, and the fifteen-turn harness from A promise to search is not an answer #543's testing (relation ×3, board, timeline ×2, "说短一点", "你搜了吗", "啥情况", "你是谁" ×2, "你好") runs green with the same or better answers and no invented searches.check_call's rules pinned as they are today (its tests move with it).Order
LlmClientbehind rig's model trait, the eight tools registered, the two hooks, the SSE frames reproduced. Run the harness. Report what fit and what did not.tool_choice: requiredplusanswer/no_evidence_neededtools).Not this: text matching on the model's prose ("please wait", "我将搜索"); more prompt wording; a second nudge.