Skip to content

Commit 2bcbe65

Browse files
committed
refactor(glue): drop "chat" from the Intent classifier
Removes the dead "chat" intent value, the isGreeting regex shortcut, and the GREETING_PATTERNS table. classifyIntent now decides only between agent | plan | clarify, and the rewritten system prompt tells the classifier that greetings, gratitude, and meta-questions all belong to the agent. parseIntent returns null on the legacy "chat" string so an old classifier model that still emits "chat" from training data defaults to "agent" via the existing fallback path — no silent stranding of real requests.
1 parent b1df6f3 commit 2bcbe65

3 files changed

Lines changed: 49 additions & 77 deletions

File tree

src/agent/router.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { routeUserInput } from "./router.js";
44

55
function mockGlue(replies: { intent?: string }): GlueClient {
66
const fast = vi.fn(async (_prompt: string, system?: string) => {
7-
if (system?.includes("classify")) return replies.intent ?? "agent";
7+
// Match the intent-classifier system prompt case-insensitively so
8+
// this mock keeps working if the prompt's capitalisation drifts.
9+
if (system && /classif/i.test(system)) return replies.intent ?? "agent";
810
return "agent";
911
});
1012
return { fast, smart: fast } as unknown as GlueClient;

src/glue/intent.test.ts

Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { classifyIntent, isGreeting, parseIntent } from "./intent.js";
2+
import { classifyIntent, parseIntent } from "./intent.js";
33

44
function fakeGlue(reply: string) {
55
return {
@@ -19,35 +19,20 @@ function failingGlue() {
1919
} as unknown as Parameters<typeof classifyIntent>[0];
2020
}
2121

22-
describe("isGreeting", () => {
23-
it("matches short greetings and acknowledgements", () => {
24-
expect(isGreeting("hi")).toBe(true);
25-
expect(isGreeting("hey")).toBe(true);
26-
expect(isGreeting("thanks")).toBe(true);
27-
expect(isGreeting("ty")).toBe(true);
28-
expect(isGreeting("ok")).toBe(true);
29-
expect(isGreeting("good morning")).toBe(true);
30-
});
31-
32-
it("rejects content that just starts with a greeting word", () => {
33-
expect(isGreeting("hi can you help me write a parser for json")).toBe(false);
34-
expect(isGreeting("thanks for your help with the auth refactor please review")).toBe(false);
35-
});
36-
37-
it("rejects unrelated short messages", () => {
38-
expect(isGreeting("debug the build")).toBe(false);
39-
expect(isGreeting("read main.go")).toBe(false);
40-
});
41-
});
42-
4322
describe("parseIntent", () => {
4423
it("parses bare words", () => {
4524
expect(parseIntent("agent")).toBe("agent");
4625
expect(parseIntent("plan")).toBe("plan");
47-
expect(parseIntent("chat")).toBe("chat");
4826
expect(parseIntent("clarify")).toBe("clarify");
4927
});
5028

29+
it("no longer recognizes the dead 'chat' intent", () => {
30+
// Chat was removed when we ripped out the glue intercept. A model
31+
// that still says "chat" gets null here so the caller defaults to
32+
// "agent" — which is what we want.
33+
expect(parseIntent("chat")).toBeNull();
34+
});
35+
5136
it("strips trailing punctuation", () => {
5237
expect(parseIntent("agent.")).toBe("agent");
5338
expect(parseIntent("plan!")).toBe("plan");
@@ -70,15 +55,12 @@ describe("classifyIntent", () => {
7055
await expect(classifyIntent(glue, " ", { hasHistory: true })).resolves.toBe("clarify");
7156
});
7257

73-
it("short-circuits greetings to 'chat' on continuation", async () => {
58+
it("routes greetings to the agent (the glue chat shortcut is gone)", async () => {
59+
// Previously "thanks!" would have skipped the LLM and shortcut to
60+
// "chat". Now the classifier is asked, and the prompt explicitly
61+
// tells it small talk is the agent's job — so we expect "agent".
7462
const glue = fakeGlue("agent");
75-
await expect(classifyIntent(glue, "thanks!", { hasHistory: true })).resolves.toBe("chat");
76-
expect(glue.fast).not.toHaveBeenCalled();
77-
});
78-
79-
it("does NOT short-circuit greetings on first message (no history)", async () => {
80-
const glue = fakeGlue("agent");
81-
await classifyIntent(glue, "hi", { hasHistory: false });
63+
await expect(classifyIntent(glue, "thanks!", { hasHistory: true })).resolves.toBe("agent");
8264
expect(glue.fast).toHaveBeenCalled();
8365
});
8466

@@ -98,4 +80,11 @@ describe("classifyIntent", () => {
9880
const glue = fakeGlue("idk lol");
9981
await expect(classifyIntent(glue, "do something", { hasHistory: true })).resolves.toBe("agent");
10082
});
83+
84+
it("falls back to 'agent' when a stale model returns the removed 'chat' intent", async () => {
85+
// Belt-and-braces: if a cheap classifier still emits "chat" from
86+
// training data, we don't want to silently strand the request.
87+
const glue = fakeGlue("chat");
88+
await expect(classifyIntent(glue, "what's up", { hasHistory: true })).resolves.toBe("agent");
89+
});
10190
});

src/glue/intent.ts

Lines changed: 26 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,42 @@
11
import type { GlueClient } from "./client.js";
22

3-
export type Intent = "agent" | "plan" | "chat" | "clarify";
4-
5-
const INTENT_SYSTEM_PROMPT = `You classify the user's message into ONE of these intents:
3+
/**
4+
* Intent classifications the router actually acts on. "chat" used to
5+
* be in this set but was removed when we ripped out the
6+
* cheap-model chat intercept — small talk now goes to the main agent.
7+
* "clarify" remains so we can wire an ask-back path later, but for
8+
* now it routes the same as "agent".
9+
*/
10+
export type Intent = "agent" | "plan" | "clarify";
611

7-
- agent: a coding/automation/build/fix request. Run-the-tools work. (Default for anything actionable.)
8-
- plan: a complex multi-step ask that benefits from upfront planning before code is written ("add auth to my Next app", "rewrite the worker as a state machine").
9-
- chat: small talk, gratitude, greetings, meta-questions about the agent itself.
10-
- clarify: ambiguous request where the agent couldn't act productively without more info.
12+
const INTENT_SYSTEM_PROMPT = `Classify the user's message into ONE of these intents. Output exactly one word, no prose.
1113
12-
Output exactly one word: agent | plan | chat | clarify. No prose.`;
14+
- agent: a coding, automation, build, fix, or run-the-tools request. Default for anything actionable, including small talk, greetings, gratitude, and meta-questions about the agent itself — the main agent handles those directly now.
15+
- plan: a complex multi-step ask that benefits from upfront planning before any code is written (e.g. "add auth to my Next app", "rewrite the worker as a state machine"). Reserve this for genuinely multi-file architectural work.
16+
- clarify: ambiguous or contradictory request where acting without more information would be a mistake.
1317
14-
const GREETING_PATTERNS = [
15-
/^h(i|ello|ey)\b/i,
16-
/^howdy\b/i,
17-
/^thanks?\b/i,
18-
/^thank you\b/i,
19-
/^ty\b/i,
20-
/^ok\b/i,
21-
/^okay\b/i,
22-
/^nice\b/i,
23-
/^cool\b/i,
24-
/^great\b/i,
25-
/^good (morning|afternoon|evening|night)\b/i,
26-
];
18+
Reply with exactly one of: agent | plan | clarify.`;
2719

2820
export interface ClassifyOptions {
2921
hasHistory: boolean;
3022
signal?: AbortSignal;
3123
}
3224

3325
/**
34-
* Decide whether the user's message should run the main agent (tool
35-
* work), enter plan mode (Q&A → reviewable plan), be answered as chat
36-
* (no agent run), or trigger a clarifying question.
37-
*
38-
* Fast-tracks:
39-
* - First message in a session: default to "agent" for anything
40-
* non-trivial (history-less context biases toward action).
41-
* - Continuations starting with greetings/thanks: short-circuit to
42-
* "chat" without an LLM call.
26+
* Decide whether the user's message should auto-trigger plan mode
27+
* (Q&A → reviewable plan → agent execution) or just fall through to
28+
* the main agent. Greetings and meta-questions used to get a
29+
* "chat" short-circuit; that's gone — the main agent answers those
30+
* itself now.
4331
*
44-
* On LLM error or unparseable output, defaults to "agent" — failing
45-
* open is preferable to silently dropping a real request.
32+
* On LLM error or unparseable output, defaults to "agent". Failing
33+
* open is preferable to silently swallowing a real request because a
34+
* cheap classifier model hiccuped.
4635
*/
4736
export async function classifyIntent(glue: GlueClient, message: string, opts: ClassifyOptions): Promise<Intent> {
4837
const trimmed = message.trim();
4938
if (!trimmed) return "clarify";
5039

51-
if (opts.hasHistory && isGreeting(trimmed)) {
52-
return "chat";
53-
}
54-
5540
let raw: string;
5641
try {
5742
raw = await glue.fast(trimmed, INTENT_SYSTEM_PROMPT, opts.signal);
@@ -62,24 +47,20 @@ export async function classifyIntent(glue: GlueClient, message: string, opts: Cl
6247
return parseIntent(raw) ?? "agent";
6348
}
6449

65-
export function isGreeting(message: string): boolean {
66-
if (message.split(/\s+/).length > 4) return false;
67-
return GREETING_PATTERNS.some((re) => re.test(message));
68-
}
69-
7050
export function parseIntent(raw: string): Intent | null {
7151
const word = raw
7252
.trim()
7353
.toLowerCase()
7454
.replace(/[^a-z]/g, "");
7555
if (word === "agent") return "agent";
7656
if (word === "plan") return "plan";
77-
if (word === "chat") return "chat";
7857
if (word === "clarify") return "clarify";
79-
// Be lenient: take the first matching token from a longer reply.
58+
// Lenient pass: pick the first matching token from a longer reply so a
59+
// chatty cheap model that ignores the "one word" instruction doesn't
60+
// silently fall through to the "agent" default.
8061
for (const candidate of raw.toLowerCase().split(/[^a-z]+/)) {
81-
if (candidate === "agent" || candidate === "plan" || candidate === "chat" || candidate === "clarify") {
82-
return candidate as Intent;
62+
if (candidate === "agent" || candidate === "plan" || candidate === "clarify") {
63+
return candidate;
8364
}
8465
}
8566
return null;

0 commit comments

Comments
 (0)