fix: enforce standalone recommendation turn limit

This commit is contained in:
ZiyaZhang committed 2026-10-19 11:13:07 -07:00
1 parent 4f40d71a22
commit 4c864e4ae5
8 files changed
+91 -23

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `plugin-recommendations`, `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
+1 -1
View File
@@ -14,7 +14,7 @@
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `plugin-recommendations`, `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
@@ -21,8 +21,8 @@
en: 37c1a422f44826c8
zh: cda21de617a138b6
/deepseek-ai-dsh-experimental-plugin-recommendations/understand-the-implementation:
en: fc32b7af18264765
zh: f684e20587f31971
en: 4c6968c0c7bef97a
zh: 5f452974b8d4066f
/deepseek-ai-dsh-experimental-plugin-recommendations/further-exploration:
en: cfce6b9a473bd4f7
zh: e70d8a69bac6349f
@@ -43,7 +43,7 @@ Enable requires permission to modify the profile. The Host rechecks catalog elig
<details>
<summary>Implementation internals — click to expand</summary>
The catalog and activation reuse `pluginManager`. The main Agent interprets expressed needs; Host code enforces the official shipped catalog and a separate human action. Trusted catalog text is persisted in tool-result metadata, while a Session projection folds decision receipts. Concurrent actions on one card share an operation, and disposal drains admitted changes. Profile files own installation state; experimental Session records only own the historical decision. Removing the bundle withdraws tools, cards and its generated Remote contribution.
The catalog and activation reuse `pluginManager`. The main Agent interprets expressed needs; Host code enforces the official shipped catalog, one successful proposal per Turn, and a separate human action. Concurrent proposals reserve the Turn before catalog lookup; failed proposals release that reservation. Replaying a settled proposal preserves the limit after reload, while a later Turn can recommend again. Trusted catalog text is persisted in tool-result metadata, while a Session projection folds decision receipts. Concurrent actions on one card share an operation, and disposal drains admitted changes. Profile files own installation state; experimental Session records only own the historical decision. Removing the bundle withdraws tools, cards and its generated Remote contribution.
- [Source](src/index.ts) — registration and service entry.
- [Tests](tests/) — actions, recovery and lifecycle evidence.
@@ -43,7 +43,7 @@ kind: "package-bundle"
<details>
<summary>实现细节 — 点击展开</summary>
目录和启用操作复用 `pluginManager`。主 Agent 理解用户表达的需求;Host 限制为随应用提供的官方目录,并要求用户单独点击。可信目录文案存入工具结果元数据,Session 投影累积选择回执。同一卡片的并发点击共享操作,卸载服务会等待已开始的变更。配置档案文件拥有安装状态,实验性 Session 记录仅保存历史选择。关闭插件会撤下工具、卡片和对应的 Remote。
目录和启用操作复用 `pluginManager`。主 Agent 理解用户表达的需求;Host 限制为随应用提供的官方目录,每轮只允许一个成功建议,并要求用户单独点击。并发建议在查询目录前预留本轮名额,失败后释放;已完成建议通过回放保留限制,重新加载不会重置,下一轮可以再次推荐。可信目录文案存入工具结果元数据,Session 投影累积选择回执。同一卡片的并发点击共享操作,卸载服务会等待已开始的变更。配置档案文件拥有安装状态,实验性 Session 记录仅保存历史选择。关闭插件会撤下工具、卡片和对应的 Remote。
- [源码](src/index.ts) — 注册与服务入口。
- [测试](tests/) — 操作、恢复和生命周期验证。
@@ -36,12 +36,14 @@ export default class PluginRecommendations extends TypertRemoteService {
static inject = ['tools', 'pluginManager', 'agents', 'sessionProjections', 'remoteAccess']
private projection!: ProjectionHandle<RecommendationState>
private readonly operations = new Map<string, Promise<RecommendationActionResult>>()
private readonly proposals = new Map<string, { turn: number; callId: ToolCallId }>()
private closing = false
constructor(ctx: Context) { super(ctx, 'pluginRecommendations') }
protected async [Service.init](): Promise<void> {
this.projection = await this.ctx.sessionProjections.register(recommendationProjection)
this.ctx.on('agent/disposed', ({ agent }) => { this.proposals.delete(agent.id) })
this.ctx.effect(() => async () => {
this.closing = true
await Promise.allSettled([...this.operations.values()])
@@ -83,19 +85,32 @@ export default class PluginRecommendations extends TypertRemoteService {
if (request === '' || args.reason.trim() === '') {
throw new PluginRecommendationError('Describe the explicit user need and explain how the plugin helps.', 'RECOMMENDATION_NEED')
}
const bundle = (await this.ctx.pluginManager.listBundles()).find(item => item.name === args.name)
exec.signal.throwIfAborted()
if (bundle === undefined || !eligible(bundle) || bundle.enabled) {
throw new PluginRecommendationError('This plugin is not an available, disabled built-in official plugin. Read list_official_plugins again.', 'RECOMMENDATION_UNAVAILABLE')
const state = this.projection.stateOf(agent.session)
const pending = this.proposals.get(agent.id)
const first = state.firstCall ?? (pending?.turn === state.turn ? pending.callId : undefined)
if (first !== undefined && first !== exec.callId) {
throw new PluginRecommendationError('This turn already has a recommendation. Finish the answer.', 'RECOMMENDATION_LIMIT')
}
const recommendation: PluginRecommendation = {
callId: exec.callId, name: bundle.name, request, reason: args.reason.trim(),
titleEn: localized(bundle.meta?.title, 'en', bundle.name),
titleZh: localized(bundle.meta?.title, 'zh', bundle.name),
descriptionEn: localized(bundle.meta?.description, 'en', bundle.description ?? ''),
descriptionZh: localized(bundle.meta?.description, 'zh', bundle.description ?? ''),
const reservation = { turn: state.turn, callId: exec.callId }
this.proposals.set(agent.id, reservation)
try {
const bundle = (await this.ctx.pluginManager.listBundles()).find(item => item.name === args.name)
exec.signal.throwIfAborted()
if (bundle === undefined || !eligible(bundle) || bundle.enabled) {
throw new PluginRecommendationError('This plugin is not an available, disabled built-in official plugin. Read list_official_plugins again.', 'RECOMMENDATION_UNAVAILABLE')
}
const recommendation: PluginRecommendation = {
callId: exec.callId, name: bundle.name, request, reason: args.reason.trim(),
titleEn: localized(bundle.meta?.title, 'en', bundle.name),
titleZh: localized(bundle.meta?.title, 'zh', bundle.name),
descriptionEn: localized(bundle.meta?.description, 'en', bundle.description ?? ''),
descriptionZh: localized(bundle.meta?.description, 'zh', bundle.description ?? ''),
}
return JSON.stringify(recommendation)
} catch (error) {
if (this.proposals.get(agent.id) === reservation) this.proposals.delete(agent.id)
throw error
}
return JSON.stringify(recommendation)
},
}))
}
@@ -24,6 +24,7 @@ const viewSchema = z.object({ decisions: z.record(z.string(), outcomeSchema) })
export const stateSchema = z.object({
proposals: z.record(z.string(), recommendationSchema),
view: viewSchema,
turn: z.number(), firstCall: z.string().nullable(),
})
/** Validated proposals and durable card decisions. */
@@ -44,10 +45,11 @@ export function recommendationOf(event: SessionEvent): PluginRecommendation | un
/** Whole-log fold; late decisions survive card pagination and reconnect. */
export const recommendationProjection = {
key: 'pluginRecommendations',
stateVersion: 1,
stateVersion: 2,
stateSchema,
init: (): RecommendationState => ({ proposals: {}, view: { decisions: {} } }),
init: (): RecommendationState => ({ proposals: {}, view: { decisions: {} }, turn: 0, firstCall: null }),
apply: (state, event) => {
if (event.type === 'turn/start') return { ...state, turn: event.data.turn, firstCall: null }
const record = pluginRecordOf(event)
if (record?.type === 'plugin:plugin-recommendations/decision') {
const decision = z.object({ callId: z.string().min(1), outcome: outcomeSchema }).parse(record.data)
@@ -55,7 +57,8 @@ export const recommendationProjection = {
}
const recommendation = recommendationOf(event)
return recommendation === undefined ? state
: { ...state, proposals: { ...state.proposals, [recommendation.callId]: recommendation } }
: { ...state, firstCall: state.firstCall ?? recommendation.callId,
proposals: { ...state.proposals, [recommendation.callId]: recommendation } }
},
wire: {
summary: false,
@@ -19,7 +19,7 @@ async function fixture() {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await mountAgentLoopTestDependencies(ctx)
const harness = await mountAgentLoopTestHarness(ctx)
await mountAgentLoopTestHarness(ctx)
await ctx.plugin(RemoteAccess)
const manager = {
listBundles: vi.fn(async (): Promise<BundleInfo[]> => [bundle]),
@@ -27,7 +27,8 @@ async function fixture() {
}
ctx.provide('pluginManager', manager)
const fiber = await ctx.plugin(PluginRecommendations)
const agent = await harness.create(SessionId('recommendations'))
const handle = await ctx.agents.create({ sessionId: SessionId('recommendations') })
const agent = handle.agent
const events: SessionEvent[] = []
ctx.on('session/event', (session, event) => { if (session === agent.session) events.push(event) })
const steer = vi.spyOn(agent, 'steer').mockImplementation(() => {})
@@ -47,10 +48,59 @@ async function fixture() {
}, { surfaceOp: 'append' })
return result
}
return { ctx, agent, manager, fiber, execute, propose, steer, events }
return { ctx, agent, handle, manager, fiber, execute, propose, steer, events }
}
describe('official recommendations', () => {
it('admits one proposal per turn when this experiment is enabled alone, including concurrent calls and retries', async () => {
const f = await fixture()
f.agent.session.append('turn/start', { turn: 1 })
const gate = Promise.withResolvers<BundleInfo[]>()
const entered = Promise.withResolvers<undefined>()
f.manager.listBundles.mockImplementationOnce(() => { entered.resolve(undefined); return gate.promise })
const execute = (id: string, name = bundle.name) => f.ctx.tools.execute({
name: 'suggest_plugin', arguments: { name, request: 'history search', reason: 'find past work' },
agent: f.agent, callId: ToolCallId(id), signal: new AbortController().signal,
})
const first = execute('first')
await entered.promise
const second = await execute('parallel')
gate.resolve([bundle])
const result = await first
expect(second.isError).toBe(true)
expect(result.isError).toBe(false)
f.agent.session.append('tool/result', { turn: 1, step: 1,
message: createToolResultMessage({ callId: ToolCallId('first'), content: result.content, isError: false }),
...result.meta === undefined ? {} : { meta: result.meta },
}, { surfaceOp: 'append' })
await f.fiber.dispose()
await f.ctx.plugin(PluginRecommendations)
expect((await execute('after-reload')).isError).toBe(true)
f.agent.session.append('turn/start', { turn: 2 })
expect((await execute('invalid', 'missing')).isError).toBe(true)
expect((await execute('next-turn')).isError).toBe(false)
await f.handle.dispose()
})
it('does not release a newer turn reservation when an older lookup fails late', async () => {
const f = await fixture()
const gate = Promise.withResolvers<BundleInfo[]>()
const entered = Promise.withResolvers<undefined>()
f.manager.listBundles.mockImplementationOnce(() => { entered.resolve(undefined); return gate.promise })
const execute = (id: string) => f.ctx.tools.execute({
name: 'suggest_plugin', arguments: { name: bundle.name, request: 'search', reason: 'history' },
agent: f.agent, callId: ToolCallId(id), signal: new AbortController().signal,
})
f.agent.session.append('turn/start', { turn: 1 })
const old = execute('old')
await entered.promise
f.agent.session.append('turn/start', { turn: 2 })
expect((await execute('new')).isError).toBe(false)
gate.resolve([])
expect((await old).isError).toBe(true)
expect((await execute('duplicate')).isError).toBe(true)
})
it('uses catalog fallbacks when translations or descriptions are absent', async () => {
const f = await fixture()
const noMeta = { ...bundle }