repeat-tool-guard.spec.ts 18 KB

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