src / tools / commandTool.ts
src / tools / commandTool.ts
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { errorResult, okResult } from "../core/result";
import { applyGate, compact, consumeApproval, hashOperation } from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
import {
DEFAULT_TAIL_CHARS,
MAX_TAIL_CHARS,
MIN_TAIL_CHARS,
type CommandResult,
} from "../execution/processRunner";
interface OutputTail {
stdout: string;
stderr: string;
}
/**
* The zod schema already bounds tail_chars, but the implementation is also
* reachable from sub-agents and tests that call it directly, so the bounds are
* enforced here too rather than trusted from the caller.
*/
function resolveTailChars(requested: number | undefined): number {
if (requested === undefined) return DEFAULT_TAIL_CHARS;
if (
!Number.isInteger(requested) ||
requested < MIN_TAIL_CHARS ||
requested > MAX_TAIL_CHARS
) {
throw new AgenticError(
"INVALID_INPUT",
`tail_chars must be a whole number between ${MIN_TAIL_CHARS} and ${MAX_TAIL_CHARS}.`,
);
}
return requested;
}
function commandData(result: CommandResult, tail?: OutputTail) {
return {
id: result.id,
status: result.status,
executable: result.executable,
resolvedExecutable: result.resolvedExecutable,
resolvedScript: result.resolvedScript,
args: result.args,
cwd: result.cwd,
startedAt: result.startedAt,
finishedAt: result.finishedAt,
durationMs: result.durationMs,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
stdout: result.stdoutPreview,
stderr: result.stderrPreview,
stdoutBytes: result.stdoutBytes,
stderrBytes: result.stderrBytes,
stdoutCapturedBytes: result.stdoutCapturedBytes,
stderrCapturedBytes: result.stderrCapturedBytes,
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
error: result.error,
...(tail ? { stdoutTail: tail.stdout, stderrTail: tail.stderr } : {}),
};
}
export function createCommandTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_command",
description:
"Run an allowlisted program without a shell. Actions: run, start, status, cancel. executable and args are separate (node, npm, npx, python, git): install deps, build, run tests after every edit. start with timeout_seconds 0 keeps a background job (dev server, watcher) running until you cancel it; poll it with status and tail_chars. Manual mode stages mutations: pending_approval + an id, report it and stop, re-issue after /accept. Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["run", "start", "status", "cancel"]),
executable: z.string().optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
input: z.string().optional(),
timeout_seconds: z.number().min(0).optional(),
idempotency_key: z.string().optional(),
job_id: z.string().optional(),
tail_chars: z.number().int().min(MIN_TAIL_CHARS).max(MAX_TAIL_CHARS).optional(),
},
implementation: async (input: {
action: "run" | "start" | "status" | "cancel";
executable?: string;
args?: string[];
cwd?: string;
input?: string;
timeout_seconds?: number;
idempotency_key?: string;
job_id?: string;
tail_chars?: number;
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.processExecutionEnabled) {
throw new AgenticError("PROCESS_DISABLED", "Process execution is disabled.");
}
if (input.action === "run" || input.action === "start") {
if (!input.executable) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_command ${input.action} requires executable.`,
);
}
// run waits for the process with no abort path of its own, so an
// unbounded foreground command would hang the whole prediction.
if (input.action === "run" && input.timeout_seconds === 0) {
// Two small models in the live matrix zero-filled this field and
// read a message that only described `start`; neither found the
// repair for the call it had actually made, and one repeated the
// identical call three times. Name the fix for `run` first.
throw new AgenticError(
"INVALID_INPUT",
`timeout_seconds 0 means no timeout, which action run cannot use: for run, omit timeout_seconds (${runtime.processes.maxTimeoutSeconds} seconds applies) or pass a positive number of seconds. Use start with timeout_seconds 0 for a background job, then poll it with status and cancel it when done.`,
);
}
const spec = {
executable: input.executable,
args: input.args,
cwd: input.cwd,
input: input.input,
timeoutSeconds: input.timeout_seconds,
idempotencyKey: input.idempotency_key,
};
const described = await runtime.processes.describe(spec);
const gate = await applyGate(runtime, ctx, {
kind: "command",
operation: `command.${input.action}`,
operationHash: hashOperation("command", described.specHash),
title: `Run ${[described.executable, ...described.args].join(" ")}`.slice(0, 300),
description: `cwd ${described.cwd}`,
destructive: false,
reference: {},
resume: { tool: "workspace_command", arguments: compact({ ...input }) },
});
if (gate.outcome === "stage") return gate.result;
const started = await runtime.processes.start(spec);
await consumeApproval(runtime, ctx, gate, { jobId: started.id });
const result = input.action === "run" ? await started.promise : started.result;
return okResult({
operation: `command.${input.action}`,
summary:
input.action === "start"
? `Started ${result.executable} as ${result.id}.`
: `${result.executable} completed with status ${result.status} and exit ${
result.exitCode ?? result.signal ?? "n/a"
}.`,
data: commandData(result),
artifacts: result.artifacts,
importance:
result.status === "completed" || result.status === "running" ? "normal" : "high",
facts: [
`${result.id} ${result.status}`,
`${result.executable} ${result.args.join(" ")} -> ${
result.exitCode ?? result.signal ?? result.status
}`,
],
omit: ["data.stdout", "data.stderr"],
});
}
if (!input.job_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_command ${input.action} requires job_id.`,
);
}
// Validated before the job is touched, so a bad tail_chars never reads.
const tailChars =
input.action === "status" ? resolveTailChars(input.tail_chars) : undefined;
const result =
input.action === "cancel"
? await runtime.processes.cancel(input.job_id)
: await runtime.processes.status(input.job_id);
// A live job is tailed by default so polling always shows progress; a
// finished job is tailed only on request, since its previews already
// carry the head and tail of each stream.
const live = result.status === "running" || result.status === "queued";
const tail =
tailChars !== undefined && (input.tail_chars !== undefined || live)
? await runtime.processes.tail(input.job_id, tailChars)
: undefined;
// Cancelling a job that already finished is not an error, but reporting
// it as a plain status hid a wrong-job-id mistake in a live run: the
// model "cancelled" a finished job and left the real one running. Say
// so plainly in both the summary and a fact, since the compressor keeps
// only those two after compaction.
const cancelledNothing = input.action === "cancel" && result.status !== "canceled";
return okResult({
operation: `command.${input.action}`,
summary: cancelledNothing
? `${result.id} was already ${result.status} before cancel: no process was running, so nothing was stopped. Check the job id if you expected to stop a running job.`
: `${result.id}: ${result.status}.`,
data: commandData(result, tail),
artifacts: result.artifacts,
importance: result.status === "failed" || result.status === "timed_out" ? "high" : "normal",
facts: [
`${result.id} ${result.status}`,
...(cancelledNothing
? [`cancel stopped nothing: ${result.id} was already ${result.status}`]
: []),
],
omit: [
"data.stdout",
"data.stderr",
...(tail ? ["data.stdoutTail", "data.stderrTail"] : []),
],
});
} catch (error) {
return errorResult(`command.${input.action}`, error);
}
},
});
}
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { errorResult, okResult } from "../core/result";
import { applyGate, compact, consumeApproval, hashOperation } from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
import {
DEFAULT_TAIL_CHARS,
MAX_TAIL_CHARS,
MIN_TAIL_CHARS,
type CommandResult,
} from "../execution/processRunner";
interface OutputTail {
stdout: string;
stderr: string;
}
/**
* The zod schema already bounds tail_chars, but the implementation is also
* reachable from sub-agents and tests that call it directly, so the bounds are
* enforced here too rather than trusted from the caller.
*/
function resolveTailChars(requested: number | undefined): number {
if (requested === undefined) return DEFAULT_TAIL_CHARS;
if (
!Number.isInteger(requested) ||
requested < MIN_TAIL_CHARS ||
requested > MAX_TAIL_CHARS
) {
throw new AgenticError(
"INVALID_INPUT",
`tail_chars must be a whole number between ${MIN_TAIL_CHARS} and ${MAX_TAIL_CHARS}.`,
);
}
return requested;
}
function commandData(result: CommandResult, tail?: OutputTail) {
return {
id: result.id,
status: result.status,
executable: result.executable,
resolvedExecutable: result.resolvedExecutable,
resolvedScript: result.resolvedScript,
args: result.args,
cwd: result.cwd,
startedAt: result.startedAt,
finishedAt: result.finishedAt,
durationMs: result.durationMs,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
stdout: result.stdoutPreview,
stderr: result.stderrPreview,
stdoutBytes: result.stdoutBytes,
stderrBytes: result.stderrBytes,
stdoutCapturedBytes: result.stdoutCapturedBytes,
stderrCapturedBytes: result.stderrCapturedBytes,
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
error: result.error,
...(tail ? { stdoutTail: tail.stdout, stderrTail: tail.stderr } : {}),
};
}
export function createCommandTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_command",
description:
"Run an allowlisted program without a shell. Actions: run, start, status, cancel. executable and args are separate (node, npm, npx, python, git): install deps, build, run tests after every edit. start with timeout_seconds 0 keeps a background job (dev server, watcher) running until you cancel it; poll it with status and tail_chars. Manual mode stages mutations: pending_approval + an id, report it and stop, re-issue after /accept. Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["run", "start", "status", "cancel"]),
executable: z.string().optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
input: z.string().optional(),
timeout_seconds: z.number().min(0).optional(),
idempotency_key: z.string().optional(),
job_id: z.string().optional(),
tail_chars: z.number().int().min(MIN_TAIL_CHARS).max(MAX_TAIL_CHARS).optional(),
},
implementation: async (input: {
action: "run" | "start" | "status" | "cancel";
executable?: string;
args?: string[];
cwd?: string;
input?: string;
timeout_seconds?: number;
idempotency_key?: string;
job_id?: string;
tail_chars?: number;
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.processExecutionEnabled) {
throw new AgenticError("PROCESS_DISABLED", "Process execution is disabled.");
}
if (input.action === "run" || input.action === "start") {
if (!input.executable) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_command ${input.action} requires executable.`,
);
}
// run waits for the process with no abort path of its own, so an
// unbounded foreground command would hang the whole prediction.
if (input.action === "run" && input.timeout_seconds === 0) {
// Two small models in the live matrix zero-filled this field and
// read a message that only described `start`; neither found the
// repair for the call it had actually made, and one repeated the
// identical call three times. Name the fix for `run` first.
throw new AgenticError(
"INVALID_INPUT",
`timeout_seconds 0 means no timeout, which action run cannot use: for run, omit timeout_seconds (${runtime.processes.maxTimeoutSeconds} seconds applies) or pass a positive number of seconds. Use start with timeout_seconds 0 for a background job, then poll it with status and cancel it when done.`,
);
}
const spec = {
executable: input.executable,
args: input.args,
cwd: input.cwd,
input: input.input,
timeoutSeconds: input.timeout_seconds,
idempotencyKey: input.idempotency_key,
};
const described = await runtime.processes.describe(spec);
const gate = await applyGate(runtime, ctx, {
kind: "command",
operation: `command.${input.action}`,
operationHash: hashOperation("command", described.specHash),
title: `Run ${[described.executable, ...described.args].join(" ")}`.slice(0, 300),
description: `cwd ${described.cwd}`,
destructive: false,
reference: {},
resume: { tool: "workspace_command", arguments: compact({ ...input }) },
});
if (gate.outcome === "stage") return gate.result;
const started = await runtime.processes.start(spec);
await consumeApproval(runtime, ctx, gate, { jobId: started.id });
const result = input.action === "run" ? await started.promise : started.result;
return okResult({
operation: `command.${input.action}`,
summary:
input.action === "start"
? `Started ${result.executable} as ${result.id}.`
: `${result.executable} completed with status ${result.status} and exit ${
result.exitCode ?? result.signal ?? "n/a"
}.`,
data: commandData(result),
artifacts: result.artifacts,
importance:
result.status === "completed" || result.status === "running" ? "normal" : "high",
facts: [
`${result.id} ${result.status}`,
`${result.executable} ${result.args.join(" ")} -> ${
result.exitCode ?? result.signal ?? result.status
}`,
],
omit: ["data.stdout", "data.stderr"],
});
}
if (!input.job_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_command ${input.action} requires job_id.`,
);
}
// Validated before the job is touched, so a bad tail_chars never reads.
const tailChars =
input.action === "status" ? resolveTailChars(input.tail_chars) : undefined;
const result =
input.action === "cancel"
? await runtime.processes.cancel(input.job_id)
: await runtime.processes.status(input.job_id);
// A live job is tailed by default so polling always shows progress; a
// finished job is tailed only on request, since its previews already
// carry the head and tail of each stream.
const live = result.status === "running" || result.status === "queued";
const tail =
tailChars !== undefined && (input.tail_chars !== undefined || live)
? await runtime.processes.tail(input.job_id, tailChars)
: undefined;
// Cancelling a job that already finished is not an error, but reporting
// it as a plain status hid a wrong-job-id mistake in a live run: the
// model "cancelled" a finished job and left the real one running. Say
// so plainly in both the summary and a fact, since the compressor keeps
// only those two after compaction.
const cancelledNothing = input.action === "cancel" && result.status !== "canceled";
return okResult({
operation: `command.${input.action}`,
summary: cancelledNothing
? `${result.id} was already ${result.status} before cancel: no process was running, so nothing was stopped. Check the job id if you expected to stop a running job.`
: `${result.id}: ${result.status}.`,
data: commandData(result, tail),
artifacts: result.artifacts,
importance: result.status === "failed" || result.status === "timed_out" ? "high" : "normal",
facts: [
`${result.id} ${result.status}`,
...(cancelledNothing
? [`cancel stopped nothing: ${result.id} was already ${result.status}`]
: []),
],
omit: [
"data.stdout",
"data.stderr",
...(tail ? ["data.stdoutTail", "data.stderrTail"] : []),
],
});
} catch (error) {
return errorResult(`command.${input.action}`, error);
}
},
});
}