Просмотр исходного кода

fix(subagent): tolerate policy-only preset states

Dudu-0223 1 месяц назад
Родитель
Сommit
2722c202ad

+ 14 - 12
packages/subagent/tool-subagent/src/invariant.ts

@@ -15,20 +15,22 @@ export const name = 'tool-subagent-invariant'
 /** Service required before the companion can reserve package ownership. */
 export const inject = ['invariants']
 
-/** Assert that a durable opt-in is represented by both model-facing definitions. */
+/** Assert that model-selectable definitions are complete and reconstructable. */
 const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
   ctx.on('agent/pre-step', async ({ agent }, next) => {
-    if (subagentModelSelectionPolicy(agent.session) !== undefined) {
-      const schemas = ctx.tools.schemas(agent)
-      const selectable = schemas.some((schema) => {
-        const properties = (schema.parameters as { properties?: Record<string, unknown> }).properties
-        return properties?.['provider'] !== undefined
-          && properties['model'] !== undefined
-          && properties['reasoning_effort'] !== undefined
-      })
-      if (!selectable || !schemas.some(schema => schema.name === 'list_subagent_models')) {
-        fail('a subagent/model-selection-policy session must expose route fields and list_subagent_models')
-      }
+    const schemas = ctx.tools.schemas(agent)
+    const selectable = schemas.some((schema) => {
+      const properties = (schema.parameters as { properties?: Record<string, unknown> }).properties
+      return properties?.['provider'] !== undefined
+        && properties['model'] !== undefined
+        && properties['reasoning_effort'] !== undefined
+    })
+    const discoverable = schemas.some(schema => schema.name === 'list_subagent_models')
+    if (
+      (selectable || discoverable)
+      && (subagentModelSelectionPolicy(agent.session) === undefined || !selectable || !discoverable)
+    ) {
+      fail('model-selectable subagent definitions require a durable policy, route fields, and list_subagent_models')
     }
     return next()
   }, { global: true })

+ 29 - 2
packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts

@@ -189,6 +189,8 @@ describe('SubagentModelSelectionConfig', () => {
 
   it('installs per-Agent definitions for a shared preset scope', async () => {
     const ctx = await boot()
+    await ctx.plugin(InvariantRegistry, { enabled: true })
+    await ctx.plugin(ToolInvariant)
     const preset = createScope(ctx, { preset: 'standard' })
     const other = createScope(ctx, { preset: 'minimal' })
     await preset.ctx.plugin(tool, {
@@ -219,9 +221,22 @@ describe('SubagentModelSelectionConfig', () => {
     enabledBinding!.rebind(scopeOf(other.ctx)!)
     ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
     await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(false) })
+    const next = () => Promise.resolve({ kind: 'enter' as const, messages: [] })
+    const payload = {
+      agent: enabled.agent,
+      messages: [],
+      turn: 1,
+      step: 1,
+      signal: new AbortController().signal,
+    }
+    await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next))
+      .resolves.toEqual({ kind: 'enter', messages: [] })
+
     enabledBinding!.rebind(scopeOf(preset.ctx)!)
     ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
     await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(true) })
+    await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next))
+      .resolves.toEqual({ kind: 'enter', messages: [] })
 
     await enabled.dispose()
     ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
@@ -322,7 +337,7 @@ describe('SubagentModelSelectionConfig', () => {
     await withoutAgent.fiber.dispose()
   })
 
-  it('checks the durable decision against the published tool definitions', async () => {
+  it('checks model-selectable definitions without rejecting a policy-only preset', async () => {
     const ctx = await boot()
     await ctx.plugin(InvariantRegistry, { enabled: true })
     await ctx.plugin(ToolInvariant)
@@ -341,7 +356,7 @@ describe('SubagentModelSelectionConfig', () => {
 
     disabled.session.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS })
     await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next))
-      .rejects.toThrow('must expose route fields and list_subagent_models')
+      .resolves.toEqual({ kind: 'enter', messages: [] })
 
     await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
       enabled: true,
@@ -350,6 +365,18 @@ describe('SubagentModelSelectionConfig', () => {
     const enabled = await createAgent(ctx, 'invariant-enabled')
     await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next))
       .resolves.toEqual({ kind: 'enter', messages: [] })
+
+    const enabledSchemas = ctx.tools.schemas(enabled)
+    const schemas = vi.spyOn(ctx.tools, 'schemas')
+    schemas.mockReturnValue(enabledSchemas.filter(schema => schema.name !== 'list_subagent_models'))
+    await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next))
+      .rejects.toThrow('require a durable policy, route fields, and list_subagent_models')
+
+    schemas.mockReturnValue(enabledSchemas)
+    await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
+    const withoutPolicy = await createAgent(ctx, 'invariant-without-policy')
+    await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: withoutPolicy }, next))
+      .rejects.toThrow('require a durable policy, route fields, and list_subagent_models')
     await ctx.fiber.dispose()
   })
 })