feat(agent): add process execution API and rewrite execute tool (#22416)

## Summary

Adds a new agent-side process management HTTP API and rewrites the chat
execute tool to use it instead of SSH sessions.

## What changed

### New agent/agentproc/ package

- **headtail.go** — Thread-safe io.Writer with bounded memory (16KB head
+ 16KB tail ring buffer). Provides LLM-ready output with truncation
metadata and long-line truncation at 2048 bytes.
- **headtail_test.go** — 16 tests including race detector coverage for
concurrent writes.
- **process.go** — Manager + Process types for lifecycle management
using agentexec.Execer for proper OOM/nice scores.
- **api.go** — HTTP API following the agentfiles chi router pattern. 4
endpoints: start, list, output, signal.

### Agent wiring (agent/agent.go, agent/api.go)

Mounts the process API at /api/v0/processes, mirroring how agentfiles is
mounted.

### SDK (codersdk/workspacesdk/agentconn.go)

4 new AgentConn interface methods + 7 request/response types:
- StartProcess, ListProcesses, ProcessOutput, SignalProcess

### Execute tool rewrite (coderd/chatd/chattool/execute.go)

- SSH to Agent API: conn.StartProcess() + conn.ProcessOutput() polling
- New parameters: workdir, run_in_background
- Structured response: success, exit_code, wall_duration_ms, error,
truncated, note, background_process_id
- Non-interactive env vars: GIT_EDITOR=true, TERM=dumb, NO_COLOR=1,
PAGER=cat, etc.
- Output truncation: HeadTailBuffer caps at 32KB for LLM consumption
- File-dump detection with advisory notes suggesting read_file
- Default timeout: 60s to 10s
- Foreground polling: 200ms intervals until exit or timeout

## Architecture

State lives on the agent, surviving coderd failover and instance
changes. Any coderd replica can query any agent via HTTP over tailnet.
This commit is contained in:
Kyle Carberry
2026-02-28 12:33:52 -05:00
committed by GitHub
parent 0ad2f9ecd7
commit a621c3cb13
11 changed files with 2315 additions and 68 deletions
@@ -213,6 +213,21 @@ func (mr *MockAgentConnMockRecorder) ListContainers(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListContainers", reflect.TypeOf((*MockAgentConn)(nil).ListContainers), ctx)
}
// ListProcesses mocks base method.
func (m *MockAgentConn) ListProcesses(ctx context.Context) (workspacesdk.ListProcessesResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListProcesses", ctx)
ret0, _ := ret[0].(workspacesdk.ListProcessesResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListProcesses indicates an expected call of ListProcesses.
func (mr *MockAgentConnMockRecorder) ListProcesses(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProcesses", reflect.TypeOf((*MockAgentConn)(nil).ListProcesses), ctx)
}
// ListeningPorts mocks base method.
func (m *MockAgentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) {
m.ctrl.T.Helper()
@@ -260,6 +275,21 @@ func (mr *MockAgentConnMockRecorder) Ping(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockAgentConn)(nil).Ping), ctx)
}
// ProcessOutput mocks base method.
func (m *MockAgentConn) ProcessOutput(ctx context.Context, id string) (workspacesdk.ProcessOutputResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ProcessOutput", ctx, id)
ret0, _ := ret[0].(workspacesdk.ProcessOutputResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ProcessOutput indicates an expected call of ProcessOutput.
func (mr *MockAgentConnMockRecorder) ProcessOutput(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProcessOutput", reflect.TypeOf((*MockAgentConn)(nil).ProcessOutput), ctx, id)
}
// PrometheusMetrics mocks base method.
func (m *MockAgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) {
m.ctrl.T.Helper()
@@ -401,6 +431,20 @@ func (mr *MockAgentConnMockRecorder) SSHOnPort(ctx, port any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHOnPort", reflect.TypeOf((*MockAgentConn)(nil).SSHOnPort), ctx, port)
}
// SignalProcess mocks base method.
func (m *MockAgentConn) SignalProcess(ctx context.Context, id, signal string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SignalProcess", ctx, id, signal)
ret0, _ := ret[0].(error)
return ret0
}
// SignalProcess indicates an expected call of SignalProcess.
func (mr *MockAgentConnMockRecorder) SignalProcess(ctx, id, signal any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignalProcess", reflect.TypeOf((*MockAgentConn)(nil).SignalProcess), ctx, id, signal)
}
// Speedtest mocks base method.
func (m *MockAgentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) {
m.ctrl.T.Helper()
@@ -416,6 +460,21 @@ func (mr *MockAgentConnMockRecorder) Speedtest(ctx, direction, duration any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Speedtest", reflect.TypeOf((*MockAgentConn)(nil).Speedtest), ctx, direction, duration)
}
// StartProcess mocks base method.
func (m *MockAgentConn) StartProcess(ctx context.Context, req workspacesdk.StartProcessRequest) (workspacesdk.StartProcessResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "StartProcess", ctx, req)
ret0, _ := ret[0].(workspacesdk.StartProcessResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// StartProcess indicates an expected call of StartProcess.
func (mr *MockAgentConnMockRecorder) StartProcess(ctx, req any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartProcess", reflect.TypeOf((*MockAgentConn)(nil).StartProcess), ctx, req)
}
// TailnetConn mocks base method.
func (m *MockAgentConn) TailnetConn() *tailnet.Conn {
m.ctrl.T.Helper()