render.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. /**
  2. * Model-facing result rendering for the bash tool.
  3. *
  4. * @module @deepseek-ai/dsh-tool-bash/render
  5. */
  6. import type { BashProcessRead, BashRunResult, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
  7. import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
  8. import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
  9. /** Append the truncation notice (with the full-output spill path) to a stream's text. */
  10. function streamText(output: CollectedOutput): string {
  11. if (!output.truncated) return output.text
  12. return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
  13. }
  14. /**
  15. * Shape one finished run into the text the model sees: stdout, then a marked
  16. * stderr section, then exit-status markers. Non-zero exits are reported, not
  17. * errored — the model decides how to react; only infrastructure failures
  18. * (spawn errors, aborts) surface as isError results.
  19. * @param result - the completed foreground run from the executor.
  20. * @param escalationModes - the escalation targets this composition advertises;
  21. * non-empty adds the same-turn escalation hint after a denial marker
  22. * (default `[]`: no hint).
  23. * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
  24. */
  25. export function renderResult(
  26. result: BashRunResult,
  27. escalationModes: readonly SandboxMode[] = [],
  28. ): string {
  29. const out = streamText(result.stdout)
  30. const err = streamText(result.stderr)
  31. let body = out
  32. if (err.length > 0) {
  33. // Single newline between sections (stdout usually ends with one already).
  34. if (body.length > 0 && !body.endsWith('\n')) body += '\n'
  35. body += `[stderr]\n${err}`
  36. }
  37. if (body.length === 0) body = '(no output)'
  38. const markers: string[] = []
  39. // Keep the exit marker last because parseExitStatus anchors there.
  40. if (result.sandbox?.denied) {
  41. markers.push(sandboxDenialMarker(result.sandbox.mode))
  42. // Hint only when the composition exposes escalation, before the final exit marker.
  43. if (escalationModes.length > 0) {
  44. markers.push(escalationHintMarker('command'))
  45. }
  46. }
  47. // A command may trap SIGTERM and exit 0 after timeout; still report interruption.
  48. if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
  49. if (result.signal !== null) {
  50. markers.push(`[killed by signal: ${result.signal}]`)
  51. } else if (result.exitCode !== 0) {
  52. markers.push(`[exit code: ${result.exitCode}]`)
  53. }
  54. if (markers.length === 0) return body
  55. if (!body.endsWith('\n')) body += '\n'
  56. return body + markers.join('\n')
  57. }
  58. /**
  59. * Shape one background-process read into the `task_output` delta the model
  60. * sees: the incremental delta, plus the lossy-read notice (with full-stream
  61. * spill paths) when in-memory truncation dropped unread bytes. Empty-delta
  62. * rendering (`(no new output)`) is the generic control surface's job.
  63. * @param read - one incremental read from the process handle.
  64. * @param sandbox - settled sandbox facts, when this was a confined process.
  65. * @param escalationModes - escalation targets advertised by this composition.
  66. * @returns the delta text with any loss or sandbox notice appended.
  67. */
  68. export function renderProcessRead(
  69. read: BashProcessRead,
  70. sandbox?: BashSandboxInfo,
  71. escalationModes: readonly SandboxMode[] = [],
  72. ): string {
  73. const notices: string[] = []
  74. if (read.lossy) {
  75. const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
  76. notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
  77. }
  78. if (sandbox?.runnerFailed) {
  79. notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
  80. } else if (sandbox?.denied) {
  81. notices.push(sandboxDenialMarker(sandbox.mode))
  82. if (escalationModes.length > 0) {
  83. notices.push(escalationHintMarker('command'))
  84. }
  85. }
  86. if (notices.length === 0) return read.delta
  87. return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
  88. }
  89. /**
  90. * Recover the structured exit status from a rendered {@link renderResult}
  91. * string — the inverse of the status markers it appends. A killed marker
  92. * yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both
  93. * means a clean exit 0.
  94. *
  95. * Replay only retains the rendered content text, not the original
  96. * `BashRunResult`, so terminal presentation must recover the exit pill here.
  97. * Requiring a leading newline and the end of the string keeps ordinary output
  98. * that merely ends with marker-like text from matching unless the final line
  99. * is indistinguishable from a real marker.
  100. * @param text - rendered model-facing bash result.
  101. * @returns the recovered terminal exit code or signal.
  102. */
  103. export function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
  104. const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
  105. if (signal?.[1] !== undefined) return { signal: signal[1] }
  106. const exit = /\n\[exit code: (\d+)\]$/.exec(text)
  107. if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
  108. return { exitCode: 0 }
  109. }