feat: 背景工作線(work 分類、子代理派工、完成後由角色回報)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-10-15 08:32:07 +00:00
co-authored by Claude Sonnet 5.5
parent e99e7b1536
commit 8c25728206
4 changed files with 87 additions and 14 deletions
+6
View File
@@ -62,6 +62,12 @@
"description": "輸入先由 Haiku 一次分類,再依難度選模型與思考程度", "description": "輸入先由 Haiku 一次分類,再依難度選模型與思考程度",
"default": true "default": true
}, },
"background": {
"type": "boolean",
"title": "背景工作線",
"description": "需要動手的要求交給背景子代理處理,對話線立刻回應、完成後由角色回報",
"default": true
},
"candidates": { "candidates": {
"type": "string", "type": "string",
"title": "候選模型", "title": "候選模型",
+47 -8
View File
@@ -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(外掛目錄內),把萃取產生的角色包註冊成可啟動的角色。 // 讀 characters/index.json(外掛目錄內),把萃取產生的角色包註冊成可啟動的角色。
async function loadPacks($: Engine): Promise<void> { async function loadPacks($: Engine): Promise<void> {
const dir = new URL('../characters/', import.meta.url).pathname const dir = `${$.plugin.root}/characters/`
try { try {
if (!(await $.fs.exists(`${dir}index.json`))) return 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 }[] } 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 active: string | undefined
let toolRegistered = false let toolRegistered = false
let route: Route | undefined let route: Route | undefined
let delegating = false
const jobs = new Map<string, string>()
let lastPick: { model: ModelName; effort: Effort } | undefined let lastPick: { model: ModelName; effort: Effort } | undefined
let prefixed = false let prefixed = false
const holder: { sys?: Sys } = {} const holder: { sys?: Sys } = {}
@@ -291,6 +297,13 @@ export const register: Register = (on, options) => {
return out 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) => { on('session.start', async ($, e, next) => {
if (isEnabled) { if (isEnabled) {
await loadPacks($) await loadPacks($)
@@ -373,6 +386,7 @@ export const register: Register = (on, options) => {
} else { } else {
text += `\n${relationText(relationOf(c.timeline, 0, false))}` text += `\n${relationText(relationOf(c.timeline, 0, false))}`
} }
if (delegating) text += '\n本輪的實際工作已交給背景工作線處理,你不要自己動手做。請只用角色口吻簡短告知已開始處理;完成後會有回報,屆時再向使用者說明結果。'
return { return {
...res, ...res,
sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }], 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 && flag('todoRemind', true)) await remindDue($, c.name)
if (!c || e.command || e.isMeta) return next(e) if (!c || e.command || e.isMeta) return next(e)
prefixed = false prefixed = false
delegating = false
// 工作線完成後由本外掛送回的回報:直接讓角色說明,不再分類或派工。
if (e.origin?.kind === 'plugin') return next(e)
const axes = applyOverrides(c.axes, overrides()) const axes = applyOverrides(c.axes, overrides())
if (routingOn()) { if (routingOn()) {
const res = await $.model const res = await $.model
@@ -391,6 +408,17 @@ export const register: Register = (on, options) => {
.catch(() => undefined) .catch(() => undefined)
route = parseRoute(res && res.isAnswered ? res.text : '') route = parseRoute(res && res.isAnswered ? res.text : '')
if (route.ack) $.ui.log(withPrefix(c.name, route.ack)) 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 { } else {
route = undefined route = undefined
} }
@@ -408,17 +436,28 @@ export const register: Register = (on, options) => {
on('turn.step', async function* ($, e, next) { on('turn.step', async function* ($, e, next) {
const c = isEnabled ? findCharacter(active) : undefined const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.agentId || !routingOn()) return yield* next(e) if (!c || e.agentId || !routingOn()) return yield* next(e)
const pick = choose(route ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' }, { // 對話線:工作已交給背景工作線時,主迴圈只負責以角色口吻回應,用最便宜的模型。
candidates: candidates(), const pick = delegating ? { model: 'haiku' as ModelName, effort: 'low' as Effort } : await pickRoute($, route, routeOpts())
lockModel: lockOf<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']),
lockEffort: lockOf<Effort>('lockEffort', ['low', 'medium', 'high']),
quotaRemain: await quotaRemain($),
downgradeAt: num('downgradeAt', 15),
})
lastPick = { model: pick.model, effort: pick.effort } lastPick = { model: pick.model, effort: pick.effort }
return yield* next({ ...e, 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) => { on('session.append', { door: 'response' }, async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.agentId || !flag('rewrite', true) || e.message.type !== 'assistant') return next(e) if (!c || e.agentId || !flag('rewrite', true) || e.message.type !== 'assistant') return next(e)
+4 -3
View File
@@ -1,15 +1,15 @@
export type ModelName = 'haiku' | 'sonnet' | 'opus' export type ModelName = 'haiku' | 'sonnet' | 'opus'
export type Effort = 'low' | 'medium' | 'high' export type Effort = 'low' | 'medium' | 'high'
export type Tone = 'neutral' | 'praise' | 'scold' 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 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 = export const CLASSIFY_PROMPT =
'你是路由器。讀下面使用者輸入,只回傳一行 JSON:' + '你是路由器。讀下面使用者輸入,只回傳一行 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' 'difficulty 低用 haiku,中用 sonnet,高或需深入推理用 opus;tone 是使用者對角色的態度(誇獎/感謝為 praise,責罵為 scold)。\n\n使用者輸入:\n'
const isModel = (v: unknown): v is ModelName => v === 'haiku' || v === 'sonnet' || v === 'opus' 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, effort: isEffort(j.effort) ? j.effort : FALLBACK_ROUTE.effort,
tone: isTone(j.tone) ? j.tone : 'neutral', tone: isTone(j.tone) ? j.tone : 'neutral',
ack: typeof j.ack === 'string' ? j.ack.slice(0, 200) : '', ack: typeof j.ack === 'string' ? j.ack.slice(0, 200) : '',
work: j.work === true,
} }
} catch { } catch {
return FALLBACK_ROUTE return FALLBACK_ROUTE
+30 -3
View File
@@ -1,9 +1,9 @@
import { expect, mock, test } from 'claude-code/testing' 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 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.store(on)
mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') }) mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') })
on('ui.log', () => ({ value: undefined }) as never) on('ui.log', () => ({ value: undefined }) as never)
@@ -11,7 +11,7 @@ const setup = (on: any): Ctl => {
on('session.start', () => ({ cwd: '/tmp' }) as never) on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' })) on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] })) 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('model.complete', (_$: any, e: any) => ({ value: ctl.complete(e) }) as never)
on('session.usage', () => ({ value: ctl.usage }) as never) on('session.usage', () => ({ value: ctl.usage }) as never)
on('turn.step', async function* (_$: any, e: any) { on('turn.step', async function* (_$: any, e: any) {
@@ -142,3 +142,30 @@ test('未成年角色維持友情,即使好感度最高且開啟戀愛式依
await $.command.run({ command: 'anima-affinity', args: '100' } as never) await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('關係定位:友情')).toBe(true) 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)
})