Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 3 additions & 22 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/app"
boardtui "github.com/docker/docker-agent/pkg/board/tui"
"github.com/docker/docker-agent/pkg/bootstrap"
"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/config"
latestcfg "github.com/docker/docker-agent/pkg/config/latest"
Expand Down Expand Up @@ -928,34 +929,14 @@ func (f *runExecFlags) loadAgentFrom(ctx context.Context, req runtime.LoadTeamRe
// current agent name are passed in because they're resolved by callers from
// different sources (e.g. the spawner uses the same store as the parent).
func (f *runExecFlags) runtimeOpts(loadResult *teamloader.LoadResult, runConfig *config.RuntimeConfig, sessStore session.Store, agentName string) []runtime.Opt {
modelSwitcherCfg := &runtime.ModelSwitcherConfig{
Models: loadResult.Models,
Providers: loadResult.Providers,
ModelsGateway: runConfig.ModelsGateway,
EncryptedConfig: loadResult.EncryptedConfig,
EnvProvider: runConfig.EnvProvider(),
ProviderRegistry: loadResult.ProviderRegistry,
AgentDefaultModels: loadResult.AgentDefaultModels,
}
// Share the models.dev store the team loader already warmed (parsing the
// multi-MB catalog once) so the runtime doesn't build its own cold store
// and re-pay the parse on the first /model open. On error we leave it unset
// and the runtime falls back to its lazy default.
if store, err := runConfig.ModelsDevStore(); err == nil {
modelSwitcherCfg.ModelsStore = store
} else {
slog.Warn("Failed to obtain shared models.dev store; runtime will use its own", "error", err)
}
loadedOpts := bootstrap.RuntimeOpts(loadResult, runConfig)
opts := []runtime.Opt{
runtime.WithSessionStore(sessStore),
runtime.WithCurrentAgent(agentName),
runtime.WithWorkingDir(runConfig.WorkingDir),
runtime.WithTracer(otel.Tracer(AppName)),
runtime.WithModelSwitcherConfig(modelSwitcherCfg),
runtime.WithBudget(loadResult.Budget),
runtime.WithNamedBudgets(loadResult.Budgets, loadResult.AgentBudgets),
}
return opts
return append(opts, loadedOpts...)
}

// snapshotRuntimeOpts wires the snapshot builtin into a runtime.
Expand Down
41 changes: 41 additions & 0 deletions docs/guides/go-sdk/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,47 @@ These entry points exclude the application, CLI, MCP toolset implementation, and
JavaScript engine. Runtime event types still retain MCP protocol support and the
runtime's own builtin tools; this is not a runtime or remote-session refactor.

## Explicit Loader Defaults

`pkg/teamloader/defaults.LoaderOpts()` supplies the full provider, toolset and
source registries without registering runtime factories. Enable optional
execution features on each runtime with
`runtime.WithHarnessFactory(codingharness.Factory)` and
`runtime.WithCommandEvaluatorFactory(jscommands.Factory)`.

The existing `loaderdefaults.Opts()` remains a compatibility helper: it returns
those loader options and also registers the harness and JavaScript factories
globally. Both helpers link the full defaults; dependency-light embedders should
continue supplying their own registries.

## Loaded Configuration to Runtime

`pkg/bootstrap.RuntimeOpts(loaded, runConfig)` translates a
`teamloader.LoadResult` into model-switching and budget options. It reuses the
shared model catalog and the encrypted configuration resolved during loading.
It does not load a team, create a session, register factories or own resources.

```go
opts := bootstrap.RuntimeOpts(loaded, runConfig)
opts = append(opts,
runtime.WithSessionStore(store),
runtime.WithCurrentAgent(agentName),
runtime.WithWorkingDir(workingDir),
runtime.WithHarnessFactory(codingharness.Factory),
runtime.WithCommandEvaluatorFactory(jscommands.Factory),
)
rt, err := runtime.New(ctx, loaded.Team, opts...)
```

Environment and catalog initialization happen when `RuntimeOpts` is called.
Catalog initialization failures retain the runtime's lazy fallback. Later
options can override the assembly defaults; callers still select interaction
policy, tracing, storage and cleanup. In particular, callers own the supplied
session store, and a shared team's toolsets must outlive all its runtimes.

The CLI uses this helper for both its initial runtime and TUI-spawned runtimes.
Serving adapters retain their existing wiring and budget behavior.

## Headless Embedded Chat (`pkg/embeddedchat`)

`pkg/embeddedchat` is a thin wrapper around the Docker Agent runtime that lets you drive an agent from your own UI instead of running Docker Agent's Bubble Tea application. It handles runtime construction, event projection, and conversation state, exposing a simple `Send` / `Confirm` / `Restart` / `Close` API.
Expand Down
1 change: 1 addition & 0 deletions e2e/dependencies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func TestDependencies(t *testing.T) {
deps := listTransitiveDeps(t,
"./pkg/config",
"./pkg/teamloader",
"./pkg/bootstrap",
"./pkg/embeddedchat",
"./pkg/tools/builtin/api/client",
"./examples/golibrary/leanapi",
Expand Down
36 changes: 36 additions & 0 deletions pkg/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Package bootstrap translates loaded agent configuration into runtime options.
// Callers retain ownership of session stores, interaction policy and lifetimes.
package bootstrap

import (
"log/slog"

"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/teamloader"
)

// RuntimeOpts carries model-switching configuration and budgets into a runtime.
// It resolves the shared environment and model store when called, not when applied.
func RuntimeOpts(loaded *teamloader.LoadResult, runConfig *config.RuntimeConfig) []runtime.Opt {
modelSwitcherCfg := &runtime.ModelSwitcherConfig{
Models: loaded.Models,
Providers: loaded.Providers,
ModelsGateway: runConfig.ModelsGateway,
EncryptedConfig: loaded.EncryptedConfig,
EnvProvider: runConfig.EnvProvider(),
ProviderRegistry: loaded.ProviderRegistry,
AgentDefaultModels: loaded.AgentDefaultModels,
}
// Reuse the loader's warmed catalog; a failed lookup keeps the runtime's lazy fallback.
if store, err := runConfig.ModelsDevStore(); err == nil {
modelSwitcherCfg.ModelsStore = store
} else {
slog.Warn("Failed to obtain shared models.dev store; runtime will use its own", "error", err)
}
return []runtime.Opt{
runtime.WithModelSwitcherConfig(modelSwitcherCfg),
runtime.WithBudget(loaded.Budget),
runtime.WithNamedBudgets(loaded.Budgets, loaded.AgentBudgets),
}
}
213 changes: 213 additions & 0 deletions pkg/bootstrap/bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package bootstrap

import (
"context"
"io"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/model/provider"
"github.com/docker/docker-agent/pkg/model/provider/base"
"github.com/docker/docker-agent/pkg/model/provider/options"
"github.com/docker/docker-agent/pkg/modelsdev"
"github.com/docker/docker-agent/pkg/paths"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/team"
"github.com/docker/docker-agent/pkg/teamloader"
"github.com/docker/docker-agent/pkg/tools"
)

type testProvider struct {
base.Config
}

func (testProvider) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) {
return &testStream{}, nil
}

func (testProvider) MaxTokens() int { return 0 }

type testStream struct {
done bool
}

func (s *testStream) Recv() (chat.MessageStreamResponse, error) {
if s.done {
return chat.MessageStreamResponse{}, io.EOF
}
s.done = true
return chat.MessageStreamResponse{
Choices: []chat.MessageStreamChoice{{Delta: chat.MessageDelta{Content: "hello"}}},
Usage: &chat.Usage{InputTokens: 3, OutputTokens: 2},
}, nil
}

func (*testStream) Close() {}

func testLoaded() *teamloader.LoadResult {
p := &testProvider{Config: base.Config{ModelConfig: latest.ModelConfig{Provider: "test", Model: "model"}}}
return &teamloader.LoadResult{
Team: team.New(team.WithAgents(agent.New("root", "", agent.WithModel(p)))),
}
}

func TestRuntimeOptsModelConfiguration(t *testing.T) {
t.Parallel()

loaded := testLoaded()
loaded.Models = map[string]latest.ModelConfig{"alternate": {Provider: "test", Model: "alternate"}}
loaded.Providers = map[string]latest.ProviderConfig{"custom": {}}
loaded.AgentDefaultModels = map[string]string{"root": "alternate"}
loaded.EncryptedConfig = "loaded-encrypted-config"
env := environment.NewMapEnvProvider(map[string]string{"TEST_KEY": "value"})
store := modelsdev.NewDatabaseStore(&modelsdev.Database{})
runConfig := &config.RuntimeConfig{
Config: config.Config{ModelsGateway: "https://gateway.example", EncryptedConfig: "unresolved-config"},
EnvProviderOverride: env,
ModelsDevStoreOverride: store,
}
calls := 0
loaded.ProviderRegistry = provider.NewRegistry(map[string]provider.Factory{
"test": func(_ context.Context, cfg *latest.ModelConfig, gotEnv environment.Provider, opts ...options.Opt) (provider.Provider, error) {
calls++
assert.Equal(t, loaded.Models["alternate"].Model, cfg.Model)
assert.Same(t, env, gotEnv)
gotOpts := options.Apply(opts...)
assert.Equal(t, runConfig.ModelsGateway, gotOpts.Gateway())
assert.Equal(t, loaded.EncryptedConfig, gotOpts.EncryptedConfig())
assert.Equal(t, loaded.Providers, gotOpts.Providers())
assert.Same(t, store, gotOpts.ModelsDevStore())
return &testProvider{Config: base.Config{ModelConfig: *cfg}}, nil
},
})

opts := RuntimeOpts(loaded, runConfig)
assert.Zero(t, calls, "assembly must not construct model clients")
rt, err := runtime.New(t.Context(), loaded.Team, opts...)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, rt.Close()) })
assert.True(t, rt.SupportsModelSwitching())
require.NoError(t, rt.SetAgentModel(t.Context(), "root", "alternate"))
assert.Equal(t, 1, calls)
require.NoError(t, rt.SetAgentModel(t.Context(), "root", ""))
assert.Equal(t, 1, calls, "clearing an override must reuse the original model")
ag, err := loaded.Team.DefaultAgent()
require.NoError(t, err)
assert.Equal(t, "test/model", ag.Model(t.Context()).ID().String())
}

func TestRuntimeOptsBudgets(t *testing.T) {
t.Parallel()
for _, name := range []string{"run", "shared", "unbudgeted"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
loaded := testLoaded()
switch name {
case "run":
loaded.Budget = &latest.BudgetConfig{MaxTokens: 10}
case "shared":
loaded.Budgets = map[string]latest.BudgetConfig{"shared": {MaxTokens: 10}, "unused": {MaxTokens: 1}}
loaded.AgentBudgets = map[string][]string{"root": {"shared"}}
}
runConfig := &config.RuntimeConfig{
EnvProviderOverride: environment.NewMapEnvProvider(nil),
ModelsDevStoreOverride: modelsdev.NewDatabaseStore(&modelsdev.Database{}),
}
rt, err := runtime.New(t.Context(), loaded.Team, RuntimeOpts(loaded, runConfig)...)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, rt.Close()) })

sess := session.New(session.WithUserMessage("hello"))
var statuses []runtime.BudgetStatus
for event := range rt.RunStream(t.Context(), sess) {
if usage, ok := event.(*runtime.BudgetUsageEvent); ok {
statuses = usage.Budgets
}
}
assert.Equal(t, "hello", sess.GetLastAssistantMessageContent())
if name == "unbudgeted" {
assert.Empty(t, statuses)
return
}
require.Len(t, statuses, 1)
assert.Equal(t, name, statuses[0].Name)
assert.Equal(t, int64(10), statuses[0].MaxTokens)
assert.Equal(t, int64(5), statuses[0].Tokens)
})
}
}

func TestRuntimeOptsCallerOverrides(t *testing.T) {
t.Parallel()
loaded := testLoaded()
loaded.Models = map[string]latest.ModelConfig{"alternate": {Provider: "test", Model: "alternate"}}
overrideStore := modelsdev.NewDatabaseStore(&modelsdev.Database{})
called := false
loaded.ProviderRegistry = provider.NewRegistry(map[string]provider.Factory{
"test": func(_ context.Context, cfg *latest.ModelConfig, _ environment.Provider, opts ...options.Opt) (provider.Provider, error) {
called = true
gotOpts := options.Apply(opts...)
assert.Same(t, overrideStore, gotOpts.ModelsDevStore())
return &testProvider{Config: base.Config{ModelConfig: *cfg}}, nil
},
})
runConfig := &config.RuntimeConfig{
EnvProviderOverride: environment.NewMapEnvProvider(nil),
ModelsDevStoreOverride: modelsdev.NewDatabaseStore(&modelsdev.Database{}),
}
opts := RuntimeOpts(loaded, runConfig)
store := session.NewInMemorySessionStore()
opts = append(opts, runtime.WithModelStore(overrideStore), runtime.WithSessionStore(store))
rt, err := runtime.New(t.Context(), loaded.Team, opts...)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, rt.Close()) })
assert.Same(t, store, rt.SessionStore())
require.NoError(t, rt.SetAgentModel(t.Context(), "root", "alternate"))
assert.True(t, called)
}

func TestRuntimeOptsResolvesEnvironmentWhenAssembled(t *testing.T) {
t.Parallel()
workingDir := t.TempDir()
file := filepath.Join(workingDir, "test.env")
require.NoError(t, os.WriteFile(file, []byte("TEST_KEY=before\n"), 0o600))
runConfig := &config.RuntimeConfig{
Config: config.Config{WorkingDir: workingDir, EnvFiles: []string{"test.env"}},
ModelsDevStoreOverride: modelsdev.NewDatabaseStore(&modelsdev.Database{}),
}
loaded := testLoaded()
opts := RuntimeOpts(loaded, runConfig)
require.NoError(t, os.WriteFile(file, []byte("TEST_KEY=after\n"), 0o600))
rt, err := runtime.New(t.Context(), loaded.Team, opts...)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, rt.Close()) })
value, ok := runConfig.EnvProvider().Get(t.Context(), "TEST_KEY")
require.True(t, ok)
assert.Equal(t, "before", value)
}

func TestRuntimeOptsModelStoreFailureFallsBack(t *testing.T) {
// Cache-dir overrides are process-global.
original := paths.GetCacheDir()
paths.SetCacheDir(filepath.Join(t.TempDir(), "file"))
t.Cleanup(func() { paths.SetCacheDir(original) })
require.NoError(t, os.WriteFile(paths.GetCacheDir(), nil, 0o600))
loaded := testLoaded()
runConfig := &config.RuntimeConfig{EnvProviderOverride: environment.NewMapEnvProvider(nil)}
opts := RuntimeOpts(loaded, runConfig)
_, err := runConfig.ModelsDevStore()
require.Error(t, err)
rt, err := runtime.New(t.Context(), loaded.Team, opts...)
require.NoError(t, err, "failed catalog initialization must not prevent runtime construction")
t.Cleanup(func() { require.NoError(t, rt.Close()) })
}
9 changes: 8 additions & 1 deletion pkg/chatserver/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ func appendLatestUser(sess *session.Session, msgs []ChatCompletionMessage) bool
return false
}

// runtimeRunner is the execution and interaction contract the chat adapter needs.
type runtimeRunner interface {
RunStream(ctx context.Context, sess *session.Session) <-chan runtime.Event
Resume(ctx context.Context, req runtime.ResumeRequest)
ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any, elicitationID ...string) error
}

// agentEmit collects the side-effect callbacks invoked by runAgentLoop as
// it drives the runtime. All callbacks are optional; nil means "ignore
// this kind of event".
Expand All @@ -211,7 +218,7 @@ type agentEmit struct {
// All ErrorEvents seen in the run are joined into the returned error so
// callers can see the full picture; the loop keeps draining until the
// stream closes so the runtime can shut down cleanly.
func runAgentLoop(ctx context.Context, rt runtime.Runtime, sess *session.Session, emit agentEmit) error {
func runAgentLoop(ctx context.Context, rt runtimeRunner, sess *session.Session, emit agentEmit) error {
var runErrs []error
toolIndex := 0
for ev := range rt.RunStream(ctx, sess) {
Expand Down
Loading
Loading