index.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. /**
  2. * Model-facing persistent `bash` tool over the owner-scoped PTY seam.
  3. * @module @deepseek-ai/dsh-tool-bash-persistent
  4. */
  5. import { randomUUID } from 'node:crypto'
  6. import type { Context } from 'cordis'
  7. import z from 'schemastery'
  8. import type { Agent } from '@deepseek-ai/dsh-agent'
  9. import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty'
  10. import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
  11. import { defineTool } from '@deepseek-ai/dsh-tools'
  12. // TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
  13. const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
  14. const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
  15. const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
  16. const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
  17. const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
  18. // One page is enough to find a just-emitted completion marker; the full
  19. // scrollback is assembled only when a command settles or needs partial output.
  20. const SCROLLBACK_PAGE_LINES = 1_000
  21. const POLL_INTERVAL_MS = 25
  22. const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
  23. interface ResolvedConfig {
  24. backendType: string
  25. timeoutMs: number
  26. maxOutputChars: number
  27. description: string
  28. }
  29. interface CommandMarkers {
  30. start: string
  31. end: string
  32. }
  33. interface RetainedOutput {
  34. text: string
  35. truncated: boolean
  36. }
  37. interface CapturedOutput {
  38. text: string
  39. incomplete: boolean
  40. exitCode?: number
  41. }
  42. interface PersistentShells {
  43. get(owner: Agent, signal: AbortSignal): Promise<PtySessionId>
  44. reset(owner: Agent, reason: string): Promise<void>
  45. }
  46. function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string {
  47. if (content.length <= maxOutputChars && !incomplete) return content
  48. return content.length <= maxOutputChars
  49. ? content + TRUNCATED_MESSAGE
  50. : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
  51. }
  52. function markers(): CommandMarkers {
  53. const nonce = randomUUID()
  54. return {
  55. start: `__DSH_PERSISTENT_BASH_START_${nonce}__`,
  56. end: `__DSH_PERSISTENT_BASH_END_${nonce}:`,
  57. }
  58. }
  59. function quoteForBash(value: string): string {
  60. return `$'${value
  61. .replaceAll('\\', '\\\\')
  62. .replaceAll("'", "\\'")
  63. .replaceAll('\r', '\\r')
  64. .replaceAll('\n', '\\n')}'`
  65. }
  66. function wrapCommand(command: string, marker: CommandMarkers): string {
  67. // Keep the wrapper on one physical line. An interactive bash prints PS2 for
  68. // embedded newlines before executing the buffer, which would leak terminal
  69. // prompts and marker source text into the model-facing result.
  70. return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"`
  71. }
  72. function stripPrompt(text: string): string {
  73. let result = text.replace(/\r?\n$/, '')
  74. while (result.endsWith(SHELL_PROMPT)) {
  75. result = result.slice(0, -SHELL_PROMPT.length)
  76. }
  77. return result.endsWith('\n') ? result.slice(0, -1) : result
  78. }
  79. function commandOutput(
  80. snapshot: RetainedOutput,
  81. marker: CommandMarkers,
  82. ): CapturedOutput | undefined {
  83. const text = snapshot.text
  84. const end = text.lastIndexOf(marker.end)
  85. const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
  86. if (status === undefined) return undefined
  87. const startMarker = text.lastIndexOf(marker.start, end)
  88. const start = startMarker < 0 ? 0 : startMarker + marker.start.length
  89. return {
  90. text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
  91. incomplete: startMarker < 0,
  92. exitCode: Number(status),
  93. }
  94. }
  95. function promptCompleted(result: PtySendResult): boolean {
  96. return result.viewport.endsWith(SHELL_PROMPT)
  97. || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
  98. || result.viewport.endsWith(`${SHELL_PROMPT}\n`)
  99. }
  100. function partialOutput(
  101. snapshot: RetainedOutput,
  102. marker: CommandMarkers,
  103. fallback: string,
  104. fallbackTruncated = false,
  105. ): CapturedOutput {
  106. const startMarker = snapshot.text.lastIndexOf(marker.start)
  107. if (startMarker >= 0) {
  108. return {
  109. text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
  110. incomplete: false,
  111. }
  112. }
  113. const fallbackStart = fallback.lastIndexOf(marker.start)
  114. const afterStart = fallbackStart < 0
  115. ? fallback
  116. : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
  117. const fallbackEnd = afterStart.lastIndexOf(marker.end)
  118. const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
  119. return {
  120. text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')),
  121. incomplete: fallbackTruncated || fallbackStart < 0,
  122. }
  123. }
  124. async function pause(): Promise<void> {
  125. await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
  126. }
  127. function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined {
  128. if (page.text.length === 0 || page.lineEnd <= offset) return undefined
  129. return page.lineEnd
  130. }
  131. function retainedScrollback(
  132. ctx: Context,
  133. owner: Agent,
  134. id: PtySessionId,
  135. latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
  136. ): RetainedOutput {
  137. const pages: string[] = latest.text.length === 0 ? [] : [latest.text]
  138. let offset = latest.lineEnd
  139. let truncated = latest.truncated
  140. while (true) {
  141. if (offset >= latest.totalLines) break
  142. const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
  143. truncated ||= page.truncated
  144. if (page.text.length > 0) pages.unshift(page.text)
  145. const next = nextScrollbackOffset(page, offset)
  146. if (next === undefined || next >= page.totalLines) break
  147. offset = next
  148. }
  149. return { text: pages.join('\n'), truncated }
  150. }
  151. function renderCaptured(output: CapturedOutput, maxOutputChars: number): string {
  152. const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
  153. const withPrefix = output.incomplete && output.text.length > 0
  154. ? LOST_PREFIX_MESSAGE + rendered
  155. : rendered
  156. const marker = output.exitCode !== undefined && output.exitCode !== 0
  157. ? `[exit code: ${output.exitCode}]`
  158. : undefined
  159. return appendStatusMarker(withPrefix, marker)
  160. }
  161. function appendStatusMarker(content: string, marker: string | undefined): string {
  162. if (marker === undefined) return content
  163. return content.length === 0 ? marker : `${content}\n${marker}`
  164. }
  165. function renderShellExitStatus(
  166. content: string,
  167. exitCode: number | null,
  168. signal: NodeJS.Signals | null,
  169. ): string {
  170. const marker = signal !== null
  171. ? `[shell killed by signal: ${signal}]`
  172. : exitCode !== null
  173. ? `[shell exited: code ${exitCode}]`
  174. : '[shell exited]'
  175. return appendStatusMarker(content, marker)
  176. }
  177. function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
  178. const pending = new WeakMap<Agent, Promise<PtySessionId>>()
  179. const live = new Map<Agent, PtySessionId>()
  180. const creating = new Set<Promise<PtySessionId>>()
  181. const ownerCleanupInstalled = new WeakSet<Agent>()
  182. const lifecycle = new AbortController()
  183. const close = async (owner: Agent, id: PtySessionId, reason: string): Promise<void> => {
  184. if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return
  185. await ctx.pty.kill(owner, id, reason)
  186. }
  187. ctx.effect(() => async () => {
  188. lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation'))
  189. await Promise.allSettled([...creating])
  190. const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
  191. await Promise.all(closing)
  192. live.clear()
  193. }, 'tool-bash-persistent shell cleanup')
  194. const reset = async (owner: Agent, reason: string): Promise<void> => {
  195. pending.delete(owner)
  196. const id = live.get(owner)
  197. live.delete(owner)
  198. if (id !== undefined) await close(owner, id, reason)
  199. }
  200. const get = (owner: Agent, signal: AbortSignal): Promise<PtySessionId> => {
  201. const existing = pending.get(owner)
  202. if (existing !== undefined) return existing
  203. const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
  204. const creation = (async () => {
  205. try {
  206. const cwd = owner.session.header.cwd
  207. const spawned = await ctx.pty.spawn(owner, {
  208. type: config.backendType,
  209. ...cwd === undefined ? {} : { cwd },
  210. }, combinedSignal)
  211. live.set(owner, spawned.sessionId)
  212. if (!ownerCleanupInstalled.has(owner)) {
  213. ownerCleanupInstalled.add(owner)
  214. owner.ctx.effect(() => () => {
  215. pending.delete(owner)
  216. live.delete(owner)
  217. }, 'tool-bash-persistent owner cache cleanup')
  218. }
  219. const setup = ctx.pty.startSend(owner, spawned.sessionId, {
  220. text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
  221. submit: true,
  222. signal: combinedSignal,
  223. })
  224. const result = await setup.done
  225. if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
  226. throw new Error('persistent bash shell did not accept initialization')
  227. }
  228. return spawned.sessionId
  229. } catch (error: unknown) {
  230. await reset(owner, 'persistent bash initialization failed')
  231. throw error
  232. }
  233. })()
  234. const tracked = creation.finally(() => {
  235. creating.delete(tracked)
  236. })
  237. creating.add(tracked)
  238. pending.set(owner, tracked)
  239. return tracked
  240. }
  241. return { get, reset }
  242. }
  243. async function executeCommand(
  244. ctx: Context,
  245. shells: PersistentShells,
  246. owner: Agent,
  247. command: string,
  248. config: ResolvedConfig,
  249. upstream: AbortSignal,
  250. ): Promise<string> {
  251. using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
  252. const id = await shells.get(owner, commandDeadline.signal)
  253. const marker = markers()
  254. const wrapped = wrapCommand(command, marker)
  255. let first = true
  256. let fallback = ''
  257. let fallbackTruncated = false
  258. while (true) {
  259. let operation
  260. let result
  261. try {
  262. operation = ctx.pty.startSend(owner, id, {
  263. text: first ? wrapped : '',
  264. submit: first,
  265. signal: commandDeadline.signal,
  266. })
  267. first = false
  268. result = await operation.done
  269. } catch (error: unknown) {
  270. await shells.reset(owner, 'persistent bash send failed')
  271. throw error
  272. }
  273. const incremental = operation.readOutput()
  274. fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
  275. fallbackTruncated ||= incremental.truncated || result.truncated
  276. const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES })
  277. const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
  278. if (timedOut !== undefined) {
  279. const snapshot = retainedScrollback(ctx, owner, id, latest)
  280. const partial = renderCaptured(
  281. partialOutput(snapshot, marker, fallback, fallbackTruncated),
  282. config.maxOutputChars,
  283. )
  284. await shells.reset(owner, 'persistent bash command timed out')
  285. return [
  286. // TODO: Report a timeout only; this signal does not establish an OOM.
  287. `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`,
  288. partial,
  289. SHELL_RESET_MESSAGE,
  290. ].join('\n')
  291. }
  292. if (commandDeadline.signal.aborted) {
  293. await shells.reset(owner, 'persistent bash command aborted')
  294. commandDeadline.signal.throwIfAborted()
  295. }
  296. if (latest.text.includes(marker.end)) {
  297. const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
  298. if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
  299. }
  300. if (result.sessionStatus.kind === 'exited') {
  301. const snapshot = retainedScrollback(ctx, owner, id, latest)
  302. await shells.reset(owner, 'persistent bash shell exited')
  303. return [
  304. renderShellExitStatus(
  305. renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
  306. result.sessionStatus.exitCode,
  307. result.sessionStatus.signal,
  308. ),
  309. SHELL_RESET_MESSAGE,
  310. ].filter(part => part.length > 0).join('\n')
  311. }
  312. if (promptCompleted(result)) {
  313. const snapshot = retainedScrollback(ctx, owner, id, latest)
  314. return renderCaptured(
  315. partialOutput(snapshot, marker, fallback, fallbackTruncated),
  316. config.maxOutputChars,
  317. )
  318. }
  319. await pause()
  320. }
  321. }
  322. /**
  323. * Register the model-facing persistent `bash` tool.
  324. * @param ctx - plugin context carrying tools and the owner-scoped PTY service.
  325. * @param config - selected PTY backend and command deadline.
  326. */
  327. function registerPersistentBash(ctx: Context, config: ResolvedConfig): void {
  328. const shells = persistentShells(ctx, config)
  329. const queues = new WeakMap<Agent, Promise<void>>()
  330. const serialized = async <T>(owner: Agent, operation: () => Promise<T>): Promise<T> => {
  331. const prior = queues.get(owner) ?? Promise.resolve()
  332. const run = prior.then(operation, operation)
  333. const tail = run.then(() => undefined, () => undefined)
  334. queues.set(owner, tail)
  335. try {
  336. return await run
  337. } finally {
  338. if (queues.get(owner) === tail) queues.delete(owner)
  339. }
  340. }
  341. ctx.tools.register(defineTool({
  342. name: 'bash',
  343. description: config.description,
  344. parameters: {
  345. command: {
  346. type: 'string',
  347. required: true,
  348. description: 'The bash command to run. Relative path is preferred in the command.',
  349. },
  350. },
  351. output: {
  352. schema: { type: 'string' },
  353. render: (_args, value) => [{ type: 'text', text: value }],
  354. },
  355. async execute(args, exec) {
  356. if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
  357. const owner = exec.agent
  358. if (owner === undefined) throw new Error('bash requires an owning agent session')
  359. return serialized(owner, async () => {
  360. exec.signal.throwIfAborted()
  361. return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
  362. })
  363. },
  364. presentCall: args => ({ card: 'terminal', title: args.command }),
  365. }))
  366. }
  367. export const name = 'tool-bash-persistent'
  368. export const inject = ['tools', 'pty']
  369. /** Configuration for the persistent Bash tool. */
  370. export interface Config {
  371. /** PTY backend used for each owner-isolated persistent shell (default `shell`). */
  372. backendType?: string
  373. /** Wall-clock limit for one command (default 300000). */
  374. timeoutMs?: number
  375. /** Maximum returned command-output characters before clipping (default 16000). */
  376. maxOutputChars?: number
  377. /** Model-facing tool description; deployments may describe their environment. */
  378. description?: string
  379. }
  380. /** Runtime configuration schema for the persistent Bash tool. */
  381. export const Config: z<Config> = z.object({
  382. backendType: z.string().default('shell'),
  383. timeoutMs: z.number().default(300_000),
  384. maxOutputChars: z.number().default(16_000),
  385. description: z.string().default(DEFAULT_DESCRIPTION),
  386. })
  387. /** Register one owner-scoped persistent `bash` tool. */
  388. export function apply(ctx: Context, config: Config): void {
  389. const resolved: ResolvedConfig = {
  390. backendType: config.backendType ?? 'shell',
  391. timeoutMs: config.timeoutMs ?? 300_000,
  392. maxOutputChars: config.maxOutputChars ?? 16_000,
  393. description: config.description ?? DEFAULT_DESCRIPTION,
  394. }
  395. if (resolved.backendType.trim().length === 0) {
  396. throw new Error('tool-bash-persistent: backendType must be non-empty')
  397. }
  398. if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) {
  399. throw new Error('tool-bash-persistent: timeoutMs must be a positive safe integer')
  400. }
  401. if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
  402. throw new Error('tool-bash-persistent: maxOutputChars must be a positive safe integer')
  403. }
  404. if (resolved.description.trim().length === 0) {
  405. throw new Error('tool-bash-persistent: description must be non-empty')
  406. }
  407. registerPersistentBash(ctx, resolved)
  408. }