Skip to content

Commit b27e9d2

Browse files
authored
Merge pull request #2510 from dgageot/board/add-docker-agent-serve-chat-command-0b138539
feat: add `docker agent serve chat` command (OpenAI-compatible API)
2 parents 2cda184 + 0e1c5a8 commit b27e9d2

20 files changed

Lines changed: 2995 additions & 3 deletions

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
!./**/*.css
88
!./**/*.go
99
!./**/*.txt
10+
!/pkg/chatserver/openapi.json
1011
!/pkg/config/builtin-agents/*.yaml
1112
!/pkg/tui/styles/themes/*.yaml

cmd/root/chat.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package root
2+
3+
import (
4+
"os"
5+
"time"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/docker/docker-agent/pkg/chatserver"
10+
"github.com/docker/docker-agent/pkg/cli"
11+
"github.com/docker/docker-agent/pkg/config"
12+
"github.com/docker/docker-agent/pkg/telemetry"
13+
)
14+
15+
type chatFlags struct {
16+
agentName string
17+
listenAddr string
18+
corsOrigin string
19+
apiKey string
20+
apiKeyEnv string
21+
maxRequestSize int64
22+
requestTimeout time.Duration
23+
conversationsMaxItems int
24+
conversationTTL time.Duration
25+
maxIdleRuntimes int
26+
runConfig config.RuntimeConfig
27+
}
28+
29+
func newChatCmd() *cobra.Command {
30+
var flags chatFlags
31+
32+
cmd := &cobra.Command{
33+
Use: "chat <agent-file>|<registry-ref>",
34+
Short: "Start an agent as an OpenAI-compatible chat completions server",
35+
Long: `Start an HTTP server that exposes the agent through an OpenAI-compatible
36+
API at /v1/chat/completions and /v1/models. This lets tools that already
37+
speak OpenAI's chat protocol (such as Open WebUI) drive a docker-agent
38+
agent without any custom integration.`,
39+
Example: ` docker-agent serve chat ./agent.yaml
40+
docker-agent serve chat ./team.yaml --agent reviewer
41+
docker-agent serve chat agentcatalog/pirate --listen 127.0.0.1:9090`,
42+
Args: cobra.ExactArgs(1),
43+
RunE: flags.runChatCommand,
44+
}
45+
46+
cmd.Flags().StringVarP(&flags.agentName, "agent", "a", "", "Name of the agent to expose (all agents if not specified)")
47+
cmd.Flags().StringVarP(&flags.listenAddr, "listen", "l", "127.0.0.1:8083", "Address to listen on")
48+
cmd.Flags().StringVar(&flags.corsOrigin, "cors-origin", "", "Allowed CORS origin (e.g. https://example.com); empty disables CORS entirely")
49+
cmd.Flags().StringVar(&flags.apiKey, "api-key", "", "Required Bearer token clients must present (Authorization: Bearer <token>); empty disables auth")
50+
cmd.Flags().StringVar(&flags.apiKeyEnv, "api-key-env", "", "Read the API key from this environment variable instead of the command line")
51+
cmd.Flags().Int64Var(&flags.maxRequestSize, "max-request-size", 1<<20, "Maximum request body size in bytes (default 1 MiB)")
52+
cmd.Flags().DurationVar(&flags.requestTimeout, "request-timeout", 5*time.Minute, "Per-request timeout (covers model + tool calls + streaming)")
53+
cmd.Flags().IntVar(&flags.conversationsMaxItems, "conversations-max", 0, "Cache up to N conversations server-side, keyed by X-Conversation-Id (0 disables; clients must resend full history)")
54+
cmd.Flags().DurationVar(&flags.conversationTTL, "conversation-ttl", 30*time.Minute, "Idle TTL after which a cached conversation is evicted")
55+
cmd.Flags().IntVar(&flags.maxIdleRuntimes, "max-idle-runtimes", 4, "Maximum number of idle runtimes pooled per agent (0 disables pooling)")
56+
addRuntimeConfigFlags(cmd, &flags.runConfig)
57+
58+
return cmd
59+
}
60+
61+
func (f *chatFlags) runChatCommand(cmd *cobra.Command, args []string) (commandErr error) {
62+
ctx := cmd.Context()
63+
telemetry.TrackCommand(ctx, "serve", append([]string{"chat"}, args...))
64+
defer func() { // do not inline this defer so that commandErr is not resolved early
65+
telemetry.TrackCommandError(ctx, "serve", append([]string{"chat"}, args...), commandErr)
66+
}()
67+
68+
out := cli.NewPrinter(cmd.OutOrStdout())
69+
agentFilename := args[0]
70+
71+
ln, cleanup, err := newListener(ctx, f.listenAddr)
72+
if err != nil {
73+
return err
74+
}
75+
defer cleanup()
76+
77+
out.Println("Listening on", ln.Addr().String())
78+
out.Println("OpenAI-compatible chat completions endpoint: http://" + ln.Addr().String() + "/v1/chat/completions")
79+
80+
apiKey := f.apiKey
81+
if f.apiKeyEnv != "" {
82+
if v := os.Getenv(f.apiKeyEnv); v != "" {
83+
apiKey = v
84+
}
85+
}
86+
87+
return chatserver.Run(ctx, agentFilename, chatserver.Options{
88+
AgentName: f.agentName,
89+
RunConfig: &f.runConfig,
90+
CORSOrigin: f.corsOrigin,
91+
APIKey: apiKey,
92+
MaxRequestBytes: f.maxRequestSize,
93+
RequestTimeout: f.requestTimeout,
94+
ConversationsMaxSessions: f.conversationsMaxItems,
95+
ConversationTTL: f.conversationTTL,
96+
MaxIdleRuntimes: f.maxIdleRuntimes,
97+
}, ln)
98+
}

cmd/root/serve.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ func newServeCmd() *cobra.Command {
1313

1414
cmd.AddCommand(newA2ACmd())
1515
cmd.AddCommand(newACPCmd())
16-
cmd.AddCommand(newMCPCmd())
1716
cmd.AddCommand(newAPICmd())
17+
cmd.AddCommand(newChatCmd())
18+
cmd.AddCommand(newMCPCmd())
1819

1920
return cmd
2021
}

e2e/binary/binary_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@ func TestAutoComplete(t *testing.T) {
5454
res, err := Exec(binDir+"/docker-agent", "__complete", "serve", "")
5555
require.NoError(t, err)
5656
props := lines(res.Stdout)
57-
require.Greater(t, len(props), 4)
57+
require.Greater(t, len(props), 5)
5858
require.Contains(t, props[0], "a2a")
5959
require.Contains(t, props[0], "Start an agent as an A2A")
6060
require.Contains(t, props[1], "acp")
6161
require.Contains(t, props[2], "api")
62-
require.Contains(t, props[3], "mcp")
62+
require.Contains(t, props[3], "chat")
63+
require.Contains(t, props[4], "mcp")
6364
})
6465

6566
t.Run("cli plugin auto-complete docker agent", func(t *testing.T) {

examples/chat/main.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// A very, very basic chat client for `docker agent serve chat`.
2+
//
3+
// PR #2510 (`feat: add docker agent serve chat command`) exposes any
4+
// docker-agent agent through an OpenAI-compatible HTTP server. The whole
5+
// point of that feature is that any tool already speaking OpenAI's
6+
// /v1/chat/completions protocol can drive a docker-agent agent without
7+
// custom integration. This example demonstrates exactly that: it uses the
8+
// official github.com/openai/openai-go SDK, only repointed at the local
9+
// chat server, to run an interactive REPL against an agent.
10+
//
11+
// Prerequisites:
12+
//
13+
// # Start an agent in chat mode (in another terminal):
14+
// ./bin/docker-agent serve chat ./examples/42.yaml
15+
// # It listens on http://127.0.0.1:8083 by default.
16+
//
17+
// Then run this client:
18+
//
19+
// go run ./examples/chat
20+
// # or, to pin a specific agent in a multi-agent team:
21+
// go run ./examples/chat -model root
22+
// # or, to point at a different server:
23+
// go run ./examples/chat -base http://127.0.0.1:9090/v1
24+
//
25+
// Type a message and press <Enter>. Type "exit" (or send EOF with ^D) to
26+
// quit.
27+
package main
28+
29+
import (
30+
"bufio"
31+
"context"
32+
"errors"
33+
"flag"
34+
"fmt"
35+
"io"
36+
"log"
37+
"os"
38+
"os/signal"
39+
"strings"
40+
"syscall"
41+
42+
"github.com/openai/openai-go/v3"
43+
"github.com/openai/openai-go/v3/option"
44+
)
45+
46+
func main() {
47+
baseURL := flag.String("base", "http://127.0.0.1:8083/v1", "Base URL of the docker-agent chat server")
48+
model := flag.String("model", "", "Agent name to talk to (defaults to the team's default agent)")
49+
stream := flag.Bool("stream", true, "Stream the agent's response token-by-token")
50+
flag.Parse()
51+
52+
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
53+
err := run(ctx, *baseURL, *model, *stream)
54+
cancel()
55+
if err != nil && !errors.Is(err, context.Canceled) {
56+
log.Fatal(err)
57+
}
58+
}
59+
60+
func run(ctx context.Context, baseURL, model string, stream bool) error {
61+
// The chat server doesn't validate API keys, but the OpenAI SDK
62+
// requires *some* string to be passed.
63+
client := openai.NewClient(
64+
option.WithBaseURL(baseURL),
65+
option.WithAPIKey("not-needed"),
66+
)
67+
68+
// Ask the server which agents are exposed and pick a default model
69+
// when the user didn't pin one. This also doubles as a connectivity
70+
// check.
71+
if model == "" {
72+
picked, err := pickDefaultModel(ctx, &client)
73+
if err != nil {
74+
return fmt.Errorf("listing models: %w", err)
75+
}
76+
model = picked
77+
}
78+
fmt.Printf("Connected to %s — chatting with %q. Type \"exit\" to quit.\n", baseURL, model)
79+
80+
// History keeps the conversation going across turns. The chat server
81+
// is stateless: it builds a fresh session per request from whatever
82+
// messages the client sends, so it's the client's job to remember.
83+
var history []openai.ChatCompletionMessageParamUnion
84+
85+
in := bufio.NewScanner(os.Stdin)
86+
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
87+
for {
88+
fmt.Print("\n> ")
89+
if !in.Scan() {
90+
if err := in.Err(); err != nil {
91+
return err
92+
}
93+
fmt.Println()
94+
return nil // EOF
95+
}
96+
userInput := strings.TrimSpace(in.Text())
97+
if userInput == "" {
98+
continue
99+
}
100+
if userInput == "exit" || userInput == "quit" {
101+
return nil
102+
}
103+
104+
history = append(history, openai.UserMessage(userInput))
105+
106+
reply, err := chat(ctx, &client, model, history, stream)
107+
if err != nil {
108+
return err
109+
}
110+
history = append(history, openai.AssistantMessage(reply))
111+
}
112+
}
113+
114+
// pickDefaultModel queries /v1/models and returns the first agent name
115+
// the server advertises.
116+
func pickDefaultModel(ctx context.Context, client *openai.Client) (string, error) {
117+
page, err := client.Models.List(ctx)
118+
if err != nil {
119+
return "", err
120+
}
121+
if len(page.Data) == 0 {
122+
return "", errors.New("server exposes no models")
123+
}
124+
return page.Data[0].ID, nil
125+
}
126+
127+
// chat sends the conversation to the server, prints the assistant's reply
128+
// to stdout (streaming if requested) and returns the final assembled
129+
// content so the caller can append it to the history.
130+
func chat(
131+
ctx context.Context,
132+
client *openai.Client,
133+
model string,
134+
history []openai.ChatCompletionMessageParamUnion,
135+
stream bool,
136+
) (string, error) {
137+
params := openai.ChatCompletionNewParams{
138+
Model: model,
139+
Messages: history,
140+
}
141+
142+
if !stream {
143+
resp, err := client.Chat.Completions.New(ctx, params)
144+
if err != nil {
145+
return "", err
146+
}
147+
if len(resp.Choices) == 0 {
148+
return "", errors.New("server returned no choices")
149+
}
150+
content := resp.Choices[0].Message.Content
151+
fmt.Println(content)
152+
return content, nil
153+
}
154+
155+
s := client.Chat.Completions.NewStreaming(ctx, params)
156+
var b strings.Builder
157+
for s.Next() {
158+
chunk := s.Current()
159+
if len(chunk.Choices) == 0 {
160+
continue
161+
}
162+
delta := chunk.Choices[0].Delta.Content
163+
if delta == "" {
164+
continue
165+
}
166+
fmt.Print(delta)
167+
b.WriteString(delta)
168+
}
169+
if err := s.Err(); err != nil && !errors.Is(err, io.EOF) {
170+
return "", err
171+
}
172+
fmt.Println()
173+
return b.String(), nil
174+
}

0 commit comments

Comments
 (0)