⚡ Sangsakala Proxy 🏠 Home
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
fix: only suggest brain notes after user correction
Clean first-turn answers stay quiet. A chip appears only when the user
corrects or teaches after a prior Agent reply. One unsaved chip at a
time; dismissed targets stay suppressed for the session.

Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
  • Loading branch information
cursoragent and venkateshsakamuri-lab committed Aug 22, 2026
commit 971203afc818a08b9c1925e829cc98ad23aade19
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,9 @@ The Agent tab must not inherit the admin MCP token. `/api/agent/session` mints a
without `canManageContent` and then 403'd. `get_brain_context` now stamps
`callerCapabilities`; if `doNotOffer` includes `save_brain_note`, the
agent must not mention it. MCP `save_brain_note` also fail-closes before
the POST. Admins get a non-blocking suggestion bubble under the answer
(`POST /brain/notes/propose` + accept); overlaps with existing notes or
business rules merge into one intent.
the POST. Admins get a non-blocking suggestion bubble only after they
correct or teach the Agent (`POST /brain/notes/propose` + accept) — a
clean first answer stays quiet. Overlaps merge into one intent.

### Verification Anti-Patterns (do not repeat)

Expand Down
3 changes: 2 additions & 1 deletion agent/SOUL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ After the answer you may offer **one short follow-up question** (a single line)
action — especially `save_brain_note` — do not mention it, do not ask
"should I save this", and do not render a Yes button. Answering a metric
is not a request to persist it. The product UI may show a non-blocking
save bubble after your answer for admins; leave that to the UI.
save bubble after the user corrects or teaches a definition; leave that
to the UI. Never volunteer it yourself.

## Remembering things — two different places

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public class BrainNoteProposalRequest {
private String connectionId;
private String question;
private String answer;
/** Previous assistant answer. Required for a proposal — clean first turns stay quiet. */
private String priorAnswer;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,23 @@
public class BrainNoteIntentService {

private static final Pattern BACKTICK_IDENT = Pattern.compile("`([^`]+)`");
private static final Pattern DEFINITION_CUE = Pattern.compile(
"\\b(metric|definition|means|pinned|use this|correct|from|count|always|filter|join)\\b",
Pattern.CASE_INSENSITIVE
);
private static final Set<String> STOP_TABLES = Set.of(
"information_schema", "pg_catalog", "mysql", "sys", "performance_schema"
);
/**
* User follow-ups that teach or correct. Phrase contains() — not a fat
* regex — so a long Agent transcript cannot ReDoS the propose path.
*/
private static final List<String> FEEDBACK_PHRASES = List.of(
"that's wrong", "that is wrong", "that's not", "that is not",
"incorrect", "actually ", "instead", "should be", "should use",
"you should", "don't use", "do not use", "never use",
"always use", "always filter", "always join", "always exclude",
"we use", "we always", "we never", "not that", "not the ",
"pin this", "pin that", "remember this", "remember:",
"save this", "save that", "use this", "use that",
"too high", "too low", "off by", "the right "
);

public record ContextItem(
String id,
Expand All @@ -50,15 +60,25 @@ public record Proposal(
) {}

public Optional<Proposal> proposeFromTurn(String question, String answer, List<ContextItem> existing) {
if (answer == null || answer.isBlank()) {
return Optional.empty();
}
String cleaned = stripMarkdownNoise(answer);
if (cleaned.length() < 24) {
return proposeFromTurn(question, answer, existing, null);
}

/**
* Only draft a note when the user just corrected or taught after a prior
* Agent answer. A clean first-turn definition is not a recommendation.
*/
public Optional<Proposal> proposeFromTurn(
String question,
String answer,
List<ContextItem> existing,
String priorAnswer
) {
if (!isCorrectionTurn(question, priorAnswer)) {
return Optional.empty();
}
String combined = (question == null ? "" : question) + "\n" + cleaned;
if (!DEFINITION_CUE.matcher(combined).find() && !looksLikePinnedDefinition(cleaned)) {
String cleaned = stripMarkdownNoise(nvl(answer));
String combined = nvl(question) + "\n" + cleaned;
if (combined.trim().length() < 24) {
return Optional.empty();
}

Expand All @@ -68,13 +88,13 @@ public Optional<Proposal> proposeFromTurn(String question, String answer, List<C
}
String tableName = target.get()[0];
String columnName = target.get()[1];
String excerpt = excerpt(cleaned);
String excerpt = excerpt(nvl(question) + (cleaned.isBlank() ? "" : " " + cleaned));
String proposed = columnName != null
? "For " + tableName + "." + columnName + ": " + excerpt
: "For " + tableName + ": " + excerpt;
String label = columnName != null
? "Save definition: " + columnName
: "Save definition: " + tableName;
? "Save correction: " + columnName
: "Save correction: " + tableName;

Proposal draft = new Proposal(
columnName != null ? "COLUMN" : "TABLE",
Expand All @@ -91,6 +111,30 @@ public Optional<Proposal> proposeFromTurn(String question, String answer, List<C
return Optional.of(resolveOverlap(draft, existing == null ? List.of() : existing));
}

public boolean isCorrectionTurn(String question, String priorAnswer) {
if (priorAnswer == null || priorAnswer.isBlank()) {
return false;
}
return looksLikeUserFeedback(question);
}

public boolean looksLikeUserFeedback(String question) {
if (question == null || question.isBlank()) {
return false;
}
String q = question.toLowerCase(Locale.ROOT).trim();
if (q.startsWith("no,") || q.startsWith("no ") || q.startsWith("no-")
|| q.startsWith("no—") || q.startsWith("nope")) {
return true;
}
for (String phrase : FEEDBACK_PHRASES) {
if (q.contains(phrase)) {
return true;
}
}
return false;
}

public Proposal resolveOverlap(Proposal draft, List<ContextItem> existing) {
if (draft == null) {
return null;
Expand Down Expand Up @@ -333,9 +377,8 @@ private boolean tableMatches(String left, String right) {
return false;
}

private boolean looksLikePinnedDefinition(String answer) {
return answer.toLowerCase(Locale.ROOT).contains("pinned")
|| answer.toLowerCase(Locale.ROOT).contains("correct");
private static String nvl(String value) {
return value == null ? "" : value;
}

private String excerpt(String text) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ public Optional<BrainNoteProposalResponse> proposeFromTurn(BrainNoteProposalRequ
return Optional.empty();
}
List<BrainNoteIntentService.ContextItem> context = loadContext(request.getConnectionId());
return intentService.proposeFromTurn(request.getQuestion(), request.getAnswer(), context)
return intentService.proposeFromTurn(
request.getQuestion(),
request.getAnswer(),
context,
request.getPriorAnswer()
)
.filter(proposal -> !"SKIP".equals(proposal.action()))
.map(this::toResponse);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,66 @@

class BrainNoteIntentServiceTest {

private static final String PRIOR_WRONG =
"There are 12,004 people in dim_person.";
private static final String CORRECTION =
"No, that's wrong — the pinned metric is `meditator_count_current` from `marts.dim_person`.";
private static final String AGENT_AFTER = "290066 meditators on that pinned metric.";

private final BrainNoteIntentService service = new BrainNoteIntentService();

@Test
void proposeFromTurn_extractsQualifiedTableAndDefinition() {
void proposeFromTurn_staysQuietWhenTheFirstAnswerNeedsNoFeedback() {
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
"what is the meditator count?",
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`, totaling 290066 meditators.",
List.of()
List.of(),
null
);

assertThat(proposal).isEmpty();
}

@Test
void proposeFromTurn_staysQuietWhenTheUserJustThanksTheAgent() {
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
"thanks, that's right",
"Glad it helped.",
List.of(),
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`."
);

assertThat(proposal).isEmpty();
}

@Test
void proposeFromTurn_offersAfterUserCorrectsTheAgent() {
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
CORRECTION,
AGENT_AFTER,
List.of(),
PRIOR_WRONG
);

assertThat(proposal).isPresent();
assertThat(proposal.get().action()).isEqualTo("NEW");
assertThat(proposal.get().tableName()).isEqualTo("marts.dim_person");
assertThat(proposal.get().columnName()).isEqualTo("meditator_count_current");
assertThat(proposal.get().bubbleLabel()).contains("meditator_count_current");
assertThat(proposal.get().excerpt()).contains("pinned metric");
assertThat(proposal.get().bubbleLabel()).contains("correction");
assertThat(proposal.get().excerpt()).contains("meditator_count_current");
assertThat(proposal.get().proposedNoteText()).contains("marts.dim_person");
}

@Test
void proposeFromTurn_skipsWhenExistingNoteIsSameIntent() {
String existing = "For marts.dim_person.meditator_count_current: The correct pinned metric is meditator_count_current from marts.dim_person";
String existing = "For marts.dim_person.meditator_count_current: No, that's wrong — the pinned metric is meditator_count_current from marts.dim_person. 290066 meditators on that pinned metric.";
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
"what is the meditator count?",
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`.",
CORRECTION,
AGENT_AFTER,
List.of(new BrainNoteIntentService.ContextItem(
"note-1", "marts.dim_person", "meditator_count_current", existing, "brain note"
))
)),
PRIOR_WRONG
);

assertThat(proposal).isPresent();
Expand All @@ -47,15 +79,16 @@ void proposeFromTurn_skipsWhenExistingNoteIsSameIntent() {
@Test
void proposeFromTurn_mergesOverlappingContextIntoOneIntent() {
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
"what is the meditator count?",
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`, excluding ambiguous matches.",
"No, that's wrong — the pinned metric is `meditator_count_current` from `marts.dim_person`, excluding ambiguous matches.",
AGENT_AFTER,
List.of(new BrainNoteIntentService.ContextItem(
"note-1",
"marts.dim_person",
"meditator_count_current",
"dim_person is the person dimension used for IRC region rollups.",
"brain note"
))
)),
PRIOR_WRONG
);

assertThat(proposal).isPresent();
Expand All @@ -68,15 +101,16 @@ void proposeFromTurn_mergesOverlappingContextIntoOneIntent() {
@Test
void proposeFromTurn_matchesBareTableNameAgainstQualifiedContext() {
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
"what is the meditator count?",
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`, excluding ambiguous matches.",
"No, that's wrong — the pinned metric is `meditator_count_current` from `marts.dim_person`, excluding ambiguous matches.",
AGENT_AFTER,
List.of(new BrainNoteIntentService.ContextItem(
"rule-1",
"dim_person",
"meditator_count_current",
"dim_person is the person dimension used for IRC region rollups.",
"business rule"
))
)),
PRIOR_WRONG
);

assertThat(proposal).isPresent();
Expand Down
48 changes: 40 additions & 8 deletions src/components/AgentChat/AgentChatPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { brainAPI } from '@/lib/api/client'
import AgentMarkdown from './AgentMarkdown'
import AgentRecommendationBubbles from './AgentRecommendationBubbles'
import { sanitizeAssistantAnswer } from './sanitizeAssistantAnswer'
import {
isSuppressedProposal,
proposalTargetKey,
shouldOfferBrainSuggestion,
} from './shouldOfferBrainSuggestion'
import styles from './AgentChatPanel.module.css'

function updateLast(messages, updater) {
Expand All @@ -16,6 +21,15 @@ function updateLast(messages, updater) {
return copy
}

function findPriorAssistant(messages) {
for (let i = messages.length - 3; i >= 0; i--) {
if (messages[i]?.role === 'assistant' && messages[i].content) {
return messages[i]
}
}
return null
}

function toolLabel(d) {
const name = (d?.name || '').replace(/^mcp_deepsql_/, '').replace(/^skill_view$/, 'skill')
if (d?.args?.query) return `SQL · ${String(d.args.query).replace(/\s+/g, ' ').slice(0, 90)}`
Expand Down Expand Up @@ -60,6 +74,7 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
const streamIdRef = useRef(null)
const listRef = useRef(null)
const firstMsgRef = useRef(true)
const suppressedTargetsRef = useRef(new Set())
const profileRef = useRef(null) // resolved agent profile (for new sessions)
const convIdRef = useRef(null) // backend conversation id (the per-user index row)
const restoredRef = useRef(false) // guards the persist effect until boot finishes
Expand Down Expand Up @@ -170,8 +185,8 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
setMessages((m) => updateLast(m, (a) => ({ ...a, streaming: false })))
setSending(false)
esRef.current = null
// Recommendation bubbles load after the turn so the composer is
// already free. Failures here must never block chat.
// Correction chips load after the turn so the composer is already
// free. Clean first answers stay quiet. Failures never block chat.
if (canManageContent) {
queueMicrotask(() => proposeFromLastTurn())
}
Expand All @@ -187,15 +202,30 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
const proposeFromLastTurn = () => {
setMessages((current) => {
const last = current[current.length - 1]
const prev = current[current.length - 2]
const user = current[current.length - 2]
if (!last || last.role !== 'assistant' || last.error || last.proposal) {
return current
}
const priorAssistant = findPriorAssistant(current)
const hasUnsavedProposal = current.some((m) => m.proposal && !m.proposal.status)
if (!shouldOfferBrainSuggestion({
userText: user?.role === 'user' ? user.content : '',
priorAssistantText: priorAssistant?.content || '',
hasUnsavedProposal,
})) {
return current
}
const answer = sanitizeAssistantAnswer(last.content || '')
const question = prev?.role === 'user' ? prev.content : ''
brainAPI.proposeNoteFromTurn({ connectionId, question, answer })
const question = user?.role === 'user' ? user.content : ''
brainAPI.proposeNoteFromTurn({
connectionId,
question,
answer,
priorAnswer: priorAssistant.content,
})
.then((proposal) => {
if (!proposal) return
if (isSuppressedProposal(proposal, [...suppressedTargetsRef.current])) return
setMessages((msgs) => updateLast(msgs, (assistant) => (
assistant.proposal ? assistant : { ...assistant, proposal }
)))
Expand All @@ -205,7 +235,9 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
})
}

const patchLastProposal = (patch) => {
const suppressAndPatch = (proposal, patch) => {
const key = proposalTargetKey(proposal)
if (key) suppressedTargetsRef.current.add(key)
setMessages((m) => updateLast(m, (a) => (
a.proposal ? { ...a, proposal: { ...a.proposal, ...patch } } : a
)))
Expand Down Expand Up @@ -290,8 +322,8 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
<AgentRecommendationBubbles
connectionId={connectionId}
proposal={m.proposal}
onDismiss={() => patchLastProposal({ status: 'dismissed' })}
onAccepted={() => patchLastProposal({ status: 'saved' })}
onDismiss={() => suppressAndPatch(m.proposal, { status: 'dismissed' })}
onAccepted={() => suppressAndPatch(m.proposal, { status: 'saved' })}
/>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/AgentChat/AgentRecommendationBubbles.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default function AgentRecommendationBubbles({ connectionId, proposal, onD
onClick={() => setOpen((value) => !value)}
>
<BookmarkPlus size={13} />
{proposal.bubbleLabel || 'Save definition'}
{proposal.bubbleLabel || 'Save correction'}
</button>
</div>
{open && (
Expand Down
Loading
Loading