feat: add support acp in common chat (#369)

This commit is contained in:
Petr Mironychev
2026-07-21 13:10:00 +02:00
committed by GitHub
parent af85efb554
commit 442263a6a7
158 changed files with 14270 additions and 4118 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <functional>
#include <optional>
#include <QString>
#include <QStringList>
#include <LLMQore/AcpTypes.hpp>
#include "acp/AgentDefinition.hpp"
#include "acp/AgentKnowledgeService.hpp"
#include "acp/AgentSpawn.hpp"
#include "acp/ChatPermissionProvider.hpp"
#include "session/ChatBackend.hpp"
#include "session/ContentBlock.hpp"
#include "session/TurnContext.hpp"
#include "session/TurnLedger.hpp"
namespace QodeAssist::Acp {
class AcpChatBackend : public Session::ChatBackend
{
Q_OBJECT
public:
using ClientFactory = std::function<
AgentProcess(const AgentDefinition &agent, const QString &cwd, QObject *parent)>;
using StoredContentLoader
= std::function<QByteArray(const QString &chatFilePath, const QString &storedPath)>;
explicit AcpChatBackend(QObject *parent = nullptr);
void setClientFactory(ClientFactory factory);
void setStoredContentLoader(StoredContentLoader loader);
void setKnowledgeService(AgentKnowledgeService *service);
void bindAgent(const AgentDefinition &agent);
QString boundAgentId() const;
QString boundAgentName() const;
const QList<LLMQore::Acp::AvailableCommand> &availableCommands() const
{
return m_availableCommands;
}
QString acpSessionId() const { return m_sessionId; }
QString bindingSessionId() const
{
return m_sessionId.isEmpty() ? m_resumeSessionId : m_sessionId;
}
void resumeSession(const QString &sessionId);
void startFreshSession();
void setHandoverSummary(const QString &summary);
bool isResumePending() const { return !m_resumeSessionId.isEmpty(); }
void sendTurn(const Session::TurnRequest &request) override;
void cancel() override;
bool respondPermission(const QString &requestId, const QString &optionId) override;
Session::TurnContextNeeds contextNeeds() const override { return {false}; }
void setChatFilePath(const QString &filePath) override;
void clearToolSession(const QString &filePath) override;
signals:
void agentSessionUnavailable(const QString &reason);
void availableCommandsChanged();
private:
struct PendingTurn
{
QList<Session::ContentBlock> userBlocks;
};
void startClient();
void adoptEarlyCommands();
void startSession();
void resumeOrStartSession();
void sendPrompt();
void authenticateAndRetry();
QList<LLMQore::Acp::ContentBlock> buildPrompt() const;
void appendAttachment(
QList<LLMQore::Acp::ContentBlock> &blocks,
const Session::AttachmentBlock &attachment) const;
void appendImage(
QList<LLMQore::Acp::ContentBlock> &blocks, const Session::ImageBlock &image) const;
void connectClient();
void requestPermission(
const QString &requestId,
const LLMQore::Acp::ToolCall &toolCall,
const QList<LLMQore::Acp::PermissionOption> &options);
void emitPermissionsCancelled(const QString &turnId, const QStringList &requestIds);
void cancelPendingPermissions(const QString &turnId);
void finishTurn(const QString &turnId);
void failTurn(const QString &error, bool dropProcess);
void releaseClient();
QList<LLMQore::Acp::McpServer> knowledgeServers();
void stopKnowledgeServer();
ClientFactory m_clientFactory;
StoredContentLoader m_storedContentLoader;
AgentKnowledgeService *m_knowledgeService = nullptr;
bool m_knowledgeServerRunning = false;
ChatPermissionProvider *m_permissions = nullptr;
std::optional<AgentDefinition> m_agent;
LLMQore::Acp::AcpClient *m_client = nullptr;
LLMQore::Acp::InitializeResult m_agentInfo;
QString m_sessionId;
QString m_workingDirectory;
QString m_runner;
QString m_chatFilePath;
QString m_resumeSessionId;
QString m_handoverSummary;
QList<LLMQore::Acp::AvailableCommand> m_availableCommands;
QList<LLMQore::Acp::AvailableCommand> m_earlyCommands;
QString m_earlyCommandsSessionId;
QString m_earlyTitle;
QString m_earlyTitleSessionId;
Session::TurnLedger m_ledger;
int m_clientGeneration = 0;
bool m_establishingSession = false;
PendingTurn m_pendingTurn;
QStringList m_stderr;
int m_turnCounter = 0;
bool m_authenticated = false;
};
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,88 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentBinding.hpp"
#include <QCoreApplication>
namespace QodeAssist::Acp {
namespace {
bool isUsableId(const QString &id)
{
if (id.size() > maxAgentBindingIdLength)
return false;
for (const QChar c : id) {
if (c.category() == QChar::Other_Control || c.category() == QChar::Other_Format)
return false;
}
return true;
}
} // namespace
QString AgentBinding::displayId() const
{
return agentId.size() <= maxAgentBindingIdLength
? agentId
: agentId.first(maxAgentBindingIdLength - 1) + QChar(0x2026);
}
QJsonObject AgentBinding::toJson() const
{
QJsonObject json;
json["agentId"] = agentId;
if (!sessionId.isEmpty())
json["sessionId"] = sessionId;
return json;
}
AgentBinding AgentBinding::fromJson(const QJsonValue &value, QString *error)
{
const auto fail = [error](const QString &reason) {
if (error)
*error = reason;
return AgentBinding{};
};
if (value.isUndefined() || value.isNull())
return {};
if (!value.isObject()) {
return fail(
QCoreApplication::translate("QodeAssist", "the agent binding is not an object"));
}
const QJsonObject json = value.toObject();
if (!json.value("agentId").isString() && json.contains("agentId")) {
return fail(
QCoreApplication::translate("QodeAssist", "the agent id is not a string"));
}
if (!json.value("sessionId").isString() && json.contains("sessionId")) {
return fail(
QCoreApplication::translate("QodeAssist", "the agent session id is not a string"));
}
AgentBinding binding{json["agentId"].toString(), json["sessionId"].toString()};
if (binding.agentId.isEmpty() && !binding.sessionId.isEmpty()) {
return fail(
QCoreApplication::translate(
"QodeAssist", "the agent binding names a session but no agent"));
}
if (!isUsableId(binding.agentId) || !isUsableId(binding.sessionId)) {
return fail(
QCoreApplication::translate("QodeAssist", "the agent binding holds an unusable id"));
}
return binding;
}
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QJsonObject>
#include <QJsonValue>
#include <QString>
namespace QodeAssist::Acp {
inline constexpr qsizetype maxAgentBindingIdLength = 256;
struct AgentBinding
{
QString agentId;
QString sessionId;
bool isEmpty() const { return agentId.isEmpty(); }
QString displayId() const;
bool operator==(const AgentBinding &other) const = default;
QJsonObject toJson() const;
static AgentBinding fromJson(const QJsonValue &value, QString *error = nullptr);
};
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,65 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentCatalog.hpp"
#include <algorithm>
#include <QHash>
namespace QodeAssist::Acp {
namespace {
constexpr std::array<AgentSource, agentSourceCount> mergeOrderLowestPriorityFirst{
AgentSource::BundledSnapshot, AgentSource::LiveRegistry, AgentSource::UserFile};
} // namespace
void AgentCatalog::setLayer(AgentSource source, const QList<AgentDefinition> &agents)
{
m_layers[static_cast<int>(source)] = agents;
rebuild();
}
QList<AgentDefinition> AgentCatalog::launchableAgents() const
{
QList<AgentDefinition> result;
for (const AgentDefinition &agent : m_merged) {
if (agent.isLaunchable())
result.append(agent);
}
return result;
}
std::optional<AgentDefinition> AgentCatalog::agent(const QString &id) const
{
for (const AgentDefinition &agent : m_merged) {
if (agent.id == id)
return agent;
}
return std::nullopt;
}
void AgentCatalog::rebuild()
{
QHash<QString, AgentDefinition> byId;
for (AgentSource source : mergeOrderLowestPriorityFirst) {
for (const AgentDefinition &agent : std::as_const(m_layers[static_cast<int>(source)]))
byId.insert(agent.id, agent);
}
m_merged = byId.values();
std::sort(
m_merged.begin(),
m_merged.end(),
[](const AgentDefinition &left, const AgentDefinition &right) {
const int byName = QString::compare(left.name, right.name, Qt::CaseInsensitive);
if (byName != 0)
return byName < 0;
return left.id < right.id;
});
}
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,35 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <array>
#include <optional>
#include <QList>
#include <QString>
#include "acp/AgentDefinition.hpp"
namespace QodeAssist::Acp {
class AgentCatalog
{
public:
void setLayer(AgentSource source, const QList<AgentDefinition> &agents);
QList<AgentDefinition> agents() const { return m_merged; }
QList<AgentDefinition> launchableAgents() const;
std::optional<AgentDefinition> agent(const QString &id) const;
int size() const { return m_merged.size(); }
private:
void rebuild();
std::array<QList<AgentDefinition>, agentSourceCount> m_layers;
QList<AgentDefinition> m_merged;
};
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,165 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentCatalogStore.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <coreplugin/icore.h>
#include <logger/Logger.hpp>
#include "acp/AgentRegistryParser.hpp"
namespace QodeAssist::Acp {
namespace {
QByteArray readFile(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return {};
return file.readAll();
}
} // namespace
AgentCatalogStore::AgentCatalogStore(QObject *parent)
: QObject(parent)
, m_network(new QNetworkAccessManager(this))
{}
QString AgentCatalogStore::userAgentsDirectory()
{
const QString path = QStringLiteral("%1/qodeassist/agents")
.arg(Core::ICore::userResourcePath().toFSPathString());
QDir().mkpath(path);
return path;
}
QString AgentCatalogStore::registryCachePath()
{
const QString directory
= QStringLiteral("%1/qodeassist").arg(Core::ICore::userResourcePath().toFSPathString());
QDir().mkpath(directory);
return directory + QStringLiteral("/acp-registry-cache.json");
}
QUrl AgentCatalogStore::registryUrl()
{
return QUrl(
QStringLiteral("https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"));
}
QString AgentCatalogStore::bundledSnapshotPath()
{
return QStringLiteral(":/resources/agents/acp-registry-snapshot.json");
}
bool AgentCatalogStore::hasCachedRegistry() const
{
return QFileInfo::exists(registryCachePath());
}
void AgentCatalogStore::reload()
{
m_warnings.clear();
loadBundledSnapshot();
loadRegistryCache();
loadUserFiles();
emit catalogChanged();
}
void AgentCatalogStore::loadBundledSnapshot()
{
const AgentParseResult result = AgentRegistryParser::parse(
readFile(bundledSnapshotPath()),
AgentSource::BundledSnapshot,
QStringLiteral("bundled snapshot"));
m_warnings.append(result.warnings);
m_catalog.setLayer(AgentSource::BundledSnapshot, result.agents);
}
void AgentCatalogStore::loadRegistryCache()
{
const QString path = registryCachePath();
if (!QFileInfo::exists(path)) {
m_catalog.setLayer(AgentSource::LiveRegistry, {});
return;
}
const AgentParseResult result = AgentRegistryParser::parse(
readFile(path), AgentSource::LiveRegistry, QStringLiteral("registry cache"));
m_warnings.append(result.warnings);
m_catalog.setLayer(AgentSource::LiveRegistry, result.agents);
}
void AgentCatalogStore::loadUserFiles()
{
const QDir directory(userAgentsDirectory());
const QStringList files
= directory.entryList({QStringLiteral("*.json")}, QDir::Files, QDir::Name);
QList<AgentDefinition> agents;
for (const QString &file : files) {
const QString path = directory.filePath(file);
const AgentParseResult result
= AgentRegistryParser::parse(readFile(path), AgentSource::UserFile, file);
m_warnings.append(result.warnings);
agents.append(result.agents);
}
m_catalog.setLayer(AgentSource::UserFile, agents);
}
void AgentCatalogStore::refreshFromRegistry()
{
if (m_reply)
return;
m_reply = m_network->get(QNetworkRequest(registryUrl()));
connect(m_reply, &QNetworkReply::finished, this, [this]() {
QNetworkReply *reply = m_reply;
m_reply = nullptr;
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
emit refreshFinished(false, reply->errorString());
return;
}
const QByteArray payload = reply->readAll();
const AgentParseResult result = AgentRegistryParser::parse(
payload, AgentSource::LiveRegistry, QStringLiteral("registry"));
if (result.agents.isEmpty()) {
emit refreshFinished(false, tr("The registry response contained no agents."));
return;
}
QSaveFile cache(registryCachePath());
if (!cache.open(QIODevice::WriteOnly) || cache.write(payload) != payload.size()
|| !cache.commit()) {
emit refreshFinished(
false, tr("Cannot write the registry cache: %1").arg(cache.errorString()));
return;
}
LOG_MESSAGE(QString("ACP registry refreshed: %1 agents").arg(result.agents.size()));
reload();
emit refreshFinished(true, {});
});
}
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,57 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QUrl>
#include "acp/AgentCatalog.hpp"
QT_BEGIN_NAMESPACE
class QNetworkAccessManager;
class QNetworkReply;
QT_END_NAMESPACE
namespace QodeAssist::Acp {
class AgentCatalogStore : public QObject
{
Q_OBJECT
public:
explicit AgentCatalogStore(QObject *parent = nullptr);
static QString userAgentsDirectory();
static QString registryCachePath();
static QUrl registryUrl();
static QString bundledSnapshotPath();
const AgentCatalog &catalog() const { return m_catalog; }
QStringList warnings() const { return m_warnings; }
bool hasCachedRegistry() const;
bool isRefreshing() const { return m_reply != nullptr; }
void reload();
void refreshFromRegistry();
signals:
void catalogChanged();
void refreshFinished(bool ok, const QString &error);
private:
void loadBundledSnapshot();
void loadRegistryCache();
void loadUserFiles();
QNetworkAccessManager *m_network = nullptr;
QNetworkReply *m_reply = nullptr;
AgentCatalog m_catalog;
QStringList m_warnings;
};
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,94 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentDefinition.hpp"
#include <QSysInfo>
namespace QodeAssist::Acp {
bool AgentDefinition::isLaunchable() const
{
switch (distribution.kind) {
case AgentDistributionKind::Npx:
case AgentDistributionKind::Uvx:
return !distribution.package.isEmpty();
case AgentDistributionKind::Command:
return !distribution.command.isEmpty();
case AgentDistributionKind::Binary:
case AgentDistributionKind::Unknown:
return false;
}
return false;
}
QString agentSourceName(AgentSource source)
{
switch (source) {
case AgentSource::UserFile:
return QStringLiteral("user file");
case AgentSource::LiveRegistry:
return QStringLiteral("registry");
case AgentSource::BundledSnapshot:
return QStringLiteral("bundled");
}
return {};
}
QString agentDistributionName(AgentDistributionKind kind)
{
switch (kind) {
case AgentDistributionKind::Npx:
return QStringLiteral("npx");
case AgentDistributionKind::Uvx:
return QStringLiteral("uvx");
case AgentDistributionKind::Binary:
return QStringLiteral("binary");
case AgentDistributionKind::Command:
return QStringLiteral("command");
case AgentDistributionKind::Unknown:
return QStringLiteral("unknown");
}
return {};
}
QString currentBinaryPlatform()
{
const QString kernel = QSysInfo::kernelType();
QString os;
if (kernel == QLatin1String("darwin"))
os = QStringLiteral("darwin");
else if (kernel == QLatin1String("linux"))
os = QStringLiteral("linux");
else if (kernel == QLatin1String("winnt"))
os = QStringLiteral("windows");
else
return {};
const QString cpu = QSysInfo::currentCpuArchitecture();
QString arch;
if (cpu == QLatin1String("arm64") || cpu == QLatin1String("aarch64"))
arch = QStringLiteral("aarch64");
else if (cpu == QLatin1String("x86_64"))
arch = QStringLiteral("x86_64");
else
return {};
return os + QLatin1Char('-') + arch;
}
const AgentBinaryTarget *binaryTargetForCurrentPlatform(const AgentDefinition &agent)
{
const QString platform = currentBinaryPlatform();
if (platform.isEmpty())
return nullptr;
for (const AgentBinaryTarget &target : agent.distribution.binaryTargets) {
if (target.platform == platform)
return &target;
}
return nullptr;
}
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,69 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QList>
#include <QString>
#include <QStringList>
namespace QodeAssist::Acp {
enum class AgentSource { BundledSnapshot, LiveRegistry, UserFile };
inline constexpr int agentSourceCount = 3;
enum class AgentDistributionKind { Unknown, Npx, Uvx, Binary, Command };
struct AgentEnvVariable
{
QString name;
QString value;
};
struct AgentBinaryTarget
{
QString platform;
QString archive;
QString sha256;
QString cmd;
QStringList args;
QList<AgentEnvVariable> env;
};
struct AgentDistribution
{
AgentDistributionKind kind = AgentDistributionKind::Unknown;
QString package;
QString command;
QStringList args;
QList<AgentEnvVariable> env;
QList<AgentBinaryTarget> binaryTargets;
};
struct AgentDefinition
{
QString id;
QString name;
QString version;
QString description;
QString icon;
QString repository;
QString website;
QString license;
QStringList authors;
AgentDistribution distribution;
AgentSource source = AgentSource::BundledSnapshot;
QString origin;
bool isLaunchable() const;
};
QString agentSourceName(AgentSource source);
QString agentDistributionName(AgentDistributionKind kind);
QString currentBinaryPlatform();
const AgentBinaryTarget *binaryTargetForCurrentPlatform(const AgentDefinition &agent);
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,21 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
namespace QodeAssist::Acp {
class AgentKnowledgeService
{
public:
virtual ~AgentKnowledgeService() = default;
virtual QString start() = 0;
virtual void stop() = 0;
virtual QString serverName() const = 0;
};
} // namespace QodeAssist::Acp

185
sources/acp/AgentLaunch.cpp Normal file
View File

@@ -0,0 +1,185 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentLaunch.hpp"
#include <QDir>
#include <QProcess>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QStandardPaths>
namespace QodeAssist::Acp {
namespace {
QList<LLMQore::Acp::EnvVariable> toLlmqoreEnv(const QList<AgentEnvVariable> &env)
{
QList<LLMQore::Acp::EnvVariable> result;
result.reserve(env.size());
for (const AgentEnvVariable &variable : env)
result.append({variable.name, variable.value});
return result;
}
void applyRunner(
LLMQore::Acp::AcpAgentConfig &config,
const QString &runner,
const QStringList &runnerFlags,
const AgentDistribution &distribution)
{
config.command = runner;
config.args = runnerFlags;
config.args.append(distribution.package);
config.args.append(distribution.args);
}
QProcessEnvironment harvestLoginShellEnvironment()
{
QProcessEnvironment result;
#ifndef Q_OS_WIN
static const QRegularExpression assignment(QStringLiteral("^([A-Za-z_][A-Za-z0-9_]*)=(.*)$"));
const QString shell = qEnvironmentVariable("SHELL", QStringLiteral("/bin/sh"));
QProcess process;
process.setProcessChannelMode(QProcess::SeparateChannels);
process.start(
shell,
{QStringLiteral("-l"), QStringLiteral("-i"), QStringLiteral("-c"), QStringLiteral("env")});
if (!process.waitForFinished(5000)) {
process.kill();
process.waitForFinished(1000);
return result;
}
const QString output = QString::fromUtf8(process.readAllStandardOutput());
const QList<QString> lines = output.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
for (const QString &line : lines) {
const QRegularExpressionMatch match = assignment.match(line);
if (match.hasMatch())
result.insert(match.captured(1), match.captured(2));
}
#endif
return result;
}
const QProcessEnvironment &loginShellEnvironment()
{
static const QProcessEnvironment environment = harvestLoginShellEnvironment();
return environment;
}
} // namespace
std::optional<LLMQore::Acp::AcpAgentConfig> agentLaunchConfig(
const AgentDefinition &agent, const QString &workingDirectory)
{
if (!agent.isLaunchable())
return std::nullopt;
LLMQore::Acp::AcpAgentConfig config;
config.cwd = workingDirectory;
config.env = toLlmqoreEnv(agent.distribution.env);
switch (agent.distribution.kind) {
case AgentDistributionKind::Npx:
applyRunner(
config, QStringLiteral("npx"), QStringList{QStringLiteral("-y")}, agent.distribution);
break;
case AgentDistributionKind::Uvx:
applyRunner(config, QStringLiteral("uvx"), {}, agent.distribution);
break;
case AgentDistributionKind::Command:
config.command = agent.distribution.command;
config.args = agent.distribution.args;
break;
case AgentDistributionKind::Binary:
case AgentDistributionKind::Unknown:
return std::nullopt;
}
return config;
}
QStringList splitSearchPaths(const QString &value)
{
QStringList directories;
const QList<QString> entries = value.split(QDir::listSeparator(), Qt::SkipEmptyParts);
for (const QString &entry : entries) {
const QString trimmed = entry.trimmed();
if (!trimmed.isEmpty())
directories.append(trimmed);
}
return directories;
}
void applyExtraSearchPaths(LLMQore::Acp::AcpAgentConfig &config, const QStringList &extraDirectories)
{
if (extraDirectories.isEmpty())
return;
const bool isBareName = !config.command.isEmpty() && !config.command.contains(QLatin1Char('/'))
&& !config.command.contains(QLatin1Char('\\'));
if (isBareName) {
const QString resolved = QStandardPaths::findExecutable(config.command, extraDirectories);
if (!resolved.isEmpty())
config.command = resolved;
}
const QChar separator = QDir::listSeparator();
const QString inherited = QProcessEnvironment::systemEnvironment().value(QStringLiteral("PATH"));
const QString merged = inherited.isEmpty()
? extraDirectories.join(separator)
: extraDirectories.join(separator) + separator + inherited;
config.env.prepend({QStringLiteral("PATH"), merged});
}
QStringList splitVariableNames(const QString &value)
{
static const QRegularExpression separators(QStringLiteral("[,;\\s]+"));
QStringList names;
const QList<QString> entries = value.split(separators, Qt::SkipEmptyParts);
for (const QString &entry : entries) {
const QString trimmed = entry.trimmed();
if (!trimmed.isEmpty())
names.append(trimmed);
}
return names;
}
void applyForwardedEnvironment(LLMQore::Acp::AcpAgentConfig &config, const QStringList &variableNames)
{
if (variableNames.isEmpty())
return;
const QProcessEnvironment inherited = QProcessEnvironment::systemEnvironment();
QList<LLMQore::Acp::EnvVariable> forwarded;
QStringList missing;
for (const QString &name : variableNames) {
if (inherited.contains(name))
forwarded.append({name, inherited.value(name)});
else
missing.append(name);
}
if (!missing.isEmpty()) {
const QProcessEnvironment &shell = loginShellEnvironment();
for (const QString &name : std::as_const(missing)) {
if (shell.contains(name))
forwarded.append({name, shell.value(name)});
}
}
for (auto it = forwarded.crbegin(); it != forwarded.crend(); ++it)
config.env.prepend(*it);
}
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,29 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <optional>
#include <QString>
#include <LLMQore/AcpAgentConfig.hpp>
#include "acp/AgentDefinition.hpp"
namespace QodeAssist::Acp {
std::optional<LLMQore::Acp::AcpAgentConfig> agentLaunchConfig(
const AgentDefinition &agent, const QString &workingDirectory);
QStringList splitSearchPaths(const QString &value);
void applyExtraSearchPaths(LLMQore::Acp::AcpAgentConfig &config, const QStringList &extraDirectories);
QStringList splitVariableNames(const QString &value);
void applyForwardedEnvironment(
LLMQore::Acp::AcpAgentConfig &config, const QStringList &variableNames);
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,223 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentRegistryParser.hpp"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
namespace QodeAssist::Acp {
namespace {
QStringList readStringList(const QJsonValue &value)
{
QStringList result;
const QJsonArray array = value.toArray();
for (const QJsonValue &entry : array) {
if (entry.isString())
result.append(entry.toString());
}
return result;
}
QList<AgentEnvVariable> readEnv(const QJsonValue &value)
{
QList<AgentEnvVariable> result;
if (value.isObject()) {
const QJsonObject object = value.toObject();
for (auto it = object.constBegin(); it != object.constEnd(); ++it)
result.append({it.key(), it.value().toString()});
return result;
}
const QJsonArray array = value.toArray();
for (const QJsonValue &entry : array) {
const QJsonObject object = entry.toObject();
const QString name = object.value(QLatin1String("name")).toString();
if (!name.isEmpty())
result.append({name, object.value(QLatin1String("value")).toString()});
}
return result;
}
AgentDistribution readRunnerDistribution(const QJsonObject &object, AgentDistributionKind kind)
{
AgentDistribution distribution;
distribution.kind = kind;
distribution.package = object.value(QLatin1String("package")).toString();
distribution.args = readStringList(object.value(QLatin1String("args")));
distribution.env = readEnv(object.value(QLatin1String("env")));
return distribution;
}
AgentDistribution readBinaryDistribution(const QJsonObject &object)
{
AgentDistribution distribution;
distribution.kind = AgentDistributionKind::Binary;
for (auto it = object.constBegin(); it != object.constEnd(); ++it) {
const QJsonObject entry = it.value().toObject();
AgentBinaryTarget target;
target.platform = it.key();
target.archive = entry.value(QLatin1String("archive")).toString();
target.sha256 = entry.value(QLatin1String("sha256")).toString();
target.cmd = entry.value(QLatin1String("cmd")).toString();
target.args = readStringList(entry.value(QLatin1String("args")));
target.env = readEnv(entry.value(QLatin1String("env")));
distribution.binaryTargets.append(target);
}
return distribution;
}
AgentDistribution readCommandDistribution(const QJsonObject &object)
{
AgentDistribution distribution;
distribution.kind = AgentDistributionKind::Command;
distribution.command = object.value(QLatin1String("cmd")).toString();
if (distribution.command.isEmpty())
distribution.command = object.value(QLatin1String("command")).toString();
distribution.args = readStringList(object.value(QLatin1String("args")));
distribution.env = readEnv(object.value(QLatin1String("env")));
return distribution;
}
QString distributionProblem(const AgentDistribution &distribution)
{
switch (distribution.kind) {
case AgentDistributionKind::Unknown:
return QStringLiteral("has no supported distribution");
case AgentDistributionKind::Npx:
case AgentDistributionKind::Uvx:
if (distribution.package.isEmpty())
return QStringLiteral("has a %1 distribution without a package")
.arg(agentDistributionName(distribution.kind));
break;
case AgentDistributionKind::Command:
if (distribution.command.isEmpty())
return QStringLiteral("has a command distribution without a cmd");
break;
case AgentDistributionKind::Binary:
if (distribution.binaryTargets.isEmpty())
return QStringLiteral("has a binary distribution without platform targets");
break;
}
return {};
}
AgentDistribution readDistribution(const QJsonObject &object)
{
if (object.contains(QLatin1String("npx")))
return readRunnerDistribution(
object.value(QLatin1String("npx")).toObject(), AgentDistributionKind::Npx);
if (object.contains(QLatin1String("uvx")))
return readRunnerDistribution(
object.value(QLatin1String("uvx")).toObject(), AgentDistributionKind::Uvx);
if (object.contains(QLatin1String("command")))
return readCommandDistribution(object.value(QLatin1String("command")).toObject());
if (object.contains(QLatin1String("binary")))
return readBinaryDistribution(object.value(QLatin1String("binary")).toObject());
return {};
}
} // namespace
namespace AgentRegistryParser {
AgentParseResult parse(const QJsonValue &root, AgentSource source, const QString &origin)
{
AgentParseResult result;
QJsonArray entries;
if (root.isArray()) {
entries = root.toArray();
} else if (root.isObject()) {
const QJsonObject object = root.toObject();
if (object.contains(QLatin1String("agents"))) {
const QJsonValue agents = object.value(QLatin1String("agents"));
if (!agents.isArray()) {
result.warnings.append(QStringLiteral("%1: 'agents' is not an array").arg(origin));
return result;
}
entries = agents.toArray();
} else if (object.contains(QLatin1String("id")))
entries.append(root);
else
result.warnings.append(QStringLiteral("%1: no agents found").arg(origin));
} else {
result.warnings.append(QStringLiteral("%1: not a JSON object or array").arg(origin));
return result;
}
int index = 0;
for (const QJsonValue &entry : std::as_const(entries)) {
const int position = index++;
if (!entry.isObject()) {
result.warnings.append(
QStringLiteral("%1: entry %2 is not an object").arg(origin).arg(position));
continue;
}
const QJsonObject object = entry.toObject();
AgentDefinition agent;
agent.id = object.value(QLatin1String("id")).toString();
if (agent.id.isEmpty()) {
result.warnings.append(
QStringLiteral("%1: entry %2 has no id").arg(origin).arg(position));
continue;
}
agent.name = object.value(QLatin1String("name")).toString();
if (agent.name.isEmpty())
agent.name = agent.id;
agent.version = object.value(QLatin1String("version")).toString();
agent.description = object.value(QLatin1String("description")).toString();
agent.icon = object.value(QLatin1String("icon")).toString();
agent.repository = object.value(QLatin1String("repository")).toString();
agent.website = object.value(QLatin1String("website")).toString();
agent.license = object.value(QLatin1String("license")).toString();
agent.authors = readStringList(object.value(QLatin1String("authors")));
agent.distribution = readDistribution(
object.value(QLatin1String("distribution")).toObject());
agent.source = source;
agent.origin = origin;
const QString problem = distributionProblem(agent.distribution);
if (!problem.isEmpty()) {
result.warnings.append(
QStringLiteral("%1: agent '%2' %3").arg(origin, agent.id, problem));
}
result.agents.append(agent);
}
return result;
}
AgentParseResult parse(const QByteArray &json, AgentSource source, const QString &origin)
{
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(json, &error);
if (document.isNull()) {
AgentParseResult result;
result.warnings.append(QStringLiteral("%1: %2").arg(origin, error.errorString()));
return result;
}
if (document.isArray())
return parse(QJsonValue(document.array()), source, origin);
return parse(QJsonValue(document.object()), source, origin);
}
} // namespace AgentRegistryParser
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QByteArray>
#include <QJsonValue>
#include <QList>
#include <QString>
#include <QStringList>
#include "acp/AgentDefinition.hpp"
namespace QodeAssist::Acp {
struct AgentParseResult
{
QList<AgentDefinition> agents;
QStringList warnings;
};
namespace AgentRegistryParser {
AgentParseResult parse(const QJsonValue &root, AgentSource source, const QString &origin);
AgentParseResult parse(const QByteArray &json, AgentSource source, const QString &origin);
} // namespace AgentRegistryParser
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,76 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentSpawn.hpp"
#include <QCoreApplication>
#include <QDir>
#include <extensionsystem/pluginmanager.h>
#include <extensionsystem/pluginspec.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <LLMQore/AcpClient.hpp>
#include "acp/AgentLaunch.hpp"
#include "settings/AgentsSettings.hpp"
namespace QodeAssist::Acp {
namespace {
LLMQore::Acp::Implementation clientIdentity()
{
QString version;
for (const ExtensionSystem::PluginSpec *spec : ExtensionSystem::PluginManager::plugins()) {
if (spec->name() == QLatin1String("QodeAssist")) {
version = spec->version();
break;
}
}
return {QStringLiteral("QodeAssist"), version, QStringLiteral("QodeAssist")};
}
} // namespace
QString agentWorkingDirectory()
{
if (const ProjectExplorer::Project *project = ProjectExplorer::ProjectManager::startupProject())
return project->projectDirectory().path();
return QDir::homePath();
}
AgentProcess spawnAgent(const AgentDefinition &agent, const QString &cwd, QObject *parent)
{
auto config = agentLaunchConfig(agent, cwd);
if (!config)
return {};
auto &settings = Settings::agentsSettings();
applyExtraSearchPaths(*config, splitSearchPaths(settings.agentExtraPaths.volatileValue()));
applyForwardedEnvironment(
*config, splitVariableNames(settings.agentForwardedVariables.volatileValue()));
auto *transport = config->createTransport(nullptr);
auto *client = new LLMQore::Acp::AcpClient(transport, clientIdentity(), parent);
transport->setParent(client);
return {client, config->command};
}
QString runnerHint(const QString &command)
{
if (command.endsWith(QLatin1String("npx"))) {
return QCoreApplication::translate(
"QtC::QodeAssist", "Make sure Node.js is installed and 'npx' is on PATH.");
}
if (command.endsWith(QLatin1String("uvx"))) {
return QCoreApplication::translate(
"QtC::QodeAssist", "Make sure uv is installed and 'uvx' is on PATH.");
}
return {};
}
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QString>
#include "acp/AgentDefinition.hpp"
namespace LLMQore::Acp {
class AcpClient;
}
namespace QodeAssist::Acp {
struct AgentProcess
{
LLMQore::Acp::AcpClient *client = nullptr;
QString command;
};
QString agentWorkingDirectory();
AgentProcess spawnAgent(const AgentDefinition &agent, const QString &cwd, QObject *parent);
QString runnerHint(const QString &command);
} // namespace QodeAssist::Acp

180
sources/acp/AgentTester.cpp Normal file
View File

@@ -0,0 +1,180 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentTester.hpp"
#include <chrono>
#include <LLMQore/AcpClient.hpp>
#include <LLMQore/RpcStdioTransport.hpp>
#include "acp/AgentSpawn.hpp"
namespace QodeAssist::Acp {
namespace {
constexpr int maxStderrLines = 20;
QString capabilityList(const LLMQore::Acp::AgentCapabilities &capabilities)
{
QStringList prompt;
if (capabilities.promptCapabilities.image)
prompt.append(AgentTester::tr("image"));
if (capabilities.promptCapabilities.audio)
prompt.append(AgentTester::tr("audio"));
if (capabilities.promptCapabilities.embeddedContext)
prompt.append(AgentTester::tr("embedded context"));
return prompt.join(QStringLiteral(", "));
}
QString mcpList(const LLMQore::Acp::AgentCapabilities &capabilities)
{
QStringList transports;
if (capabilities.mcpCapabilities.http)
transports.append(QStringLiteral("http"));
if (capabilities.mcpCapabilities.sse)
transports.append(QStringLiteral("sse"));
return transports.join(QStringLiteral(", "));
}
QString describe(const AgentDefinition &agent, const LLMQore::Acp::InitializeResult &result)
{
const QString name = result.agentInfo && !result.agentInfo->name.isEmpty()
? result.agentInfo->name
: agent.name;
const QString version = result.agentInfo && !result.agentInfo->version.isEmpty()
? result.agentInfo->version
: agent.version;
QStringList lines;
lines.append(version.isEmpty() ? name : QStringLiteral("%1 %2").arg(name, version));
lines.append(AgentTester::tr("Protocol version: %1").arg(result.protocolVersion));
lines.append(
AgentTester::tr("Session persistence (loadSession): %1")
.arg(
result.agentCapabilities.loadSession ? AgentTester::tr("supported")
: AgentTester::tr("not supported")));
const QString prompt = capabilityList(result.agentCapabilities);
lines.append(
AgentTester::tr("Prompt content: %1")
.arg(prompt.isEmpty() ? AgentTester::tr("text only") : prompt));
const QString mcp = mcpList(result.agentCapabilities);
if (!mcp.isEmpty())
lines.append(AgentTester::tr("MCP transports: %1").arg(mcp));
if (result.authMethods.isEmpty()) {
lines.append(AgentTester::tr("Authentication: not required"));
} else {
QStringList methods;
for (const LLMQore::Acp::AuthMethod &method : result.authMethods)
methods.append(method.name.isEmpty() ? method.id : method.name);
lines.append(AgentTester::tr("Authentication: %1").arg(methods.join(QStringLiteral(", "))));
}
return lines.join(QLatin1Char('\n'));
}
} // namespace
AgentTester::AgentTester(QObject *parent)
: QObject(parent)
{}
void AgentTester::start(const AgentDefinition &agent, const QString &workingDirectory)
{
if (m_running)
return;
const AgentProcess process = spawnAgent(agent, workingDirectory, this);
if (!process.client) {
emit finished(false, tr("This agent has no launchable distribution."));
return;
}
m_stderr.clear();
m_runner = process.command;
m_running = true;
m_client = process.client;
m_transport = qobject_cast<LLMQore::Rpc::StdioClientTransport *>(m_client->transport());
connect(m_client, &LLMQore::Acp::AcpClient::agentStderr, this, [this](const QString &line) {
if (m_stderr.size() < maxStderrLines)
m_stderr.append(line);
});
connect(m_client, &LLMQore::Acp::AcpClient::errorOccurred, this, [this](const QString &error) {
report(false, error);
});
m_client->connectAndInitialize(std::chrono::seconds(60))
.then(
this,
[this, agent](const LLMQore::Acp::InitializeResult &result) {
report(true, describe(agent, result));
})
.onFailed(this, [this](const std::exception &e) {
report(false, QString::fromUtf8(e.what()));
});
}
void AgentTester::cancel()
{
if (m_running)
report(false, tr("Test cancelled."));
}
void AgentTester::report(bool ok, const QString &text)
{
if (!m_running)
return;
m_running = false;
const QString details = ok ? text : text + diagnostics();
releaseClient();
emit finished(ok, details);
}
QString AgentTester::diagnostics() const
{
QString result;
const QString hint = runnerHint(m_runner);
if (!hint.isEmpty())
result += QLatin1Char('\n') + hint;
if (!m_stderr.isEmpty()) {
result += QLatin1Char('\n') + tr("Agent output:") + QLatin1Char('\n')
+ m_stderr.join(QLatin1Char('\n'));
}
return result;
}
void AgentTester::releaseClient()
{
LLMQore::Acp::AcpClient *client = m_client;
LLMQore::Rpc::StdioClientTransport *transport = m_transport;
m_client = nullptr;
m_transport = nullptr;
if (client)
client->disconnect(this);
QMetaObject::invokeMethod(
this,
[client, transport]() {
if (client) {
client->shutdown();
client->deleteLater();
}
if (transport)
transport->deleteLater();
},
Qt::QueuedConnection);
}
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,50 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include "acp/AgentDefinition.hpp"
namespace LLMQore::Acp {
class AcpClient;
}
namespace LLMQore::Rpc {
class StdioClientTransport;
}
namespace QodeAssist::Acp {
class AgentTester : public QObject
{
Q_OBJECT
public:
explicit AgentTester(QObject *parent = nullptr);
void start(const AgentDefinition &agent, const QString &workingDirectory);
void cancel();
bool isRunning() const { return m_running; }
signals:
void finished(bool ok, const QString &report);
private:
void report(bool ok, const QString &text);
void releaseClient();
QString diagnostics() const;
LLMQore::Acp::AcpClient *m_client = nullptr;
LLMQore::Rpc::StdioClientTransport *m_transport = nullptr;
QStringList m_stderr;
QString m_runner;
bool m_running = false;
};
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,29 @@
add_library(QodeAssistAcp STATIC
AgentBinding.hpp AgentBinding.cpp
AgentDefinition.hpp AgentDefinition.cpp
AgentRegistryParser.hpp AgentRegistryParser.cpp
AgentCatalog.hpp AgentCatalog.cpp
AgentLaunch.hpp AgentLaunch.cpp
AgentCatalogStore.hpp AgentCatalogStore.cpp
AgentSpawn.hpp AgentSpawn.cpp
AgentTester.hpp AgentTester.cpp
ChatPermissionProvider.hpp ChatPermissionProvider.cpp
AcpChatBackend.hpp AcpChatBackend.cpp
)
target_link_libraries(QodeAssistAcp
PUBLIC
Qt::Core
Qt::Network
QtCreator::Core
QtCreator::Utils
QtCreator::ProjectExplorer
QtCreator::ExtensionSystem
LLMQore
QodeAssistSession
PRIVATE
QodeAssistLogger
QodeAssistSettings
)
target_include_directories(QodeAssistAcp PUBLIC ${CMAKE_SOURCE_DIR}/sources)

View File

@@ -0,0 +1,56 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatPermissionProvider.hpp"
#include <memory>
#include <utility>
#include <QPromise>
namespace QodeAssist::Acp {
ChatPermissionProvider::ChatPermissionProvider(Session::TurnLedger *ledger, QObject *parent)
: LLMQore::Acp::AcpPermissionProvider(parent)
, m_ledger(ledger)
{}
void ChatPermissionProvider::setRequestHandler(RequestHandler handler)
{
m_requestHandler = std::move(handler);
}
QFuture<LLMQore::Acp::RequestPermissionResult> ChatPermissionProvider::requestPermission(
const QString &sessionId,
const LLMQore::Acp::ToolCall &toolCall,
const QList<LLMQore::Acp::PermissionOption> &options)
{
Q_UNUSED(sessionId)
auto promise = std::make_shared<QPromise<LLMQore::Acp::RequestPermissionResult>>();
promise->start();
const auto finish = [promise](const LLMQore::Acp::RequestPermissionResult &result) {
promise->addResult(result);
promise->finish();
};
if (!m_requestHandler) {
finish(LLMQore::Acp::RequestPermissionResult::cancelled());
return promise->future();
}
const QString requestId = m_ledger->registerPermission(
[finish](const QString &optionId) {
finish(LLMQore::Acp::RequestPermissionResult::selected(optionId));
},
[finish] { finish(LLMQore::Acp::RequestPermissionResult::cancelled()); });
QFuture<LLMQore::Acp::RequestPermissionResult> future = promise->future();
m_requestHandler(requestId, toolCall, options);
return future;
}
} // namespace QodeAssist::Acp

View File

@@ -0,0 +1,41 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <functional>
#include <QString>
#include <LLMQore/AcpPermissionProvider.hpp>
#include "session/TurnLedger.hpp"
namespace QodeAssist::Acp {
class ChatPermissionProvider : public LLMQore::Acp::AcpPermissionProvider
{
Q_OBJECT
public:
using RequestHandler = std::function<void(
const QString &requestId,
const LLMQore::Acp::ToolCall &toolCall,
const QList<LLMQore::Acp::PermissionOption> &options)>;
explicit ChatPermissionProvider(Session::TurnLedger *ledger, QObject *parent = nullptr);
void setRequestHandler(RequestHandler handler);
QFuture<LLMQore::Acp::RequestPermissionResult> requestPermission(
const QString &sessionId,
const LLMQore::Acp::ToolCall &toolCall,
const QList<LLMQore::Acp::PermissionOption> &options) override;
private:
RequestHandler m_requestHandler;
Session::TurnLedger *m_ledger = nullptr;
};
} // namespace QodeAssist::Acp