Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

71 changes: 65 additions & 6 deletions src/AIAgentManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,8 @@
m_plan.goal = goal;
m_observations.clear();
m_failureCounts.clear();
m_docCapabilities = m_registry.routeByKeywords(goal);
m_intentTerms.clear();
const bool confident = routeGoal();
m_currentStep = -1; m_pendingIndex = -1; m_pendingReason.clear();
m_replans = 0; m_plannerRetries = 0; m_replanFailedIndex = -1;
m_cancelRequested = false; m_lastSummary.clear(); m_lastError.clear();
Expand All @@ -349,11 +350,57 @@
emit planChanged(); emit confirmationChanged();
trace(QStringLiteral("task"), goal);

SentryReporter::addBreadcrumb("ai.agent.plan", QStringLiteral("task started (%1 routed capabilities)").arg(m_docCapabilities.size()));
requestPlan();
SentryReporter::addBreadcrumb("ai.agent.plan", QStringLiteral("task started (%1 routed capabilities, %2)")
.arg(m_docCapabilities.size()).arg(confident ? "confident" : "asking intent"));
if (confident || !m_intentKeywordsEnabled) requestPlan();
else requestIntentKeywords();
return true;
}

bool AIAgentManager::routeGoal(const QStringList& extraTerms)
{
m_route = m_registry.route(m_plan.goal, extraTerms);
m_docCapabilities = m_route.capabilities;
QStringList top;
for (int i = 0; i < m_route.scores.size() && i < 6; ++i)
top << QStringLiteral("%1(%2)").arg(m_route.scores[i].name).arg(m_route.scores[i].score, 0, 'f', 1);
trace(QStringLiteral("route"), QStringLiteral("capabilities: %1\nlexicon added: %2\nextra terms: %3\ntop tools: %4\nconfident: %5")
.arg(m_docCapabilities.join(", "), m_route.expandedTerms.join(' '), extraTerms.join(' '),
top.join(", "), m_route.confident ? "yes" : "no"));
return m_route.confident;
}

// The tool docs are English; the request may be anything. One short
// generation turns "cria um dragão vermelho" into "generate mesh, prompt,
// material colour" and the lexical router does the rest.
void AIAgentManager::requestIntentKeywords()
{
setState(State::Planning);
m_awaiting = Awaiting::Intent;
const QString sys = QStringLiteral(

Check warning on line 380 in src/AIAgentManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace the redundant type with "auto".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaCxlxUYF5XL3k8zeTjw&open=AaCxlxUYF5XL3k8zeTjw&pullRequest=1054
"You translate a user's request for a 3D mesh editor into ENGLISH keywords naming the editor operations "
"and objects involved. Reply with 3 to 8 comma-separated English keywords and nothing else. "
"Examples: 'generate mesh, image prompt, vehicle' / 'material, colour, apply' / 'rig, skeleton, skin weights' / "
"'export, glb' / 'load mesh, file' / 'transform, scale' / 'animation, walk, motion' / 'scene info'.");
const QString user = QStringLiteral("Request: %1\nKeywords:").arg(m_plan.goal);
trace(QStringLiteral("intent request"), user);
m_planner->request(sys, user, 40);
}

void AIAgentManager::handleIntentReply(const QString& text)
{
QStringList terms;
static const QRegularExpression sep(R"([,;/\n]+)");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (const QString& t : text.split(sep, Qt::SkipEmptyParts)) {
const QString w = t.trimmed().toLower();
if (!w.isEmpty() && w.size() < 40 && !terms.contains(w)) terms << w;
}
m_intentTerms = terms.mid(0, 8);
SentryReporter::addBreadcrumb("ai.agent.plan", QStringLiteral("intent keywords: %1").arg(m_intentTerms.join(", ")));
routeGoal(m_intentTerms); // confident or not, we plan now — the planner can still ask for more
requestPlan();
}

void AIAgentManager::cancel()
{
if (!busy()) return;
Expand Down Expand Up @@ -430,7 +477,7 @@
"6. Tools described as acting on 'the selected mesh' (auto_rig, compute_skin_weights, validate_mesh, generate_lods, auto_uv_unwrap, retopologize, remove_skeleton, ...) use the CURRENT SELECTION: call select_entity {\"name\": ...} first unless the scene state already shows it selected.\n"
"7. Colours are [R,G,B] arrays in 0..1 (red = [1,0,0]). To recolour an object: create_material with a diffuse colour, then apply_material to the object.\n"
"8. Never invent file paths. generate_mesh_from_image takes EITHER the user's real image in image_path OR, when the user gave no image, a description of the object in prompt (text → image → 3D). To create something that does not exist yet, use prompt.\n")
.arg(m_registry.promptIndex(), capabilityIds.join(", "), m_registry.promptToolsFor(capabilityIds))
.arg(m_registry.promptIndex(), capabilityIds.join(", "), m_registry.promptToolsFor(capabilityIds, m_route))
.arg(m_limits.maxSteps);
const QString history = withHistory ? conversationContext() : QString();
if (!history.isEmpty()) s += QStringLiteral("\n%1\n").arg(history);
Expand Down Expand Up @@ -666,9 +713,10 @@
if (m_awaiting == Awaiting::None) return;
const Awaiting what = m_awaiting;
m_awaiting = Awaiting::None;
trace(what == Awaiting::Plan ? QStringLiteral("plan reply") : QStringLiteral("replan reply"), text);
trace(what == Awaiting::Plan ? QStringLiteral("plan reply") : (what == Awaiting::Intent ? QStringLiteral("intent reply") : QStringLiteral("replan reply")), text);

Check warning on line 716 in src/AIAgentManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested conditional operator into an independent statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaCxlxUYF5XL3k8zeTj0&open=AaCxlxUYF5XL3k8zeTj0&pullRequest=1054
if (m_cancelRequested) return;
if (what == Awaiting::Plan) handlePlanReply(text);
if (what == Awaiting::Intent) handleIntentReply(text);
else if (what == Awaiting::Plan) handlePlanReply(text);
else handleReplanReply(text);
}

Expand All @@ -684,6 +732,10 @@
if (m_docCapabilities.contains(id)) { if (alreadyHad) *alreadyHad << id; continue; }
m_docCapabilities << id;
added << id;
// explicitly requested → show it whole (drop any partial scoring so
// shortlist() falls back to "all tools")
for (int i = m_route.scores.size() - 1; i >= 0; --i)

Check warning on line 737 in src/AIAgentManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

implicit conversion loses integer precision: 'qsizetype' (aka 'long long') to 'int'

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaCxlxUYF5XL3k8zeTjk&open=AaCxlxUYF5XL3k8zeTjk&pullRequest=1054
if (m_route.scores[i].capability == id) m_route.scores.removeAt(i);
}
if (added.isEmpty()) return false;
SentryReporter::addBreadcrumb("ai.agent.plan", QStringLiteral("expanded capabilities: %1").arg(added.join(", ")));
Expand Down Expand Up @@ -801,6 +853,13 @@
void AIAgentManager::onPlannerFailed(const QString& error)
{
if (m_awaiting == Awaiting::None) return;
if (m_awaiting == Awaiting::Intent) {
// The keyword step is an optimisation: plan with the lexical route.
m_awaiting = Awaiting::None;
trace(QStringLiteral("intent failed"), error);
requestPlan();
return;
}
m_awaiting = Awaiting::None;
m_lastError = QStringLiteral("planner error: %1").arg(error);
say(QStringLiteral("The AI model failed: %1").arg(error));
Expand Down
17 changes: 16 additions & 1 deletion src/AIAgentManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ class AIAgentManager : public QObject
/// Takes ownership (parented). Production creates an LlmPlannerBackend lazily.
void setPlanner(AgentPlannerBackend* planner);
void setLimits(const AIAgent::Limits& limits) { m_limits = limits; }
/// When the lexical router is not confident, ask the LLM for English
/// operation keywords first (any-language requests). Default on; tests
/// with scripted planners switch it off unless they exercise it.
void setIntentKeywordsEnabled(bool on) { m_intentKeywordsEnabled = on; }
/// Undo stack the task's mutating steps are grouped on (default: UndoManager's).
void setUndoStack(QUndoStack* stack) { m_undoStack = stack; }
/// Extra text appended to every planner prompt (the scene summary). The
Expand Down Expand Up @@ -224,13 +228,21 @@ private slots:
explicit AIAgentManager(QObject* parent = nullptr);
~AIAgentManager() override;

enum class Awaiting { None, Plan, Replan };
enum class Awaiting { None, Intent, Plan, Replan };

void setState(AIAgent::State s);
void ensurePlanner();
void requestPlan(const QString& extraInstruction = QString());
void requestReplan(int failedIndex);
QString systemPrompt(const QStringList& capabilityIds, bool withHistory = true, int sceneChars = -1) const;
/// Route the goal (BM25 + lexicon, plus `extraTerms`) into
/// m_docCapabilities / m_route; returns the route's confidence.
bool routeGoal(const QStringList& extraTerms = {});
/// Ask the LLM for a few English operation keywords (any-language
/// requests, odd paraphrases) before routing — only when routing is
/// not confident.
void requestIntentKeywords();
void handleIntentReply(const QString& text);
/// systemPrompt() shrunk until `system + user + reply` fits the planner's
/// context window: history dropped first, then capabilities beyond the
/// most relevant, then the scene state, then the tool docs themselves.
Expand Down Expand Up @@ -262,6 +274,8 @@ private slots:
AIAgent::Plan m_plan;
QVector<AIAgent::Observation> m_observations;
QStringList m_docCapabilities; // capabilities whose docs the planner has seen
AIToolRouter::Route m_route; // per-request tool relevance (prompt pruning)
QStringList m_intentTerms; // English keywords the LLM added for routing
QHash<QString, int> m_failureCounts; // step signature → failures
int m_currentStep = -1;
int m_pendingIndex = -1;
Expand All @@ -272,6 +286,7 @@ private slots:
bool m_macroOpen = false;
bool m_cancelRequested = false;
bool m_trustedMode = false;
bool m_intentKeywordsEnabled = true;
QString m_lastSummary;
QString m_lastError;

Expand Down
44 changes: 43 additions & 1 deletion src/AIAgentManager_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,19 @@ class FakePlanner : public AgentPlannerBackend
void request(const QString& sys, const QString& user, int) override
{
systemPrompts << sys; userPrompts << user; pendingFlag = true;
if (replies.isEmpty()) { QTimer::singleShot(0, this, [this]() { pendingFlag = false; emit failed("no scripted reply"); }); return; }
if (failNextRequest || replies.isEmpty()) {
failNextRequest = false;
QTimer::singleShot(0, this, [this]() { pendingFlag = false; emit failed("no scripted reply"); });
return;
}
const QString r = replies.takeFirst();
QTimer::singleShot(0, this, [this, r]() { if (!stoppedFlag) { pendingFlag = false; emit completed(r); } });
}
void stop() override { stoppedFlag = true; QTimer::singleShot(0, this, [this]() { pendingFlag = false; emit stopped(); }); }
bool pending() const override { return pendingFlag; }
int contextTokens() const override { return ctxTokens; }
int ctxTokens = 0;
bool failNextRequest = false;
bool isAvailable = true;
bool stoppedFlag = false;
bool pendingFlag = false;
Expand Down Expand Up @@ -153,6 +158,7 @@ struct AgentFixture : public ::testing::Test {
m->setPlanner(planner);
m->setUndoStack(&undo);
m->setTrustedMode(false);
m->setIntentKeywordsEnabled(false); // the fake tool list has little vocabulary; the intent test turns it on
QObject::connect(m, &AIAgentManager::chatMessage, [this](const QString& role, const QString& text, bool) {
transcript << role + ": " + text;
});
Expand Down Expand Up @@ -459,6 +465,42 @@ TEST_F(AgentFixture, RepeatedRequestForAlreadyProvidedDocsIsNudgedNotFailed)
EXPECT_TRUE(m->lastError().contains("kept asking")) << m->lastError().toStdString();
}

// Global users: a request in any language first gets an English-keyword
// round from the LLM, and the lexical router routes on those. English
// requests with lexical signal skip that round.
TEST_F(AgentFixture, NonEnglishRequestGetsAnIntentKeywordRoundBeforePlanning)
{
m->setIntentKeywordsEnabled(true);
planner->replies << "generate mesh, image prompt, material colour"; // intent keywords
planner->replies << planJson({{"auto_rig", {{"template", "generic"}}}}); // then the plan
ASSERT_TRUE(m->startTask("cria um dragão vermelho")); // no English word → no lexical signal
ASSERT_TRUE(pumpToEnd(m));
ASSERT_EQ(planner->userPrompts.size(), 2);
EXPECT_TRUE(planner->userPrompts[0].contains("Keywords:")) << "first round asks for English keywords";
EXPECT_TRUE(planner->systemPrompts[0].contains("ENGLISH keywords"));
EXPECT_TRUE(planner->userPrompts[1].startsWith("Task:")) << "second round is the plan";
EXPECT_EQ(m->state(), State::Completed);

// an English request with lexical signal plans immediately
AIAgentManager::kill(); SetUp();
m->setIntentKeywordsEnabled(true);
planner->replies << planJson({{"auto_rig", {{"template", "humanoid"}}}});
ASSERT_TRUE(m->startTask("rig the wolf"));
ASSERT_TRUE(pumpToEnd(m));
ASSERT_EQ(planner->userPrompts.size(), 1);
EXPECT_TRUE(planner->userPrompts[0].startsWith("Task:"));
EXPECT_TRUE(planner->systemPrompts[0].contains("- auto_rig:"));

// if the keyword round fails, the lexical route is used and planning proceeds
AIAgentManager::kill(); SetUp();
m->setIntentKeywordsEnabled(true);
planner->failNextRequest = true; // the intent round errors out
planner->replies << planJson({{"get_scene_info", {}}}); // the plan round still has its reply
ASSERT_TRUE(m->startTask("何がありますか"));
ASSERT_TRUE(pumpToEnd(m));
EXPECT_EQ(m->state(), State::Completed) << m->lastSummary().toStdString();
}

TEST_F(AgentFixture, QuestionIsAnsweredWithoutRunningTools)
{
planner->replies << "{\"summary\": \"The scene holds one entity, Floor.\"}";
Expand Down
Loading
Loading