fix(subagent): preserve continuable behavior after rebase
This commit is contained in:
@@ -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': {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,5 +57,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。
|
||||
- **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,5 +52,4 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona:
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。
|
||||
- **全新表示不含父 agent transcript(文本记录)**:子 agent 会继承 cwd、谱系、模型及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。
|
||||
|
||||
Reference in New Issue
Block a user