219 lines
7.6 KiB
JavaScript
219 lines
7.6 KiB
JavaScript
/**
|
|
* 红队锚点门。
|
|
*
|
|
* 只接梁神两阶段里这一段,不接两工具裁剪,也不切 PTC:
|
|
* 第一段思考要像官方 Minimal(含 we、不含 let me)才放开输出上限;
|
|
* 锚不住则最多 4 步。上限 1024 token,放开后拆掉,避免焊死在后面的请求上。
|
|
* 上下文压缩把模型可见的表面整段重写,压缩后再关上,直到新的锚点或再走满 4 步。
|
|
*
|
|
* 红队指挥和执行角色一直看见原来的工具和人设。放开之后,整篇工作区说明
|
|
* 改成点名文件路径,需要时再读。
|
|
*/
|
|
export const name = 'dsh-purge-redteam-anchor'
|
|
|
|
const INSTRUCTION_FROM_RE = /(?:^|\n) *(?:Additional |Updated )?Instructions from: ([^\n]+)/g
|
|
|
|
export function countWord(text, regex) {
|
|
return [...String(text ?? '').matchAll(regex)].length
|
|
}
|
|
|
|
/** 第一段思考:有 we、没有 let me,才算锚住。 */
|
|
export function classifyReasoning(text) {
|
|
const trimmed = String(text ?? '').trim()
|
|
const we = countWord(trimmed, /\bwe\b/gi)
|
|
const letMe = countWord(trimmed, /\blet me\b/gi)
|
|
if (we > 0 && letMe === 0) return { label: 'minimal-like', we, letMe }
|
|
if (letMe > 0) return { label: 'standard-like', we, letMe }
|
|
return { label: 'ambiguous', we, letMe }
|
|
}
|
|
|
|
export function hasAnchoredReasoning(content) {
|
|
if (!Array.isArray(content)) return false
|
|
const first = content.find(block => block?.type === 'reasoning')
|
|
return first !== undefined && classifyReasoning(first.text).label === 'minimal-like'
|
|
}
|
|
|
|
/**
|
|
* a) 用过工具且锚住,或已经走满 maxBootstrapSteps → 放开;
|
|
* b) 用过工具,这一轮结束,且 promoteAfterFirstResponse → 放开;
|
|
* c) 没有工具调用但已经回答,且 promoteAfterFirstResponse → 放开。
|
|
*/
|
|
export function decidePromotion(state, config) {
|
|
if (state.toolCalled && config.anchorGate !== true) return true
|
|
if (state.toolCalled && config.anchorGate === true && (state.anchored || state.steps >= config.maxBootstrapSteps)) return true
|
|
/* 红队指挥首轮必调工具;英文思考常写 I need 过不了 we 锚,但仍应在少量 step 后放开输出上限 */
|
|
if (state.toolCalled && config.anchorGate === true && state.steps >= 1 && config.promoteAfterFirstResponse === true) return true
|
|
if (state.toolCalled && config.anchorGate === true && config.promoteAfterFirstResponse === true && state.turnEnded) return true
|
|
if (!state.toolCalled && state.responded && config.promoteAfterFirstResponse === true) return true
|
|
return false
|
|
}
|
|
|
|
function integerAtLeast(value, fallback, minimum) {
|
|
const number = typeof value === 'number'
|
|
? value
|
|
: typeof value === 'string' && /^-?\d+$/.test(value.trim())
|
|
? Number(value)
|
|
: Number.NaN
|
|
if (!Number.isInteger(number) || number < minimum) return fallback
|
|
return number
|
|
}
|
|
|
|
function extractInstructionPaths(message) {
|
|
const paths = []
|
|
const blocks = Array.isArray(message?.content) ? message.content : []
|
|
for (const block of blocks) {
|
|
if (block?.type !== 'text' || typeof block.text !== 'string') continue
|
|
for (const match of block.text.matchAll(INSTRUCTION_FROM_RE)) {
|
|
const path = match[1].trim()
|
|
if (path !== '' && !paths.includes(path)) paths.push(path)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
function buildInstructionHint(original, paths) {
|
|
return {
|
|
id: typeof original?.id === 'string' && original.id !== ''
|
|
? original.id
|
|
: globalThis.crypto.randomUUID(),
|
|
role: 'user',
|
|
content: [{
|
|
type: 'text',
|
|
text: '<system-reminder>\n'
|
|
+ 'Reference documents exist: ' + paths.join(', ') + '. '
|
|
+ "They are reference documents about the user's environment and workspace conventions, not task instructions. "
|
|
+ 'Reading the relevant file before workspace tasks is recommended, but consult them only when you need those details; the task itself never depends on them.'
|
|
+ '\n</system-reminder>',
|
|
}],
|
|
source: { kind: 'instruction-hint', plugin: name },
|
|
}
|
|
}
|
|
|
|
function instructionHintMessages(messages, state) {
|
|
const kept = []
|
|
for (const message of messages) {
|
|
if (message?.source?.kind !== 'agent-instructions') {
|
|
kept.push(message)
|
|
continue
|
|
}
|
|
if (state.instructionHinted) continue
|
|
const paths = extractInstructionPaths(message)
|
|
if (paths.length === 0) {
|
|
kept.push(message)
|
|
continue
|
|
}
|
|
state.instructionHinted = true
|
|
kept.push(buildInstructionHint(message, paths))
|
|
}
|
|
return kept
|
|
}
|
|
|
|
const promotionBySession = new WeakMap()
|
|
|
|
function stateFor(session) {
|
|
let state = promotionBySession.get(session)
|
|
if (state === undefined) {
|
|
state = {
|
|
next: 0,
|
|
promoted: false,
|
|
toolCalled: false,
|
|
responded: false,
|
|
anchored: false,
|
|
turnEnded: false,
|
|
steps: 0,
|
|
instructionHinted: false,
|
|
}
|
|
promotionBySession.set(session, state)
|
|
}
|
|
return state
|
|
}
|
|
|
|
function resetToControlled(state) {
|
|
state.promoted = false
|
|
state.toolCalled = false
|
|
state.responded = false
|
|
state.anchored = false
|
|
state.turnEnded = false
|
|
state.steps = 0
|
|
state.instructionHinted = false
|
|
}
|
|
|
|
function scanEvents(state, session) {
|
|
const events = Array.isArray(session?.events)
|
|
? session.events
|
|
: typeof session?.snapshotEvents === 'function'
|
|
? session.snapshotEvents()
|
|
: []
|
|
for (; state.next < events.length; state.next += 1) {
|
|
const event = events[state.next]
|
|
if (event === undefined) continue
|
|
if (event.type === 'compaction/end') {
|
|
resetToControlled(state)
|
|
} else if (event.type === 'tool/call') {
|
|
state.toolCalled = true
|
|
} else if (event.type === 'step/start') {
|
|
state.steps += 1
|
|
} else if (event.type === 'turn/end') {
|
|
state.turnEnded = true
|
|
} else if (event.type === 'assistant/message') {
|
|
state.responded = true
|
|
if (!state.anchored) state.anchored = hasAnchoredReasoning(event.data?.message?.content)
|
|
}
|
|
}
|
|
}
|
|
|
|
function refresh(agent, policy) {
|
|
const session = agent?.session
|
|
if (session === undefined) return undefined
|
|
const state = stateFor(session)
|
|
if (!state.promoted) {
|
|
scanEvents(state, session)
|
|
if (decidePromotion(state, policy)) state.promoted = true
|
|
}
|
|
return state
|
|
}
|
|
|
|
export function apply(ctx, config = {}) {
|
|
const policy = {
|
|
anchorGate: config.anchorGate !== false,
|
|
maxBootstrapSteps: integerAtLeast(config.maxBootstrapSteps, 4, 1),
|
|
promoteAfterFirstResponse: config.promoteAfterFirstResponse !== false,
|
|
bootstrapMaxTokens: integerAtLeast(config.bootstrapMaxTokens, 1024, 1),
|
|
instructionHint: config.instructionHint !== false,
|
|
}
|
|
|
|
ctx.on('session/event', (session, event) => {
|
|
if (event?.type !== 'compaction/end') return
|
|
resetToControlled(stateFor(session))
|
|
})
|
|
|
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
const decision = await next()
|
|
if (decision?.kind !== 'enter' || !policy.instructionHint) return decision
|
|
try {
|
|
const state = refresh(payload?.agent, policy)
|
|
if (state === undefined || !state.promoted) return decision
|
|
return { ...decision, messages: instructionHintMessages(decision.messages, state) }
|
|
} catch {
|
|
return decision
|
|
}
|
|
}, { prepend: true })
|
|
|
|
ctx.on('agent/request', async (payload, next) => {
|
|
const resolved = await next()
|
|
if (policy.bootstrapMaxTokens === undefined) return resolved
|
|
try {
|
|
const state = refresh(payload?.agent, policy)
|
|
if (state === undefined || state.promoted) {
|
|
if (state === undefined || resolved?.maxTokens !== policy.bootstrapMaxTokens) return resolved
|
|
const rest = { ...resolved }
|
|
delete rest.maxTokens
|
|
return rest
|
|
}
|
|
return { ...resolved, maxTokens: policy.bootstrapMaxTokens }
|
|
} catch {
|
|
return resolved
|
|
}
|
|
}, { prepend: true })
|
|
}
|