diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index 971b2a9..c8a733c 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -154,11 +154,12 @@ Item { Layout.fillWidth: true function reparentWrapper(): void { - const newParent = open ? rootParent : toolsBg; + const newParent = open ? toolsBg.rootParent : toolsBg; const pos = toolsWrapper.mapToItem(newParent, 0, 0); toolsWrapper.parent = newParent; toolsWrapper.x = pos.x; toolsWrapper.y = pos.y; + console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height); } BlobGroup { @@ -187,7 +188,7 @@ Item { id: toolsWrapper width: toolsBg.width - height: toolList.implicitHeight + toolList.anchors.margins * 2 + height: toolBox.implicitHeight states: State { name: "open" @@ -265,16 +266,24 @@ Item { CustomRect { id: toolBox - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + height: Math.min(implicitHeight, parent.height) + implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2 radius: Tokens.rounding.small StateLayer { - onClicked: toolsBg.open = true + onClicked: { + console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height); + toolsBg.open = true; + } } ColumnLayout { id: toolList - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter anchors.margins: Tokens.padding.medium Repeater { diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 7261555..4e89694 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -77,6 +77,7 @@ Item { anchors.top: parent.top active: Config.llm.enabled width: parent.width + property bool tre: true sourceComponent: Item { id: chatPage diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index 85e8010..a716d21 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -40,7 +40,6 @@ Chat::Chat(QObject* parent) }); connect(m_client, &LlmClient::busyChanged, this, [this]() { - // A fresh run supersedes the previous error. if (m_client->busy() && !m_lastError.isEmpty()) { m_lastError.clear(); Q_EMIT lastErrorChanged(); @@ -161,6 +160,10 @@ void Chat::refreshModels() { m_client->refreshModels(); } +void Chat::refreshFromServer() { + m_client->refreshFromServer(); +} + void Chat::selectModel(const QString& id) { if (id.isEmpty()) return; m_client->setModel(id); diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index 276b4a3..0d4919b 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -14,9 +14,6 @@ namespace ZShell::llm { class LlmClient; -// QML-facing facade. Persistence lives in ChatStore, network streaming in -// LlmClient; this class only wires them together and exposes the -// application-wide state. class Chat : public QObject { Q_OBJECT QML_ELEMENT @@ -55,6 +52,7 @@ class Chat : public QObject { Q_INVOKABLE void stop(); Q_INVOKABLE void dismissError(); Q_INVOKABLE void refreshModels(); + Q_INVOKABLE void refreshFromServer(); Q_INVOKABLE void selectModel(const QString& id); static Chat* create(QQmlEngine*, QJSEngine*); diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp index 4f0b116..7464804 100644 --- a/Plugins/ZShell/Llm/codehighlighter.hpp +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -37,10 +37,10 @@ class CodeHighlighter : public QObject { private: struct State { - bool bad = false; // permanent failure, do not retry + bool bad = false; void* lib = nullptr; - const void* lang = nullptr; // const TSLanguage* - void* query = nullptr; // TSQuery* + const void* lang = nullptr; + void* query = nullptr; }; [[nodiscard]] static const QHash& aliases(); @@ -59,15 +59,14 @@ class CodeHighlighter : public QObject { const QVariantList& spans) const; struct SpanCacheEntry { - QString code; // re-compared on lookup; a hash collision can - // never deliver the wrong spans + QString code; QVariantList spans; }; mutable QHash> m_states; mutable QMutex m_stateMutex; mutable QHash m_spanCache; - mutable QStringList m_spanCacheOrder; // LRU order, oldest first + mutable QStringList m_spanCacheOrder; mutable int m_spanCacheBytes = 0; mutable QMutex m_cacheMutex; static CodeHighlighter* s_instance; diff --git a/Plugins/ZShell/Llm/filetool.cpp b/Plugins/ZShell/Llm/filetool.cpp index a8e9b81..d4c7b6b 100644 --- a/Plugins/ZShell/Llm/filetool.cpp +++ b/Plugins/ZShell/Llm/filetool.cpp @@ -53,9 +53,9 @@ QJsonObject FileReadTool::parameters() const { QJsonObject limit; limit[QStringLiteral("type")] = QStringLiteral("integer"); limit[QStringLiteral("minimum")] = 1; - limit[QStringLiteral("description")] = - QStringLiteral("Maximum number of characters to return. Defaults " - "to a value that fits the model's context window."); + limit[QStringLiteral("description")] = QStringLiteral( + "Maximum number of characters to return. Defaults " + "to a value that fits the model's context window."); QJsonObject properties; properties[QStringLiteral("path")] = path; @@ -74,6 +74,7 @@ QJsonObject FileReadTool::parameters() const { void FileReadTool::execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) { Q_UNUSED(toolCallId); const QString path = args[QStringLiteral("path")].toString().trimmed(); @@ -82,7 +83,8 @@ void FileReadTool::execute( return; } const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0)); - const int budget = inlineBudgetChars(); + int budget = inlineBudgetChars(); + if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars); const int limit = qBound(1, args[QStringLiteral("limit")].toInt(budget), budget); @@ -90,8 +92,9 @@ void FileReadTool::execute( if (!file.open(QIODevice::ReadOnly)) { const QFileInfo info(path); if (info.exists() && info.isDir()) { - done(makeError(QStringLiteral("Path is a directory, not a " - "file: %1") + done(makeError(QStringLiteral( + "Path is a directory, not a " + "file: %1") .arg(path))); } else { done(makeError(QStringLiteral("Cannot open file: %1").arg(path))); @@ -106,8 +109,9 @@ void FileReadTool::execute( } if (offset >= fileSize) { file.close(); - done(makeError(QStringLiteral("Offset %1 is beyond the end of the " - "file (%2 bytes)") + done(makeError(QStringLiteral( + "Offset %1 is beyond the end of the " + "file (%2 bytes)") .arg(offset) .arg(fileSize))); return; @@ -117,15 +121,13 @@ void FileReadTool::execute( done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path))); return; } - // UTF-8 uses at most 4 bytes per character, so this many bytes always - // cover `limit` characters. const qint64 wanted = qint64(limit) * 4 + 16; const QByteArray chunk = file.read(qMin(wanted, fileSize - offset)); file.close(); if (chunk.contains('\0')) { - done(makeError(QStringLiteral("Binary files are not supported: %1") - .arg(path))); + done(makeError( + QStringLiteral("Binary files are not supported: %1").arg(path))); return; } const QString content = QString::fromUtf8(chunk); diff --git a/Plugins/ZShell/Llm/filetool.hpp b/Plugins/ZShell/Llm/filetool.hpp index 30b6480..cd2c06e 100644 --- a/Plugins/ZShell/Llm/filetool.hpp +++ b/Plugins/ZShell/Llm/filetool.hpp @@ -4,10 +4,6 @@ namespace ZShell::llm { -// Lets the model read text files from the local filesystem, in -// byte-offset windows small enough to fit the context. Primarily used to -// page through the full webfetch output stored under -// /tmp/zshell-llm/webfetch/. class FileReadTool : public LlmTool { Q_OBJECT @@ -20,6 +16,7 @@ class FileReadTool : public LlmTool { void execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) override; }; diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index 7082930..2822aa3 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -220,8 +220,6 @@ LlmSegment* ChatGeneration::beginToolCall( LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); segment->setName(name); segment->setToolCallId(toolCallId); - // Awaiting user approval until LlmClient::approveTools() promotes it - // to Running; denyTools()/endStream() finalize it as an error. segment->setStatus(LlmSegment::Status::Pending); segment->begin(); addSegment(segment); diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index c85635e..6650aaf 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -12,15 +12,16 @@ #include #include #include +#include #include #include +#include + namespace ZShell::llm { namespace { -// Minimum prompt budget kept even when the transcript alone nearly fills -// the window, so the model always sees the triggering message. constexpr int kMinContextTokens = 512; qsizetype jsonValueChars(const QJsonValue& value) { @@ -49,16 +50,11 @@ int estimateMessageTokens(const QJsonObject& message) { return int(jsonValueChars(message) / LlmTool::CharsPerToken) + 4; } -// One message model row and the JSON messages it produces. Trimming works -// in whole units so an assistant turn and its tool results always travel -// together. struct ContextUnit { QList messages; int tokens = 0; }; -// Shrink the free-text fields of a unit until it fits the budget. Used -// when a single unit (e.g. one huge user message) alone exceeds it. void shrinkUnitToBudget(QList& unit, int budgetTokens) { static const QStringList kTextKeys = { QStringLiteral("content"), QStringLiteral("reasoning_content")}; @@ -90,6 +86,41 @@ void shrinkUnitToBudget(QList& unit, int budgetTokens) { } } +int estimateArrayTokens(const QJsonArray& messages) { + int total = 0; + for (const QJsonValue& value : messages) + total += estimateMessageTokens(value.toObject()); + return total; +} + +constexpr int kToolResultStubChars = 512; + +constexpr double kPromptEstimateDiscount = 10.0 / 12.0; + +constexpr int kMinToolResultTokens = 128; + +int completionReserve(int contextSize) { + return qBound(2048, contextSize / 8, 8192); +} + +int promptBudgetTokens(int contextSize) { + return int( + (contextSize - completionReserve(contextSize)) * + kPromptEstimateDiscount); +} + +constexpr int kRefreshIntervalMs = 60'000; + +constexpr int kProbeTimeoutMs = 2000; + +int contextSizeFromErrorMessage(const QString& message) { + static const QRegularExpression re( + QLatin1String("available context size \\((\\d+)")); + const auto match = re.match(message); + if (match.hasMatch()) return match.captured(1).toInt(); + return 0; +} + } // namespace QString LlmClient::completionsPath( @@ -140,7 +171,12 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) { &ToolRegistry::enabledChanged, this, &LlmClient::toolsEnabledChanged); - probeContextSize(); + + m_refreshTimer.setParent(this); + m_refreshTimer.setInterval(kRefreshIntervalMs); + connect( + &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer); + m_refreshTimer.start(); } LlmClient::~LlmClient() { @@ -153,8 +189,8 @@ void LlmClient::setEndpoint(const QString& value) { if (m_endpoint == value) return; m_endpoint = value; Q_EMIT endpointChanged(); - probeContextSize(); - if (m_model.isEmpty()) refreshModels(); + m_refreshTimer.start(); + refreshFromServer(); } void LlmClient::setModel(const QString& value) { @@ -170,6 +206,8 @@ void LlmClient::setTemperature(double value) { void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { if (m_busy || !session || !target) return; + refreshModels(); + m_contextRetryUsed = false; m_active = session; m_streaming = target; target->setStreaming(true); @@ -202,11 +240,15 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { m_reasoningMark = 0; setApprovalPending(false); - sendRound(); + probeContextSize([this, session, target]() { + if (m_active != session || m_streaming != target) return; + sendRound(); + }); } void LlmClient::sendRound() { if (!m_active || !m_streaming) return; + probeContextSize(); m_finishReason.clear(); m_callBuilders.clear(); m_callResults.clear(); @@ -234,8 +276,6 @@ void LlmClient::sendRound() { return; } - // The transcript (this generation so far) is always sent in full, so - // reserve its estimated size out of the context budget. int transcriptTokens = 0; for (const QJsonValue& value : m_transcript) transcriptTokens += estimateMessageTokens(value.toObject()); @@ -244,6 +284,30 @@ void LlmClient::sendRound() { for (const QJsonValue& value : m_transcript) messages.append(value); + if (m_contextSize > 0) { + const int promptLimit = promptBudgetTokens(m_contextSize); + while (estimateArrayTokens(messages) > promptLimit) { + bool stubbed = false; + for (qsizetype i = 0; i < messages.size(); ++i) { + QJsonObject message = messages.at(i).toObject(); + if (message.value("role").toString() != QLatin1String("tool")) + continue; + const QString content = message.value("content").toString(); + if (content.size() <= kToolResultStubChars) continue; + QString stub = content.left(kToolResultStubChars); + stub += QStringLiteral( + "\n[truncated to fit the context window; was " + "%1 characters]") + .arg(content.size()); + message["content"] = stub; + messages[i] = message; + stubbed = true; + break; + } + if (!stubbed) break; + } + } + QJsonObject body; QJsonObject streamOptions; streamOptions[QStringLiteral("include_usage")] = true; @@ -283,8 +347,21 @@ void LlmClient::sendRound() { roundFinished(); else if (error == QNetworkReply::OperationCanceledError) finishTurn(); - else - fail(serverErrorMessage(responseBody, errorString)); + else { + const QString message = + serverErrorMessage(responseBody, errorString); + const int reported = contextSizeFromErrorMessage(message); + if (reported > 0 && reported != m_contextSize && + !m_contextRetryUsed) { + qWarning() << "LlmClient: prompt overflow, server reports" + << reported << "tokens; retrying round"; + setContextSize(reported); + m_contextRetryUsed = true; + sendRound(); + return; + } + fail(message); + } }); } @@ -349,21 +426,17 @@ QJsonArray LlmClient::buildContextMessages( } if (m_contextSize > 0) { - // Keep headroom for the model's own reply so the prompt cannot - // fill the whole window. - const int completionReserve = qBound(1024, m_contextSize / 8, 8192); - int budget = m_contextSize - completionReserve - extraReserveTokens; + int budget = m_contextSize - completionReserve(m_contextSize) - + extraReserveTokens; budget = qMax(budget, kMinContextTokens); int total = 0; for (const ContextUnit& unit : units) total += unit.tokens; - // Drop oldest units until the prompt fits the budget. while (units.size() > 1 && total > budget) { total -= units.first().tokens; units.removeFirst(); } - // A conversation cannot start with an assistant turn. while ( units.size() > 1 && units.first().messages.first()[QStringLiteral("role")].toString() == @@ -371,7 +444,6 @@ QJsonArray LlmClient::buildContextMessages( total -= units.first().tokens; units.removeFirst(); } - // A single oversized unit still gets sent, shrunk to fit. if (units.size() == 1 && units.first().tokens > budget) shrinkUnitToBudget(units.first().messages, budget); } @@ -417,7 +489,21 @@ void LlmClient::roundFinished() { bool hasCalls = false; for (const auto& builder : m_callBuilders) if (builder.seen) hasCalls = true; - if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { + if (!hasCalls) { + finishTurn(); + return; + } + if (m_finishReason != QLatin1String("tool_calls")) { + qWarning() << "LlmClient: stream ended" << m_finishReason + << "with incomplete tool calls, ending turn"; + for (const auto& builder : m_callBuilders) { + if (!builder.seen || !builder.segment) continue; + builder.segment->finishTool( + QStringLiteral( + "Stream truncated before the tool call " + "completed"), + false); + } finishTurn(); return; } @@ -458,8 +544,6 @@ void LlmClient::roundFinished() { m_transcript.append(assistant); m_round++; - // The whole batch of this round waits for a single user decision; - // approveTools() runs all of it, denyTools() ends the turn. setApprovalPending(true); } @@ -468,6 +552,18 @@ void LlmClient::executeAllCalls() { m_pendingCalls = 0; m_callResults = QList(m_callBuilders.size()); + int batchSize = 0; + for (const auto& call : m_callBuilders) + if (call.seen) ++batchSize; + int perCallChars = 0; + if (batchSize > 0 && m_contextSize > 0) { + const int available = promptBudgetTokens(m_contextSize) - + estimateArrayTokens(m_transcript); + const int perCallTokens = + qMax(available / batchSize, kMinToolResultTokens); + perCallChars = perCallTokens * LlmTool::CharsPerToken; + } + for (int i = 0; i < m_callBuilders.size(); ++i) { const auto& call = m_callBuilders.at(i); if (!call.seen) continue; @@ -498,18 +594,22 @@ void LlmClient::executeAllCalls() { } ++m_pendingCalls; - tool->execute(call.id, args, [this, i, call](const QJsonObject& result) { - if (!m_streaming) return; - const bool success = result.contains(QStringLiteral("output")); - const QString content = - success ? result[QStringLiteral("output")].toString() - : QStringLiteral("Error: ") + - result[QStringLiteral("error")].toString(); - m_callResults[i] = {content, success}; - if (LlmSegment* segment = call.segment) - segment->finishTool(content, success); - if (--m_pendingCalls == 0) flushCallResults(); - }); + tool->execute( + call.id, + args, + perCallChars, + [this, i, call](const QJsonObject& result) { + if (!m_streaming) return; + const bool success = result.contains(QStringLiteral("output")); + const QString content = + success ? result[QStringLiteral("output")].toString() + : QStringLiteral("Error: ") + + result[QStringLiteral("error")].toString(); + m_callResults[i] = {content, success}; + if (LlmSegment* segment = call.segment) + segment->finishTool(content, success); + if (--m_pendingCalls == 0) flushCallResults(); + }); } if (m_pendingCalls == 0) flushCallResults(); } @@ -533,8 +633,6 @@ void LlmClient::flushCallResults() { void LlmClient::setApprovalPending(bool value) { if (m_approvalPending == value) return; m_approvalPending = value; - // Mirror onto the streaming generation; QML reads and answers from - // there, so only the affected ProcessBlock reacts. if (ChatGeneration* streaming = m_streaming) streaming->setApprovalPending(value); } @@ -548,8 +646,6 @@ void LlmClient::approveTools() { if (segment->type() != LlmSegment::Type::ToolCall || segment->status() != LlmSegment::Status::Pending) continue; - // Restart the segment timer so elapsed time covers execution, not - // the wait for approval. segment->setStatus(LlmSegment::Status::Running); segment->begin(); } @@ -592,7 +688,10 @@ void LlmClient::stop() { finishTurn(); return; } - if (m_reply) m_reply->abort(); + if (m_reply) + m_reply->abort(); + else + endStream(); } void LlmClient::endStream() { @@ -604,8 +703,6 @@ void LlmClient::endStream() { generation->setStreaming(false); m_approvalPending = false; generation->setApprovalPending(false); - // Finalize tool calls that never received an approval (turn cancelled, - // round limit reached, ...). for (auto* segment : generation->segments()) if (segment->type() == LlmSegment::Type::ToolCall && segment->status() == LlmSegment::Status::Pending) @@ -633,6 +730,7 @@ void LlmClient::finishTurn() { m_pendingClear.clear(); } if (session) session->persist(); + refreshFromServer(); } void LlmClient::clearOnFinish(ChatSession* session) { @@ -707,6 +805,12 @@ void LlmClient::handleLine(const QByteArray& line) { } } +void LlmClient::refreshFromServer() { + if (m_endpoint.trimmed().isEmpty()) return; + probeContextSize(); + refreshModels(); +} + void LlmClient::refreshModels() { const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); @@ -742,6 +846,9 @@ void LlmClient::refreshModels() { if (m_model.isEmpty()) { m_model = models.first(); Q_EMIT modelChanged(); + } else if (!models.contains(m_model)) { + m_model = models.first(); + Q_EMIT modelChanged(); } }); } @@ -753,15 +860,22 @@ void LlmClient::setContextSize(int size) { Q_EMIT contextSizeChanged(); } -void LlmClient::probeContextSize() { +void LlmClient::probeContextSize(std::function done) { QString base = m_endpoint.trimmed(); while (base.endsWith('/')) base.chop(1); const QUrl url = QUrl::fromUserInput(base + "/props"); - if (!url.isValid() || url.host().isEmpty()) return; + if (!url.isValid() || url.host().isEmpty()) { + if (done) done(); + return; + } auto* reply = m_manager.get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { + auto settled = std::make_shared(false); + const auto finish = [this, reply, settled, done]() { + if (*settled) return; + *settled = true; + const QNetworkReply::NetworkError error = reply->error(); const QByteArray data = reply->readAll(); reply->deleteLater(); @@ -786,7 +900,15 @@ void LlmClient::probeContextSize() { .toInt(0); } } - setContextSize(size > 0 ? size : 4096); + if (size > 0) + setContextSize(size); + else if (m_contextSize <= 0) + setContextSize(4096); + if (done) done(); + }; + connect(reply, &QNetworkReply::finished, this, finish); + QTimer::singleShot(kProbeTimeoutMs, this, [reply, settled]() { + if (!*settled) reply->abort(); }); } diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index f0346ff..7c864b1 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include @@ -42,7 +43,8 @@ class LlmClient : public QObject { void setModel(const QString& value); void setTemperature(double value); void setContextSize(int size); - void probeContextSize(); + void probeContextSize(std::function done = {}); + Q_INVOKABLE void refreshFromServer(); [[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } @@ -56,10 +58,6 @@ class LlmClient : public QObject { void clearOnFinish(ChatSession* session); void sessionRemoved(ChatSession* session); - // Tool calls are executed only after the user approves the batch the - // model requested. The per-generation state (toolApprovalPending, - // pendingToolCalls) is mirrored onto ChatGeneration; these are the - // entry points QML reaches through it. void approveTools(); void denyTools(); @@ -118,9 +116,7 @@ class LlmClient : public QObject { ToolRegistry* m_tools = nullptr; QNetworkReply* m_reply = nullptr; QByteArray m_buffer; - // QPointer: these objects are owned by the ChatStore tree, which Qt - // destroys *before* this client on shutdown (children die in creation - // order). The pointers must self-null instead of dangling. + QTimer m_refreshTimer; QPointer m_active; QPointer m_streaming; QPointer m_pendingClear; @@ -146,6 +142,7 @@ class LlmClient : public QObject { bool m_roundDone = false; bool m_toolPhase = false; bool m_approvalPending = false; + bool m_contextRetryUsed = false; int m_pendingCalls = 0; static constexpr int kMaxToolRounds = 12; }; diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp index 589ec12..ecef553 100644 --- a/Plugins/ZShell/Llm/segment.hpp +++ b/Plugins/ZShell/Llm/segment.hpp @@ -29,8 +29,6 @@ class LlmSegment : public QObject { enum class Type : int { Reasoning = 0, ToolCall, Content }; Q_ENUM(Type) - // Pending is appended last: statuses are persisted as integers in the - // chat database, so existing values must not move. enum class Status : int { None = 0, Running, Success, Error, Pending }; Q_ENUM(Status) diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp index ab8b052..1bb8107 100644 --- a/Plugins/ZShell/Llm/tool.cpp +++ b/Plugins/ZShell/Llm/tool.cpp @@ -13,8 +13,6 @@ void LlmTool::setContextSize(int value) { int LlmTool::inlineBudgetChars() const { if (m_contextSize <= 0) return DefaultInlineChars; - // About a quarter of the context window, clamped so tiny contexts - // still get a usable result and huge ones stay sane. const int tokens = qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken); return tokens * CharsPerToken; diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp index 8ca95a1..3ce0d86 100644 --- a/Plugins/ZShell/Llm/tool.hpp +++ b/Plugins/ZShell/Llm/tool.hpp @@ -21,29 +21,21 @@ class LlmTool : public QObject { [[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QJsonObject parameters() const = 0; - // toolCallId identifies the call within the conversation; tools may use - // it to name files they write for later retrieval. virtual void execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) = 0; virtual void cancel(); [[nodiscard]] QJsonObject specification() const; - // The endpoint's context size in tokens (0 = unknown). Set by LlmClient. [[nodiscard]] int contextSize() const { return m_contextSize; } void setContextSize(int value); - // Rough cap, in characters, for tool output embedded in a tool result - // message. Scales with the context size so a single result cannot - // consume the whole window. [[nodiscard]] int inlineBudgetChars() const; - // ~4 characters per token; a deliberately coarse estimate used for - // budgeting (never for exact accounting). static constexpr int CharsPerToken = 4; - // Inline budget when the context size is unknown. static constexpr int DefaultInlineChars = 64 * 1024; protected: @@ -66,7 +58,6 @@ class ToolRegistry : public QObject { [[nodiscard]] QJsonArray specifications() const; void cancelAll(); - // Propagated to every registered tool, including later ones. void setContextSize(int value); Q_SIGNALS: diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp index 2fb799e..1d5e76f 100644 --- a/Plugins/ZShell/Llm/webfetchtool.cpp +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -112,10 +112,12 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) { void WebFetchTool::execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) { auto* job = new Job; job->done = std::move(done); job->toolCallId = toolCallId; + job->budgetChars = outputBudgetChars; m_jobs.append(job); auto fail = [this, job](const QString& message) { @@ -248,23 +250,24 @@ void WebFetchTool::execute( const QString savedPath = saveToFile(*job, content, mime); QString output = content; - const int budget = inlineBudgetChars(); + int budget = inlineBudgetChars(); + if (job->budgetChars > 0) budget = qMin(budget, job->budgetChars); if (content.size() > budget) { output = content.left(budget); - const QString note = QStringLiteral( - "\n\n[... truncated: showing %1 of %2 characters") - .arg(budget) - .arg(content.size()); + const QString note = + QStringLiteral( + "\n\n[... truncated: showing %1 of %2 characters") + .arg(budget) + .arg(content.size()); if (!savedPath.isEmpty()) - output += note + - QStringLiteral( - ". The full content is saved to %1; use " - "the readfile tool to read the rest.") - .arg(savedPath); + output += note + QStringLiteral( + ". The full content is saved to %1; use " + "the readfile tool to read the rest.") + .arg(savedPath); else output += note + QStringLiteral( - ". The remaining content is not " - "available."); + ". The remaining content is not " + "available."); } completeJob(job, makeOutput(output)); }); @@ -273,14 +276,13 @@ void WebFetchTool::execute( QString WebFetchTool::saveToFile( const Job& job, const QString& content, const QString& mime) const { QDir dir(StoragePath); - if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) - return QString(); + if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString(); QString fileName; fileName.reserve(job.toolCallId.size()); for (const QChar& c : job.toolCallId) fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') || - c == QLatin1Char('_')) + c == QLatin1Char('_')) ? c : QLatin1Char('_'); if (fileName.isEmpty()) fileName = QStringLiteral("fetch"); @@ -293,8 +295,7 @@ QString WebFetchTool::saveToFile( else if (mime.contains(QLatin1String("xml"))) extension = QStringLiteral("xml"); - const QString path = - dir.filePath(fileName + QLatin1Char('.') + extension); + const QString path = dir.filePath(fileName + QLatin1Char('.') + extension); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return QString(); diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp index 1c69bb9..c992c77 100644 --- a/Plugins/ZShell/Llm/webfetchtool.hpp +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -20,8 +20,6 @@ class WebFetchTool : public LlmTool { static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int DefaultTimeoutSeconds = 30; static constexpr int MaxTimeoutSeconds = 120; - // Where the full (untruncated) output of each call is stored so the - // model can page through it with the readfile tool. static const QString StoragePath; explicit WebFetchTool(QObject* parent = nullptr); @@ -33,6 +31,7 @@ class WebFetchTool : public LlmTool { void execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) override; void cancel() override; @@ -47,14 +46,13 @@ class WebFetchTool : public LlmTool { bool tooLarge = false; QString format; QString toolCallId; + int budgetChars = 0; std::function done; }; void completeJob(Job* job, QJsonObject result); - // Writes the full output to StoragePath; returns the file path, or an - // empty string when saving failed. - QString saveToFile(const Job& job, const QString& content, - const QString& mime) const; + QString saveToFile( + const Job& job, const QString& content, const QString& mime) const; QNetworkAccessManager m_manager; QList m_jobs;