model-selection.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  4. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  5. import ToolRuntime from '@deepseek-ai/dsh-tools'
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  8. import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
  9. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  10. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  11. import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts'
  12. import * as mock from './scripted-provider.ts'
  13. import * as tool from '../src/index.ts'
  14. import {
  15. assertAllowedModelRoutes,
  16. assertAllowedModelSelection,
  17. preflightChildLlmRoute,
  18. } from '../src/model-selection.ts'
  19. import { callSubagent, modelSelectionSetupAgent, setup, text } from './harness.ts'
  20. const REASONING = {
  21. efforts: [
  22. { id: ReasoningEffortId('low'), name: 'Low' },
  23. { id: ReasoningEffortId('high'), name: 'High' },
  24. ],
  25. defaultEffort: ReasoningEffortId('high'),
  26. } as const
  27. function parentWithRoute(
  28. options: Agent['options'] = {
  29. provider: 'alpha',
  30. model: 'parent-model',
  31. reasoningEffort: ReasoningEffortId('high'),
  32. },
  33. ): Agent {
  34. const id = SessionId('parent-with-route')
  35. return { id, options, session: Session.create(id) } as unknown as Agent
  36. }
  37. describe('dsh-tool-subagent model selection', () => {
  38. it('rejects empty route ids at the configuration boundary', () => {
  39. expect(() => { assertAllowedModelRoutes([{ provider: '', model: 'model' }]) })
  40. .toThrow('requires non-empty provider and model ids')
  41. expect(() => { assertAllowedModelRoutes([{ provider: 'provider', model: '' }]) })
  42. .toThrow('requires non-empty provider and model ids')
  43. expect(() => { assertAllowedModelRoutes({ provider: 'provider', model: 'model' }) })
  44. .toThrow('requires an array of routes')
  45. expect(() => { assertAllowedModelRoutes([{ provider: 1, model: 'model' }]) })
  46. .toThrow('requires non-empty provider and model ids')
  47. })
  48. it('allows pure inheritance but rejects explicit values outside a Session allowlist', () => {
  49. const policy = {
  50. routes: [{ provider: 'alpha', model: 'allowed-model' }],
  51. }
  52. const parent = { provider: 'alpha', model: 'parent-model' }
  53. expect(() => { assertAllowedModelSelection(policy, parent, undefined, {}) }).not.toThrow()
  54. expect(() => {
  55. assertAllowedModelSelection(
  56. policy,
  57. parent,
  58. { provider: 'alpha', model: 'allowed-model' },
  59. { provider: 'alpha', model: 'allowed-model' },
  60. )
  61. }).not.toThrow()
  62. expect(() => {
  63. assertAllowedModelSelection(
  64. policy,
  65. parent,
  66. { provider: 'alpha', model: 'other-model' },
  67. { provider: 'alpha', model: 'other-model' },
  68. )
  69. }).toThrow('is not allowed for this Session')
  70. expect(() => {
  71. assertAllowedModelSelection(
  72. policy,
  73. parent,
  74. { reasoningEffort: ReasoningEffortId('low') },
  75. { reasoning_effort: 'low' },
  76. )
  77. }).toThrow('alpha/parent-model')
  78. expect(() => {
  79. assertAllowedModelSelection(
  80. policy,
  81. {},
  82. { reasoningEffort: ReasoningEffortId('low') },
  83. { reasoning_effort: 'low' },
  84. )
  85. }).toThrow('without an effective provider and model')
  86. })
  87. it('leaves deployment or parent defaults outside the allowlist usable when the call selects nothing', async () => {
  88. let starts = 0
  89. const ctx = await setup(
  90. { provider: 'mock', withModelSelection: true },
  91. { onStart: () => { starts += 1 } },
  92. )
  93. const parent = modelSelectionSetupAgent(ctx)
  94. ;(parent as unknown as { options: Agent['options'] }).options = {
  95. provider: 'deployment-provider',
  96. model: 'deployment-model',
  97. }
  98. const result = await callSubagent(ctx, { description: 'default route', prompt: 'do it' })
  99. expect(result.isError).toBe(false)
  100. expect(starts).toBe(1)
  101. })
  102. it('exposes Session-authorized route fields and discovery when selection is enabled', async () => {
  103. const ctx = await setup({ provider: 'mock', withModelSelection: true })
  104. const agent = modelSelectionSetupAgent(ctx)
  105. const schema = ctx.tools.schemas(agent).find(entry => entry.name === 'subagent')!
  106. const props = (schema.parameters as { properties?: Record<string, unknown> }).properties ?? {}
  107. expect(Object.keys(props).sort()).toEqual([
  108. 'description',
  109. 'model',
  110. 'prompt',
  111. 'provider',
  112. 'reasoning_effort',
  113. 'run_in_background',
  114. ])
  115. expect(schema.description).toContain('list_subagent_models')
  116. expect(ctx.tools.get('list_subagent_models', agent)).toBeDefined()
  117. expect(schema.description).not.toContain('alpha')
  118. const registration = ctx.llm.registerAdapter(['alpha'], new MockAdapter([]))
  119. const definition = ctx.tools.get('subagent', agent)
  120. registration.replace(['beta'])
  121. expect(ctx.tools.get('subagent', agent)).toBe(definition)
  122. expect(definition?.description).not.toContain('beta')
  123. })
  124. it('hides and rejects route fields when selection is disabled', async () => {
  125. const ctx = await setup({ provider: 'mock' })
  126. const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')!
  127. const props = (schema.parameters as { properties?: Record<string, unknown> }).properties ?? {}
  128. expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background'])
  129. expect(schema.description).not.toContain('list_subagent_models')
  130. expect(ctx.tools.get('list_subagent_models')).toBeUndefined()
  131. const result = await callSubagent(ctx, {
  132. description: 'forced route',
  133. prompt: 'do it',
  134. provider: 'alpha',
  135. model: 'fast-model',
  136. })
  137. expect(result.isError).toBe(true)
  138. expect(text(result)).toContain('child model selection is disabled for this tool instance')
  139. })
  140. it('rejects enabled model selection when the provider cannot apply Agent options', async () => {
  141. await expect(setup(
  142. { provider: 'mock', withModelSelection: true, maxDepth: 'provider-managed' },
  143. { capabilities: { agentOptions: false } },
  144. )).rejects.toThrow('provider "mock" does not support child model selection')
  145. })
  146. it('selects an unlisted complete route and clears a configured effort when the route changes', async () => {
  147. const requests: SubagentStartRequest[] = []
  148. const ctx = await setup({
  149. provider: 'mock',
  150. withModelSelection: true,
  151. agentOptions: {
  152. provider: 'alpha',
  153. model: 'configured-model',
  154. reasoningEffort: ReasoningEffortId('high'),
  155. maxTokens: 321,
  156. },
  157. }, { onStart: (request) => { requests.push(request) } })
  158. ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
  159. const parent = modelSelectionSetupAgent(ctx)
  160. ;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options
  161. const selected = await callSubagent(ctx, {
  162. description: 'route work',
  163. prompt: 'do it',
  164. provider: 'alpha',
  165. model: 'unlisted-model',
  166. })
  167. expect(selected.isError).toBe(false)
  168. expect(requests[0]?.agentOptions).toEqual({
  169. provider: 'alpha',
  170. model: 'unlisted-model',
  171. maxTokens: 321,
  172. })
  173. const effort = await callSubagent(ctx, {
  174. description: 'same route effort',
  175. prompt: 'do it',
  176. provider: 'alpha',
  177. model: 'configured-model',
  178. reasoning_effort: 'low',
  179. })
  180. expect(effort.isError).toBe(false)
  181. expect(requests[1]?.agentOptions).toEqual({
  182. provider: 'alpha',
  183. model: 'configured-model',
  184. reasoningEffort: 'low',
  185. maxTokens: 321,
  186. })
  187. })
  188. it('accepts an effort-only override for the effective configured or parent route', async () => {
  189. const requests: SubagentStartRequest[] = []
  190. const ctx = await setup({
  191. provider: 'mock',
  192. withModelSelection: true,
  193. agentOptions: { provider: 'alpha' },
  194. }, { onStart: (request) => { requests.push(request) } })
  195. ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
  196. const parent = modelSelectionSetupAgent(ctx)
  197. ;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options
  198. const result = await callSubagent(ctx, {
  199. description: 'effort work',
  200. prompt: 'do it',
  201. reasoning_effort: 'low',
  202. })
  203. expect(result.isError).toBe(false)
  204. expect(requests[0]?.agentOptions).toEqual({ provider: 'alpha', reasoningEffort: 'low' })
  205. const inherited = await setup({ provider: 'mock', withModelSelection: true })
  206. inherited.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
  207. const inheritedParent = modelSelectionSetupAgent(inherited)
  208. ;(inheritedParent as unknown as { options: Agent['options'] }).options = parentWithRoute().options
  209. const inheritedResult = await callSubagent(inherited, {
  210. description: 'parent effort work',
  211. prompt: 'do it',
  212. reasoning_effort: 'low',
  213. })
  214. expect(inheritedResult.isError).toBe(false)
  215. })
  216. it('inherits a parent effort only when an explicit route stays unchanged', async () => {
  217. const ctx = await setup({ provider: 'mock', withModelSelection: true })
  218. ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
  219. const parent = modelSelectionSetupAgent(ctx)
  220. ;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options
  221. const result = await callSubagent(ctx, {
  222. description: 'same route work',
  223. prompt: 'do it',
  224. provider: 'alpha',
  225. model: 'parent-model',
  226. })
  227. expect(result.isError).toBe(false)
  228. })
  229. it('compares explicit routes with the latest logged parent selection', async () => {
  230. const requests: SubagentStartRequest[] = []
  231. const ctx = await setup({
  232. provider: 'mock',
  233. withModelSelection: true,
  234. agentOptions: { reasoningEffort: ReasoningEffortId('high') },
  235. }, { onStart: (request) => { requests.push(request) } })
  236. ctx.llm.registerAdapter(['current-provider'], new MockAdapter([], REASONING))
  237. const parent = modelSelectionSetupAgent(ctx)
  238. ;(parent as unknown as { options: Agent['options'] }).options = {
  239. provider: 'created-provider', model: 'created-model',
  240. }
  241. parent.session.append('request/header', {
  242. header: { config: { provider: 'current-provider', model: 'current-model' } },
  243. reason: 'initial',
  244. })
  245. const result = await callSubagent(ctx, {
  246. description: 'same current route',
  247. prompt: 'do it',
  248. provider: 'current-provider',
  249. model: 'current-model',
  250. })
  251. expect(result.isError).toBe(false)
  252. expect(requests[0]?.agentOptions).toEqual({
  253. provider: 'current-provider',
  254. model: 'current-model',
  255. reasoningEffort: 'high',
  256. })
  257. })
  258. it('rejects an effort without any effective route', async () => {
  259. const ctx = await setup({ provider: 'mock', withModelSelection: true })
  260. const result = await callSubagent(ctx, {
  261. description: 'missing route',
  262. prompt: 'do it',
  263. reasoning_effort: 'low',
  264. })
  265. expect(result.isError).toBe(true)
  266. expect(text(result)).toContain('without an effective provider and model')
  267. })
  268. it('rejects preflight without an effective provider and model', async () => {
  269. const ctx = await setup({ provider: 'mock' })
  270. await expect(preflightChildLlmRoute(ctx.llm, {}, undefined, AbortSignal.abort()))
  271. .rejects.toThrow('without an effective provider and model')
  272. })
  273. it.each([
  274. { provider: 'alpha' },
  275. { model: 'fast-model' },
  276. ])('rejects a partial model-facing route before child creation', async (route) => {
  277. let starts = 0
  278. const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } })
  279. const result = await callSubagent(ctx, { description: 'partial route', prompt: 'do it', ...route })
  280. expect(result.isError).toBe(true)
  281. expect(text(result)).toContain('`provider` and `model` must be supplied together')
  282. expect(starts).toBe(0)
  283. })
  284. it.each([
  285. { provider: '', model: 'fast-model', expected: '`provider` must be non-empty' },
  286. { provider: 'alpha', model: '', expected: '`model` must be non-empty' },
  287. { reasoning_effort: '', expected: '`reasoning_effort` must be non-empty' },
  288. ])('rejects empty model-facing values', async ({ expected, ...selection }) => {
  289. const ctx = await setup({ provider: 'mock', withModelSelection: true })
  290. const result = await callSubagent(ctx, { description: 'empty route', prompt: 'do it', ...selection })
  291. expect(result.isError).toBe(true)
  292. expect(text(result)).toContain(expected)
  293. })
  294. it('uses the LLM runtime for provider and reasoning-effort validation before child creation', async () => {
  295. let starts = 0
  296. const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } })
  297. ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
  298. const unsupported = await callSubagent(ctx, {
  299. description: 'bad effort',
  300. prompt: 'do it',
  301. provider: 'alpha',
  302. model: 'fast-model',
  303. reasoning_effort: 'max',
  304. })
  305. expect(unsupported.isError).toBe(true)
  306. expect(text(unsupported)).toContain('does not support reasoning effort "max"')
  307. const missing = await callSubagent(ctx, {
  308. description: 'bad provider',
  309. prompt: 'do it',
  310. provider: 'missing',
  311. model: 'fast-model',
  312. })
  313. expect(missing.isError).toBe(true)
  314. expect(text(missing)).toContain('no adapter registered for provider "missing"')
  315. expect(starts).toBe(0)
  316. })
  317. it('validates a configured effort before child creation', async () => {
  318. let starts = 0
  319. const ctx = await setup({
  320. provider: 'mock',
  321. agentOptions: {
  322. provider: 'alpha',
  323. model: 'parent-model',
  324. reasoningEffort: ReasoningEffortId('high'),
  325. },
  326. }, { onStart: () => { starts += 1 } })
  327. ctx.llm.registerAdapter(['alpha'], new MockAdapter([], {
  328. efforts: [{ id: ReasoningEffortId('low'), name: 'Low' }],
  329. defaultEffort: ReasoningEffortId('low'),
  330. }))
  331. const result = await callSubagent(
  332. ctx,
  333. { description: 'same route', prompt: 'do it' },
  334. { agent: parentWithRoute() },
  335. )
  336. expect(result.isError).toBe(true)
  337. expect(text(result)).toContain('does not support reasoning effort "high"')
  338. expect(starts).toBe(0)
  339. })
  340. it('validates a configured route before child creation', async () => {
  341. let starts = 0
  342. const ctx = await setup({
  343. provider: 'mock',
  344. agentOptions: { provider: 'missing', model: 'configured-model' },
  345. }, { onStart: () => { starts += 1 } })
  346. const result = await callSubagent(
  347. ctx,
  348. { description: 'configured route', prompt: 'do it' },
  349. { agent: parentWithRoute() },
  350. )
  351. expect(result.isError).toBe(true)
  352. expect(text(result)).toContain('no adapter registered for provider "missing"')
  353. expect(starts).toBe(0)
  354. })
  355. it('rejects selected routes or configured efforts when the LLM service is absent', async () => {
  356. const ctx = new Context()
  357. await ctx.plugin(SessionProjectionRegistry)
  358. await ctx.plugin(SystemPrompt)
  359. await ctx.plugin(ToolRuntime)
  360. await ctx.plugin(SubagentRuntime)
  361. await mock.mountScriptedProvider(ctx, { name: 'mock' })
  362. await ctx.plugin(tool, {
  363. provider: 'mock',
  364. agentOptions: {
  365. provider: 'alpha',
  366. model: 'fast-model',
  367. reasoningEffort: ReasoningEffortId('high'),
  368. },
  369. })
  370. const configured = await callSubagent(ctx, { description: 'configured effort', prompt: 'do it' })
  371. expect(configured.isError).toBe(true)
  372. expect(text(configured)).toContain('`llm` service is unavailable')
  373. })
  374. it('keeps pure inherited routing usable without an LLM service lookup', async () => {
  375. let starts = 0
  376. const ctx = new Context()
  377. await ctx.plugin(SessionProjectionRegistry)
  378. await ctx.plugin(SystemPrompt)
  379. await ctx.plugin(ToolRuntime)
  380. await ctx.plugin(SubagentRuntime)
  381. await mock.mountScriptedProvider(ctx, { name: 'mock', onStart: () => { starts += 1 } })
  382. await ctx.plugin(tool, { provider: 'mock' })
  383. const result = await callSubagent(ctx, { description: 'inherit route', prompt: 'do it' })
  384. expect(result.isError).toBe(false)
  385. expect(starts).toBe(1)
  386. })
  387. it('warns that changing a fork route can lose inherited-prefix reuse', async () => {
  388. const ctx = await setup({ provider: 'mock', withModelSelection: true }, { inheritsParentContext: true })
  389. const schema = ctx.tools.schemas(modelSelectionSetupAgent(ctx)).find(entry => entry.name === 'subagent')!
  390. expect(schema.description).toContain('inherits this conversation')
  391. expect(schema.description).toContain('can prevent provider-side reuse of the inherited conversation prefix')
  392. })
  393. it('propagates an exact-route resolver failure before child creation', async () => {
  394. let starts = 0
  395. const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } })
  396. const adapter = new MockAdapter([])
  397. vi.spyOn(adapter, 'resolveModel').mockRejectedValue(new Error('selected route unavailable'))
  398. ctx.llm.registerAdapter(['alpha'], adapter)
  399. const result = await callSubagent(ctx, {
  400. description: 'route work',
  401. prompt: 'do it',
  402. provider: 'alpha',
  403. model: 'fast-model',
  404. })
  405. expect(result.isError).toBe(true)
  406. expect(text(result)).toContain('selected route unavailable')
  407. expect(starts).toBe(0)
  408. })
  409. })