index.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. /** Scoped tool that declares filesystem deliveries in their owning Session. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import z from '@deepseek-ai/schemastery'
  4. import { FsError } from '@deepseek-ai/dsh-fs'
  5. import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools'
  6. import type {} from '@deepseek-ai/dsh-agent'
  7. import type {} from '@deepseek-ai/dsh-session-projection'
  8. import type { Session } from '@deepseek-ai/dsh-session'
  9. import type { PresentedFile } from './types.ts'
  10. /** Stable Loader identity. */
  11. export const name = 'tool-present'
  12. /** Per-call delivery limit. */
  13. export interface Config {
  14. /** Maximum number of files in one call. */
  15. maxFiles: number
  16. }
  17. /** Validated delivery limit. */
  18. export const Config: z<Config> = z.object({
  19. maxFiles: z.number().default(8),
  20. })
  21. /** Services used by the scoped delivery tool. */
  22. export const inject = ['tools', 'fs', 'sessionProjections']
  23. /**
  24. * Register present with durable file references in its tool result.
  25. * @param ctx - agent-scoped services.
  26. * @param config - maximum files per call.
  27. */
  28. export function apply(ctx: Context, config: Config): void {
  29. if (!Number.isSafeInteger(config.maxFiles) || config.maxFiles < 1) {
  30. throw new Error('present requires a positive integer maxFiles')
  31. }
  32. const pending = new WeakMap<ToolExecution, { session: Session; turn: number; files: PresentedFile[] }>()
  33. ctx.tools.register(defineTool({
  34. name: 'present',
  35. description: 'Declare existing files accessible through the Session filesystem as final deliverables. '
  36. + 'When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. '
  37. + 'Mentioning its path in your reply does not replace this call. The files must already exist. '
  38. + 'The user opens the current source files; their contents are not copied or preserved.',
  39. parameters: {
  40. files: {
  41. type: 'array', required: true,
  42. items: {
  43. type: 'object', additionalProperties: false,
  44. properties: {
  45. path: { type: 'string', required: true, description: 'Path of an existing regular file. Relative paths use the Session working directory.' },
  46. description: { type: 'string', description: 'Brief description for the user.' },
  47. },
  48. },
  49. },
  50. },
  51. output: {
  52. schema: {
  53. type: 'object', additionalProperties: false,
  54. properties: {
  55. turn: { type: 'integer', required: true },
  56. files: {
  57. type: 'array', required: true,
  58. items: {
  59. type: 'object', additionalProperties: false,
  60. properties: {
  61. path: { type: 'string', required: true },
  62. description: { type: 'string' },
  63. },
  64. },
  65. },
  66. },
  67. },
  68. render: (_args, value) => [{ type: 'text', text: value.files.map(file => `Presented ${file.path}`).join('\n') }],
  69. },
  70. async execute(args, exec) {
  71. if (exec.agent === undefined) throw new Error('present requires an agent Session')
  72. const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary')
  73. if (boundary === undefined || boundary.openTurnStartSeq === null) throw new Error('present requires an open turn')
  74. if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`)
  75. const cwd = exec.agent.session.header.cwd
  76. if (cwd === undefined) throw new Error('present requires a workspace')
  77. const options = { cwd, signal: exec.signal }
  78. const files: PresentedFile[] = []
  79. for (const file of args.files) {
  80. if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path')
  81. const entry = await ctx.fs.lstat(file.path, { cwd }, exec.signal)
  82. if (entry !== undefined && entry.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`)
  83. const target = await ctx.fs.resolve(file.path, options)
  84. const info = await ctx.fs.stat(target, exec.signal)
  85. if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND')
  86. if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`)
  87. files.push({ ...file })
  88. }
  89. exec.signal.throwIfAborted()
  90. pending.set(exec, { session: exec.agent.session, turn: boundary.lastTurn, files })
  91. return { turn: boundary.lastTurn, files }
  92. },
  93. }))
  94. ctx.on('tools/result', (exec, result) => {
  95. const delivery = pending.get(exec)
  96. pending.delete(exec)
  97. if (delivery === undefined || result.isError) return
  98. const { session, turn, files } = delivery
  99. session.append('deliverables/presented', {
  100. turn, callId: exec.callId, files,
  101. })
  102. })
  103. }