repeat-tool-guard.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
  4. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  5. import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  8. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  9. import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
  10. import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard'
  11. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  12. const testToolSignal = new AbortController().signal
  13. /**
  14. * Behavior suite for the repeat-tool-call guard: chain semantics (identical /
  15. * different-tracked / untracked-transparent / per-agent / resets), threshold
  16. * escalation incl. the `thresholds[0]` gentle-text rule, canonicalization,
  17. * fold-onto-downstream-decision, and fail-loud config validation — all driven
  18. * through a real agent loop against a scripted mock adapter (no network).
  19. */
  20. /** Boot the core spine + the guard; the caller registers adapters and extra listeners. */
  21. async function harness(config: Config = {}): Promise<Context> {
  22. const ctx = new Context()
  23. await mountAgentLoopTestDependencies(ctx)
  24. await ctx.plugin(AgentLoop, { agents: [] })
  25. await ctx.plugin(RepeatToolGuard, config)
  26. ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  27. ctx.tools.register(defineContentToolFixture({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  28. return ctx
  29. }
  30. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  31. return new Promise((resolve) => { const d = ctx.on('agent/status', ({ agent: s, status: st }) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
  32. }
  33. /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */
  34. function reminders(agent: Agent): { text: string; source: unknown }[] {
  35. return [...agent.session.events]
  36. .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user')
  37. .map(e => ({
  38. text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'),
  39. source: e.data.source,
  40. }))
  41. }
  42. // The reminder is a `notice`-form context; its summary names the repeated
  43. // call so a reader sees it without expanding the row.
  44. const guardSource = (tool: string, count: number) => ({
  45. kind: 'plugin',
  46. plugin: 'repeat-tool-guard',
  47. form: 'notice',
  48. summary: `${tool} × ${count}`,
  49. })
  50. describe('threshold escalation', () => {
  51. it('reminds gently at the first default threshold (3) and in detail at the second (5)', async () => {
  52. const ctx = await harness()
  53. const adapter = new MockAdapter([
  54. ...Array.from({ length: 5 }, (_, i) => toolCallResponse(`c${i}`, 'probe', { q: 'same' })),
  55. textResponse('done'),
  56. ])
  57. ctx.llm.registerAdapter(['mock'], adapter)
  58. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  59. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  60. await waitForIdle(ctx, agent)
  61. const found = reminders(agent)
  62. expect(found).toHaveLength(2)
  63. expect(found[0]!.text).toContain('repeating the exact same tool call')
  64. expect(found[0]!.source).toEqual(guardSource('probe', 3))
  65. expect(found[1]!.text).toContain('consecutive_calls: 5')
  66. expect(found[1]!.text).toContain('- tool: probe')
  67. expect(found[1]!.text).toContain('{"q":"same"}')
  68. expect(found[1]!.source).toEqual(guardSource('probe', 5))
  69. })
  70. it('keys the gentle text to thresholds[0], not the literal 3', async () => {
  71. const ctx = await harness({ thresholds: [4, 2] }) // unsorted on purpose: normalized ascending
  72. const adapter = new MockAdapter([
  73. ...Array.from({ length: 4 }, (_, i) => toolCallResponse(`c${i}`, 'probe', {})),
  74. textResponse('done'),
  75. ])
  76. ctx.llm.registerAdapter(['mock'], adapter)
  77. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  78. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  79. await waitForIdle(ctx, agent)
  80. const found = reminders(agent)
  81. expect(found).toHaveLength(2)
  82. expect(found[0]!.text).toContain('repeating the exact same tool call') // gentle at 2
  83. expect(found[1]!.text).toContain('consecutive_calls: 4') // detailed at 4
  84. })
  85. })
  86. describe('chain semantics', () => {
  87. it('caps the detailed reminder arguments at argumentsPreviewChars (detection still keys on the full string)', async () => {
  88. const ctx = await harness({ thresholds: [2, 3], argumentsPreviewChars: 24 })
  89. const bigPayload = 'x'.repeat(400)
  90. const adapter = new MockAdapter([
  91. toolCallResponse('c1', 'probe', { body: bigPayload }),
  92. toolCallResponse('c2', 'probe', { body: bigPayload }),
  93. toolCallResponse('c3', 'probe', { body: bigPayload }),
  94. textResponse('done'),
  95. ])
  96. ctx.llm.registerAdapter(['mock'], adapter)
  97. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  98. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  99. await waitForIdle(ctx, agent)
  100. const found = reminders(agent)
  101. expect(found).toHaveLength(2) // gentle at 2, detailed at 3 — full-key matching survived the cap
  102. const detailed = found[1]!.text
  103. expect(detailed).toContain('- arguments: {"body":"xxxxxxxxxxxxxx') // 24-char head
  104. expect(detailed).toContain('… (+387 more chars)')
  105. expect(detailed).not.toContain(bigPayload)
  106. })
  107. it('a different tracked call resets the chain', async () => {
  108. const ctx = await harness()
  109. const adapter = new MockAdapter([
  110. toolCallResponse('c1', 'probe', { q: 1 }),
  111. toolCallResponse('c2', 'probe', { q: 1 }),
  112. toolCallResponse('c3', 'other', {}), // tracked, different → reset
  113. toolCallResponse('c4', 'probe', { q: 1 }),
  114. toolCallResponse('c5', 'probe', { q: 1 }),
  115. toolCallResponse('c6', 'probe', { q: 1 }), // 3rd consecutive AFTER the reset
  116. textResponse('done'),
  117. ])
  118. ctx.llm.registerAdapter(['mock'], adapter)
  119. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  120. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  121. await waitForIdle(ctx, agent)
  122. expect(reminders(agent)).toHaveLength(1)
  123. })
  124. it('excluded calls are transparent: they neither count nor reset', async () => {
  125. const ctx = await harness({ exclude: ['other'] })
  126. const adapter = new MockAdapter([
  127. toolCallResponse('c1', 'probe', { q: 1 }),
  128. toolCallResponse('c2', 'other', {}), // excluded → invisible to the chain
  129. toolCallResponse('c3', 'probe', { q: 1 }),
  130. toolCallResponse('c4', 'other', {}),
  131. toolCallResponse('c5', 'probe', { q: 1 }), // 3rd consecutive probe
  132. textResponse('done'),
  133. ])
  134. ctx.llm.registerAdapter(['mock'], adapter)
  135. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  136. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  137. await waitForIdle(ctx, agent)
  138. const found = reminders(agent)
  139. expect(found).toHaveLength(1)
  140. expect(found[0]!.text).toContain('repeating the exact same tool call')
  141. })
  142. it('include patterns track only matching tools (wildcard star)', async () => {
  143. const ctx = await harness({ include: ['pro*'] })
  144. const adapter = new MockAdapter([
  145. toolCallResponse('c1', 'other', {}),
  146. toolCallResponse('c2', 'other', {}),
  147. toolCallResponse('c3', 'other', {}), // 3 identical, but untracked
  148. toolCallResponse('c4', 'probe', {}),
  149. toolCallResponse('c5', 'probe', {}),
  150. toolCallResponse('c6', 'probe', {}), // 3 identical, tracked
  151. textResponse('done'),
  152. ])
  153. ctx.llm.registerAdapter(['mock'], adapter)
  154. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  155. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  156. await waitForIdle(ctx, agent)
  157. const found = reminders(agent)
  158. expect(found).toHaveLength(1)
  159. expect(found[0]!.text).toContain('repeating the exact same tool call')
  160. })
  161. it('escapes regex metacharacters in patterns (a dot matches only a literal dot)', async () => {
  162. const ctx = await harness({ exclude: ['pr.be'] }) // would match 'probe' as a regex; must not as a wildcard
  163. const adapter = new MockAdapter([
  164. ...Array.from({ length: 3 }, (_, i) => toolCallResponse(`c${i}`, 'probe', {})),
  165. textResponse('done'),
  166. ])
  167. ctx.llm.registerAdapter(['mock'], adapter)
  168. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  169. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  170. await waitForIdle(ctx, agent)
  171. expect(reminders(agent)).toHaveLength(1) // probe was NOT excluded
  172. })
  173. it('canonicalization ignores property order, deeply', async () => {
  174. const ctx = await harness()
  175. const adapter = new MockAdapter([
  176. toolCallResponse('c1', 'probe', { a: 1, nested: { x: [1, 2], y: null } }),
  177. toolCallResponse('c2', 'probe', { nested: { y: null, x: [1, 2] }, a: 1 }),
  178. toolCallResponse('c3', 'probe', { a: 1, nested: { x: [1, 2], y: null } }),
  179. textResponse('done'),
  180. ])
  181. ctx.llm.registerAdapter(['mock'], adapter)
  182. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  183. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  184. await waitForIdle(ctx, agent)
  185. expect(reminders(agent)).toHaveLength(1) // all three canonicalize identically
  186. })
  187. it('keys chains per agent: one agent repeating never trips another', async () => {
  188. const ctx = await harness()
  189. ctx.llm.registerAdapter(['mock-a'], new MockAdapter([
  190. toolCallResponse('a1', 'probe', { q: 1 }),
  191. toolCallResponse('a2', 'probe', { q: 1 }),
  192. textResponse('done'),
  193. ]))
  194. ctx.llm.registerAdapter(['mock-b'], new MockAdapter([
  195. toolCallResponse('b1', 'probe', { q: 1 }),
  196. toolCallResponse('b2', 'probe', { q: 1 }),
  197. toolCallResponse('b3', 'probe', { q: 1 }),
  198. textResponse('done'),
  199. ]))
  200. const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' })
  201. const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' })
  202. agentA.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  203. agentB.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  204. await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)])
  205. expect(reminders(agentA)).toHaveLength(0) // 2 repeats < 3, despite B's 3 in the same registry
  206. expect(reminders(agentB)).toHaveLength(1)
  207. })
  208. it('a new user prompt resets the chain', async () => {
  209. const ctx = await harness()
  210. const adapter = new MockAdapter([
  211. toolCallResponse('c1', 'probe', { q: 1 }),
  212. toolCallResponse('c2', 'probe', { q: 1 }),
  213. textResponse('turn one done'),
  214. toolCallResponse('c3', 'probe', { q: 1 }), // without the reset this would be the 3rd
  215. textResponse('turn two done'),
  216. ])
  217. ctx.llm.registerAdapter(['mock'], adapter)
  218. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  219. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  220. await waitForIdle(ctx, agent)
  221. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }))
  222. await waitForIdle(ctx, agent)
  223. expect(reminders(agent)).toHaveLength(0)
  224. })
  225. it('drops an agent chain on disposal', async () => {
  226. const ctx = await harness({ thresholds: [2] })
  227. ctx.llm.registerAdapter(['mock'], new MockAdapter([
  228. toolCallResponse('c1', 'probe', { q: 1 }),
  229. textResponse('done'),
  230. toolCallResponse('c2', 'probe', { q: 1 }), // same id, fresh agent: count 1, not 2
  231. textResponse('done'),
  232. ]))
  233. // Loop agents are torn down by disposing the scope that created them
  234. // (the loop.spec pattern): a child plugin fiber owns `first`.
  235. let first!: Agent
  236. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  237. first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
  238. }, { inject: ['agentLoop'] }))
  239. first.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  240. await waitForIdle(ctx, first)
  241. await fiber.dispose()
  242. await first.whenIdle()
  243. const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
  244. second.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  245. await waitForIdle(ctx, second)
  246. expect(reminders(second)).toHaveLength(0)
  247. })
  248. it('counts denied calls: hammering a denied tool still draws the reminder', async () => {
  249. const ctx = await harness({ thresholds: [2] })
  250. ctx.on('tools/pre-execute', async () => ({ kind: 'deny' as const, reason: 'sealed' }))
  251. const adapter = new MockAdapter([
  252. toolCallResponse('c1', 'probe', { q: 1 }),
  253. toolCallResponse('c2', 'probe', { q: 1 }),
  254. textResponse('done'),
  255. ])
  256. ctx.llm.registerAdapter(['mock'], adapter)
  257. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  258. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  259. await waitForIdle(ctx, agent)
  260. expect(reminders(agent)).toHaveLength(1)
  261. })
  262. it('ignores direct executes with no agent (they neither crash nor advance any chain)', async () => {
  263. const ctx = await harness({ thresholds: [2] })
  264. const direct = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('d1'), name: 'probe', arguments: { q: 1 } })
  265. expect(direct.isError).toBe(false)
  266. ctx.llm.registerAdapter(['mock'], new MockAdapter([
  267. toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2
  268. textResponse('done'),
  269. ]))
  270. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  271. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  272. await waitForIdle(ctx, agent)
  273. expect(reminders(agent)).toHaveLength(0)
  274. })
  275. })
  276. describe('fold onto the downstream decision', () => {
  277. it('folds the reminder onto a downstream block and keeps its feedback', async () => {
  278. const ctx = await harness({ thresholds: [2] })
  279. ctx.on('tools/post-execute', async () => ({
  280. kind: 'block' as const,
  281. feedback: [{ type: 'text' as const, text: 'nope' }],
  282. additionalContexts: [createUserMessage({
  283. content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'plugin' as const, plugin: 'test' },
  284. })],
  285. }))
  286. const adapter = new MockAdapter([
  287. toolCallResponse('c1', 'probe', { q: 1 }),
  288. toolCallResponse('c2', 'probe', { q: 1 }),
  289. textResponse('done'),
  290. ])
  291. ctx.llm.registerAdapter(['mock'], adapter)
  292. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  293. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  294. await waitForIdle(ctx, agent)
  295. const found = reminders(agent)
  296. expect(found).toHaveLength(3)
  297. // Only the repeated call adds guard context; downstream provenance survives.
  298. expect(found[0]!.text).toBe('downstream-ctx')
  299. expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
  300. expect(found[1]!.text).toContain('repeating the exact same tool call')
  301. expect(found[1]!.source).toEqual(guardSource('probe', 2))
  302. expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } })
  303. // The block's feedback reached the tool result unchanged.
  304. const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
  305. expect(results.every(r => r.data.message.content[0].isError)).toBe(true)
  306. expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'nope' }])
  307. })
  308. it('preserves a downstream canonical value replacement while folding', async () => {
  309. const ctx = await harness({ thresholds: [2] })
  310. ctx.on('tools/post-execute', async () => ({
  311. kind: 'accept' as const,
  312. value: [{ type: 'text' as const, text: 'replaced' }],
  313. }))
  314. const adapter = new MockAdapter([
  315. toolCallResponse('c1', 'probe', { q: 1 }),
  316. toolCallResponse('c2', 'probe', { q: 1 }),
  317. textResponse('done'),
  318. ])
  319. ctx.llm.registerAdapter(['mock'], adapter)
  320. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  321. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  322. await waitForIdle(ctx, agent)
  323. const found = reminders(agent)
  324. expect(found).toHaveLength(1)
  325. expect(found[0]!.text).toContain('repeating the exact same tool call')
  326. const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
  327. expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'replaced' }])
  328. })
  329. })
  330. describe('config validation fails loud', () => {
  331. async function spine(): Promise<Context> {
  332. const ctx = new Context()
  333. await mountAgentLoopTestDependencies(ctx)
  334. await ctx.plugin(AgentLoop, { agents: [] })
  335. return ctx
  336. }
  337. it('rejects an empty thresholds list', async () => {
  338. const ctx = await spine()
  339. await expect(ctx.plugin(RepeatToolGuard, { thresholds: [] })).rejects.toThrow(/must not be empty/)
  340. })
  341. it('rejects a threshold below 2', async () => {
  342. const ctx = await spine()
  343. await expect(ctx.plugin(RepeatToolGuard, { thresholds: [1, 3] })).rejects.toThrow(/integer >= 2/)
  344. })
  345. it('rejects a non-integer threshold', async () => {
  346. const ctx = await spine()
  347. await expect(ctx.plugin(RepeatToolGuard, { thresholds: [2.5] })).rejects.toThrow(/integer >= 2/)
  348. })
  349. it('rejects duplicate thresholds', async () => {
  350. const ctx = await spine()
  351. await expect(ctx.plugin(RepeatToolGuard, { thresholds: [3, 3] })).rejects.toThrow(/duplicates/)
  352. })
  353. it('rejects a non-positive or fractional argumentsPreviewChars', async () => {
  354. const ctx = await spine()
  355. await expect(ctx.plugin(RepeatToolGuard, { argumentsPreviewChars: 0 })).rejects.toThrow(/argumentsPreviewChars/)
  356. const ctx2 = await spine()
  357. await expect(ctx2.plugin(RepeatToolGuard, { argumentsPreviewChars: 12.5 })).rejects.toThrow(/argumentsPreviewChars/)
  358. })
  359. })