Repository navigation
The chat loop runs on rig - #548
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
|
Two more runs to separate the model from the prompt, both on this branch, same scratch base, twelve fresh data questions each (relation ×3, relation-again ×3, timeline ×3, board ×3). 1. Prompt redefined, same model (DeepSeek-V3). The reasons the model gave for
Halved, not gone. Two of the three remaining skips cite "general knowledge" while the prompt now says in those words that general knowledge is never a reason. The model reads the instruction and overrides it. 2. Same prompt, other model (Qwen2.5-72B-Instruct on the same endpoint; Qwen3-235B is disabled on this key). 12/12 called a real tool first; So: the skips are DeepSeek-V3's; the prompt's "user's data" framing made them more likely and is fixed on this branch. Every skip that remains is a stored |
The prompt keeps both halves: the knowledge-base wording and the don't-skip paragraph from the spike, the three graph tools and their routing from #558. The agent now passes the question to ToolCtx and reads dispatch's ToolResult, as the hand-written loop on dev does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
Closes #546. Started as the spike the issue asked for; the decision is made and recorded in 0042. The report below is kept as measured.
What changed
The hand-written loop in
chat.rsis gone.chat.rsnow assembles a rig agent, drives its stream, and translates rig's items into the SSE frames the frontend already consumes. Three new pieces:api/rig_model.rs:LlmClientbehind rig'sCompletionModel. rig's request (preamble, history, documents, tool definitions,tool_choice) is spelled out as the OpenAI-protocol JSONLlmClientalready speaks;LlmClient's turns and streams come back as rig's responses. The wire stays ours: read timeout, error bodies (LLM errors preserve their response bodies #538), out-of-credit versus rate-limit classification all survive, and the wholeanyhowchain travels through rig'sCompletionErrorso the loop can still classify a failure. Two decisions live here: rig's "documents" (the entities identified earlier in the conversation) become asystemmessage placed right before the current question, not a fakeusermessage (The chat loop is hand-written, and every policy lands as another branch #546's second finding: the model used to answer that block as if the user had written it); andToolChoice::Nonesends no tools field at all, which every endpoint accepts, instead of"tool_choice": "none", which not every endpoint does.api/agent.rs: the tools and the policy. The eight tools are rigDynamicTools built fromtools_schema(the list MCP also uses), still executed bytools::dispatch; a ninth,no_evidence_needed, is the terminal for questions that are not about the knowledge base. The policy is oneAgentHook:on_completion_callsetstool_choice: requireduntil some tool has run and, on the request after the budget, withdraws the tools and orders an answer;on_tool_callrunscheck_call(unchanged, its tests unchanged) and skips a call that fails it, feeding the model the same message as before;on_tool_resulthands the tool's UI step to the stream;on_model_turn_finishedcatches an endpoint that ignoresrequired(text-only first turn, no tool run) and sends it back once.utopia-llm: a typedRejectederror and atool_choicepass-through. Non-2xx responses that are neither out-of-credit nor rate-limit now carry their status code as a type (the text is unchanged). The loop degrades to one-shot RAG only when the first request with tools comes back 400/422, which is what "the endpoint rejects tool calling" looks like on the wire. Before, any first-round error did that (The chat loop is hand-written, and every policy lands as another branch #546's first finding).The #543 guard (
STALL_NUDGE,DONE, the no-evidence note) is deleted: termination is structural now. A turn cannot end before a tool has run, so "请稍等,我将调用工具" is no longer a possible final state.Unchanged: the SSE contract (
conversation,stepwith the UTF-16at,sources,delta,done,error, same order), reconnect throughlive, the four persisted fields (steps,sources,resolved,tool_exchange; the next turn replays them, now converted to rig messages), the record axis (tools untouched), the budget answer, the tool list MCP sees.The harness
The fifteen turns from #543's testing, DeepSeek-V3 through SiliconFlow, same scratch database. All fifteen finished with
done; noerror, no degradation, no stall.no_evidence_neededfind_entitiesno_evidence_neededno_evidence_neededfind_entities,entity_factsno_evidence_neededfind_entities,entity_factsno_evidence_neededno_evidence_neededno_evidence_neededsearch_chunksno_evidence_neededsearch_chunksno_evidence_neededfind_entitiesWhat fit
chat.rslost 630 lines.tool_choice: required: every first turn called a tool. The fallback for an endpoint that ignores it (the turn-finished hook) never fired; the fallback for an endpoint that rejects it (RigModelresends withouttool_choice) never fired either. Both are in place, neither has been exercised by a real endpoint yet.DONEevery time, including after promising a search. Here no turn ended on a promise, and no hidden second request was made.What did not
Structural termination guarantees a decision, not a correct one. Three of the six fresh data questions (turns 1, 6, 14) ended with
no_evidence_needed, and the stored reason is the same each time: the model assumed the knowledge base does not hold OpenAI. It hadfind_entitiesone call away and did not spend it. Turn 14 is the worst shape, a claim about the base's contents made without looking. The same question in turns 11 and 13 was searched and cited.What is different from before is that the miss is now an explicit, logged, stored decision: the call is in
tool_exchangewith the model's reason, andsourcesis empty. That makes #547's "no sources" mark exact, and it gives a number to watch per model. Under #543 the same miss was an absence.Not tried, on purpose: rewording
no_evidence_needed's description or result, or filtering the question's text. Both are the "more prompt wording" the issue ruled out. Two structural candidates, neither measured:no_evidence_neededfrom the first request of a conversation whose message names something (an entity lookup first, then the terminal is available) — this is text matching by another name and I do not recommend it;Dependencies
rig-coreandrig-agent0.42, both withdefault-features = false(no provider clients, no TLS stack of their own). Seventeen small crates,schemarsthe largest.Tests
rig_model: preamble first, documents as a system message before the question, tool exchange round-trips in protocol shape,tool_choicemapping,Nonedrops the tools field, cut-off arguments stay a string (socheck_callstill refuses them), a missing call id is minted.agent: the last exchange is replayed before its conclusion with tool names recovered; the entity block's shape and limit.check_call's six tests unchanged;utopia-llm's seventeen unchanged (error text identical).Decided
The loop is replaced, with the skips accepted as the model's and #547 as the surface for them. The terminal-result measurement ran (below) and did not move the rate.
Rebased onto dev, 2026-09-13
The branch was 50 commits behind. Merging dev conflicted in one file,
chat.rs, in three places:neighbors,timelineandpaths_betweenwith their routing. Both halves are kept.agent.rsand was carried over:ToolCtxnow takes the question (tools use it to order namesakes), anddispatchreturns aToolResultstruct since Expose structured MCP reads with stable ledger identities #601.cargo clippy -D warningsis clean and the server's 254 unit tests pass.The harness again, on the new tool set
Twelve fresh-conversation data questions (
rounds6.sh), DeepSeek-V3 through SiliconFlow, same scratch base.no_evidence_needed, no search)The drop from 3 to 1 is not something this branch did. dev added
paths_between, and five of the six "how are X and Y related" questions went straight to it. A tool that obviously fits the question is taken; the skips that remain are on the questions where it is less obvious.One experiment, negative
The terminal's result was the cheapest untried lever: instead of "Understood. Answer the user directly now.", report the one fact the loop knows at that moment — no knowledge-base tool has run in this turn; if the question is about facts, call the tool now; otherwise answer, and claim nothing about what the base contains or lacks. It does not force a search, so greetings are unaffected.
Identical rates. The first run of the new result looked like 2 of 2 turned back; the next two runs undid it. The failure shape the spike reported recurred under the new wording: a turn that called
no_evidence_neededand then said OpenAI and Anthropic have "no direct affiliation or cooperation", having looked at nothing — with the result it had just been handed telling it not to. Another gave "general knowledge" as its reason, which the system prompt forbids by name.The change is reverted rather than kept for being more accurate: it was not measured to help, and wording that rides along without evidence is what this spike set out to stop adding.
This matches the model comparison recorded on 2026-09-09: the same prompt on Qwen2.5-72B went 12 / 12 to a tool first. Skipping is a property of DeepSeek-V3, and neither the prompt nor the tool result moves it. The product-level answers are the chat model a base is configured with, and #547's mark on answers that cite nothing — which is now exact, because a skip is a recorded, reasoned decision rather than an absence.
Seen along the way, not addressed here
error: Model returned an empty answer. This is the "empty reply is not retried" defect noted in The chat loop is hand-written, and every policy lands as another branch #546.Merged dev again, 2026-09-13: the empty-reply retry moves into a hook
dev had gained #631 in the meantime: an empty reply (no text, no tool call) is asked again once with
EMPTY_REPLY_RETRY, and only a second empty reply becomeserror: Model returned an empty answer. It was written into the hand-written loop, which this branch deletes, so the merge conflicted inchat.rs.The behaviour now lives in
Policy::on_model_turn_finished: a turn with no tool call and no non-blank text returnsModelTurnAction::Retry(RetryRequest::Feedback(EMPTY_REPLY_RETRY)), once per conversation turn (Shared::asked_again). A turn that stays empty is not also sent back by therequiredfallback, so a silent endpoint costs exactly one extra request. rig keeps the content-less assistant turn out of the history and appends the retry as a user message, which is the same request shape #631 sent.#631's two DB-backed tests (
chat_empty_reply_tests, a scripted OpenAI-compatible endpoint under wiremock) run unchanged against the rig loop:EMPTY_REPLY_RETRY, the answer is streamed and stored, no error frame;With the hook disabled, the first test fails, so it does exercise the port. This also closes the "empty final answer" item under Seen along the way above.
Checks:
cargo clippy --workspace --all-targets -D warningsandcargo fmt --checkclean;utopia-server263 tests pass on a migrated database.The harness on the merged build
Eighteen turns, DeepSeek-V3 through SiliconFlow, on a copy of the scratch base: the six turns of
rounds.shand the twelve fresh-conversation data questions ofrounds6.sh.done, 0error, 0 degradations.no_evidence_neededand no search: 2 of 12 data questions (earlier runs: 1 / 12, 3 / 36). One more turn reached for the terminal and then searched.🤖 Generated with Claude Code