mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user