mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-07-23 11:41:03 -04:00
428 lines
16 KiB
C++
428 lines
16 KiB
C++
// Copyright (C) 2026 Petr Mironychev
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
|
|
|
#include "completion/AcpCompletionEngine.hpp"
|
|
|
|
#include <chrono>
|
|
|
|
#include <LLMQore/AcpClient.hpp>
|
|
#include <LLMQore/CallbackPermissionProvider.hpp>
|
|
|
|
#include "acp/AgentSpawn.hpp"
|
|
#include "context/DocumentContextReader.hpp"
|
|
#include "logger/Logger.hpp"
|
|
#include "settings/SettingsConstants.hpp"
|
|
|
|
namespace QodeAssist {
|
|
|
|
namespace {
|
|
|
|
void upsertEnv(QList<Acp::AgentEnvVariable> &env, const QString &name, const QString &value)
|
|
{
|
|
for (auto &variable : env) {
|
|
if (variable.name == name) {
|
|
variable.value = value;
|
|
return;
|
|
}
|
|
}
|
|
env.append({name, value});
|
|
}
|
|
|
|
LLMQore::Acp::RequestPermissionResult autoAllow(
|
|
const QString &,
|
|
const LLMQore::Acp::ToolCall &toolCall,
|
|
const QList<LLMQore::Acp::PermissionOption> &options)
|
|
{
|
|
QStringList optionDump;
|
|
for (const auto &option : options)
|
|
optionDump.append(QString("%1(%2)").arg(option.optionId, option.kind));
|
|
|
|
const LLMQore::Acp::PermissionOption *chosen = nullptr;
|
|
for (const auto &option : options) {
|
|
if (option.kind == QLatin1String(LLMQore::Acp::PermissionOptionKind::AllowOnce)) {
|
|
chosen = &option;
|
|
break;
|
|
}
|
|
if (!chosen && option.kind == QLatin1String(LLMQore::Acp::PermissionOptionKind::AllowAlways))
|
|
chosen = &option;
|
|
}
|
|
if (!chosen && !options.isEmpty())
|
|
chosen = &options.first();
|
|
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: permission requested for tool '%1' (kind=%2); options=[%3]; %4")
|
|
.arg(
|
|
toolCall.title,
|
|
toolCall.kind,
|
|
optionDump.join(QStringLiteral(", ")),
|
|
chosen ? QString("auto-allowing '%1'").arg(chosen->optionId)
|
|
: QStringLiteral("cancelling (no option offered)")));
|
|
|
|
return chosen ? LLMQore::Acp::RequestPermissionResult::selected(chosen->optionId)
|
|
: LLMQore::Acp::RequestPermissionResult::cancelled();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
AcpCompletionEngine::AcpCompletionEngine(
|
|
const Settings::CodeCompletionSettings &completeSettings,
|
|
const Settings::GeneralSettings &generalSettings,
|
|
AgentResolver agentResolver,
|
|
Context::IDocumentReader &documentReader,
|
|
Tools::ProposeCompletionTool *proposeTool,
|
|
QObject *parent)
|
|
: CompletionEngine(parent)
|
|
, m_completeSettings(completeSettings)
|
|
, m_generalSettings(generalSettings)
|
|
, m_agentResolver(std::move(agentResolver))
|
|
, m_documentReader(documentReader)
|
|
, m_proposeTool(proposeTool)
|
|
, m_clientFactory(&Acp::spawnAgent)
|
|
, m_completionServer(new Mcp::CompletionMcpServer(this))
|
|
, m_permissions(new LLMQore::Acp::CallbackPermissionProvider(&autoAllow, this))
|
|
{
|
|
if (m_proposeTool) {
|
|
connect(
|
|
m_proposeTool,
|
|
&Tools::ProposeCompletionTool::completionProposed,
|
|
this,
|
|
&AcpCompletionEngine::handleCompletionProposed);
|
|
}
|
|
}
|
|
|
|
void AcpCompletionEngine::setClientFactory(ClientFactory factory)
|
|
{
|
|
if (factory)
|
|
m_clientFactory = std::move(factory);
|
|
}
|
|
|
|
bool AcpCompletionEngine::hasConfiguredAgent() const
|
|
{
|
|
const QString agentId = m_completeSettings.completionAgentId();
|
|
if (agentId.isEmpty() || !m_agentResolver)
|
|
return false;
|
|
return m_agentResolver(agentId).has_value();
|
|
}
|
|
|
|
void AcpCompletionEngine::request(quint64 requestId, const CompletionContext &context)
|
|
{
|
|
if (m_active) {
|
|
const auto stale = *m_active;
|
|
m_active.reset();
|
|
if (m_client && !m_sessionId.isEmpty())
|
|
m_client->cancel(m_sessionId);
|
|
emit completionFailed(stale.requestId, QStringLiteral("Superseded by a newer request"));
|
|
}
|
|
|
|
const QString agentId = m_completeSettings.completionAgentId();
|
|
if (agentId.isEmpty()) {
|
|
emit completionFailed(
|
|
requestId, QStringLiteral("Agentic (ACP agent) completion has no agent selected"));
|
|
return;
|
|
}
|
|
|
|
if (!m_agentResolver || !m_agentResolver(agentId).has_value()) {
|
|
emit completionFailed(
|
|
requestId, QString("ACP completion agent '%1' is not in the catalogue").arg(agentId));
|
|
return;
|
|
}
|
|
|
|
auto documentInfo = m_documentReader.readDocument(context.filePath);
|
|
if (!documentInfo.document) {
|
|
emit completionFailed(
|
|
requestId, QString("Document is not available: %1").arg(context.filePath));
|
|
return;
|
|
}
|
|
|
|
Context::DocumentContextReader reader(
|
|
documentInfo.document, documentInfo.mimeType, documentInfo.filePath);
|
|
const auto ctx = reader.prepareContext(context.line, context.column, m_completeSettings);
|
|
const QString codeContext = ctx.prefix.value_or("") + "<cursor>" + ctx.suffix.value_or("");
|
|
|
|
m_active
|
|
= ActiveRequest{requestId, documentInfo.filePath, context.line, context.column, codeContext};
|
|
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: request %1 for %2 (line %3, col %4), agent '%5'")
|
|
.arg(QString::number(requestId), documentInfo.filePath)
|
|
.arg(context.line)
|
|
.arg(context.column)
|
|
.arg(agentId));
|
|
|
|
if (m_boundAgentId != agentId) {
|
|
if (!m_boundAgentId.isEmpty())
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: selected agent changed %1 -> %2, releasing client")
|
|
.arg(m_boundAgentId, agentId));
|
|
releaseClient();
|
|
}
|
|
m_boundAgentId = agentId;
|
|
|
|
ensureClientAndPrompt();
|
|
}
|
|
|
|
void AcpCompletionEngine::ensureClientAndPrompt()
|
|
{
|
|
if (m_client && m_sessionReady) {
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: reusing live session %1, sending a new prompt")
|
|
.arg(m_sessionId));
|
|
sendPrompt();
|
|
return;
|
|
}
|
|
|
|
if (m_client) {
|
|
LOG_MESSAGE("ACP completion: client is still establishing its session, will prompt once ready");
|
|
return;
|
|
}
|
|
|
|
const auto agent = m_agentResolver ? m_agentResolver(m_boundAgentId) : std::nullopt;
|
|
if (!agent) {
|
|
failActive(QString("ACP completion agent '%1' vanished from the catalogue").arg(m_boundAgentId));
|
|
return;
|
|
}
|
|
|
|
Acp::AgentDefinition launchAgent = *agent;
|
|
const QString model = m_generalSettings.ccModel();
|
|
if (!model.isEmpty()) {
|
|
upsertEnv(launchAgent.distribution.env, QStringLiteral("ANTHROPIC_MODEL"), model);
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: injecting ANTHROPIC_MODEL=%1 into agent env").arg(model));
|
|
}
|
|
|
|
const QString cwd = Acp::agentWorkingDirectory();
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: spawning agent '%1' (%2) in %3")
|
|
.arg(launchAgent.id, launchAgent.name, cwd));
|
|
const Acp::AgentProcess process = m_clientFactory(launchAgent, cwd, this);
|
|
m_client = process.client;
|
|
if (!m_client) {
|
|
failActive(QStringLiteral("This agent has no launchable distribution"));
|
|
return;
|
|
}
|
|
|
|
m_client->setPermissionProvider(m_permissions);
|
|
|
|
const int generation = ++m_clientGeneration;
|
|
connect(m_client, &LLMQore::Acp::AcpClient::errorOccurred, this, [this, generation](const QString &error) {
|
|
if (generation != m_clientGeneration)
|
|
return;
|
|
LOG_MESSAGE(QString("ACP completion: agent error: %1").arg(error));
|
|
failActive(error);
|
|
releaseClient();
|
|
});
|
|
connect(m_client, &LLMQore::Acp::AcpClient::disconnected, this, [this, generation]() {
|
|
if (generation != m_clientGeneration)
|
|
return;
|
|
LOG_MESSAGE("ACP completion: agent disconnected");
|
|
m_sessionReady = false;
|
|
m_sessionId.clear();
|
|
});
|
|
connect(m_client, &LLMQore::Acp::AcpClient::agentStderr, this, [this, generation](const QString &line) {
|
|
if (generation != m_clientGeneration)
|
|
return;
|
|
LOG_MESSAGE(QString("ACP completion [agent stderr]: %1").arg(line));
|
|
});
|
|
connect(
|
|
m_client,
|
|
&LLMQore::Acp::AcpClient::toolCallStarted,
|
|
this,
|
|
[this, generation](const QString &, const LLMQore::Acp::ToolCall &call) {
|
|
if (generation != m_clientGeneration)
|
|
return;
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: agent tool call started: title='%1' kind=%2 status=%3")
|
|
.arg(call.title, call.kind, call.status));
|
|
});
|
|
connect(
|
|
m_client,
|
|
&LLMQore::Acp::AcpClient::toolCallUpdated,
|
|
this,
|
|
[this, generation](const QString &, const LLMQore::Acp::ToolCall &call) {
|
|
if (generation != m_clientGeneration)
|
|
return;
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: agent tool call updated: title='%1' kind=%2 status=%3")
|
|
.arg(call.title, call.kind, call.status));
|
|
});
|
|
|
|
LOG_MESSAGE("ACP completion: connecting and initializing the agent");
|
|
m_client->connectAndInitialize(std::chrono::seconds(30))
|
|
.then(this, [this, generation, cwd](const LLMQore::Acp::InitializeResult &info) {
|
|
if (generation != m_clientGeneration || !m_client)
|
|
return;
|
|
const bool http = info.agentCapabilities.mcpCapabilities.http;
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: agent initialized (protocol %1, MCP http=%2 sse=%3)")
|
|
.arg(QString::number(info.protocolVersion),
|
|
http ? QStringLiteral("true") : QStringLiteral("false"),
|
|
info.agentCapabilities.mcpCapabilities.sse ? QStringLiteral("true")
|
|
: QStringLiteral("false")));
|
|
if (!http) {
|
|
LOG_MESSAGE(
|
|
"ACP completion: WARNING — this agent does not advertise HTTP MCP support, so "
|
|
"it will likely ignore the QodeAssist MCP server and never call "
|
|
"propose_completion. Use an agent that accepts HTTP MCP servers.");
|
|
}
|
|
|
|
const QString mcpUrl = m_completionServer->start(m_proposeTool);
|
|
if (mcpUrl.isEmpty()) {
|
|
failActive(QStringLiteral(
|
|
"The QodeAssist completion MCP server could not start; ACP completion needs it"));
|
|
releaseClient();
|
|
return;
|
|
}
|
|
LLMQore::Acp::NewSessionParams params;
|
|
params.cwd = cwd;
|
|
params.mcpServers
|
|
= {LLMQore::Acp::McpServer::http(Mcp::CompletionMcpServer::serverName(), mcpUrl)};
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: creating session, offering MCP server '%1' at %2")
|
|
.arg(Mcp::CompletionMcpServer::serverName(), mcpUrl));
|
|
m_client->newSession(params, std::chrono::seconds(30))
|
|
.then(this, [this, generation](const LLMQore::Acp::NewSessionResult &result) {
|
|
if (generation != m_clientGeneration || !m_client)
|
|
return;
|
|
m_sessionId = result.sessionId;
|
|
m_sessionReady = true;
|
|
LOG_MESSAGE(QString("ACP completion: session %1 established").arg(m_sessionId));
|
|
sendPrompt();
|
|
})
|
|
.onFailed(this, [this, generation](const std::exception &e) {
|
|
if (generation != m_clientGeneration)
|
|
return;
|
|
LOG_MESSAGE(QString("ACP completion: session/new failed: %1")
|
|
.arg(QString::fromUtf8(e.what())));
|
|
failActive(QString::fromUtf8(e.what()));
|
|
releaseClient();
|
|
});
|
|
})
|
|
.onFailed(this, [this, generation](const std::exception &e) {
|
|
if (generation != m_clientGeneration)
|
|
return;
|
|
LOG_MESSAGE(QString("ACP completion: initialize failed: %1")
|
|
.arg(QString::fromUtf8(e.what())));
|
|
failActive(QString::fromUtf8(e.what()));
|
|
releaseClient();
|
|
});
|
|
}
|
|
|
|
void AcpCompletionEngine::sendPrompt()
|
|
{
|
|
if (!m_active || !m_client || m_sessionId.isEmpty())
|
|
return;
|
|
|
|
const int generation = m_clientGeneration;
|
|
const quint64 activeId = m_active->requestId;
|
|
const QString text = promptText();
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: prompting session %1 for request %2")
|
|
.arg(m_sessionId, QString::number(activeId)));
|
|
LOG_MESSAGE(QString("ACP completion: prompt text:\n%1").arg(text));
|
|
m_client->prompt(m_sessionId, {LLMQore::Acp::ContentBlock::makeText(text)})
|
|
.then(this, [this, generation, activeId](const LLMQore::Acp::PromptResult &result) {
|
|
if (generation != m_clientGeneration || !m_active || m_active->requestId != activeId)
|
|
return;
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: prompt for request %1 finished with stopReason '%2' but "
|
|
"no proposal arrived")
|
|
.arg(QString::number(activeId), result.stopReason));
|
|
m_active.reset();
|
|
emit completionFailed(
|
|
activeId,
|
|
QStringLiteral("The agent finished without proposing a completion"));
|
|
})
|
|
.onFailed(this, [this, generation, activeId](const std::exception &e) {
|
|
if (generation != m_clientGeneration || !m_active || m_active->requestId != activeId)
|
|
return;
|
|
m_active.reset();
|
|
LOG_MESSAGE(QString("ACP completion failed: %1").arg(QString::fromUtf8(e.what())));
|
|
emit completionFailed(activeId, QString::fromUtf8(e.what()));
|
|
});
|
|
}
|
|
|
|
QString AcpCompletionEngine::promptText() const
|
|
{
|
|
if (!m_active)
|
|
return {};
|
|
return QString(
|
|
"Complete the code at the <cursor> marker in the snippet below. Be fast: do NOT "
|
|
"use any other tools, do NOT read files, run commands, or search — work only "
|
|
"from this snippet.\n\n"
|
|
"<code_context>\n%1\n</code_context>\n\n"
|
|
"First work out the exact code that should replace <cursor> (just the "
|
|
"continuation — no explanations, no code fences, and do not repeat the code "
|
|
"already before or after the cursor). Then make ONE call to propose_completion "
|
|
"with that code as the `text` argument. The `text` must be the real completion "
|
|
"and must never be empty. file=%2, line=%3, column=%4.")
|
|
.arg(m_active->codeContext)
|
|
.arg(m_active->filePath)
|
|
.arg(m_active->line)
|
|
.arg(m_active->column);
|
|
}
|
|
|
|
void AcpCompletionEngine::cancel(quint64 requestId)
|
|
{
|
|
if (!m_active || m_active->requestId != requestId)
|
|
return;
|
|
m_active.reset();
|
|
if (m_client && !m_sessionId.isEmpty())
|
|
m_client->cancel(m_sessionId);
|
|
}
|
|
|
|
void AcpCompletionEngine::handleCompletionProposed(
|
|
const QString &filePath, int line, int column, const QString &text)
|
|
{
|
|
Q_UNUSED(line)
|
|
Q_UNUSED(column)
|
|
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: propose_completion called for '%1' (%2 chars)")
|
|
.arg(filePath, QString::number(text.size())));
|
|
|
|
if (!m_active) {
|
|
LOG_MESSAGE("ACP completion: ignoring proposal — no completion request is active");
|
|
return;
|
|
}
|
|
|
|
if (m_active->filePath != filePath) {
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: proposal path '%1' differs from the requested '%2'; "
|
|
"accepting anyway (single active request)")
|
|
.arg(filePath, m_active->filePath));
|
|
}
|
|
|
|
const quint64 externalId = m_active->requestId;
|
|
m_active.reset();
|
|
LOG_MESSAGE(
|
|
QString("ACP completion: rendering proposal for request %1").arg(QString::number(externalId)));
|
|
emit completionReady(externalId, text);
|
|
}
|
|
|
|
void AcpCompletionEngine::failActive(const QString &error)
|
|
{
|
|
if (!m_active)
|
|
return;
|
|
const auto stale = *m_active;
|
|
m_active.reset();
|
|
LOG_MESSAGE(QString("ACP completion failed: %1").arg(error));
|
|
emit completionFailed(stale.requestId, error);
|
|
}
|
|
|
|
void AcpCompletionEngine::releaseClient()
|
|
{
|
|
++m_clientGeneration;
|
|
m_sessionReady = false;
|
|
m_sessionId.clear();
|
|
if (m_client) {
|
|
m_client->shutdown();
|
|
m_client->deleteLater();
|
|
m_client = nullptr;
|
|
}
|
|
if (m_completionServer)
|
|
m_completionServer->stop();
|
|
}
|
|
|
|
} // namespace QodeAssist
|