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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user