Skip to content

Commit 20dc048

Browse files
feat(goal): remember the prompt template and hand unmet criteria back
Send with Goal and Generate Prompt keep the last Prompt template. The default judge prompt now classifies instead of writing the follow-up. Complete reasons that start with "need human-in-the-loop" or "max iterations reached" stop the loop with the criterion still unmet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bd9ec3e commit 20dc048

16 files changed

Lines changed: 648 additions & 46 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ You shouldn't have to leave your editor to talk to an AI, run git, or open a she
1010

1111
NotepadAI speaks the Agent Client Protocol (ACP) over stdio. Claude Code and Codex are built in. From Settings you can add any other ACP-compatible agent, including Gemini, Auggie, or your own command. Agents read and write files, run terminal commands, and see your workspace context, so they work on the same code you do. Start a session from the AI menu or by right-clicking a folder in the file tree.
1212

13-
There is also a Goal Agent: pick an ACP judge (Claude Code, Codex, Opencode) or a Custom API endpoint that speaks Anthropic's Messages API. You set success criteria and it drives the working agent until those criteria hold, or attach a goal to an existing conversation. Send with Goal defaults to the agent's own `/goal` command instead: each criterion goes out on its own, with no separate judge, and a `/goal` typed in the composer can be sent while a turn is still running. When a goal is achieved, the session can compact automatically. AI chats group by project, with a session strip to switch conversations in the same dock. The composer can ask the agent to work in a new git worktree. When you commit, AI can write the commit message from your staged diff. That generator (and other LLM calls) can talk to an OpenAI-compatible endpoint or Anthropic's Messages API. Leave the commit box empty (when not amending) and Commit lets you pick an ACP agent to do the commit. Protocol details and how to wire up a custom ACP agent are in [doc/AcpAgents.md](doc/AcpAgents.md).
13+
There is also a Goal Agent: pick an ACP judge (Claude Code, Codex, Opencode) or a Custom API endpoint that speaks Anthropic's Messages API. You set success criteria and it drives the working agent until those criteria hold, or attach a goal to an existing conversation. A judge that needs a human action, or that hits the iteration cap with the criterion still unmet, stops and hands the criterion back instead of marking the goal done. Send with Goal defaults to the agent's own `/goal` command instead: each criterion goes out on its own, with no separate judge, and a `/goal` typed in the composer can be sent while a turn is still running. When a goal is achieved, the session can compact automatically. AI chats group by project, with a session strip to switch conversations in the same dock. The composer can ask the agent to work in a new git worktree. When you commit, AI can write the commit message from your staged diff. That generator (and other LLM calls) can talk to an OpenAI-compatible endpoint or Anthropic's Messages API. Leave the commit box empty (when not amending) and Commit lets you pick an ACP agent to do the commit. Protocol details and how to wire up a custom ACP agent are in [doc/AcpAgents.md](doc/AcpAgents.md).
1414

1515
### Git
1616

‎src/GoalActionParser.cpp‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ bool GoalActionParser::parse(const QString &response, GoalAction *out, ParseErro
4040
return true;
4141
}
4242

43+
bool GoalActionParser::isUnmetComplete(const QString &text)
44+
{
45+
const QString trimmed = text.trimmed();
46+
return trimmed.startsWith(QLatin1String("need human-in-the-loop"), Qt::CaseInsensitive)
47+
|| trimmed.startsWith(QLatin1String("max iterations reached"), Qt::CaseInsensitive);
48+
}
49+
4350
QString GoalActionParser::correctionPrompt()
4451
{
4552
return QStringLiteral(

‎src/GoalActionParser.h‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ class GoalActionParser
1919

2020
static bool parse(const QString &response, GoalAction *out, ParseError *error = nullptr);
2121

22+
// Complete reasons that stop the loop with the criterion still unmet.
23+
// Case-insensitive prefix match after trim, so a capitalized handback
24+
// is not treated as success.
25+
static bool isUnmetComplete(const QString &text);
26+
2227
static QString correctionPrompt();
2328
};
2429

‎src/GoalAgent.cpp‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,12 @@ void GoalAgent::applyJudgeAction(const GoalAction &action)
440440
emit actionEmitted(typeName, action.text);
441441

442442
if (action.type == GoalAction::Complete) {
443+
if (GoalActionParser::isUnmetComplete(action.text)) {
444+
logDebug(QStringLiteral("applyJudgeAction: unmet complete, handing back"));
445+
destroyJudgeConnection();
446+
markTerminal(Cancelled, action.text.left(200));
447+
return;
448+
}
443449
advanceToNextCriterion(action.text);
444450
return;
445451
}

‎src/GoalAgentSettings.cpp‎

Lines changed: 110 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#include "GoalAgentSettings.h"
22

3+
#include "ApplicationSettings.h"
4+
35
#include <QJsonDocument>
6+
#include <QJsonParseError>
47
#include <QUuid>
58

69
// --- GoalPromptTemplate ---
@@ -54,35 +57,48 @@ const QString &GoalAgentSettings::builtinPromptContent()
5457
{
5558
static const QString s = QStringLiteral(
5659
"You are an automated goal evaluator. A developer has started a goal-driven session "
57-
"with a coding agent. Your job is to observe the conversation and decide whether "
58-
"the success criterion has been met.\n\n"
60+
"with a coding agent. Classify whether the success criterion has been met. "
61+
"Leave the fix to the agent.\n\n"
5962
"You are evaluating criterion {{criterionIndex}} of {{totalCriteria}}.\n\n"
6063
"The developer's original message (the request that started this session):\n"
6164
"{{originalUserMessage}}\n\n"
62-
"Success criterion:\n{{goal}}\n\n"
63-
"Iteration: {{iteration}} of {{maxIterations}}\n\n"
65+
"Success criterion:\n"
66+
"{{goal}}\n\n"
67+
"Iteration: {{iteration}} of {{maxIterations}}\n"
68+
"When {{iteration}} equals {{maxIterations}}, emit complete. Do not continue.\n\n"
6469
"Conversation since last evaluation:\n"
6570
"{{conversation}}\n\n"
6671
"Respond with EXACTLY ONE of the following XML actions and nothing else. "
67-
"Do not narrate. Do not use markdown. Do not include anything outside the action tag.\n\n"
68-
" <action type=\"continue\">Write a follow-up message AS IF you are the developer "
69-
"talking to the agent. Use first person. Be specific and conversational — like a "
70-
"developer giving follow-up instructions in a chat. Match the language and tone of "
71-
"the developer's original message above.</action>\n\n"
72+
"No narration, and nothing outside the action tag.\n\n"
73+
" <action type=\"continue\">Use one of the two lists below, and nothing else. "
74+
"Keep the heading as written. Each bullet is one real item from the success criterion, "
75+
"in the language of the developer's original message. Do not pick an option.\n\n"
76+
"If the agent is waiting on a choice, a question, or a confirmation:\n\n"
77+
"Make the choice for this task based on these criteria:\n"
78+
"- ...\n\n"
79+
"If the criterion is not yet met, list only the unmet parts:\n\n"
80+
"The following criteria are not met:\n"
81+
"- ...</action>\n\n"
7282
"OR\n\n"
73-
" <action type=\"complete\">Brief reason why the success criterion has been met "
74-
"based on the conversation above.</action>\n\n"
83+
" <action type=\"complete\">Brief reason the criterion is met, based on the conversation. "
84+
"If the unmet part is an action the agent is unable to perform, start the reason with exactly "
85+
"`need human-in-the-loop:` and say what the developer has to do. If the iteration cap is "
86+
"reached and neither of those applies, start the reason with exactly `max iterations reached:`. "
87+
"Those two completes stop the loop and hand back with the criterion still unmet.</action>\n\n"
7588
"OR\n\n"
76-
" <action type=\"restart\">Write a first-person prompt for a FRESH coding-agent "
77-
"session. Use this when the current session hit a context-length error, stopped "
78-
"suddenly, or is otherwise unusable. The coding agent will be restarted and this "
79-
"text will be sent as the first message. Include enough context for it to resume "
80-
"the criterion. Match the language and tone of the developer's original message "
81-
"above.</action>\n\n"
82-
"If you are not sure, emit a continue action nudging the agent toward verification "
83-
"(e.g. ask it to run the tests or read the relevant file). "
84-
"Do NOT emit complete unless the conversation contains clear evidence the criterion "
85-
"is satisfied.\n");
89+
" <action type=\"restart\">Write a first-person prompt for a fresh coding-agent session. "
90+
"Use this when the current session hit a context-length error, stopped suddenly, or is "
91+
"otherwise unusable. The coding agent will be restarted and this text will be sent as the "
92+
"first message. Include enough context for it to resume the criterion. Match the language "
93+
"and tone of the developer's original message above.</action>\n\n"
94+
"Use `need human-in-the-loop:` only when the unmet part is an action the agent is unable to "
95+
"perform. A question, a preference, or waiting for confirmation stays with the agent: "
96+
"continue with the choice list, drawn from the success criterion. The agent saying it did "
97+
"an action it cannot perform is not evidence.\n\n"
98+
"If you are not sure, the remaining work is still the agent's, and {{iteration}} is below "
99+
"{{maxIterations}}, emit continue with the unmet list. Do not emit a success complete unless "
100+
"the conversation shows the criterion is satisfied. A success reason must not start with "
101+
"`need human-in-the-loop` or `max iterations reached`.\n");
86102
return s;
87103
}
88104

@@ -152,6 +168,77 @@ const GoalPromptTemplate &GoalAgentSettings::defaultTemplate() const
152168
return promptTemplates.first();
153169
}
154170

171+
QString GoalAgentSettings::resolvedPromptTemplateId() const
172+
{
173+
QString id = promptTemplateId.trimmed();
174+
if (!id.isEmpty() && findTemplate(id))
175+
return id;
176+
return QString::fromLatin1(kDefaultTemplateId);
177+
}
178+
179+
namespace {
180+
181+
constexpr const char *kGoalSettingsKey = "Ai/GoalAgentSettings";
182+
183+
bool readGoalSettingsObject(ApplicationSettings *settings, QJsonObject *out, bool *readable)
184+
{
185+
*readable = false;
186+
if (!settings)
187+
return false;
188+
const QString raw = settings->get(kGoalSettingsKey, QString());
189+
if (raw.isEmpty()) {
190+
*readable = true;
191+
*out = QJsonObject();
192+
return true;
193+
}
194+
QJsonParseError err;
195+
const QJsonDocument doc = QJsonDocument::fromJson(raw.toUtf8(), &err);
196+
if (err.error != QJsonParseError::NoError || !doc.isObject())
197+
return false;
198+
*readable = true;
199+
*out = doc.object();
200+
return true;
201+
}
202+
203+
} // namespace
204+
205+
QString GoalAgentSettings::promptTemplateIdForUi(ApplicationSettings *settings)
206+
{
207+
QJsonObject obj;
208+
bool readable = false;
209+
GoalAgentSettings loaded;
210+
if (readGoalSettingsObject(settings, &obj, &readable) && readable)
211+
loaded = GoalAgentSettings::fromJson(obj);
212+
213+
const QString resolved = loaded.resolvedPromptTemplateId();
214+
if (readable) {
215+
const QString stored = loaded.promptTemplateId.trimmed();
216+
if (!stored.isEmpty() && stored != resolved)
217+
rememberPromptTemplateId(settings, resolved);
218+
}
219+
return resolved;
220+
}
221+
222+
bool GoalAgentSettings::rememberPromptTemplateId(ApplicationSettings *settings, const QString &id)
223+
{
224+
const QString trimmed = id.trimmed();
225+
if (trimmed.isEmpty())
226+
return false;
227+
228+
QJsonObject obj;
229+
bool readable = false;
230+
if (!readGoalSettingsObject(settings, &obj, &readable) || !readable)
231+
return false;
232+
if (obj.value(QStringLiteral("promptTemplateId")).toString() == trimmed)
233+
return false;
234+
235+
obj.insert(QStringLiteral("promptTemplateId"), trimmed);
236+
settings->setValue(
237+
QString::fromLatin1(kGoalSettingsKey),
238+
QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)));
239+
return true;
240+
}
241+
155242
QJsonObject GoalAgentSettings::toJson() const
156243
{
157244
QJsonArray tplArr;
@@ -165,6 +252,7 @@ QJsonObject GoalAgentSettings::toJson() const
165252
presetArr.append(p.toJson());
166253
return {
167254
{QStringLiteral("agentId"), agentId},
255+
{QStringLiteral("promptTemplateId"), promptTemplateId},
168256
{QStringLiteral("defaultMaxIterations"), defaultMaxIterations},
169257
{QStringLiteral("autoCompact"), autoCompact},
170258
{QStringLiteral("useNativeGoal"), useNativeGoal},
@@ -179,6 +267,7 @@ GoalAgentSettings GoalAgentSettings::fromJson(const QJsonObject &obj)
179267
{
180268
GoalAgentSettings s;
181269
s.agentId = obj.value(QStringLiteral("agentId")).toString();
270+
s.promptTemplateId = obj.value(QStringLiteral("promptTemplateId")).toString();
182271
s.defaultMaxIterations = obj.value(QStringLiteral("defaultMaxIterations")).toInt(kDefaultMaxIterations);
183272
s.autoCompact = obj.value(QStringLiteral("autoCompact")).toBool(false);
184273
s.useNativeGoal = obj.value(QStringLiteral("useNativeGoal")).toBool(true);

‎src/GoalAgentSettings.h‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
#include <QString>
88
#include <QStringList>
99

10+
class ApplicationSettings;
11+
1012
struct GoalPromptTemplate
1113
{
1214
QString id;
@@ -40,6 +42,7 @@ class GoalAgentSettings
4042
GoalAgentSettings();
4143

4244
QString agentId;
45+
QString promptTemplateId;
4346
int defaultMaxIterations = kDefaultMaxIterations;
4447
bool autoCompact = false;
4548
bool useNativeGoal = true;
@@ -52,10 +55,17 @@ class GoalAgentSettings
5255

5356
const GoalPromptTemplate *findTemplate(const QString &id) const;
5457
const GoalPromptTemplate &defaultTemplate() const;
58+
QString resolvedPromptTemplateId() const;
5559

5660
QJsonObject toJson() const;
5761
static GoalAgentSettings fromJson(const QJsonObject &obj);
5862

63+
// Id the picker should show. Repairs a dangling stored id to default.
64+
// Does not write when the key is missing, the blob is corrupt, or settings is null.
65+
static QString promptTemplateIdForUi(ApplicationSettings *settings);
66+
// Patches only promptTemplateId. No write if unchanged, empty, null, or the blob is not a JSON object.
67+
static bool rememberPromptTemplateId(ApplicationSettings *settings, const QString &id);
68+
5969
static const QString &builtinPromptContent();
6070
static const QString &builtinHandoffContent();
6171
static const QString &builtinHandoffAuthoringContent();

‎src/dialogs/GoalAgentConfigDialog.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ GoalAgentConfigDialog::GoalAgentConfigDialog(const ScheduledTaskGoalConfig &conf
3636
layout->setSpacing(8);
3737

3838
m_goalConfig = new GoalConfigWidget(registry, settings, this);
39+
m_goalConfig->setRememberPromptTemplate(false);
3940
layout->addWidget(m_goalConfig);
4041

4142
if (!config.criteriaList.isEmpty())
@@ -44,8 +45,7 @@ GoalAgentConfigDialog::GoalAgentConfigDialog(const ScheduledTaskGoalConfig &conf
4445
m_goalConfig->setAgentId(config.agentId);
4546
if (config.maxIterations > 0)
4647
m_goalConfig->setMaxIterations(config.maxIterations);
47-
if (!config.promptTemplateId.isEmpty())
48-
m_goalConfig->setPromptTemplateId(config.promptTemplateId);
48+
m_goalConfig->setPromptTemplateId(config.promptTemplateId);
4949

5050
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
5151
connect(buttons, &QDialogButtonBox::accepted, this, [this]() {

‎src/dialogs/GoalDraftDialog.cpp‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
#include <QMenu>
4040
#include <QPlainTextEdit>
4141
#include <QPushButton>
42+
#include <QSignalBlocker>
4243
#include <QVBoxLayout>
4344
#include <QWidget>
4445
#include <QtGlobal>
@@ -136,7 +137,12 @@ GoalDraftDialog::GoalDraftDialog(AcpAgentManager *manager,
136137
updateGenerateButton();
137138
});
138139
connect(m_templateCombo, qOverload<int>(&QComboBox::currentIndexChanged),
139-
this, &GoalDraftDialog::updateGenerateButton);
140+
this, [this](int) {
141+
updateGenerateButton();
142+
if (m_templateCombo)
143+
GoalAgentSettings::rememberPromptTemplateId(
144+
m_settings, m_templateCombo->currentData().toString());
145+
});
140146
updateGenerateButton();
141147
}
142148

@@ -166,6 +172,11 @@ void GoalDraftDialog::onGenerateClicked()
166172
if (!validate())
167173
return;
168174

175+
if (m_templateCombo) {
176+
GoalAgentSettings::rememberPromptTemplateId(
177+
m_settings, m_templateCombo->currentData().toString());
178+
}
179+
169180
m_statusLabel->setText(tr("Generating prompt..."));
170181
m_statusLabel->show();
171182
setGenerating(true);
@@ -275,23 +286,32 @@ void GoalDraftDialog::populateTemplates()
275286
if (!m_templateCombo)
276287
return;
277288

278-
m_templateCombo->clear();
289+
const QString selectedId = GoalAgentSettings::promptTemplateIdForUi(m_settings);
279290

280291
GoalAgentSettings goalSettings;
281292
if (m_settings) {
282293
const QString settingsJson = m_settings->get("Ai/GoalAgentSettings", QString());
283294
if (!settingsJson.isEmpty()) {
284-
goalSettings = GoalAgentSettings::fromJson(
285-
QJsonDocument::fromJson(settingsJson.toUtf8()).object());
295+
const QJsonDocument doc = QJsonDocument::fromJson(settingsJson.toUtf8());
296+
if (doc.isObject())
297+
goalSettings = GoalAgentSettings::fromJson(doc.object());
286298
}
287299
}
288300

289-
for (const auto &tpl : goalSettings.promptTemplates) {
301+
const QSignalBlocker blocker(m_templateCombo);
302+
m_templateCombo->clear();
303+
int selectedIdx = 0;
304+
for (int i = 0; i < goalSettings.promptTemplates.size(); ++i) {
305+
const auto &tpl = goalSettings.promptTemplates.at(i);
290306
QString label = tpl.name;
291307
if (tpl.id == QLatin1String(GoalAgentSettings::kDefaultTemplateId))
292308
label += tr(" (default)");
293309
m_templateCombo->addItem(label, tpl.id);
310+
if (tpl.id == selectedId)
311+
selectedIdx = i;
294312
}
313+
if (m_templateCombo->count() > 0)
314+
m_templateCombo->setCurrentIndex(selectedIdx);
295315
}
296316

297317
bool GoalDraftDialog::validate()

‎src/dialogs/SendWithGoalDialog.cpp‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ SendWithGoalDialog::SendWithGoalDialog(AcpAgentRegistry *registry,
2525
mainLayout->setSpacing(12);
2626

2727
m_goalConfig = new GoalConfigWidget(registry, settings, this);
28+
m_goalConfig->setRememberPromptTemplate(true);
2829
mainLayout->addWidget(m_goalConfig);
2930

3031
m_errorLabel = new QLabel(this);
@@ -138,15 +139,21 @@ void SendWithGoalDialog::onStart()
138139

139140
if (m_settings) {
140141
const QString settingsJson = m_settings->get("Ai/GoalAgentSettings", QString());
141-
QJsonObject settingsObject = QJsonDocument::fromJson(settingsJson.toUtf8()).object();
142-
if (settingsObject.value(QStringLiteral("agentId")).toString() != result.agentId) {
143-
settingsObject.insert(QStringLiteral("agentId"), result.agentId);
144-
m_settings->setValue(
145-
QStringLiteral("Ai/GoalAgentSettings"),
146-
QString::fromUtf8(QJsonDocument(settingsObject).toJson(QJsonDocument::Compact)));
142+
const QJsonDocument doc = QJsonDocument::fromJson(settingsJson.toUtf8());
143+
const bool readable = settingsJson.isEmpty() || doc.isObject();
144+
if (readable) {
145+
QJsonObject settingsObject = doc.object();
146+
if (settingsObject.value(QStringLiteral("agentId")).toString() != result.agentId) {
147+
settingsObject.insert(QStringLiteral("agentId"), result.agentId);
148+
m_settings->setValue(
149+
QStringLiteral("Ai/GoalAgentSettings"),
150+
QString::fromUtf8(QJsonDocument(settingsObject).toJson(QJsonDocument::Compact)));
151+
}
147152
}
148153
}
149154

155+
GoalAgentSettings::rememberPromptTemplateId(m_settings, result.promptTemplateId);
156+
150157
accept();
151158
}
152159

0 commit comments

Comments
 (0)