fix: enforce standalone recommendation turn limit
This commit is contained in:
1 parent
4f40d71a22
commit
4c864e4ae5
8 files changed
+91
-23
No files matched your search
@@ -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) |
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user