feat: add intent labels to execute tool (#25482)

> Mux opened this PR on behalf of Mike.

Fixes CODAGT-451

Adds optional `model_intent` metadata to the built-in execute tool
schema so tool calls can carry a short user-facing intent label without
duplicating the command or duration.

The Agents UI now composes that intent with the existing execute command
and duration fields, displaying labels like `Checking repository state
using git fetch origin for 2.3s` while keeping the shell command visible
as the audit-relevant action.

Existing execute calls without an intent keep the previous `Ran
<command>` fallback label, so only intent-bearing calls get the new
composed label.
This commit is contained in:
Michael Suchacz
2026-05-19 18:47:12 +02:00
committed by GitHub
parent 441854daa8
commit 951a8e7237
7 changed files with 275 additions and 9 deletions
+2 -1
View File
@@ -78,9 +78,10 @@ type ProcessToolOptions struct {
// ExecuteArgs are the parameters accepted by the execute tool.
type ExecuteArgs struct {
Command string `json:"command" description:"The shell command to execute."`
ModelIntent *string `json:"model_intent,omitempty" description:"A short, natural-language, present-participle phrase describing what you are doing. This is shown to the user alongside the command. Use plain English with no underscores or technical jargon. The UI appends \"using <command>\" and \"for <duration>\" automatically, so do not repeat the command or include a duration. Keep it under 100 characters. Good examples: \"Running the unit tests\", \"Checking repository state\", \"Inspecting build output\"."`
Timeout *string `json:"timeout,omitempty" description:"How long to wait for completion (e.g. '30s', '5m'). Default is 10s. The process keeps running if this expires and you get a background_process_id to re-attach. Only applies to foreground commands."`
WorkDir *string `json:"workdir,omitempty" description:"Working directory for the command."`
RunInBackground *bool `json:"run_in_background,omitempty" description:"Run without blocking. Use for persistent processes (dev servers, file watchers) or when you want to continue working while a command runs and check the result later with process_output. For commands whose result you need before continuing, prefer foreground with a longer timeout. Do NOT use shell & to background processes — it will not work correctly. Always use this parameter instead."`
RunInBackground *bool `json:"run_in_background,omitempty" description:"Run without blocking. Use for persistent processes (dev servers, file watchers) or when you want to continue working while a command runs and check the result later with process_output. For commands whose result you need before continuing, prefer foreground with a longer timeout. Do NOT use shell & to background processes. It will not work correctly. Always use this parameter instead."`
}
// Execute returns an AgentTool that runs a shell command in the
+62
View File
@@ -20,6 +20,20 @@ import (
func TestExecuteTool(t *testing.T) {
t.Parallel()
t.Run("SchemaIncludesOptionalModelIntent", func(t *testing.T) {
t.Parallel()
tool := chattool.Execute(chattool.ExecuteOptions{})
info := tool.Info()
modelIntentParam, ok := info.Parameters["model_intent"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "string", modelIntentParam["type"])
assert.Contains(t, modelIntentParam["description"], "alongside the command")
assert.Contains(t, modelIntentParam["description"], "do not repeat the command")
assert.Contains(t, info.Required, "command")
assert.NotContains(t, info.Required, "model_intent")
})
t.Run("EmptyCommand", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
@@ -203,6 +217,54 @@ func TestExecuteTool(t *testing.T) {
assert.Equal(t, "true", capturedReq.Env["CODER_CHAT_AGENT"])
})
t.Run("ModelIntentIgnoredByExecution", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
var capturedReq workspacesdk.StartProcessRequest
mockConn.EXPECT().
StartProcess(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, req workspacesdk.StartProcessRequest) (workspacesdk.StartProcessResponse, error) {
capturedReq = req
return workspacesdk.StartProcessResponse{ID: "proc-1"}, nil
})
exitCode := 0
mockConn.EXPECT().
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
Return(workspacesdk.ProcessOutputResponse{
Running: false,
ExitCode: &exitCode,
Output: "hello world",
}, nil)
tool := newExecuteTool(t, mockConn)
ctx := testutil.Context(t, testutil.WaitMedium)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-1",
Name: "execute",
Input: `{"command":"echo hello","model_intent":"Running a smoke test"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.Equal(t, "echo hello", capturedReq.Command)
assert.False(t, capturedReq.Background)
var parsedArgs chattool.ExecuteArgs
require.NoError(t, json.Unmarshal([]byte(`{"command":"echo hello","model_intent":"Running a smoke test"}`), &parsedArgs))
require.NotNil(t, parsedArgs.ModelIntent)
assert.Equal(t, "Running a smoke test", *parsedArgs.ModelIntent)
var result chattool.ExecuteResult
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
assert.True(t, result.Success)
assert.Equal(t, "hello world", result.Output)
var resultMap map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &resultMap))
assert.NotContains(t, resultMap, "model_intent")
})
t.Run("ForegroundNonZeroExit", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
@@ -27,6 +27,7 @@ import {
} from "./displayMode";
import {
formatShellDurationMs,
sanitizeExecuteModelIntent,
signalTooltipLabel,
type ToolStatus,
} from "./utils";
@@ -39,6 +40,7 @@ type ExecuteToolProps = {
durationMs?: number;
isBackgrounded?: boolean;
killedBySignal?: "kill" | "terminate";
modelIntent?: string;
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
};
@@ -75,6 +77,7 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
durationMs,
isBackgrounded = false,
killedBySignal,
modelIntent,
outputInitiallyOpen,
}) => {
const hasCommand = command.trim().length > 0;
@@ -99,6 +102,7 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
>
<ShellCommandLine
command={command}
modelIntent={modelIntent}
durationLabel={durationLabel}
expanded={outputOpen}
/>
@@ -167,17 +171,26 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
const ShellCommandLine: React.FC<{
command: string;
modelIntent?: string;
durationLabel: string;
expanded?: boolean;
}> = ({ command, durationLabel, expanded }) => {
}> = ({ command, modelIntent, durationLabel, expanded }) => {
const intentLabel = sanitizeExecuteModelIntent(modelIntent, command);
return (
<>
<span className="block min-w-0 truncate text-[13px] font-normal text-current">
Ran {command}
{intentLabel ? (
<>
{intentLabel} using{" "}
<code className="font-mono text-xs">{command}</code>
</>
) : (
<>Ran {command}</>
)}
</span>
{durationLabel && (
<span className="shrink-0 text-[13px] font-normal text-content-secondary">
{durationLabel}
{intentLabel ? ` for ${durationLabel}` : durationLabel}
</span>
)}
{expanded !== undefined && (
@@ -14,6 +14,7 @@ import { DesktopPanelContext } from "./DesktopPanelContext";
import { Tool } from "./Tool";
const executeCommand = "git fetch origin";
const executeIntentCommand = "npm test";
const longExecuteCommand =
"docker build --no-cache --build-arg NODE_ENV=production --build-arg API_URL=https://coder.example.com/api --build-arg SENTRY_DSN=https://example.com/sentry --build-arg FEATURE_FLAGS=agents,shell-tools --tag coder-agent:latest .";
const meta: Meta<typeof Tool> = {
@@ -54,6 +55,79 @@ export const ExecuteRunning: Story = {
},
};
export const ExecuteModelIntent: Story = {
args: {
status: "completed",
args: {
command: executeIntentCommand,
model_intent: "Running tests using npm for 5s",
},
modelIntent: "Running tests using npm for 5s",
result: {
output: "",
wall_duration_ms: 2300,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const commandButton = canvas.getByRole("button", {
name: "Expand command",
});
expect(commandButton).toHaveTextContent(
`Running tests using ${executeIntentCommand} for 2.3s`,
);
expect(commandButton).not.toHaveTextContent("Ran");
},
};
export const ExecuteModelIntentRunning: Story = {
args: {
status: "running",
args: {
command: executeCommand,
model_intent: "checking repository state",
},
modelIntent: "checking repository state",
result: {
output: "",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const commandButton = canvas.getByRole("button", {
name: "Collapse command",
});
expect(commandButton).toHaveTextContent(
`Checking repository state using ${executeCommand}`,
);
expect(commandButton).not.toHaveTextContent(" for ");
expect(commandButton).not.toHaveTextContent("Ran");
},
};
export const ExecuteModelIntentLeadingUsing: Story = {
args: {
status: "completed",
args: {
command: executeCommand,
model_intent: "using git fetch origin",
},
modelIntent: "using git fetch origin",
result: {
output: "",
wall_duration_ms: 2300,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const commandButton = canvas.getByRole("button", {
name: "Expand command",
});
expect(commandButton).toHaveTextContent(`Ran ${executeCommand}2.3s`);
expect(commandButton).not.toHaveTextContent("using git fetch origin using");
},
};
export const ExecuteSuccess: Story = {
args: {
shellToolDisplayMode: "auto",
@@ -1950,7 +2024,7 @@ export const WaitAgentComputerUseRunning: Story = {
expect(canvasElement.querySelector(".lucide-monitor")).not.toBeNull();
// The VNC preview container should mount (the connection will
// stay in "connecting" state without a real WebSocket, which
// is expected — we only verify the container renders).
// is expected; we only verify the container renders).
await waitFor(() => {
expect(
canvas.getByRole("button", { name: "Open desktop tab" }),
@@ -48,6 +48,7 @@ import {
asString,
buildEditDiff,
DIFFS_FONT_STYLE,
formatModelIntentLabel,
formatResultOutput,
getFileContentForViewer,
getFileViewerOptions,
@@ -267,6 +268,7 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
result,
isError,
killedBySignal,
modelIntent,
shellToolDisplayMode,
}) => {
const data = getExecuteRenderData(args, result);
@@ -294,6 +296,7 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
durationMs={data.durationMs}
isBackgrounded={data.isBackgrounded}
killedBySignal={killedBySignal}
modelIntent={modelIntent}
shellToolDisplayMode={shellToolDisplayMode}
/>
);
@@ -922,7 +925,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
/>
{modelIntent ? (
<span className="truncate text-[13px]">
{modelIntent.charAt(0).toUpperCase() + modelIntent.slice(1)}
{formatModelIntentLabel(modelIntent)}
</span>
) : (
<ToolLabel
@@ -992,7 +995,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
};
// ---------------------------------------------------------------------------
// process_signal — thin wrapper that promotes soft failures (success=false
// process_signal promotes soft failures (success=false
// in the result body, isError=false at protocol level) so the generic
// renderer shows the error indicator and tooltip.
// ---------------------------------------------------------------------------
@@ -1035,7 +1038,7 @@ const StartWorkspaceRenderer: FC<ToolRendererProps> = ({
};
// ---------------------------------------------------------------------------
// Renderer lookup map — maps tool names to their specialized renderers.
// Renderer lookup map for tool names and specialized renderers.
// ---------------------------------------------------------------------------
const toolRenderers: Record<string, FC<ToolRendererProps>> = {
@@ -1060,7 +1063,7 @@ const toolRenderers: Record<string, FC<ToolRendererProps>> = {
};
// ---------------------------------------------------------------------------
// Public Tool component — single wrapper div + map dispatch.
// Public Tool component with a single wrapper div and map dispatch.
// ---------------------------------------------------------------------------
export const Tool = memo(
@@ -7,6 +7,7 @@ import {
DIFFS_FONT_STYLE,
diffViewerCSS,
fileViewerCSS,
formatModelIntentLabel,
formatResultOutput,
formatShellDurationMs,
getDiffViewerOptions,
@@ -24,11 +25,64 @@ import {
parseEditFilesArgs,
parseServerEditDiffText,
parseServerEditResults,
sanitizeExecuteModelIntent,
shortDurationMs,
stripSvnIndexHeaders,
toProviderLabel,
} from "./utils";
describe("formatModelIntentLabel", () => {
it("returns empty string for empty values", () => {
expect(formatModelIntentLabel(undefined)).toBe("");
expect(formatModelIntentLabel("")).toBe("");
expect(formatModelIntentLabel(" ")).toBe("");
});
it("trims and capitalizes labels", () => {
expect(formatModelIntentLabel("checking repository state")).toBe(
"Checking repository state",
);
expect(formatModelIntentLabel(" a")).toBe("A");
expect(formatModelIntentLabel("Running tests")).toBe("Running tests");
});
});
describe("sanitizeExecuteModelIntent", () => {
it("strips redundant command and duration suffixes", () => {
expect(
sanitizeExecuteModelIntent("Running tests using npm for 5s", "npm test"),
).toBe("Running tests");
expect(
sanitizeExecuteModelIntent(
"checking status using git fetch origin",
"git fetch origin",
),
).toBe("Checking status");
});
it("strips trailing durations without command suffixes", () => {
expect(
sanitizeExecuteModelIntent("Running tests for 2.5s", "npm test"),
).toBe("Running tests");
expect(sanitizeExecuteModelIntent("for 5s", "npm test")).toBe("");
});
it("strips leading using only when it references the command", () => {
expect(
sanitizeExecuteModelIntent("using git fetch origin", "git fetch origin"),
).toBe("");
expect(
sanitizeExecuteModelIntent("Using environment variables", "npm test"),
).toBe("Using environment variables");
});
it("preserves using when it is not followed by a command reference", () => {
expect(
sanitizeExecuteModelIntent("Testing using mock data", "npm test"),
).toBe("Testing using mock data");
});
});
describe("toProviderLabel", () => {
it("returns displayName when provided", () => {
expect(toProviderLabel("GitHub", "gh-id", "oauth")).toBe("GitHub");
@@ -26,6 +26,65 @@ const fileEntrySchema = Yup.object({
type FileEntry = Yup.InferType<typeof fileEntrySchema>;
export const formatModelIntentLabel = (
modelIntent: string | undefined,
): string => {
const trimmed = modelIntent?.trim() ?? "";
if (!trimmed) {
return "";
}
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
};
const trailingDurationPattern =
/(^|\s+)for\s+\d+(?:\.\d+)?\s*(?:ms|s|m|h)\s*$/i;
export const sanitizeExecuteModelIntent = (
modelIntent: string | undefined,
command: string,
): string => {
const label = formatModelIntentLabel(modelIntent);
const withoutCommand = stripRedundantUsingSuffix(label, command);
return stripTrailingDuration(withoutCommand);
};
const stripRedundantUsingSuffix = (label: string, command: string): string => {
const usingMatches = Array.from(label.matchAll(/(^|\s+)using\s+/gi));
for (let i = usingMatches.length - 1; i >= 0; i--) {
const match = usingMatches[i];
if (match.index === undefined) {
continue;
}
const suffix = stripTrailingDuration(
label.slice(match.index + match[0].length),
);
if (isCommandReference(suffix, command)) {
return label.slice(0, match.index).trim();
}
}
return label;
};
const stripTrailingDuration = (label: string): string =>
label.replace(trailingDurationPattern, "").trim();
const isCommandReference = (value: string, command: string): boolean => {
const normalizedValue = normalizeCommandReference(value);
const normalizedCommand = normalizeCommandReference(command);
if (!normalizedValue || !normalizedCommand) {
return false;
}
return (
normalizedValue === normalizedCommand ||
normalizedCommand.startsWith(`${normalizedValue} `) ||
normalizedValue.startsWith(`${normalizedCommand} `)
);
};
const normalizeCommandReference = (value: string): string =>
value.trim().toLowerCase().replace(/\s+/g, " ");
export const toProviderLabel = (
providerDisplayName: string,
providerID: string,