2026-08-01 18:48:17 +08:00
/**
* Model-facing `pwsh` tool over the `ctx.bash` executor seam. Intended for
* Windows compositions where a PowerShell executor (e.g.
* `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is
* PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
*
2026-08-02 14:18:29 +08:00
* Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface:
* foreground and `run_in_background` execution (background handles register
* with the generic `ctx.tasks` runtime), the managed `DSH_*` environment
* through the shared `bash-env` registry, and the bash marker/truncation
* rendering story. UI presentation stays on the existing generic/terminal
* cards; a pwsh-specific rendering twin is roadmap work.
2026-08-01 18:48:17 +08:00
*
* @module @deepseek-ai/dsh-tool-pwsh
*/
import { isAbsolute , resolve as resolvePath } from 'node:path'
2026-08-02 14:18:29 +08:00
import type { Context } from 'cordis'
2026-08-01 18:48:17 +08:00
import z from 'schemastery'
import { defineTool , TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
2026-08-02 19:37:25 +08:00
import type { GenericCallView , TerminalCallView , ToolResult , ToolResultView } from '@deepseek-ai/dsh-tools'
2026-08-01 18:48:17 +08:00
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { } from '@deepseek-ai/dsh-system-prompt'
2026-08-02 14:18:29 +08:00
import type { } from '@deepseek-ai/dsh-tasks'
import type { } from '@deepseek-ai/dsh-bash-env'
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import { processOutcome } from './background.ts'
import { renderPwshProcessRead , renderPwshResult } from './render.ts'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
pwsh : 'pwsh'
}
}
2026-08-01 18:48:17 +08:00
export const name = 'tool-pwsh'
2026-08-02 14:18:29 +08:00
export const inject = [ 'tools' , 'bash' , 'systemPrompt' , 'bashEnv' ]
2026-08-01 18:48:17 +08:00
2026-08-02 14:18:29 +08:00
/** Configuration for the pwsh tool. */
2026-08-01 18:48:17 +08:00
export interface Config {
2026-08-02 14:18:29 +08:00
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground? : boolean
2026-08-01 18:48:17 +08:00
}
/** Runtime configuration schema for the pwsh tool plugin. */
export const Config : z < Config > = z . object ( {
2026-08-02 14:18:29 +08:00
enableRunInBackground : z.boolean ( ) . default ( true ) ,
2026-08-01 18:48:17 +08:00
} )
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
interface PwshToolArgs {
command : string
description : string
timeoutMs? : number
workdir? : string
2026-08-02 14:18:29 +08:00
run_in_background? : boolean
2026-08-01 18:48:17 +08:00
}
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
interface PwshForegroundResult {
kind : 'foreground'
exitCode : number | null
signal : NodeJS.Signals | null
timedOut : boolean
aborted : boolean
timeoutMs : number
stdout : { text : string ; truncated : boolean ; spillPath? : string }
stderr : { text : string ; truncated : boolean ; spillPath? : string }
}
2026-08-01 19:34:12 +08:00
/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */
2026-08-01 18:48:17 +08:00
function validatePwshArgs ( args : PwshToolArgs ) : void {
if ( args . command . trim ( ) . length === 0 ) {
throw new Error ( 'invalid command: expected a non-empty string' )
}
if ( args . description . trim ( ) . length === 0 ) {
throw new Error ( 'invalid description: expected a non-empty string' )
}
if ( args . timeoutMs !== undefined && ( ! Number . isFinite ( args . timeoutMs ) || args . timeoutMs <= 0 ) ) {
throw new Error ( ` invalid timeoutMs: expected a positive number, got ${ JSON . stringify ( args . timeoutMs ) } ` )
}
}
2026-08-01 19:34:12 +08:00
/* jscpd:ignore-end */
2026-08-01 18:48:17 +08:00
2026-08-02 14:18:29 +08:00
function pwshDescription ( backgroundEnabled : boolean ) : string {
const background = backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
: 'Background execution is not available; long-running commands must finish within the timeout.'
2026-08-01 18:48:17 +08:00
return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
+ 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — '
+ 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment '
+ 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. '
2026-08-02 14:18:29 +08:00
+ 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
+ background
2026-08-01 18:48:17 +08:00
}
/**
* Resolve an explicit workdir first, making a relative one session-workspace-relative;
* otherwise use the session header cwd and leave executor defaulting as the fallback.
*/
function resolveWorkdir ( modelWorkdir : string | undefined , exec : { agent? : Agent } ) : string | undefined {
const headerCwd = exec . agent ? . session . header . cwd
if ( modelWorkdir === undefined ) return headerCwd
if ( headerCwd !== undefined && ! isAbsolute ( modelWorkdir ) ) {
return resolvePath ( headerCwd , modelWorkdir )
}
return modelWorkdir
}
2026-08-02 14:18:29 +08:00
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
2026-08-01 18:48:17 +08:00
function canonicalPwshResult ( result : BashRunResult ) : PwshForegroundResult {
const output = ( stream : BashRunResult [ 'stdout' ] ) = > ( {
text : stream.text ,
truncated : stream.truncated ,
. . . stream . spillPath !== undefined ? { spillPath : stream.spillPath } : { } ,
} )
return {
kind : 'foreground' ,
exitCode : result.exitCode ,
signal : result.signal ,
timedOut : result.timedOut ,
aborted : result.aborted ,
timeoutMs : result.timeoutMs ,
2026-08-02 14:18:29 +08:00
/* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */
2026-08-01 18:48:17 +08:00
stdout : output ( result . stdout ) ,
stderr : output ( result . stderr ) ,
}
}
2026-08-02 14:18:29 +08:00
/** Canonical background-handle properties shared by the pwsh output union. */
const BACKGROUND_OUTPUT_PROPERTIES = {
kind : { type : 'string' , required : true , const : 'background' } ,
taskId : { type : 'string' , required : true } ,
} as const
/* jscpd:ignore-end */
2026-08-01 18:48:17 +08:00
export function apply ( ctx : Context , config : Config = { } ) : void {
2026-08-02 14:18:29 +08:00
const backgroundEnabled = config . enableRunInBackground ? ? true
2026-08-01 18:48:17 +08:00
ctx . systemPrompt . section ( {
name : 'tool:pwsh' ,
order : 105 ,
2026-08-02 14:18:29 +08:00
text : 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. '
+ 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.' ,
2026-08-01 18:48:17 +08:00
} )
ctx . tools . register ( defineTool ( {
name : 'pwsh' ,
2026-08-02 14:18:29 +08:00
description : pwshDescription ( backgroundEnabled ) ,
2026-08-01 18:48:17 +08:00
parameters : {
command : { type : 'string' , required : true , description : 'The PowerShell command to execute.' } ,
description : {
type : 'string' ,
required : true ,
description : 'Clear, concise description of what this command does in active voice, '
+ '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
+ '"git status" → "Show working tree status"; "Get-Process" → "List running processes".' ,
} ,
timeoutMs : { type : 'number' , description : 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' } ,
workdir : { type : 'string' , description : 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' } ,
2026-08-02 14:18:29 +08:00
. . . backgroundEnabled ? {
run_in_background : { type : 'boolean' as const , description : 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' } ,
} : { } ,
2026-08-01 18:48:17 +08:00
} ,
output : {
2026-08-01 19:34:12 +08:00
// The foreground result wire shape mirrors dsh-tool-bash's by contract —
// consumers of one must accept the other (see the pwsh-tool-and-executor
// Agent Note).
2026-08-02 14:18:29 +08:00
/* jscpd:ignore-start -- deliberate result-schema symmetry with dsh-tool-bash. */
2026-08-01 18:48:17 +08:00
schema : {
2026-08-02 14:18:29 +08:00
oneOf : [
{
2026-08-01 18:48:17 +08:00
type : 'object' ,
additionalProperties : false ,
2026-08-02 14:18:29 +08:00
properties : BACKGROUND_OUTPUT_PROPERTIES ,
2026-08-01 18:48:17 +08:00
} ,
2026-08-02 14:18:29 +08:00
{
2026-08-01 18:48:17 +08:00
type : 'object' ,
additionalProperties : false ,
properties : {
2026-08-02 14:18:29 +08:00
kind : { type : 'string' , required : true , const : 'foreground' } ,
exitCode : { required : true , oneOf : [ { type : 'integer' } , { type : 'null' } ] } ,
signal : { required : true , oneOf : [ { type : 'string' } , { type : 'null' } ] } ,
timedOut : { type : 'boolean' , required : true } ,
aborted : { type : 'boolean' , required : true } ,
timeoutMs : { type : 'number' , required : true } ,
stdout : {
type : 'object' ,
additionalProperties : false ,
required : true ,
properties : {
text : { type : 'string' , required : true } ,
truncated : { type : 'boolean' , required : true } ,
spillPath : { type : 'string' } ,
} ,
} ,
stderr : {
type : 'object' ,
additionalProperties : false ,
required : true ,
properties : {
text : { type : 'string' , required : true } ,
truncated : { type : 'boolean' , required : true } ,
spillPath : { type : 'string' } ,
} ,
} ,
2026-08-01 18:48:17 +08:00
} ,
} ,
2026-08-02 14:18:29 +08:00
] ,
2026-08-01 18:48:17 +08:00
} ,
2026-08-01 19:34:12 +08:00
/* jscpd:ignore-end */
2026-08-01 18:48:17 +08:00
render : ( _args , value ) = > [ {
type : 'text' ,
2026-08-02 14:18:29 +08:00
text : value.kind === 'background'
? ` started background task ${ value . taskId } `
: renderPwshResult ( value ) ,
2026-08-01 18:48:17 +08:00
} ] ,
} ,
2026-08-02 14:18:29 +08:00
/* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */
2026-08-01 18:48:17 +08:00
async execute ( args : PwshToolArgs , exec ) {
validatePwshArgs ( args )
const workdir = resolveWorkdir ( args . workdir , exec )
2026-08-02 14:18:29 +08:00
const request = {
2026-08-01 18:48:17 +08:00
command : args.command ,
. . . workdir !== undefined ? { workdir } : { } ,
. . . args . timeoutMs !== undefined ? { timeoutMs : args.timeoutMs } : { } ,
2026-08-02 14:18:29 +08:00
dshEnv : ctx.bashEnv.collect ( exec ) ,
}
if ( args . run_in_background === true ) {
// Undeclared keys are allowed, so schema omission also needs enforcement.
if ( ! backgroundEnabled ) {
throw new Error ( 'run_in_background is disabled for this deployment (enableRunInBackground: false)' )
}
const tasks = ctx . get ( 'tasks' )
if ( tasks === undefined ) {
throw new Error ( 'background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks' )
}
// The caller owns cancellation until ctx.tasks commits detached ownership.
/* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort;
pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts
already-aborted signals first, so this mirror-only guard has no reachable trigger. */
if ( exec . signal . aborted ) {
const error = new HarnessError ( 'tool call aborted' , TOOL_ABORTED )
error . name = 'AbortError'
throw error
}
/* v8 ignore end */
// Task preflight finishes before the starter can spawn a process.
const id = tasks . start ( {
kind : 'pwsh' ,
label : args.command ,
. . . exec . agent ? { owner : exec.agent } : { } ,
run : ( ) = > {
const proc = ctx . bash . start ( ctx . bash . resolve ( request ) )
return {
cancel : ( ) = > void proc . kill ( ) ,
done : proc.done.then ( ( ) = > processOutcome ( proc ) ) ,
readOutput : ( ) = > renderPwshProcessRead ( proc . readOutput ( ) ) ,
}
} ,
} )
return { kind : 'background' as const , taskId : id }
}
const result = await ctx . bash . run ( ctx . bash . resolve ( {
. . . request ,
2026-08-01 18:48:17 +08:00
signal : exec.signal ,
} ) )
if ( result . aborted ) {
const error = new HarnessError ( 'tool call aborted' , TOOL_ABORTED )
error . name = 'AbortError'
throw error
}
return canonicalPwshResult ( result )
} ,
2026-08-01 19:34:12 +08:00
/* jscpd:ignore-end */
2026-08-02 19:37:25 +08:00
presentCall : ( args : PwshToolArgs ) : TerminalCallView | GenericCallView = > {
// Background acknowledgements carry no terminal exit status; the generic
// card mirrors the bash tool's background presentation.
if ( args . run_in_background === true ) {
return {
card : 'generic' ,
title : args.command ,
kind : 'execute' ,
rawInput : args.command ,
content : [ { type : 'text' , text : args.description } ] ,
}
}
return {
card : 'terminal' ,
title : args.command ,
description : args.description ,
. . . args . workdir !== undefined ? { cwd : args.workdir } : { } ,
}
} ,
2026-08-01 18:48:17 +08:00
presentResult : ( _args : unknown , result : ToolResult ) : ToolResultView | undefined = > {
const block = result . content . length === 1 ? result . content [ 0 ] : undefined
if ( block === undefined || block . type !== 'text' ) return undefined
return { card : 'generic' , content : [ { type : 'text' , text : ` \` \` \` console \ n ${ block . text . replace ( /\n+$/ , '' ) } \ n \` \` \` ` } ] }
} ,
} ) )
}