| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399 |
- /**
- * Six model-facing persistent terminal tools. Owner identity comes from the exact
- * tool execution Agent; generic `ctx.tasks` owns background ids and collection.
- * @module @deepseek-ai/dsh-tool-pty
- */
- import { Context } from 'cordis'
- import z from 'schemastery'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import type { ContentBlock } from '@deepseek-ai/dsh-llm'
- import { PtySessionId } from '@deepseek-ai/dsh-pty'
- import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty'
- import type {} from '@deepseek-ai/dsh-tasks'
- import { defineTool } from '@deepseek-ai/dsh-tools'
- import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
- import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
- declare module '@deepseek-ai/dsh-tasks' {
- interface TaskKindMap {
- 'pty-send': 'pty-send'
- }
- }
- /** Cordis plugin name. */
- export const name = 'tool-pty'
- /** Required capability, registry, and prompt services. */
- export const inject = ['pty', 'tools', 'systemPrompt']
- /** Default cap for one complete model-facing terminal result. */
- export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
- /** Smallest cap that preserves every counter-backed PTY and task id in its creation acknowledgement. */
- export const MIN_MAX_RESULT_BYTES = 64
- /** Model-facing terminal tool configuration. */
- export interface Config {
- /** Expose `run_in_background` and accept background sends (default true). */
- enableRunInBackground?: boolean
- /** Maximum UTF-8 bytes in one complete terminal or task-output result. */
- maxResultBytes?: number
- }
- /** Schemastery configuration for the terminal tool consumer. */
- export const Config: z<Config> = z.object({
- enableRunInBackground: z.boolean().default(true),
- maxResultBytes: z.number().step(1).min(MIN_MAX_RESULT_BYTES).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES),
- })
- interface SpawnArgs {
- type: string
- name?: string
- cwd?: string
- }
- interface SessionArgs {
- sessionId: string
- }
- interface SendArgs extends SessionArgs {
- text: string
- submit?: boolean
- run_in_background?: boolean
- }
- interface ReadArgs extends SessionArgs {
- offset?: number
- count?: number
- }
- interface SignalArgs extends SessionArgs {
- signal: PtySignal
- }
- const SESSION_STATUS_SCHEMA = {
- oneOf: [
- {
- type: 'object',
- additionalProperties: false,
- properties: {
- kind: { type: 'string', required: true, const: 'running' },
- },
- },
- {
- type: 'object',
- additionalProperties: false,
- properties: {
- kind: { type: 'string', required: true, const: 'exited' },
- exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
- signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
- },
- },
- ],
- } as const
- const SESSION_SNAPSHOT_PROPERTIES = {
- sessionId: { type: 'string', required: true },
- name: { type: 'string' },
- type: { type: 'string', required: true },
- pid: { type: 'integer' },
- status: { ...SESSION_STATUS_SCHEMA, required: true },
- } as const
- const SESSION_SNAPSHOT_SCHEMA = {
- type: 'object',
- additionalProperties: false,
- properties: SESSION_SNAPSHOT_PROPERTIES,
- } as const
- const BACKGROUND_TASK_OUTPUT_SCHEMA = {
- type: 'object',
- additionalProperties: false,
- properties: {
- kind: { type: 'string', required: true, const: 'background' },
- taskId: { type: 'string', required: true },
- },
- } as const
- function requireAgent(agent: Agent | undefined): Agent {
- if (agent === undefined) throw new Error('terminal tools require an initiating agent')
- return agent
- }
- function sessionId(args: SessionArgs): PtySessionIdType {
- if (args.sessionId.length === 0) {
- throw new Error('sessionId must be a non-empty string')
- }
- return PtySessionId(args.sessionId)
- }
- function textResult(text: string, maxBytes: number): ContentBlock[] {
- return [{ type: 'text', text: boundTerminalText(text, maxBytes) }]
- }
- function rawContentText(content: readonly ContentBlock[]): string | undefined {
- if (content.length !== 1) return undefined
- const block = content[0]
- return block?.type === 'text' ? block.text : undefined
- }
- function sendDetail(result: PtySendResult): string {
- return result.sessionStatus.kind === 'running'
- ? `wait: ${result.waitReason}`
- : `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}`
- }
- /** Register all terminal tools and the minimal usage guidance. */
- export function apply(ctx: Context, config: Config = {}): void {
- const enableRunInBackground = config.enableRunInBackground ?? true
- const maxResultBytes = config.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES
- if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes < MIN_MAX_RESULT_BYTES) {
- throw new Error(`tool-pty: maxResultBytes must be a safe integer of at least ${MIN_MAX_RESULT_BYTES}`)
- }
- const finalizeContent: NonNullable<ToolDefinition['finalizeContent']> = (_exec, result) => {
- const raw = rawContentText(result.content)
- return raw === undefined ? undefined : textResult(raw, maxResultBytes)
- }
- ctx.systemPrompt.section({
- name: 'tool:pty',
- order: 106,
- 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.',
- })
- ctx.tools.register(defineTool({
- name: 'terminal_open',
- 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.',
- parameters: {
- type: { type: 'string', required: true, description: 'Registered terminal backend type, usually "shell".' },
- name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' },
- cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' },
- },
- finalizeContent,
- output: {
- schema: {
- type: 'object',
- additionalProperties: false,
- properties: {
- ...SESSION_SNAPSHOT_PROPERTIES,
- motd: { type: 'string', required: true },
- },
- },
- render: (_args, value) => [{ type: 'text', text: renderSpawn(value, maxResultBytes) }],
- },
- async execute(args: SpawnArgs, exec) {
- if (args.type.length === 0) throw new Error('type must be a non-empty string')
- const result = await ctx.pty.spawn(requireAgent(exec.agent), {
- type: args.type,
- ...args.name !== undefined ? { name: args.name } : {},
- ...args.cwd !== undefined ? { cwd: args.cwd } : {},
- }, exec.signal)
- return result
- },
- presentCall: (args) => {
- const parsed = args
- return { card: 'generic', title: `Open terminal ${parsed.name ?? parsed.type}`, kind: 'execute' }
- },
- }))
- ctx.tools.register(defineTool({
- name: 'terminal_send',
- 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.'
- + (enableRunInBackground ? ' Background mode returns a task id for task_output/task_kill.' : ''),
- parameters: {
- sessionId: { type: 'string', required: true, description: 'Terminal session id returned by terminal_open or terminal_list.' },
- text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' },
- submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' },
- ...enableRunInBackground
- ? { run_in_background: { type: 'boolean' as const, description: 'Return a task id immediately; collect with task_output or stop with task_kill.' } }
- : {},
- },
- finalizeContent,
- output: {
- schema: {
- oneOf: [
- BACKGROUND_TASK_OUTPUT_SCHEMA,
- {
- type: 'object',
- additionalProperties: false,
- properties: {
- kind: { type: 'string', required: true, const: 'foreground' },
- viewport: { type: 'string', required: true },
- waitReason: {
- type: 'string',
- required: true,
- enum: ['stdin_read', 'inferred_idle', 'timeout', 'session_exit'],
- },
- sessionStatus: { ...SESSION_STATUS_SCHEMA, required: true },
- truncated: { type: 'boolean', required: true },
- },
- },
- ],
- },
- render: (_args, value) => [{
- type: 'text',
- text: value.kind === 'background'
- ? `started background task ${value.taskId}`
- : renderSend(value, maxResultBytes),
- }],
- presentationMeta: (_args, value) => value.kind === 'foreground'
- ? {
- viewport: value.viewport,
- waitReason: value.waitReason,
- sessionStatus: value.sessionStatus,
- truncated: value.truncated,
- }
- : null,
- },
- async execute(args: SendArgs, exec) {
- const owner = requireAgent(exec.agent)
- const id = sessionId(args)
- const request = { text: args.text, submit: args.submit ?? true }
- if (args.run_in_background === true) {
- if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration')
- const tasks = ctx.get('tasks')
- if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
- let cancelRequested = false
- const taskId = tasks.start({
- kind: 'pty-send',
- label: `${id}: ${args.text || '(input)'}`,
- owner,
- outputLimitBytes: maxResultBytes,
- run: () => {
- const operation = ctx.pty.startSend(owner, id, request)
- return {
- cancel: () => {
- cancelRequested = true
- operation.cancel()
- },
- done: operation.done.then(
- result => ({ status: cancelRequested ? 'killed' as const : 'completed' as const, detail: sendDetail(result) }),
- (error: unknown) => ({ status: 'failed' as const, detail: String(error) }),
- ),
- readOutput: () => renderSendRead(operation.readOutput()),
- }
- },
- })
- return { kind: 'background' as const, taskId }
- }
- const operation = ctx.pty.startSend(owner, id, { ...request, signal: exec.signal })
- const result = await operation.done
- if (exec.signal.aborted) throw new Error('terminal send aborted')
- return { kind: 'foreground' as const, ...result }
- },
- presentCall(args) {
- const parsed = args as Partial<SendArgs>
- if (parsed.run_in_background === true) {
- return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
- }
- return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` }
- },
- presentResult(args, result) {
- if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
- const raw = rawContentText(result.content)
- return raw === undefined ? undefined : { card: 'terminal', output: raw }
- },
- }))
- ctx.tools.register(defineTool({
- name: 'terminal_read',
- description: 'Read a bounded page of retained output from a persistent terminal without sending input.',
- parameters: {
- sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
- offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
- count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
- },
- finalizeContent,
- output: {
- schema: {
- type: 'object',
- additionalProperties: false,
- properties: {
- text: { type: 'string', required: true },
- totalLines: { type: 'integer', required: true },
- lineBegin: { type: 'integer', required: true },
- lineEnd: { type: 'integer', required: true },
- truncated: { type: 'boolean', required: true },
- },
- },
- render: (_args, value) => [{ type: 'text', text: renderRead(value, maxResultBytes) }],
- },
- execute(args: ReadArgs, exec) {
- const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), {
- ...args.offset !== undefined ? { offset: args.offset } : {},
- ...args.count !== undefined ? { count: args.count } : {},
- })
- return Promise.resolve(result)
- },
- presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }),
- }))
- ctx.tools.register(defineTool({
- name: 'terminal_signal',
- description: 'Send an allowed signal to the current foreground process group of a persistent terminal.',
- parameters: {
- sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
- signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.' },
- },
- finalizeContent,
- output: {
- schema: {
- type: 'object',
- additionalProperties: false,
- properties: {
- delivered: { type: 'boolean', required: true, const: true },
- targetPgid: { type: 'integer', required: true },
- },
- },
- render: (args, value) => [{ type: 'text', text: `delivered ${args.signal} to foreground process group ${value.targetPgid}` }],
- },
- async execute(args: SignalArgs, exec) {
- return ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal)
- },
- presentCall: args => ({ card: 'generic', title: `Signal terminal ${args.sessionId}`, kind: 'execute', rawInput: args }),
- }))
- ctx.tools.register(defineTool({
- name: 'terminal_close',
- description: 'Close one persistent terminal and wait until its captured owned process tree is gone.',
- parameters: {
- sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
- },
- finalizeContent,
- output: {
- schema: {
- type: 'object',
- additionalProperties: false,
- properties: {
- sessionId: { type: 'string', required: true },
- outcome: { type: 'string', required: true, enum: ['closed', 'already-closing'] },
- },
- },
- render: (_args, value) => [{
- type: 'text',
- text: value.outcome === 'closed'
- ? `closed terminal session ${value.sessionId}`
- : `terminal session ${value.sessionId} was already closing`,
- }],
- },
- async execute(args: SessionArgs, exec) {
- const id = sessionId(args)
- const closed = await ctx.pty.kill(requireAgent(exec.agent), id)
- return { sessionId: id, outcome: closed ? 'closed' as const : 'already-closing' as const }
- },
- presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }),
- }))
- ctx.tools.register(defineTool({
- name: 'terminal_list',
- description: 'List persistent terminal sessions owned by the current agent.',
- parameters: {},
- finalizeContent,
- output: {
- schema: { type: 'array', items: SESSION_SNAPSHOT_SCHEMA },
- render: (_args, value) => [{ type: 'text', text: renderList(value, maxResultBytes) }],
- },
- execute(_args: Record<string, never>, exec) {
- return Promise.resolve(ctx.pty.list(requireAgent(exec.agent)))
- },
- presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }),
- }))
- }
|