Files
zach 1fe8a3a959
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 36s
Rust / clippy (pull_request) Successful in 54s
chore: restructure llm plugin to be more coherent & fix various bugs in llm ui
2026-10-18 12:58:38 +02:00

392 lines
12 KiB
C++

#include "turn.hpp"
#include "attachment.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
#include "prompts.hpp"
#include "session.hpp"
#include "workspace.hpp"
#include <QFile>
#include <QSet>
namespace ZShell::llm {
namespace {
constexpr int kMinContextTokens = 512;
constexpr int kToolResultStubChars = 512;
constexpr double kPromptEstimateDiscount = 10.0 / 12.0;
qsizetype jsonValueChars(const QJsonValue& value) {
switch (value.type()) {
case QJsonValue::String:
return value.toString().size();
case QJsonValue::Array: {
qsizetype total = 0;
for (const QJsonValue& item : value.toArray())
total += jsonValueChars(item);
return total;
}
case QJsonValue::Object: {
qsizetype total = 0;
const QJsonObject obj = value.toObject();
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it)
total += jsonValueChars(it.value());
return total;
}
default:
return 2;
}
}
int estimateMessageTokens(const QJsonObject& message) {
return int(jsonValueChars(message) / ToolRegistry::CharsPerToken) + 4;
}
int estimateArrayTokens(const QJsonArray& messages) {
int total = 0;
for (const QJsonValue& value : messages)
total += estimateMessageTokens(value.toObject());
return total;
}
int completionReserve(int contextSize) {
return qBound(2048, contextSize / 8, 8192);
}
void shrinkUnitToBudget(QList<QJsonObject>& unit, int budgetTokens) {
static const QStringList kTextKeys = {
QStringLiteral("content"), QStringLiteral("reasoning_content")};
for (int pass = 0; pass < 8; ++pass) {
int tokens = 0;
for (const QJsonObject& message : unit)
tokens += estimateMessageTokens(message);
if (tokens <= budgetTokens) return;
qsizetype bestMessage = -1;
QString bestKey;
qsizetype bestLength = 0;
for (qsizetype i = 0; i < unit.size(); ++i) {
const QJsonObject message = unit.at(i);
for (const QString& key : kTextKeys) {
const QJsonValue value = message.value(key);
if (value.isString() && value.toString().size() > bestLength) {
bestMessage = i;
bestKey = key;
bestLength = value.toString().size();
}
}
}
if (bestMessage < 0) return;
QString text = unit.at(bestMessage).value(bestKey).toString();
text.truncate(qMax<qsizetype>(32, text.size() * 3 / 4));
const QString marker = QStringLiteral("\n[…]");
if (!text.endsWith(marker)) text += marker;
unit[bestMessage][bestKey] = text;
}
}
} // namespace
Turn::Turn(
ChatSession* session,
ChatGeneration* generation,
const ToolRegistry& registry)
: m_session(session), m_generation(generation), m_registry(registry) {}
int Turn::targetRow() const {
if (!m_session) return -1;
const ChatMessage* message = m_generation ? m_generation->message()
: nullptr;
return message ? m_session->messagesModel()->rowOf(message) : -1;
}
int Turn::transcriptTokens() const {
return estimateArrayTokens(m_transcript);
}
int Turn::promptBudgetTokens() const {
const int contextSize = m_registry.contextSize();
if (contextSize <= 0) return 0;
return int(
(contextSize - completionReserve(contextSize)) *
kPromptEstimateDiscount);
}
int Turn::batchSize() const {
int size = 0;
for (const Call& call : m_calls)
if (call.seen) ++size;
return size;
}
int Turn::outputBudgetChars() const {
const int batch = qMax(1, batchSize());
const int promptLimit = promptBudgetTokens();
if (promptLimit <= 0) return m_registry.outputBudgetChars(batch);
const int available = promptLimit - transcriptTokens();
const int tokens = qMax(available / batch, ToolRegistry::MinBudgetTokens);
return tokens * ToolRegistry::CharsPerToken;
}
void Turn::resetRound() {
roundDone = false;
streamDone = false;
toolPhase = false;
pendingCalls = 0;
finishReason.clear();
m_calls.clear();
m_results.clear();
}
void Turn::appendToTranscript(const QJsonObject& message) {
m_transcript.append(message);
}
QJsonObject Turn::roundAssistantMessage() {
QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
const QString content = m_generation->content();
const QString reasoning = m_generation->reasoning();
if (content.size() > m_contentMark) {
assistant[QStringLiteral("content")] = content.mid(m_contentMark);
m_contentMark = content.size();
}
if (reasoning.size() > m_reasoningMark) {
assistant[QStringLiteral("reasoning_content")] =
reasoning.mid(m_reasoningMark);
m_reasoningMark = reasoning.size();
}
QJsonArray calls;
for (const Call& call : m_calls) {
if (!call.seen) continue;
QJsonObject function;
function[QStringLiteral("name")] = call.name;
function[QStringLiteral("arguments")] = call.arguments;
QJsonObject entry;
entry[QStringLiteral("id")] = call.id;
entry[QStringLiteral("type")] = QStringLiteral("function");
entry[QStringLiteral("function")] = function;
calls.append(entry);
}
assistant[QStringLiteral("tool_calls")] = calls;
return assistant;
}
QJsonArray Turn::toolMessages() const {
QJsonArray messages;
for (int i = 0; i < m_calls.size(); ++i) {
const Call& call = m_calls.at(i);
if (!call.seen) continue;
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = call.id;
toolMessage[QStringLiteral("content")] = m_results.at(i).text;
messages.append(toolMessage);
}
return messages;
}
bool Turn::roundHasCalls() const {
for (const Call& call : m_calls)
if (call.seen) return true;
return false;
}
void Turn::appendSandboxPreamble(QJsonArray& messages) const {
if (!m_registry.sandboxEnabled()) return;
const QString workspace = m_session->workspace()->path();
if (workspace.isEmpty()) return;
const auto* model = m_session->messagesModel();
QStringList files;
QSet<QString> seenPaths;
for (int row = 0; row < model->rowCount(); ++row) {
const auto* message = model->at(row);
if (message->role() != ChatMessage::Role::User) continue;
for (const auto* attachment : message->attachments()) {
if (attachment->kind() != Attachment::Kind::File) continue;
const QString relativePath =
attachment->path().mid(workspace.size() + 1);
if (seenPaths.contains(relativePath)) continue;
seenPaths.insert(relativePath);
files.append(QStringLiteral("- %1 (available as %2)")
.arg(attachment->name(), relativePath));
}
}
if (files.isEmpty()) return;
QJsonObject preamble;
preamble[QStringLiteral("role")] = QStringLiteral("system");
preamble[QStringLiteral("content")] =
prompts::sandboxPreamble(workspace, files);
messages.prepend(preamble);
}
QJsonArray Turn::contextMessages() const {
const auto* model = m_session->messagesModel();
const int stopBeforeRow = targetRow();
QList<QList<QJsonObject>> units;
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
const auto* message = model->at(row);
if (message->role() == ChatMessage::Role::User) {
if (message->content().isEmpty() &&
message->attachments().isEmpty())
continue;
QList<QJsonObject> unit;
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
const QString text = message->content();
const QList<Attachment*> attachments = message->attachments();
if (attachments.isEmpty()) {
user[QStringLiteral("content")] = text;
} else {
QJsonArray parts;
if (!text.isEmpty()) {
QJsonObject textPart;
textPart[QStringLiteral("type")] = QStringLiteral("text");
textPart[QStringLiteral("text")] = text;
parts.append(textPart);
}
for (const auto* attachment : attachments) {
if (attachment->kind() != Attachment::Kind::Image) continue;
QFile file(attachment->path());
if (!file.open(QIODevice::ReadOnly)) continue;
const QByteArray base64 = file.readAll().toBase64();
file.close();
if (base64.isEmpty()) continue;
QJsonObject imageUrl;
imageUrl[QStringLiteral("url")] =
QStringLiteral("data:%1;base64,%2")
.arg(attachment->mimeType())
.arg(QString::fromLatin1(base64));
QJsonObject imagePart;
imagePart[QStringLiteral("type")] =
QLatin1String("image_url");
imagePart[QStringLiteral("image_url")] = imageUrl;
parts.append(imagePart);
}
if (parts.isEmpty())
user[QStringLiteral("content")] = text;
else
user[QStringLiteral("content")] = parts;
}
unit.append(user);
units.append(unit);
continue;
}
const ChatGeneration* generation = message->activeGeneration();
if (!generation) continue;
const QList<LlmToolCall*> calls = generation->toolCalls();
const bool hasAnyContent = !generation->content().isEmpty() ||
!generation->reasoning().isEmpty() ||
!calls.isEmpty();
if (!hasAnyContent) continue;
QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
if (!calls.isEmpty() || !generation->content().isEmpty())
assistant[QStringLiteral("content")] = generation->content();
if (!generation->reasoning().isEmpty())
assistant[QStringLiteral("reasoning_content")] =
generation->reasoning();
if (!calls.isEmpty()) {
QJsonArray callArray;
for (const auto* call : calls) {
QJsonObject function;
function[QStringLiteral("name")] = call->name();
function[QStringLiteral("arguments")] = call->arguments();
QJsonObject entry;
entry[QStringLiteral("id")] = call->toolCallId();
entry[QStringLiteral("type")] = QStringLiteral("function");
entry[QStringLiteral("function")] = function;
callArray.append(entry);
}
assistant[QStringLiteral("tool_calls")] = callArray;
}
QList<QJsonObject> unit;
unit.append(assistant);
for (const auto* call : calls) {
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = call->toolCallId();
toolMessage[QStringLiteral("content")] = call->result();
unit.append(toolMessage);
}
units.append(unit);
}
if (m_registry.contextSize() > 0) {
const int reserve = transcriptTokens();
int budget = m_registry.contextSize() -
completionReserve(m_registry.contextSize()) - reserve;
budget = qMax(budget, kMinContextTokens);
int total = 0;
for (const QList<QJsonObject>& unit : units)
for (const QJsonObject& message : unit)
total += estimateMessageTokens(message);
while (units.size() > 1 && total > budget) {
for (const QJsonObject& message : units.first())
total -= estimateMessageTokens(message);
units.removeFirst();
}
while (units.size() > 1 &&
units.first().first()[QStringLiteral("role")].toString() ==
QLatin1String("assistant")) {
for (const QJsonObject& message : units.first())
total -= estimateMessageTokens(message);
units.removeFirst();
}
if (units.size() == 1) {
QList<QJsonObject> single = units.first();
shrinkUnitToBudget(single, budget);
units[0] = single;
}
}
QJsonArray messages;
for (const QList<QJsonObject>& unit : units)
for (const QJsonObject& message : unit)
messages.append(message);
for (const QJsonValue& value : m_transcript)
messages.append(value);
if (m_registry.contextSize() > 0) {
const int promptLimit = promptBudgetTokens();
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;
}
}
appendSandboxPreamble(messages);
return messages;
}
} // namespace ZShell::llm