model-selection.spec.ts 14 KB

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