Merge pull request #4084 from pizzav-xyz/feature/keyword-detector-enabled-expansions
feat(keyword-detector): add enabled_expansions config for allowlist control
This commit is contained in:
@@ -6026,6 +6026,20 @@
|
|||||||
"keyword_detector": {
|
"keyword_detector": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"enabled_expansions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"ultrawork",
|
||||||
|
"search",
|
||||||
|
"analyze",
|
||||||
|
"team",
|
||||||
|
"hyperplan",
|
||||||
|
"hyperplan-ultrawork"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"disabled_keywords": {
|
"disabled_keywords": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const KeywordTypeSchema = z.enum(["ultrawork", "search", "analyze", "team
|
|||||||
export type KeywordType = z.infer<typeof KeywordTypeSchema>
|
export type KeywordType = z.infer<typeof KeywordTypeSchema>
|
||||||
|
|
||||||
export const KeywordDetectorConfigSchema = z.object({
|
export const KeywordDetectorConfigSchema = z.object({
|
||||||
|
enabled_expansions: z.array(KeywordTypeSchema).optional(),
|
||||||
disabled_keywords: z.array(KeywordTypeSchema).optional(),
|
disabled_keywords: z.array(KeywordTypeSchema).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ export function detectKeywords(
|
|||||||
agentName?: string,
|
agentName?: string,
|
||||||
modelID?: string,
|
modelID?: string,
|
||||||
disabledKeywords?: ReadonlyArray<KeywordType>,
|
disabledKeywords?: ReadonlyArray<KeywordType>,
|
||||||
|
enabledExpansions?: ReadonlyArray<KeywordType>,
|
||||||
): string[] {
|
): string[] {
|
||||||
return detectKeywordsWithType(text, agentName, modelID, disabledKeywords).map(
|
return detectKeywordsWithType(text, agentName, modelID, disabledKeywords, enabledExpansions).map(
|
||||||
({ message }) => message,
|
({ message }) => message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -45,6 +46,7 @@ export function detectKeywordsWithType(
|
|||||||
agentName?: string,
|
agentName?: string,
|
||||||
modelID?: string,
|
modelID?: string,
|
||||||
disabledKeywords?: ReadonlyArray<KeywordType>,
|
disabledKeywords?: ReadonlyArray<KeywordType>,
|
||||||
|
enabledExpansions?: ReadonlyArray<KeywordType>,
|
||||||
): DetectedKeyword[] {
|
): DetectedKeyword[] {
|
||||||
const textWithoutCode = removeCodeBlocks(text)
|
const textWithoutCode = removeCodeBlocks(text)
|
||||||
const disabled = new Set<KeywordType>(disabledKeywords ?? [])
|
const disabled = new Set<KeywordType>(disabledKeywords ?? [])
|
||||||
@@ -52,12 +54,19 @@ export function detectKeywordsWithType(
|
|||||||
if (disabled.has("ultrawork") || disabled.has("hyperplan")) {
|
if (disabled.has("ultrawork") || disabled.has("hyperplan")) {
|
||||||
disabled.add("hyperplan-ultrawork")
|
disabled.add("hyperplan-ultrawork")
|
||||||
}
|
}
|
||||||
|
// Allowlist: if enabledExpansions is set, only those types fire
|
||||||
|
const allowlist = enabledExpansions ? new Set<KeywordType>(enabledExpansions) : null
|
||||||
return KEYWORD_DETECTORS.map(({ type, pattern, message }) => ({
|
return KEYWORD_DETECTORS.map(({ type, pattern, message }) => ({
|
||||||
matches: pattern.test(textWithoutCode),
|
matches: pattern.test(textWithoutCode),
|
||||||
type,
|
type,
|
||||||
message: resolveMessage(message, agentName, modelID),
|
message: resolveMessage(message, agentName, modelID),
|
||||||
}))
|
}))
|
||||||
.filter((result) => result.matches && !disabled.has(result.type))
|
.filter((result) => {
|
||||||
|
if (!result.matches) return false
|
||||||
|
if (allowlist && !allowlist.has(result.type)) return false
|
||||||
|
if (disabled.has(result.type)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
.map(({ type, message }) => ({ type, message }))
|
.map(({ type, message }) => ({ type, message }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function createKeywordDetectorHook(
|
|||||||
defaultMode?: DefaultModeConfig,
|
defaultMode?: DefaultModeConfig,
|
||||||
) {
|
) {
|
||||||
const disabledKeywords = config?.disabled_keywords
|
const disabledKeywords = config?.disabled_keywords
|
||||||
|
const enabledExpansions = config?.enabled_expansions
|
||||||
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
|
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
|
||||||
if (typeof message.variant === "string") {
|
if (typeof message.variant === "string") {
|
||||||
return message.variant
|
return message.variant
|
||||||
@@ -85,7 +86,7 @@ export function createKeywordDetectorHook(
|
|||||||
|
|
||||||
const cleanText = removeSystemReminders(promptText)
|
const cleanText = removeSystemReminders(promptText)
|
||||||
const modelID = input.model?.modelID
|
const modelID = input.model?.modelID
|
||||||
let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID, disabledKeywords)
|
let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID, disabledKeywords, enabledExpansions)
|
||||||
detectedKeywords = suppressComboStandalones(detectedKeywords)
|
detectedKeywords = suppressComboStandalones(detectedKeywords)
|
||||||
|
|
||||||
if (isPlannerAgent(currentAgent)) {
|
if (isPlannerAgent(currentAgent)) {
|
||||||
|
|||||||
@@ -181,6 +181,105 @@ describe("keyword-detector message transform", () => {
|
|||||||
expect(textPart?.text).toBe(peerText)
|
expect(textPart?.text).toBe(peerText)
|
||||||
expect(textPart?.text).not.toContain("[search-mode]")
|
expect(textPart?.text).not.toContain("[search-mode]")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should only fire ultrawork when enabled_expansions is set to [ultrawork]", async () => {
|
||||||
|
// given - allowlist configured to only enable ultrawork
|
||||||
|
const collector = new ContextCollector()
|
||||||
|
const hook = createKeywordDetectorHook(
|
||||||
|
createMockPluginInput(),
|
||||||
|
collector,
|
||||||
|
undefined,
|
||||||
|
{ enabled_expansions: ["ultrawork"] }
|
||||||
|
)
|
||||||
|
const sessionID = "enabled-expansions-ultrawork-only"
|
||||||
|
const output = {
|
||||||
|
message: {} as Record<string, unknown>,
|
||||||
|
parts: [{ type: "text", text: "search for the bug" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when - keyword detection runs with enabled_expansions restricting to ultrawork
|
||||||
|
await hook["chat.message"]({ sessionID }, output)
|
||||||
|
|
||||||
|
// then - search should be blocked by allowlist even though it matches
|
||||||
|
const textPart = output.parts.find(p => p.type === "text")
|
||||||
|
expect(textPart).toBeDefined()
|
||||||
|
expect(textPart!.text).toBe("search for the bug") // no search-mode injection
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should fire only allowed expansions from allowlist", async () => {
|
||||||
|
// given - allowlist configured to only enable analyze
|
||||||
|
const collector = new ContextCollector()
|
||||||
|
const hook = createKeywordDetectorHook(
|
||||||
|
createMockPluginInput(),
|
||||||
|
collector,
|
||||||
|
undefined,
|
||||||
|
{ enabled_expansions: ["analyze"] }
|
||||||
|
)
|
||||||
|
const sessionID = "enabled-expansions-analyze-only"
|
||||||
|
const output = {
|
||||||
|
message: {} as Record<string, unknown>,
|
||||||
|
parts: [{ type: "text", text: "investigate the bug" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when - keyword detection runs with enabled_expansions restricting to analyze
|
||||||
|
await hook["chat.message"]({ sessionID }, output)
|
||||||
|
|
||||||
|
// then - analyze should fire because it's in the allowlist
|
||||||
|
const textPart = output.parts.find(p => p.type === "text")
|
||||||
|
expect(textPart).toBeDefined()
|
||||||
|
expect(textPart!.text).toContain("[analyze-mode]")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should block all expansions when enabled_expansions is empty array", async () => {
|
||||||
|
// given - empty allowlist (effectively disable all)
|
||||||
|
const collector = new ContextCollector()
|
||||||
|
const hook = createKeywordDetectorHook(
|
||||||
|
createMockPluginInput(),
|
||||||
|
collector,
|
||||||
|
undefined,
|
||||||
|
{ enabled_expansions: [] }
|
||||||
|
)
|
||||||
|
const sessionID = "enabled-expansions-empty"
|
||||||
|
const output = {
|
||||||
|
message: {} as Record<string, unknown>,
|
||||||
|
parts: [{ type: "text", text: "ultrawork fix the bug" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when - keyword detection runs with empty enabled_expansions
|
||||||
|
await hook["chat.message"]({ sessionID }, output)
|
||||||
|
|
||||||
|
// then - ultrawork should not fire
|
||||||
|
const textPart = output.parts.find(p => p.type === "text")
|
||||||
|
expect(textPart).toBeDefined()
|
||||||
|
expect(textPart!.text).toBe("ultrawork fix the bug") // no mode injection
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should allow both allowlist and denylist to coexist", async () => {
|
||||||
|
// given - allowlist enables ultrawork and search, but denylist also blocks search
|
||||||
|
const collector = new ContextCollector()
|
||||||
|
const hook = createKeywordDetectorHook(
|
||||||
|
createMockPluginInput(),
|
||||||
|
collector,
|
||||||
|
undefined,
|
||||||
|
{ enabled_expansions: ["ultrawork", "search"], disabled_keywords: ["search"] }
|
||||||
|
)
|
||||||
|
const sessionID = "enabled-and-disabled-coexist"
|
||||||
|
const output = {
|
||||||
|
message: {} as Record<string, unknown>,
|
||||||
|
parts: [{ type: "text", text: "search for the bug" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when - both config fields are set
|
||||||
|
await hook["chat.message"]({ sessionID }, output)
|
||||||
|
|
||||||
|
// then - search blocked by both allowlist (allowed) AND denylist (blocked)
|
||||||
|
// Actually search is in enabled_expansions so it would fire, but disabled_keywords blocks it
|
||||||
|
// Wait, let me reconsider: with enabled_expansions=["ultrawork", "search"], search passes the allowlist.
|
||||||
|
// Then disabled_keywords=["search"] blocks it. So no injection.
|
||||||
|
const textPart = output.parts.find(p => p.type === "text")
|
||||||
|
expect(textPart).toBeDefined()
|
||||||
|
expect(textPart!.text).toBe("search for the bug")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("keyword-detector session filtering", () => {
|
describe("keyword-detector session filtering", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user