index.ts 18 KB

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