Skip to content

Commit e3b15d0

Browse files
universe-hcyclaude
andcommitted
fix: 长会话任务锚点重注入 + 过早 end_turn 提示,缓解任务跑偏
长会话反复无锚点催促("继续/接着做")会导致任务锚点衰减、模型过早 end_turn 打转。新增两层客户端护栏(均 token 友好): - 层3 触发式锚点重注入:仅当最新用户消息是短的无锚点催促且存在未完成 任务时,注入一次 ephemeral <system-reminder> 未完成任务清单(工具循环 内不重复注入,无每轮成本)。 - 层2 过早 end_turn 提示:turn 自然完成且仍有 in_progress 任务时,显示 0-token 本地 system 提示(不发给模型)。 Co-Authored-By: claude-opus-4-8[1m] <noreply@anthropic.com>
1 parent 34b3dc9 commit e3b15d0

4 files changed

Lines changed: 280 additions & 0 deletions

File tree

src/screens/REPL.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3522,6 +3522,27 @@ export function REPL({
35223522
onQueryEvent(event);
35233523
}
35243524

3525+
// ── Unfinished-task notice ──
3526+
// When a turn completes naturally (not interrupted) but a task the model
3527+
// itself marked in_progress is still unfinished, surface a display-only
3528+
// local notice so the user doesn't have to blindly nudge "继续". This is a
3529+
// 'system' message: never sent to the model (0 token cost). Best-effort —
3530+
// a task-read failure must not affect turn teardown.
3531+
if (!abortController.signal.aborted) {
3532+
try {
3533+
const [{ listTasks, getTaskListId }, { buildUnfinishedTaskNotice }] = await Promise.all([
3534+
import('src/utils/tasks.js'),
3535+
import('src/services/api/taskAnchorReminder.js'),
3536+
]);
3537+
const notice = buildUnfinishedTaskNotice(await listTasks(getTaskListId()));
3538+
if (notice) {
3539+
setMessages(prev => [...prev, createSystemMessage(notice, 'warning')]);
3540+
}
3541+
} catch {
3542+
// Ignore — the notice is a convenience, not part of the turn contract.
3543+
}
3544+
}
3545+
35253546
if (feature('BUDDY') && typeof (globalThis as Record<string, unknown>).fireCompanionObserver === 'function') {
35263547
const _fireCompanionObserver = (globalThis as Record<string, unknown>).fireCompanionObserver as (
35273548
msgs: unknown,
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import type { Task } from '../../../utils/tasks.js'
3+
import {
4+
buildTaskAnchorReminder,
5+
buildUnfinishedTaskNotice,
6+
isAnchorlessNudge,
7+
} from '../taskAnchorReminder.js'
8+
9+
function task(partial: Partial<Task> & Pick<Task, 'id' | 'status'>): Task {
10+
return {
11+
subject: `Task ${partial.id}`,
12+
description: '',
13+
blocks: [],
14+
blockedBy: [],
15+
...partial,
16+
}
17+
}
18+
19+
describe('isAnchorlessNudge', () => {
20+
test('matches short Chinese keep-going nudges', () => {
21+
expect(isAnchorlessNudge('继续执行后续的工作')).toBe(true)
22+
expect(isAnchorlessNudge('继续完成当前任务')).toBe(true)
23+
expect(isAnchorlessNudge('自动执行后续的所有流程')).toBe(true)
24+
expect(isAnchorlessNudge('接着做')).toBe(true)
25+
})
26+
27+
test('matches short English keep-going nudges', () => {
28+
expect(isAnchorlessNudge('continue')).toBe(true)
29+
expect(isAnchorlessNudge('keep going')).toBe(true)
30+
expect(isAnchorlessNudge('go on please')).toBe(true)
31+
})
32+
33+
test('does not match empty or whitespace', () => {
34+
expect(isAnchorlessNudge('')).toBe(false)
35+
expect(isAnchorlessNudge(' ')).toBe(false)
36+
})
37+
38+
test('does not match long messages that merely contain a nudge word', () => {
39+
expect(
40+
isAnchorlessNudge(
41+
'继续用状态机方案重构 REPL 的输入处理模块,并补齐对应的单元测试',
42+
),
43+
).toBe(false)
44+
})
45+
46+
test('does not match substantive requests without nudge words', () => {
47+
expect(isAnchorlessNudge('给我看下 query.ts 的结构')).toBe(false)
48+
expect(isAnchorlessNudge('fix the login bug')).toBe(false)
49+
})
50+
})
51+
52+
describe('buildTaskAnchorReminder', () => {
53+
test('returns null when there are no unfinished tasks', () => {
54+
expect(buildTaskAnchorReminder([])).toBeNull()
55+
expect(
56+
buildTaskAnchorReminder([
57+
task({ id: '1', status: 'completed' }),
58+
task({ id: '2', status: 'completed' }),
59+
]),
60+
).toBeNull()
61+
})
62+
63+
test('lists only unfinished tasks, in_progress before pending', () => {
64+
const reminder = buildTaskAnchorReminder([
65+
task({ id: '1', status: 'completed', subject: 'done' }),
66+
task({ id: '2', status: 'pending', subject: 'second' }),
67+
task({ id: '3', status: 'in_progress', subject: 'third' }),
68+
])
69+
expect(reminder).not.toBeNull()
70+
const body = reminder!
71+
expect(body).toContain('2 unfinished task(s)')
72+
// in_progress (#3) must appear before pending (#2)
73+
expect(body.indexOf('#3 [in_progress]')).toBeLessThan(
74+
body.indexOf('#2 [pending]'),
75+
)
76+
expect(body).not.toContain('#1')
77+
expect(body).toContain('<system-reminder>')
78+
expect(body).toContain('</system-reminder>')
79+
})
80+
81+
test('orders same-status tasks by numeric id', () => {
82+
const reminder = buildTaskAnchorReminder([
83+
task({ id: '10', status: 'pending', subject: 'ten' }),
84+
task({ id: '2', status: 'pending', subject: 'two' }),
85+
])!
86+
expect(reminder.indexOf('#2 ')).toBeLessThan(reminder.indexOf('#10 '))
87+
})
88+
89+
test('caps the list and reports overflow', () => {
90+
const many: Task[] = Array.from({ length: 25 }, (_, i) =>
91+
task({ id: String(i + 1), status: 'pending' }),
92+
)
93+
const reminder = buildTaskAnchorReminder(many)!
94+
expect(reminder).toContain('25 unfinished task(s)')
95+
expect(reminder).toContain('…and 5 more')
96+
})
97+
})
98+
99+
describe('buildUnfinishedTaskNotice', () => {
100+
test('returns null when no task is in_progress', () => {
101+
expect(buildUnfinishedTaskNotice([])).toBeNull()
102+
expect(
103+
buildUnfinishedTaskNotice([
104+
task({ id: '1', status: 'pending' }),
105+
task({ id: '2', status: 'completed' }),
106+
]),
107+
).toBeNull()
108+
})
109+
110+
test('surfaces in_progress task names and pending count', () => {
111+
const notice = buildUnfinishedTaskNotice([
112+
task({ id: '1', status: 'in_progress', subject: '写模块' }),
113+
task({ id: '2', status: 'pending' }),
114+
task({ id: '3', status: 'pending' }),
115+
task({ id: '4', status: 'completed' }),
116+
])!
117+
expect(notice).toContain('#1 写模块')
118+
expect(notice).toContain('2 个待办')
119+
})
120+
121+
test('collapses overflow when more than three in_progress tasks', () => {
122+
const notice = buildUnfinishedTaskNotice(
123+
Array.from({ length: 5 }, (_, i) =>
124+
task({ id: String(i + 1), status: 'in_progress' }),
125+
),
126+
)!
127+
expect(notice).toContain('等 5 个')
128+
})
129+
})

src/services/api/claude.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ import {
5858
toolToAPISchema,
5959
} from '../../utils/api.js'
6060
import { getOauthAccountInfo } from '../../utils/auth.js'
61+
import {
62+
buildTaskAnchorReminder,
63+
isAnchorlessNudge,
64+
} from './taskAnchorReminder.js'
6165
import {
6266
getBedrockExtraBodyParamsBetas,
6367
getMergedBetas,
@@ -77,6 +81,7 @@ import {
7781
createAssistantAPIErrorMessage,
7882
createUserMessage,
7983
ensureToolResultPairing,
84+
getUserMessageText,
8085
normalizeContentFromAPI,
8186
normalizeMessagesForAPI,
8287
stripAdvisorBlocks,
@@ -1337,6 +1342,36 @@ async function* queryModel(
13371342
API_MAX_MEDIA_PER_REQUEST,
13381343
)
13391344

1345+
// ── Long-task anchor re-injection ──
1346+
// When the user's latest message is a short anchorless nudge ("继续"/"keep
1347+
// going") and the task list still has unfinished items, re-surface them once
1348+
// for this request. This fires only in that exact situation (short nudge is
1349+
// the LAST message, which is only true at turn start — during tool loops the
1350+
// last message is a tool_result), so there is no per-turn token cost. Mirrors
1351+
// the ephemeral, non-persisted isMeta injection used for deferred tools below.
1352+
{
1353+
const lastMessage = messagesForAPI.at(-1)
1354+
const lastText = lastMessage ? getUserMessageText(lastMessage) : null
1355+
if (lastText && isAnchorlessNudge(lastText)) {
1356+
try {
1357+
const { listTasks, getTaskListId } = await import(
1358+
'../../utils/tasks.js'
1359+
)
1360+
const reminder = buildTaskAnchorReminder(
1361+
await listTasks(getTaskListId()),
1362+
)
1363+
if (reminder) {
1364+
messagesForAPI = [
1365+
...messagesForAPI,
1366+
createUserMessage({ content: reminder, isMeta: true }),
1367+
]
1368+
}
1369+
} catch {
1370+
// Best-effort anchor: task read failures must never block the request.
1371+
}
1372+
}
1373+
}
1374+
13401375
// OpenAI-compatible provider: delegate to the OpenAI adapter layer
13411376
// after shared preprocessing (message normalization, tool filtering,
13421377
// media stripping) but before Anthropic-specific logic (betas, thinking, caching).
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { Task } from '../../utils/tasks.js'
2+
3+
// Short, generic "keep going" nudges that carry no task anchor. In a long
4+
// session these dilute the task anchor and the model drifts off the task
5+
// (see the long-session drift investigation, 2026-07-23). When one arrives and
6+
// the task list still has unfinished items, we re-surface them once — only in
7+
// this exact situation, so there is no per-turn token cost.
8+
const ANCHORLESS_NUDGE_PATTERN =
9+
/(||||||||keep going|carry on|go on|continue|proceed|next step|move on)/i
10+
11+
// Guard against false positives on substantive messages that merely contain a
12+
// nudge word (e.g. "继续用 X 方案重构 Y 模块…"). Real anchorless nudges are short
13+
// — the ones seen in the investigation were all ≤12 CJK chars ("继续执行后续的工作").
14+
const MAX_NUDGE_CHARS = 20
15+
16+
/**
17+
* True when `text` is a short "keep going" style nudge that carries no specific
18+
* task anchor. Long messages never match, even if they contain a nudge word.
19+
*/
20+
export function isAnchorlessNudge(text: string): boolean {
21+
const trimmed = text.trim()
22+
if (!trimmed || trimmed.length > MAX_NUDGE_CHARS) {
23+
return false
24+
}
25+
return ANCHORLESS_NUDGE_PATTERN.test(trimmed)
26+
}
27+
28+
// Cap the injected list so a large backlog can't balloon the reminder.
29+
const MAX_TASKS_IN_REMINDER = 20
30+
31+
const STATUS_ORDER: Record<string, number> = {
32+
in_progress: 0,
33+
pending: 1,
34+
}
35+
36+
/**
37+
* Builds a `<system-reminder>` re-surfacing the unfinished tasks, or null when
38+
* there are none. in_progress tasks come first, then pending, ordered by id.
39+
*/
40+
export function buildTaskAnchorReminder(tasks: Task[]): string | null {
41+
const unfinished = tasks.filter(t => t.status !== 'completed')
42+
if (unfinished.length === 0) {
43+
return null
44+
}
45+
46+
const sorted = [...unfinished].sort((a, b) => {
47+
const byStatus =
48+
(STATUS_ORDER[a.status] ?? 2) - (STATUS_ORDER[b.status] ?? 2)
49+
if (byStatus !== 0) {
50+
return byStatus
51+
}
52+
return Number(a.id) - Number(b.id)
53+
})
54+
55+
const shown = sorted.slice(0, MAX_TASKS_IN_REMINDER)
56+
const lines = shown.map(t => `- #${t.id} [${t.status}] ${t.subject}`)
57+
const overflow = sorted.length - shown.length
58+
if (overflow > 0) {
59+
lines.push(`- …and ${overflow} more`)
60+
}
61+
62+
return [
63+
'<system-reminder>',
64+
`You still have ${unfinished.length} unfinished task(s) in the current task list. The user's last message was a short "keep going" nudge without a specific anchor. Re-align to these tasks and continue the highest-priority unfinished one. Do not end your turn until the tasks are done or you genuinely need user input:`,
65+
...lines,
66+
'Use TaskUpdate to mark progress as you go. This reminder is generated by the client, not sent by the user.',
67+
'</system-reminder>',
68+
].join('\n')
69+
}
70+
71+
// How many in_progress task names to spell out in the local notice.
72+
const MAX_NOTICE_TASK_NAMES = 3
73+
74+
/**
75+
* Builds a display-only, user-facing notice for when a turn ends naturally while
76+
* tasks are still in_progress — a strong signal the model stopped mid-work. Only
77+
* in_progress tasks trigger it (a pending backlog the user is pacing does not).
78+
* Returns null when there is nothing worth surfacing. This text is shown in the
79+
* REPL and never sent to the model (zero token cost).
80+
*/
81+
export function buildUnfinishedTaskNotice(tasks: Task[]): string | null {
82+
const inProgress = tasks.filter(t => t.status === 'in_progress')
83+
if (inProgress.length === 0) {
84+
return null
85+
}
86+
const pendingCount = tasks.filter(t => t.status === 'pending').length
87+
const names = inProgress
88+
.slice(0, MAX_NOTICE_TASK_NAMES)
89+
.map(t => `#${t.id} ${t.subject}`)
90+
.join('、')
91+
const overflow = inProgress.length - MAX_NOTICE_TASK_NAMES
92+
const namesSuffix = overflow > 0 ? ` 等 ${inProgress.length} 个` : ''
93+
const pendingSuffix = pendingCount > 0 ? `,另有 ${pendingCount} 个待办` : ''
94+
return `还有进行中的任务未完成:${names}${namesSuffix}${pendingSuffix}。可直接回车继续,或用「按计划继续 Task #N」这类带锚点的话催促,避免长任务跑偏。`
95+
}

0 commit comments

Comments
 (0)