mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-07-24 20:20:59 -04:00
feat: add support acp in common chat (#369)
This commit is contained in:
1067
sources/acp/AcpChatBackend.cpp
Normal file
1067
sources/acp/AcpChatBackend.cpp
Normal file
File diff suppressed because it is too large
Load Diff
135
sources/acp/AcpChatBackend.hpp
Normal file
135
sources/acp/AcpChatBackend.hpp
Normal 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
|
||||
88
sources/acp/AgentBinding.cpp
Normal file
88
sources/acp/AgentBinding.cpp
Normal 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
|
||||
30
sources/acp/AgentBinding.hpp
Normal file
30
sources/acp/AgentBinding.hpp
Normal 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
|
||||
65
sources/acp/AgentCatalog.cpp
Normal file
65
sources/acp/AgentCatalog.cpp
Normal 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
|
||||
35
sources/acp/AgentCatalog.hpp
Normal file
35
sources/acp/AgentCatalog.hpp
Normal 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
|
||||
165
sources/acp/AgentCatalogStore.cpp
Normal file
165
sources/acp/AgentCatalogStore.cpp
Normal 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
|
||||
57
sources/acp/AgentCatalogStore.hpp
Normal file
57
sources/acp/AgentCatalogStore.hpp
Normal 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
|
||||
94
sources/acp/AgentDefinition.cpp
Normal file
94
sources/acp/AgentDefinition.cpp
Normal 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 ⌖
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
69
sources/acp/AgentDefinition.hpp
Normal file
69
sources/acp/AgentDefinition.hpp
Normal 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
|
||||
21
sources/acp/AgentKnowledgeService.hpp
Normal file
21
sources/acp/AgentKnowledgeService.hpp
Normal 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
185
sources/acp/AgentLaunch.cpp
Normal 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
|
||||
29
sources/acp/AgentLaunch.hpp
Normal file
29
sources/acp/AgentLaunch.hpp
Normal 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
|
||||
223
sources/acp/AgentRegistryParser.cpp
Normal file
223
sources/acp/AgentRegistryParser.cpp
Normal 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
|
||||
30
sources/acp/AgentRegistryParser.hpp
Normal file
30
sources/acp/AgentRegistryParser.hpp
Normal 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
|
||||
76
sources/acp/AgentSpawn.cpp
Normal file
76
sources/acp/AgentSpawn.cpp
Normal 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
|
||||
30
sources/acp/AgentSpawn.hpp
Normal file
30
sources/acp/AgentSpawn.hpp
Normal 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
180
sources/acp/AgentTester.cpp
Normal 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
|
||||
50
sources/acp/AgentTester.hpp
Normal file
50
sources/acp/AgentTester.hpp
Normal 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
|
||||
29
sources/acp/CMakeLists.txt
Normal file
29
sources/acp/CMakeLists.txt
Normal 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)
|
||||
56
sources/acp/ChatPermissionProvider.cpp
Normal file
56
sources/acp/ChatPermissionProvider.cpp
Normal 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
|
||||
41
sources/acp/ChatPermissionProvider.hpp
Normal file
41
sources/acp/ChatPermissionProvider.hpp
Normal 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
|
||||
Reference in New Issue
Block a user