From 4c864e4ae57a2bd07922a5cb53fcb17e6f007c18 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:13:07 -0700 Subject: [PATCH] fix: enforce standalone recommendation turn limit --- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- .../plugin-recommendations/README.i18n.yaml | 4 +- .../plugin-recommendations/README.md | 2 +- .../plugin-recommendations/README.zh.md | 2 +- .../plugin-recommendations/src/index.ts | 37 ++++++++---- .../plugin-recommendations/src/projection.ts | 9 ++- .../tests/recommendations.spec.ts | 56 ++++++++++++++++++- 8 files changed, 91 insertions(+), 23 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 063f53ed2c..d5ad4ba577 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 78f2a02da4..69e4a07ddd 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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) | diff --git a/packages/experimental/plugin-recommendations/README.i18n.yaml b/packages/experimental/plugin-recommendations/README.i18n.yaml index 4a75dcfd1d..c409a1bc0a 100644 --- a/packages/experimental/plugin-recommendations/README.i18n.yaml +++ b/packages/experimental/plugin-recommendations/README.i18n.yaml @@ -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 diff --git a/packages/experimental/plugin-recommendations/README.md b/packages/experimental/plugin-recommendations/README.md index ac184384d6..b7356d66d9 100644 --- a/packages/experimental/plugin-recommendations/README.md +++ b/packages/experimental/plugin-recommendations/README.md @@ -43,7 +43,7 @@ Enable requires permission to modify the profile. The Host rechecks catalog elig
Implementation internals — click to expand -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. diff --git a/packages/experimental/plugin-recommendations/README.zh.md b/packages/experimental/plugin-recommendations/README.zh.md index dc36f52c9e..49201f9d7a 100644 --- a/packages/experimental/plugin-recommendations/README.zh.md +++ b/packages/experimental/plugin-recommendations/README.zh.md @@ -43,7 +43,7 @@ kind: "package-bundle"
实现细节 — 点击展开 -目录和启用操作复用 `pluginManager`。主 Agent 理解用户表达的需求;Host 限制为随应用提供的官方目录,并要求用户单独点击。可信目录文案存入工具结果元数据,Session 投影累积选择回执。同一卡片的并发点击共享操作,卸载服务会等待已开始的变更。配置档案文件拥有安装状态,实验性 Session 记录仅保存历史选择。关闭插件会撤下工具、卡片和对应的 Remote。 +目录和启用操作复用 `pluginManager`。主 Agent 理解用户表达的需求;Host 限制为随应用提供的官方目录,每轮只允许一个成功建议,并要求用户单独点击。并发建议在查询目录前预留本轮名额,失败后释放;已完成建议通过回放保留限制,重新加载不会重置,下一轮可以再次推荐。可信目录文案存入工具结果元数据,Session 投影累积选择回执。同一卡片的并发点击共享操作,卸载服务会等待已开始的变更。配置档案文件拥有安装状态,实验性 Session 记录仅保存历史选择。关闭插件会撤下工具、卡片和对应的 Remote。 - [源码](src/index.ts) — 注册与服务入口。 - [测试](tests/) — 操作、恢复和生命周期验证。 diff --git a/packages/experimental/plugin-recommendations/src/index.ts b/packages/experimental/plugin-recommendations/src/index.ts index 1bba4adb8b..98c934c823 100644 --- a/packages/experimental/plugin-recommendations/src/index.ts +++ b/packages/experimental/plugin-recommendations/src/index.ts @@ -36,12 +36,14 @@ export default class PluginRecommendations extends TypertRemoteService { static inject = ['tools', 'pluginManager', 'agents', 'sessionProjections', 'remoteAccess'] private projection!: ProjectionHandle private readonly operations = new Map>() + private readonly proposals = new Map() private closing = false constructor(ctx: Context) { super(ctx, 'pluginRecommendations') } protected async [Service.init](): Promise { 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) }, })) } diff --git a/packages/experimental/plugin-recommendations/src/projection.ts b/packages/experimental/plugin-recommendations/src/projection.ts index 9fb5056065..7507739f22 100644 --- a/packages/experimental/plugin-recommendations/src/projection.ts +++ b/packages/experimental/plugin-recommendations/src/projection.ts @@ -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, diff --git a/packages/experimental/plugin-recommendations/tests/recommendations.spec.ts b/packages/experimental/plugin-recommendations/tests/recommendations.spec.ts index a91c9fdc01..86363ce5c9 100644 --- a/packages/experimental/plugin-recommendations/tests/recommendations.spec.ts +++ b/packages/experimental/plugin-recommendations/tests/recommendations.spec.ts @@ -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 => [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() + const entered = Promise.withResolvers() + 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() + const entered = Promise.withResolvers() + 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 }