8.2 KiB
ToolLoopRunner — implementation plan
Status: plan for "variant C" (2026-06-13). Supersedes step 5 of
context-architecture.md §6.
Context that shapes this plan:
- The tool loop STAYS in LLMQore — the library remains a complete standalone
agentic client. Variant C changes its shape, not its home: the loop
becomes a named class,
BaseClientslims toward transport. - 2026-06-12 the variant-A hook (
setContinuationPayloadBuilder+ Session feeding assembler-built continuation bodies) was implemented and then REVERTED by the project owner: the frozen-replay problem was judged contrived (replay carries the full filtered history of its base payload; mid-loop file changes reach the model via tool results; growth is bounded bymaxToolContinuations). The reverted llmqore diff is saved at/tmp/llmqore-continuation-builder.patch. - Therefore this plan has two tracks. Track 1 (the actual ask): the structural refactoring. Track 2 (host payload source) is OPTIONAL, parked, and only happens if the 2026-06-12 verdict is explicitly reversed.
- The context-architecture steps 1–4 implementation (ContextAssembler,
content cache, pinned providers, EnvBlockFormatter, ~1200 lines incl.
tests) is parked in
stash@{0}("new context refactor") ondev-release-1-0. It is NOT required for track 1.
1. Current anatomy (llmqore @ 0348ac8)
BaseClientmixes two responsibilities:- transport — HTTP/SSE per request,
ActiveRequest { stream, buffers, url, mode, usage, … }, accumulation in protocol subclasses; - loop policy —
ActiveRequest.originalPayload,ActiveRequest.continuationCount,m_maxToolContinuations,checkContinuationLimit,handleToolContinuation.
- transport — HTTP/SSE per request,
- Loop entry: protocol clients call
executeToolsFromMessage(id)at their message-end detection points (11 call sites across 7 clients); it forwardstool_useblocks toToolsManager::executeToolCall. BaseClient::tools()wiresToolsManager::toolExecutionComplete(id, results)→handleToolContinuation: round-limit check → continuation body via the protocol-virtualbuildContinuationPayload(originalPayload, message, toolResults)→finalizeTurn→sendRequest(id, storedUrl, payload, storedMode).
2. Target design
2.1 ToolLoopRunner (new, llmqore)
class LLMQORE_EXPORT ToolLoopRunner : public QObject
{
Q_OBJECT
public:
explicit ToolLoopRunner(BaseClient *client);
int maxRounds() const noexcept;
void setMaxRounds(int limit) noexcept;
private:
void onToolsCompleted(const RequestID &id,
const QHash<QString, ToolResult> &results);
void onRequestClosed(const RequestID &id);
struct LoopState
{
int rounds = 0;
};
BaseClient *m_client = nullptr;
QHash<RequestID, LoopState> m_loops;
int m_maxRounds = 10;
};
The whole loop policy on one screen:
void ToolLoopRunner::onToolsCompleted(const RequestID &id,
const QHash<QString, ToolResult> &results)
{
auto &loop = m_loops[id];
if (++loop.rounds > m_maxRounds) {
m_client->abortRequest(id, "Tool continuation limit reached");
m_loops.remove(id);
return;
}
const QJsonObject payload = m_client->buildReplayContinuation(id, results);
if (payload.isEmpty()) {
m_client->abortRequest(id, "Failed to build continuation payload");
m_loops.remove(id);
return;
}
m_client->continueRequest(id, payload);
}
LoopStateis keyed by request id — several concurrent requests on one client (two chat panels on one provider) never collide.- Cleanup:
onRequestClosed(connected torequestFailed+requestFinalized) drops the state.
2.2 BaseClient becomes transport + tool dispatch
Gains (transport primitives; continueRequest public — it is also the seam
any future host-driven mode would use; failure path via runner friendship):
ToolLoopRunner *toolLoop(); // owned, created with tools()
void continueRequest(const RequestID &id, const QJsonObject &payload);
// finalizeTurn + resend stored url/mode
QJsonObject buildReplayContinuation(const RequestID &id,
const QHash<QString, ToolResult> &results);
// originalPayload + protocol virtual
Loses (moves to the runner): handleToolContinuation,
checkContinuationLimit, m_maxToolContinuations,
ActiveRequest::continuationCount. The toolExecutionComplete connection
in tools() retargets to the runner.
Keeps: executeToolsFromMessage (the 11 protocol call sites stay
untouched), the protocol-virtual buildContinuationPayload (it IS the
replay serialization), originalPayload storage,
setMaxToolContinuations/maxToolContinuations as thin forwarders to
toolLoop() — existing consumers (QodeAssist ClientInterface,
QuickRefactorHandler, third parties) compile unchanged.
3. Track 1 — structural refactoring (the plan)
Bit-identical behavior throughout; QodeAssist only needs a submodule bump.
Phase 1 — transport primitives. Add continueRequest +
buildReplayContinuation + public abortRequest (now also the body of
cancelRequest). — DONE 2026-06-13.
Phase 2 — extract the runner. New ToolLoopRunner class; move round
state + limit; retarget the toolExecutionComplete connection; delete
handleToolContinuation / checkContinuationLimit /
ActiveRequest::continuationCount; forwarders for
setMaxToolContinuations. — DONE 2026-06-13
(include/LLMQore/ToolLoopRunner.hpp, source/core/ToolLoopRunner.cpp,
tests/tst_ToolLoopRunner.cpp — 7 cases: replay flow, round limit, missing
replay data, two interleaved ids, cleanup on finalize/cancel, forwarders;
continueRequest is virtual as the test seam; llmqore architecture docs
updated: overview, request-lifecycle diagram, tools).
Deliberate behavior delta (an improvement, worth knowing while testing): an
empty payload from the protocol's buildContinuationPayload now aborts the
request with "Missing data for tool continuation" instead of silently
sending an empty body.
Phase 3 — submodule bump (after the user runs llmqore tests).
QodeAssist: bump the submodule pointer, verify live in the plugin (Ollama +
tools, Claude + tools); update context-architecture.md
§4.3/§6.5 to point here; update project memory.
4. Track 2 — host payload source (PARKED)
Only if the 2026-06-12 "проблема надумана" verdict is explicitly reversed. Variant C makes it a ~40-line addition, so nothing is lost by parking:
ToolLoopRunner::setPayloadSource(id, std::function<QJsonObject(const RequestID &)>); registered source is authoritative for its id (empty result → abort, never silent fallback to replay).- Host prerequisite: restore the context work from
stash@{0}(ContextAssembler +Session::makePayload); expect conflicts inSession.cppwith the newerdev-release-1-0refactor commits ("Remove override tools in Session send" etc.). - Session registers the source after
provider->sendRequest(same-thread, race-free;QPointerguard). - Assembler continuation rules: pinned blocks anchor to the turn's TYPED user message (recorded 2026-06-12), manifest per round.
5. Risks
| Risk | Mitigation |
|---|---|
| Behavior drift while moving the loop | phases are mechanical; same buildContinuationPayload virtuals; llmqore tests + plugin smoke before/after |
| Two sessions, one client | LoopState keyed by request id |
| Qt 5 compatibility (0348ac8) | runner uses only signals/QHash/std::function — no Qt 6-only API |
| Cancel mid-tool-execution | unchanged: cancelRequest → failRequest → onRequestClosed clears state; ToolsManager::cleanupRequest handles in-flight tools |
| Google (model in URL) | continueRequest reuses stored per-request url/mode — same as today |
6. Deliberately not doing
- Not moving the loop or tool execution out of llmqore
(
feedback_llmqore_boundary). - Not touching the 11
executeToolsFromMessagecall sites or the protocolbuildContinuationPayloadimplementations. - No Auto/Manual mode flags.
- Track 2 is not started without an explicit decision.