index.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. /**
  2. * Six model-facing persistent terminal tools. Owner identity comes from the exact
  3. * tool execution Agent; generic `ctx.tasks` owns background ids and collection.
  4. * @module @deepseek-ai/dsh-tool-pty
  5. */
  6. import { Context } from 'cordis'
  7. import z from 'schemastery'
  8. import type { Agent } from '@deepseek-ai/dsh-agent'
  9. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  10. import { PtySessionId } from '@deepseek-ai/dsh-pty'
  11. import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty'
  12. import type {} from '@deepseek-ai/dsh-tasks'
  13. import { defineTool } from '@deepseek-ai/dsh-tools'
  14. import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
  15. import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
  16. declare module '@deepseek-ai/dsh-tasks' {
  17. interface TaskKindMap {
  18. 'pty-send': 'pty-send'
  19. }
  20. }
  21. /** Cordis plugin name. */
  22. export const name = 'tool-pty'
  23. /** Required capability, registry, and prompt services. */
  24. export const inject = ['pty', 'tools', 'systemPrompt']
  25. /** Default cap for one complete model-facing terminal result. */
  26. export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
  27. /** Smallest cap that preserves every counter-backed PTY and task id in its creation acknowledgement. */
  28. export const MIN_MAX_RESULT_BYTES = 64
  29. /** Model-facing terminal tool configuration. */
  30. export interface Config {
  31. /** Expose `run_in_background` and accept background sends (default true). */
  32. enableRunInBackground?: boolean
  33. /** Maximum UTF-8 bytes in one complete terminal or task-output result. */
  34. maxResultBytes?: number
  35. }
  36. /** Schemastery configuration for the terminal tool consumer. */
  37. export const Config: z<Config> = z.object({
  38. enableRunInBackground: z.boolean().default(true),
  39. maxResultBytes: z.number().step(1).min(MIN_MAX_RESULT_BYTES).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES),
  40. })
  41. interface SpawnArgs {
  42. type: string
  43. name?: string
  44. cwd?: string
  45. }
  46. interface SessionArgs {
  47. sessionId: string
  48. }
  49. interface SendArgs extends SessionArgs {
  50. text: string
  51. submit?: boolean
  52. run_in_background?: boolean
  53. }
  54. interface ReadArgs extends SessionArgs {
  55. offset?: number
  56. count?: number
  57. }
  58. interface SignalArgs extends SessionArgs {
  59. signal: PtySignal
  60. }
  61. const SESSION_STATUS_SCHEMA = {
  62. oneOf: [
  63. {
  64. type: 'object',
  65. additionalProperties: false,
  66. properties: {
  67. kind: { type: 'string', required: true, const: 'running' },
  68. },
  69. },
  70. {
  71. type: 'object',
  72. additionalProperties: false,
  73. properties: {
  74. kind: { type: 'string', required: true, const: 'exited' },
  75. exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
  76. signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
  77. },
  78. },
  79. ],
  80. } as const
  81. const SESSION_SNAPSHOT_PROPERTIES = {
  82. sessionId: { type: 'string', required: true },
  83. name: { type: 'string' },
  84. type: { type: 'string', required: true },
  85. pid: { type: 'integer' },
  86. status: { ...SESSION_STATUS_SCHEMA, required: true },
  87. } as const
  88. const SESSION_SNAPSHOT_SCHEMA = {
  89. type: 'object',
  90. additionalProperties: false,
  91. properties: SESSION_SNAPSHOT_PROPERTIES,
  92. } as const
  93. const BACKGROUND_TASK_OUTPUT_SCHEMA = {
  94. type: 'object',
  95. additionalProperties: false,
  96. properties: {
  97. kind: { type: 'string', required: true, const: 'background' },
  98. taskId: { type: 'string', required: true },
  99. },
  100. } as const
  101. function requireAgent(agent: Agent | undefined): Agent {
  102. if (agent === undefined) throw new Error('terminal tools require an initiating agent')
  103. return agent
  104. }
  105. function sessionId(args: SessionArgs): PtySessionIdType {
  106. if (args.sessionId.length === 0) {
  107. throw new Error('sessionId must be a non-empty string')
  108. }
  109. return PtySessionId(args.sessionId)
  110. }
  111. function textResult(text: string, maxBytes: number): ContentBlock[] {
  112. return [{ type: 'text', text: boundTerminalText(text, maxBytes) }]
  113. }
  114. function rawContentText(content: readonly ContentBlock[]): string | undefined {
  115. if (content.length !== 1) return undefined
  116. const block = content[0]
  117. return block?.type === 'text' ? block.text : undefined
  118. }
  119. function sendDetail(result: PtySendResult): string {
  120. return result.sessionStatus.kind === 'running'
  121. ? `wait: ${result.waitReason}`
  122. : `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}`
  123. }
  124. /** Register all terminal tools and the minimal usage guidance. */
  125. export function apply(ctx: Context, config: Config = {}): void {
  126. const enableRunInBackground = config.enableRunInBackground ?? true
  127. const maxResultBytes = config.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES
  128. if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes < MIN_MAX_RESULT_BYTES) {
  129. throw new Error(`tool-pty: maxResultBytes must be a safe integer of at least ${MIN_MAX_RESULT_BYTES}`)
  130. }
  131. const finalizeContent: NonNullable<ToolDefinition['finalizeContent']> = (_exec, result) => {
  132. const raw = rawContentText(result.content)
  133. return raw === undefined ? undefined : textResult(raw, maxResultBytes)
  134. }
  135. ctx.systemPrompt.section({
  136. name: 'tool:pty',
  137. order: 106,
  138. text: 'Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.',
  139. })
  140. ctx.tools.register(defineTool({
  141. name: 'terminal_open',
  142. description: 'Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.',
  143. parameters: {
  144. type: { type: 'string', required: true, description: 'Registered terminal backend type, usually "shell".' },
  145. name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' },
  146. cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' },
  147. },
  148. finalizeContent,
  149. output: {
  150. schema: {
  151. type: 'object',
  152. additionalProperties: false,
  153. properties: {
  154. ...SESSION_SNAPSHOT_PROPERTIES,
  155. motd: { type: 'string', required: true },
  156. },
  157. },
  158. render: (_args, value) => [{ type: 'text', text: renderSpawn(value, maxResultBytes) }],
  159. },
  160. async execute(args: SpawnArgs, exec) {
  161. if (args.type.length === 0) throw new Error('type must be a non-empty string')
  162. const result = await ctx.pty.spawn(requireAgent(exec.agent), {
  163. type: args.type,
  164. ...args.name !== undefined ? { name: args.name } : {},
  165. ...args.cwd !== undefined ? { cwd: args.cwd } : {},
  166. }, exec.signal)
  167. return result
  168. },
  169. presentCall: (args) => {
  170. const parsed = args
  171. return { card: 'generic', title: `Open terminal ${parsed.name ?? parsed.type}`, kind: 'execute' }
  172. },
  173. }))
  174. ctx.tools.register(defineTool({
  175. name: 'terminal_send',
  176. description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit.'
  177. + (enableRunInBackground ? ' Background mode returns a task id for task_output/task_kill.' : ''),
  178. parameters: {
  179. sessionId: { type: 'string', required: true, description: 'Terminal session id returned by terminal_open or terminal_list.' },
  180. text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' },
  181. submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' },
  182. ...enableRunInBackground
  183. ? { run_in_background: { type: 'boolean' as const, description: 'Return a task id immediately; collect with task_output or stop with task_kill.' } }
  184. : {},
  185. },
  186. finalizeContent,
  187. output: {
  188. schema: {
  189. oneOf: [
  190. BACKGROUND_TASK_OUTPUT_SCHEMA,
  191. {
  192. type: 'object',
  193. additionalProperties: false,
  194. properties: {
  195. kind: { type: 'string', required: true, const: 'foreground' },
  196. viewport: { type: 'string', required: true },
  197. waitReason: {
  198. type: 'string',
  199. required: true,
  200. enum: ['stdin_read', 'inferred_idle', 'timeout', 'session_exit'],
  201. },
  202. sessionStatus: { ...SESSION_STATUS_SCHEMA, required: true },
  203. truncated: { type: 'boolean', required: true },
  204. },
  205. },
  206. ],
  207. },
  208. render: (_args, value) => [{
  209. type: 'text',
  210. text: value.kind === 'background'
  211. ? `started background task ${value.taskId}`
  212. : renderSend(value, maxResultBytes),
  213. }],
  214. presentationMeta: (_args, value) => value.kind === 'foreground'
  215. ? {
  216. viewport: value.viewport,
  217. waitReason: value.waitReason,
  218. sessionStatus: value.sessionStatus,
  219. truncated: value.truncated,
  220. }
  221. : null,
  222. },
  223. async execute(args: SendArgs, exec) {
  224. const owner = requireAgent(exec.agent)
  225. const id = sessionId(args)
  226. const request = { text: args.text, submit: args.submit ?? true }
  227. if (args.run_in_background === true) {
  228. if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration')
  229. const tasks = ctx.get('tasks')
  230. if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
  231. let cancelRequested = false
  232. const taskId = tasks.start({
  233. kind: 'pty-send',
  234. label: `${id}: ${args.text || '(input)'}`,
  235. owner,
  236. outputLimitBytes: maxResultBytes,
  237. run: () => {
  238. const operation = ctx.pty.startSend(owner, id, request)
  239. return {
  240. cancel: () => {
  241. cancelRequested = true
  242. operation.cancel()
  243. },
  244. done: operation.done.then(
  245. result => ({ status: cancelRequested ? 'killed' as const : 'completed' as const, detail: sendDetail(result) }),
  246. (error: unknown) => ({ status: 'failed' as const, detail: String(error) }),
  247. ),
  248. readOutput: () => renderSendRead(operation.readOutput()),
  249. }
  250. },
  251. })
  252. return { kind: 'background' as const, taskId }
  253. }
  254. const operation = ctx.pty.startSend(owner, id, { ...request, signal: exec.signal })
  255. const result = await operation.done
  256. if (exec.signal.aborted) throw new Error('terminal send aborted')
  257. return { kind: 'foreground' as const, ...result }
  258. },
  259. presentCall(args) {
  260. const parsed = args as Partial<SendArgs>
  261. if (parsed.run_in_background === true) {
  262. return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
  263. }
  264. return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` }
  265. },
  266. presentResult(args, result) {
  267. if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
  268. const raw = rawContentText(result.content)
  269. return raw === undefined ? undefined : { card: 'terminal', output: raw }
  270. },
  271. }))
  272. ctx.tools.register(defineTool({
  273. name: 'terminal_read',
  274. description: 'Read a bounded page of retained output from a persistent terminal without sending input.',
  275. parameters: {
  276. sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
  277. offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
  278. count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
  279. },
  280. finalizeContent,
  281. output: {
  282. schema: {
  283. type: 'object',
  284. additionalProperties: false,
  285. properties: {
  286. text: { type: 'string', required: true },
  287. totalLines: { type: 'integer', required: true },
  288. lineBegin: { type: 'integer', required: true },
  289. lineEnd: { type: 'integer', required: true },
  290. truncated: { type: 'boolean', required: true },
  291. },
  292. },
  293. render: (_args, value) => [{ type: 'text', text: renderRead(value, maxResultBytes) }],
  294. },
  295. execute(args: ReadArgs, exec) {
  296. const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), {
  297. ...args.offset !== undefined ? { offset: args.offset } : {},
  298. ...args.count !== undefined ? { count: args.count } : {},
  299. })
  300. return Promise.resolve(result)
  301. },
  302. presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }),
  303. }))
  304. ctx.tools.register(defineTool({
  305. name: 'terminal_signal',
  306. description: 'Send an allowed signal to the current foreground process group of a persistent terminal.',
  307. parameters: {
  308. sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
  309. signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.' },
  310. },
  311. finalizeContent,
  312. output: {
  313. schema: {
  314. type: 'object',
  315. additionalProperties: false,
  316. properties: {
  317. delivered: { type: 'boolean', required: true, const: true },
  318. targetPgid: { type: 'integer', required: true },
  319. },
  320. },
  321. render: (args, value) => [{ type: 'text', text: `delivered ${args.signal} to foreground process group ${value.targetPgid}` }],
  322. },
  323. async execute(args: SignalArgs, exec) {
  324. return ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal)
  325. },
  326. presentCall: args => ({ card: 'generic', title: `Signal terminal ${args.sessionId}`, kind: 'execute', rawInput: args }),
  327. }))
  328. ctx.tools.register(defineTool({
  329. name: 'terminal_close',
  330. description: 'Close one persistent terminal and wait until its captured owned process tree is gone.',
  331. parameters: {
  332. sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
  333. },
  334. finalizeContent,
  335. output: {
  336. schema: {
  337. type: 'object',
  338. additionalProperties: false,
  339. properties: {
  340. sessionId: { type: 'string', required: true },
  341. outcome: { type: 'string', required: true, enum: ['closed', 'already-closing'] },
  342. },
  343. },
  344. render: (_args, value) => [{
  345. type: 'text',
  346. text: value.outcome === 'closed'
  347. ? `closed terminal session ${value.sessionId}`
  348. : `terminal session ${value.sessionId} was already closing`,
  349. }],
  350. },
  351. async execute(args: SessionArgs, exec) {
  352. const id = sessionId(args)
  353. const closed = await ctx.pty.kill(requireAgent(exec.agent), id)
  354. return { sessionId: id, outcome: closed ? 'closed' as const : 'already-closing' as const }
  355. },
  356. presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }),
  357. }))
  358. ctx.tools.register(defineTool({
  359. name: 'terminal_list',
  360. description: 'List persistent terminal sessions owned by the current agent.',
  361. parameters: {},
  362. finalizeContent,
  363. output: {
  364. schema: { type: 'array', items: SESSION_SNAPSHOT_SCHEMA },
  365. render: (_args, value) => [{ type: 'text', text: renderList(value, maxResultBytes) }],
  366. },
  367. execute(_args: Record<string, never>, exec) {
  368. return Promise.resolve(ctx.pty.list(requireAgent(exec.agent)))
  369. },
  370. presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }),
  371. }))
  372. }