index.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. /**
  2. * Advisory per-agent repeat-call detector. It enriches post-execute decisions
  3. * with logged model context without vetoing or rewriting calls. Configuration
  4. * and chain semantics live in the package README; rationale lives in the
  5. * repeat-tool-guard Agent Note.
  6. * @module @deepseek-ai/dsh-repeat-tool-guard
  7. */
  8. import type { Context } from 'cordis'
  9. import z from 'schemastery'
  10. import type { Agent, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
  11. import type { MessageSource } from '@deepseek-ai/dsh-llm'
  12. import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
  13. export const name = 'repeat-tool-guard'
  14. /**
  15. * Plugin config, validated by the same-named schemastery schema plus the
  16. * load-time checks in `apply` (misconfiguration fails loud: an empty
  17. * `thresholds` list, a non-integer, a value below 2, or a duplicate throws at
  18. * plugin load, never a silent fall-back). `include`/`exclude` entries are
  19. * `*`-wildcard predicates over tool names at call time, not references to
  20. * registry entries — a pattern matching no currently registered tool is valid
  21. * (`exclude: [mcp_*]` must stay legal in a deployment that loads no MCP tools).
  22. */
  23. export interface Config {
  24. /** Consecutive-repeat counts that trigger a reminder (default `[3, 5, 8]`). */
  25. thresholds?: number[]
  26. /** Tool-name patterns to track; empty means every tool is tracked. */
  27. include?: string[]
  28. /** Tool-name patterns transparent to the chain (neither count nor reset). */
  29. exclude?: string[]
  30. /**
  31. * Maximum characters of canonical arguments quoted in the DETAILED reminder
  32. * (default 500). Large payloads (a `write` body, a long command) would
  33. * otherwise ride into the next request unbounded — precisely in a loop
  34. * scenario; the cap bounds the reminder, never the detection (the chain key
  35. * always compares the FULL canonical string).
  36. */
  37. argumentsPreviewChars?: number
  38. }
  39. export const Config: z<Config> = z.object({
  40. thresholds: z.array(z.number()).default([3, 5, 8]),
  41. include: z.array(z.string()).default([]),
  42. exclude: z.array(z.string()).default([]),
  43. argumentsPreviewChars: z.number().default(500),
  44. })
  45. /**
  46. * The `{kind:'plugin'}` source stamped on every reminder this guard injects —
  47. * the label is load-bearing (an unlabeled context would render as a user
  48. * prompt in derived history).
  49. */
  50. const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'repeat-tool-guard' }
  51. /**
  52. * The gentle first-threshold reminder. Keyed to `thresholds[0]`, not a literal
  53. * count, so a custom first threshold keeps the gentle-then-detailed escalation.
  54. */
  55. const GENTLE_REMINDER =
  56. 'You are repeating the exact same tool call with identical arguments. '
  57. + 'Carefully analyze the previous result before calling again: if the task is '
  58. + 'not complete, try a different approach or different arguments instead of '
  59. + 'repeating the call.'
  60. /** The detailed later-threshold reminder naming the tool, the run length, and the canonical arguments. */
  61. function detailedReminder(toolName: string, count: number, canonicalArguments: string): string {
  62. return 'Repeated tool call detected:\n'
  63. + `- tool: ${toolName}\n`
  64. + `- consecutive_calls: ${count}\n`
  65. + `- arguments: ${canonicalArguments}\n`
  66. + 'The repeated calls are not making progress. Do not call this tool with '
  67. + 'these exact arguments again. Inspect the latest result and choose a '
  68. + 'different action, different arguments, or finish the task if enough '
  69. + 'evidence has been gathered.'
  70. }
  71. /**
  72. * Deep key-sort of a parsed-JSON value so two argument objects that differ
  73. * only in property order canonicalize identically. Arguments reach the guard
  74. * as the loop's `JSON.parse` output (or its raw-string fallback for malformed
  75. * argument JSON), so JSON's value domain is the whole input domain — no
  76. * bigint, cycle, or `undefined` handling exists because no input path can
  77. * produce them.
  78. */
  79. function sortJsonValue(value: unknown): unknown {
  80. if (Array.isArray(value)) return value.map(sortJsonValue)
  81. if (value !== null && typeof value === 'object') {
  82. const record = value as Record<string, unknown>
  83. const sorted: Record<string, unknown> = {}
  84. for (const key of Object.keys(record).sort()) {
  85. sorted[key] = sortJsonValue(record[key])
  86. }
  87. return sorted
  88. }
  89. return value
  90. }
  91. /** Canonical string form of a call's arguments: deep key-sort, then stringify. */
  92. function canonicalize(argumentsValue: unknown): string {
  93. return JSON.stringify(sortJsonValue(argumentsValue))
  94. }
  95. /** Compile one `*`-wildcard pattern to an anchored RegExp (every other regex metacharacter is matched literally). */
  96. function wildcardToRegExp(pattern: string): RegExp {
  97. const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, String.raw`\$&`)
  98. return new RegExp(`^${escaped.replaceAll('*', '.*')}$`)
  99. }
  100. /**
  101. * Head-truncate the canonical arguments for quoting in the detailed reminder,
  102. * marking how much was omitted. Bounds only the model-visible text — the
  103. * chain key always uses the full canonical string.
  104. */
  105. function previewArguments(canonical: string, cap: number): string {
  106. if (canonical.length <= cap) return canonical
  107. return `${canonical.slice(0, cap)}… (+${canonical.length - cap} more chars)`
  108. }
  109. /**
  110. * Validate `thresholds` per the fail-loud contract and return them sorted
  111. * ascending (the escalation rule reads `thresholds[0]` as the gentle tier, so
  112. * order is normalized here, once).
  113. */
  114. function validateThresholds(values: number[]): number[] {
  115. if (values.length === 0) {
  116. throw new Error('repeat-tool-guard: `thresholds` must not be empty')
  117. }
  118. for (const value of values) {
  119. if (!Number.isInteger(value) || value < 2) {
  120. throw new Error(`repeat-tool-guard: invalid threshold ${value} — every threshold must be an integer >= 2`)
  121. }
  122. }
  123. if (new Set(values).size !== values.length) {
  124. throw new Error('repeat-tool-guard: `thresholds` must not contain duplicates')
  125. }
  126. return [...values].sort((a, b) => a - b)
  127. }
  128. /**
  129. * Prepend the guard's reminder while preserving every downstream context's
  130. * source and metadata.
  131. */
  132. function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] {
  133. return [ours, ...theirs ?? []]
  134. }
  135. /** One agent's consecutive-repeat chain: the last tracked call's identity key and its run length. */
  136. interface Chain {
  137. key: string
  138. count: number
  139. }
  140. /**
  141. * Install the guard's listeners.
  142. * @param ctx - plugin context; listeners are scoped to it and disposed with it.
  143. * @param config - validated {@link Config}; `thresholds` is re-checked fail-loud here.
  144. */
  145. export function apply(ctx: Context, config: Config): void {
  146. // schemastery's .default() guarantees the fields are set after validation.
  147. const thresholds = validateThresholds(config.thresholds as number[])
  148. const thresholdSet = new Set(thresholds)
  149. const includePatterns = (config.include as string[]).map(wildcardToRegExp)
  150. const excludePatterns = (config.exclude as string[]).map(wildcardToRegExp)
  151. const argumentsPreviewChars = config.argumentsPreviewChars as number
  152. if (!Number.isInteger(argumentsPreviewChars) || argumentsPreviewChars < 1) {
  153. throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`)
  154. }
  155. const chains = new WeakMap<Agent, Chain>()
  156. /** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */
  157. function tracked(toolName: string): boolean {
  158. if (includePatterns.length > 0 && !includePatterns.some(pattern => pattern.test(toolName))) return false
  159. return !excludePatterns.some(pattern => pattern.test(toolName))
  160. }
  161. /**
  162. * Advance the calling agent's chain for one attempt and return the reminder
  163. * to deliver, if this attempt's run length hits a configured threshold.
  164. * Counting happens here — in post-execute — because denied calls also flow
  165. * through this waterfall (`ToolRegistry.execute` routes a deny through the
  166. * same pipeline), and a model hammering a denied call is exactly the loop
  167. * worth breaking.
  168. */
  169. function observe(exec: ToolExecution): HookContext | undefined {
  170. // A direct `ctx.tools.execute()` caller has no model to remind and no id
  171. // to key on; only agent-loop calls participate.
  172. if (!exec.agent) return undefined
  173. if (!tracked(exec.name)) return undefined
  174. const canonical = canonicalize(exec.arguments)
  175. const key = JSON.stringify([exec.name, canonical])
  176. const chain = chains.get(exec.agent)
  177. const count = chain !== undefined && chain.key === key ? chain.count + 1 : 1
  178. chains.set(exec.agent, { key, count })
  179. if (!thresholdSet.has(count)) return undefined
  180. const text = count === thresholds[0]
  181. ? GENTLE_REMINDER
  182. : detailedReminder(exec.name, count, previewArguments(canonical, argumentsPreviewChars))
  183. return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE }
  184. }
  185. // Observe-and-enrich, never veto: count first (state advances regardless of
  186. // the downstream outcome), DELEGATE so a later listener can still block or
  187. // replace, then fold the reminder onto whatever came back — additionalContexts
  188. // rides both decision variants, so a blocked call still gets the nudge.
  189. ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
  190. const reminder = observe(exec)
  191. const downstream = await next()
  192. if (!reminder) return downstream
  193. if (downstream.kind === 'block') {
  194. return { kind: 'block', feedback: downstream.feedback, additionalContexts: prependContext(reminder, downstream.additionalContexts) }
  195. }
  196. return {
  197. ...downstream,
  198. additionalContexts: prependContext(reminder, downstream.additionalContexts),
  199. }
  200. })
  201. // A user interjection changes the context; repetition across it is not a
  202. // loop. Pure reset hook: always delegates (attaching nothing, vetoing
  203. // nothing).
  204. ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next): Promise<PromptDecision> => {
  205. chains.delete(agent)
  206. return next()
  207. })
  208. }