repeat-tool-guard.spec.ts 17 KB

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