fix(subagent): preserve continuable behavior after rebase

This commit is contained in:
imccyu
2026-08-01 08:37:21 +08:00
parent 264bc41a13
commit bb6e6d6f3b
22 changed files with 173 additions and 60 deletions

View File

@@ -71,6 +71,7 @@ function createSteeringDelivery(): SteeringDelivery {
return {
receipt: { outcome: promise },
settle(outcome): void {
/* v8 ignore next -- each ownership transfer removes the delivery before another settlement path can reach it. */
if (settled) return
settled = true
resolve(outcome)
@@ -544,6 +545,7 @@ export class ReactLoopAgent implements Agent {
this.drainOutbox(turn)
break steps
}
/* v8 ignore next -- step() folded the same steering predicate into continueTurn immediately before returning. */
if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue
break
case 'request-failed': {

View File

@@ -470,7 +470,10 @@ describe('agent loop', () => {
parameters: {},
async execute() {
// steer while the turn is running (during tool execution)
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }))
agent.send(
createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }),
{ target: 'next-step', wakeup: true },
)
return [{ type: 'text', text: 'tool done' }]
},
}))
@@ -544,6 +547,120 @@ describe('agent loop', () => {
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
})
it('rejects failed steering commits while preserving later context', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering-commit'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.on('agent/step', (subject) => {
if (subject !== agent || receipt !== undefined) return
receipt = subject.steer(createUserMessage({
content: [{ type: 'text', text: 'rejected steering' }],
source: { kind: 'user' },
}))
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'preserved context' }],
source: { kind: 'plugin', plugin: 'loop-test' },
}))
})
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string }
if (event.type === 'steering/message' && !rejected) {
rejected = true
throw new Error('reject steering commit')
}
})
send(agent, 'first prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
if (receipt === undefined) throw new Error('agent/step did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
send(agent, 'recover')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('preserved context')
expect(request).not.toContain('rejected steering')
})
it('rejects committed steering when the step boundary fails', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-step-boundary'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.on('agent/step', (subject) => {
if (subject !== agent || receipt !== undefined) return
receipt = subject.steer(createUserMessage({
content: [{ type: 'text', text: 'committed steering' }],
source: { kind: 'user' },
}))
})
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string }
if (event.type === 'step/start') throw new Error('reject step boundary')
})
send(agent, 'prompt')
await waitForIdle(ctx, agent)
if (receipt === undefined) throw new Error('agent/step did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
})
it('retries context and steering after a context commit fails', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-context-commit'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.on('agent/step', (subject) => {
if (subject !== agent || receipt !== undefined) return
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'preserved context' }],
source: { kind: 'plugin', plugin: 'loop-test' },
}))
receipt = subject.steer(createUserMessage({
content: [{ type: 'text', text: 'preserved steering' }],
source: { kind: 'user' },
}))
})
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string; data?: { source?: { kind: string } } }
if (event.type === 'user/message' && event.data?.source?.kind === 'plugin' && !rejected) {
rejected = true
throw new Error('reject context commit')
}
})
send(agent, 'first prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
send(agent, 'recover')
await waitForIdle(ctx, agent)
if (receipt === undefined) throw new Error('agent/step did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 2, step: 1 })
expect(adapter.requests).toHaveLength(1)
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('preserved context')
expect(request).toContain('preserved steering')
})
it('inject() while idle appends context without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -722,12 +839,22 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
let contextInjected = false
ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'step/end' || contextInjected) return
contextInjected = true
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'final context' }],
source: { kind: 'plugin', plugin: 'finalize' },
}))
})
ctx.tools.register(defineContentToolFixture({
name: 'finalize',
description: '',
parameters: {},
async execute(_args, exec) {
// Steering lands while the concluding tool is still executing.
// Steering lands while the concluding tool is still executing; the
// step/end listener adds ordinary context after the normal result drain.
receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
exec.concludeTurn()
return [{ type: 'text', text: 'final' }]
@@ -744,6 +871,9 @@ describe('agent loop', () => {
if (receipt === undefined) throw new Error('concluding tool did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(events).not.toContain('steering/message')
expect(agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.content.some(block => block.type === 'text' && block.text === 'final context'))).toBe(true)
send(agent, 'follow up')
await waitForIdle(ctx, agent)
@@ -752,6 +882,7 @@ describe('agent loop', () => {
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
expect(texts).toContain('final context')
expect(texts).not.toContain('late steering')
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: 8799bc3664b2137b386b752f905e1414fb770cb9
README.zh.md: 851c174ba80bebbab8ee1255cb04be4bcec7eabd
README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734
README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170

View File

@@ -9,7 +9,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal'
import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import * as goalSession from '../src/index.ts'
declare module '@deepseek-ai/dsh-session' {
@@ -787,35 +787,6 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(1)
})
it('yields to a round whose turn/end never committed instead of misreading it as settled', async () => {
const test = await harness([textResponse('round ran')])
// A persistent pre-commit turn/end rejection reaches idle with the
// attempt's turn open and no terminal reason. The driver must yield
// instead of clearing the reservation or scheduling another round.
let roundTurn: number | undefined
test.ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'turn/start' && event.data.trigger.kind === 'message'
&& event.data.trigger.source.kind === 'goal') {
roundTurn = event.data.turn
}
if (event.type === 'turn/end' && event.data.turn === roundTurn) {
throw new Error('turn close permanently rejected')
}
})
test.ctx.goals.create(test.agent, { objective: 'survive a lost turn end' })
await waitForRequests(test.adapter, 1)
await test.agent.whenIdle()
await new Promise((resolve) => { setImmediate(resolve) })
expect(test.adapter.requests).toHaveLength(1)
expect(test.ctx.goals.get(test.agent)).toMatchObject({
phase: 'active',
activation: 'armed',
})
})
it('cancels an accepted queued round and awaits its driver task during teardown', async () => {
const test = await harness([])
let unloading: Promise<void> | undefined

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-fork/README.md
README.md: b448dc309bff07c744443530a648c7c30e4d20d9
README.zh.md: 3e14206d5637fded9edb4e173608c55e3341f8fc
README.md: 55475aee7841e91960de79887dfe9bf37afdf9da
README.zh.md: 3eec8cb51a47243a1f06416a3f8f99ae8df8e734

View File

@@ -57,5 +57,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随
## 已知限制与暂缓事项
- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。
- **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-spawn/README.md
README.md: 868f829edbcfe2eb4d66ccd0ff9988924c70298b
README.zh.md: 99cfdf0e633345d1152c59cbe5ce7a029eb6ec9d
README.md: 811f19e6e68362bd14e75d0a9059ee61fda3f015
README.zh.md: 2b189f77c4ff63ca026f472187a55c68def18ea1

View File

@@ -52,5 +52,4 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona:
## 已知限制与暂缓事项
- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。
- **全新表示不含父 agent transcript文本记录**:子 agent 会继承 cwd、谱系、模型及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。