feat: 背景工作線(work 分類、子代理派工、完成後由角色回報)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
e99e7b1536
commit
8c25728206
+47
-8
@@ -243,9 +243,13 @@ async function quotaRemain($: Engine): Promise<number | undefined> {
|
||||
}
|
||||
|
||||
|
||||
async function pickRoute($: Engine, r: Route | undefined, o: Omit<Parameters<typeof choose>[1], 'quotaRemain'>) {
|
||||
return choose(r ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '', work: false }, { ...o, quotaRemain: await quotaRemain($) })
|
||||
}
|
||||
|
||||
// 讀 characters/index.json(外掛目錄內),把萃取產生的角色包註冊成可啟動的角色。
|
||||
async function loadPacks($: Engine): Promise<void> {
|
||||
const dir = new URL('../characters/', import.meta.url).pathname
|
||||
const dir = `${$.plugin.root}/characters/`
|
||||
try {
|
||||
if (!(await $.fs.exists(`${dir}index.json`))) return
|
||||
const index = JSON.parse(await $.fs.read(`${dir}index.json`)) as { packs: { work: string; workName: string; slug: string }[] }
|
||||
@@ -268,6 +272,8 @@ export const register: Register = (on, options) => {
|
||||
let active: string | undefined
|
||||
let toolRegistered = false
|
||||
let route: Route | undefined
|
||||
let delegating = false
|
||||
const jobs = new Map<string, string>()
|
||||
let lastPick: { model: ModelName; effort: Effort } | undefined
|
||||
let prefixed = false
|
||||
const holder: { sys?: Sys } = {}
|
||||
@@ -291,6 +297,13 @@ export const register: Register = (on, options) => {
|
||||
return out
|
||||
}
|
||||
|
||||
const routeOpts = () => ({
|
||||
candidates: candidates(),
|
||||
lockModel: lockOf<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']),
|
||||
lockEffort: lockOf<Effort>('lockEffort', ['low', 'medium', 'high']),
|
||||
downgradeAt: num('downgradeAt', 15),
|
||||
})
|
||||
|
||||
on('session.start', async ($, e, next) => {
|
||||
if (isEnabled) {
|
||||
await loadPacks($)
|
||||
@@ -373,6 +386,7 @@ export const register: Register = (on, options) => {
|
||||
} else {
|
||||
text += `\n${relationText(relationOf(c.timeline, 0, false))}`
|
||||
}
|
||||
if (delegating) text += '\n本輪的實際工作已交給背景工作線處理,你不要自己動手做。請只用角色口吻簡短告知已開始處理;完成後會有回報,屆時再向使用者說明結果。'
|
||||
return {
|
||||
...res,
|
||||
sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }],
|
||||
@@ -384,6 +398,9 @@ export const register: Register = (on, options) => {
|
||||
if (c && flag('todoRemind', true)) await remindDue($, c.name)
|
||||
if (!c || e.command || e.isMeta) return next(e)
|
||||
prefixed = false
|
||||
delegating = false
|
||||
// 工作線完成後由本外掛送回的回報:直接讓角色說明,不再分類或派工。
|
||||
if (e.origin?.kind === 'plugin') return next(e)
|
||||
const axes = applyOverrides(c.axes, overrides())
|
||||
if (routingOn()) {
|
||||
const res = await $.model
|
||||
@@ -391,6 +408,17 @@ export const register: Register = (on, options) => {
|
||||
.catch(() => undefined)
|
||||
route = parseRoute(res && res.isAnswered ? res.text : '')
|
||||
if (route.ack) $.ui.log(withPrefix(c.name, route.ack))
|
||||
if (route.work && flag('background', true)) {
|
||||
const pick = await pickRoute($, route, routeOpts())
|
||||
const started = await $.agent
|
||||
.spawn({ prompt: e.text, description: e.text.slice(0, 30), model: pick.model })
|
||||
.catch(() => undefined)
|
||||
if (started && !('deny' in started && started.deny)) {
|
||||
if (started.agentId) jobs.set(started.agentId, e.text)
|
||||
delegating = true
|
||||
lastPick = pick
|
||||
}
|
||||
}
|
||||
} else {
|
||||
route = undefined
|
||||
}
|
||||
@@ -408,17 +436,28 @@ export const register: Register = (on, options) => {
|
||||
on('turn.step', async function* ($, e, next) {
|
||||
const c = isEnabled ? findCharacter(active) : undefined
|
||||
if (!c || e.agentId || !routingOn()) return yield* next(e)
|
||||
const pick = choose(route ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' }, {
|
||||
candidates: candidates(),
|
||||
lockModel: lockOf<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']),
|
||||
lockEffort: lockOf<Effort>('lockEffort', ['low', 'medium', 'high']),
|
||||
quotaRemain: await quotaRemain($),
|
||||
downgradeAt: num('downgradeAt', 15),
|
||||
})
|
||||
// 對話線:工作已交給背景工作線時,主迴圈只負責以角色口吻回應,用最便宜的模型。
|
||||
const pick = delegating ? { model: 'haiku' as ModelName, effort: 'low' as Effort } : await pickRoute($, route, routeOpts())
|
||||
lastPick = { model: pick.model, effort: pick.effort }
|
||||
return yield* next({ ...e, model: pick.model, effort: pick.effort })
|
||||
})
|
||||
|
||||
on('turn.complete', async ($, e, next) => {
|
||||
const res = await next(e)
|
||||
const task = e.agentId ? jobs.get(e.agentId) : undefined
|
||||
const c = isEnabled ? findCharacter(active) : undefined
|
||||
if (e.agentId && task !== undefined) {
|
||||
jobs.delete(e.agentId)
|
||||
if (c) {
|
||||
const body = e.reason === 'answer' ? e.answer : `(工作未完成:${e.reason})`
|
||||
void $.prompt.submit({ text: `[工作線回報] 使用者的要求:${task}\n處理結果:\n${body}\n請用角色口吻向使用者說明結果,技術內容原樣保留。` }).catch(() => undefined)
|
||||
}
|
||||
} else if (!e.agentId) {
|
||||
delegating = false
|
||||
}
|
||||
return res
|
||||
})
|
||||
|
||||
on('session.append', { door: 'response' }, async ($, e, next) => {
|
||||
const c = isEnabled ? findCharacter(active) : undefined
|
||||
if (!c || e.agentId || !flag('rewrite', true) || e.message.type !== 'assistant') return next(e)
|
||||
|
||||
+4
-3
@@ -1,15 +1,15 @@
|
||||
export type ModelName = 'haiku' | 'sonnet' | 'opus'
|
||||
export type Effort = 'low' | 'medium' | 'high'
|
||||
export type Tone = 'neutral' | 'praise' | 'scold'
|
||||
export type Route = { difficulty: number; model: ModelName; effort: Effort; tone: Tone; ack: string }
|
||||
export type Route = { difficulty: number; model: ModelName; effort: Effort; tone: Tone; ack: string; work: boolean }
|
||||
|
||||
export const LADDER: readonly ModelName[] = ['haiku', 'sonnet', 'opus']
|
||||
|
||||
export const FALLBACK_ROUTE: Route = { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' }
|
||||
export const FALLBACK_ROUTE: Route = { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '', work: false }
|
||||
|
||||
export const CLASSIFY_PROMPT =
|
||||
'你是路由器。讀下面使用者輸入,只回傳一行 JSON:' +
|
||||
'{"difficulty":1-10,"model":"haiku|sonnet|opus","effort":"low|medium|high","tone":"neutral|praise|scold","ack":"角色以口吻立刻回應的一句話"}。' +
|
||||
'{"difficulty":1-10,"model":"haiku|sonnet|opus","effort":"low|medium|high","tone":"neutral|praise|scold","ack":"角色以口吻立刻回應的一句話","work":true|false}。work 為 true 表示需要動手做事(讀寫檔、執行指令、搜尋、改程式);純閒聊或一句話可答的問題為 false。' +
|
||||
'difficulty 低用 haiku,中用 sonnet,高或需深入推理用 opus;tone 是使用者對角色的態度(誇獎/感謝為 praise,責罵為 scold)。\n\n使用者輸入:\n'
|
||||
|
||||
const isModel = (v: unknown): v is ModelName => v === 'haiku' || v === 'sonnet' || v === 'opus'
|
||||
@@ -27,6 +27,7 @@ export const parseRoute = (text: string): Route => {
|
||||
effort: isEffort(j.effort) ? j.effort : FALLBACK_ROUTE.effort,
|
||||
tone: isTone(j.tone) ? j.tone : 'neutral',
|
||||
ack: typeof j.ack === 'string' ? j.ack.slice(0, 200) : '',
|
||||
work: j.work === true,
|
||||
}
|
||||
} catch {
|
||||
return FALLBACK_ROUTE
|
||||
|
||||
+30
-3
@@ -1,9 +1,9 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any }
|
||||
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[] }
|
||||
|
||||
const setup = (on: any): Ctl => {
|
||||
const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} } }
|
||||
const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} }, submitted: [] }
|
||||
mock.store(on)
|
||||
mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') })
|
||||
on('ui.log', () => ({ value: undefined }) as never)
|
||||
@@ -11,7 +11,7 @@ const setup = (on: any): Ctl => {
|
||||
on('session.start', () => ({ cwd: '/tmp' }) as never)
|
||||
on('command.run', () => ({ text: '' }))
|
||||
on('prompt.compose', () => ({ sections: [] }))
|
||||
on('prompt.submit', (_$: any, e: any) => ({ text: e.text }) as never)
|
||||
on('prompt.submit', (_$: any, e: any) => (ctl.submitted.push(e.text), { text: e.text }) as never)
|
||||
on('model.complete', (_$: any, e: any) => ({ value: ctl.complete(e) }) as never)
|
||||
on('session.usage', () => ({ value: ctl.usage }) as never)
|
||||
on('turn.step', async function* (_$: any, e: any) {
|
||||
@@ -142,3 +142,30 @@ test('未成年角色維持友情,即使好感度最高且開啟戀愛式依
|
||||
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
|
||||
expect((await personaText($)).includes('關係定位:友情')).toBe(true)
|
||||
})
|
||||
|
||||
const workRoute = (model: string, work: boolean) => ({ isAnswered: true, usage, text: `{"difficulty":8,"model":"${model}","effort":"high","tone":"neutral","ack":"好,我來處理。","work":${work}}` })
|
||||
|
||||
test('需要動手的要求交給背景工作線,對話線改用 Haiku', async ($, on) => {
|
||||
const ctl = setup(on)
|
||||
const spawned: any[] = []
|
||||
on('agent.spawn', (_$: any, e: any) => (spawned.push(e), { model: e.model, agentId: 'a1' }) as never)
|
||||
ctl.complete = () => workRoute('opus', true)
|
||||
await start($)
|
||||
await submit($, '幫我重構 src 目錄')
|
||||
expect(spawned.length).toBe(1)
|
||||
expect(spawned[0].model).toBe('opus')
|
||||
await step($, 'main')
|
||||
expect(ctl.seen.model).toBe('haiku')
|
||||
const sys = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
|
||||
expect(sys.sections.find((s: any) => s.id === 'jsc-anima:persona')?.text).toContain('背景工作線')
|
||||
})
|
||||
|
||||
test('work 為 false 或關閉背景工作線時不派工', async ($, on) => {
|
||||
const ctl = setup(on)
|
||||
let n = 0
|
||||
on('agent.spawn', () => (n++, { model: 'm', agentId: 'a' }) as never)
|
||||
ctl.complete = () => workRoute('sonnet', false)
|
||||
await start($)
|
||||
await submit($, '你好')
|
||||
expect(n).toBe(0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user