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)
|
||||
|
||||
Reference in New Issue
Block a user