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
4 changes: 4 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,10 @@
"type": "string",
"description": "Instructions for the agent"
},
"instruction_file": {
"type": "string",
"description": "Path to a file, relative to the config file's directory, whose contents become the agent's instruction. Loaded at startup. Mutually exclusive with 'instruction'. Must be a local relative path inside the config directory (absolute paths and '..' traversal are rejected). Only supported for local file-based configs, not OCI/URL sources."
},
"harness": {
"$ref": "#/definitions/HarnessConfig",
"description": "External coding harness to run this agent with instead of a docker-agent model provider"
Expand Down
41 changes: 39 additions & 2 deletions docs/configuration/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ agents:
agent_name:
model: string # Required: model reference
description: string # Required: what this agent does
instruction: string # Required: system prompt
instruction: string # Required (unless instruction_file): system prompt
instruction_file: string # Optional: load the system prompt from a file relative to this config (mutually exclusive with instruction)
sub_agents: [list] # Optional: local or external sub-agent references
toolsets: [list] # Optional: tool configurations (use `type: rag` for RAG sources)
fallback: # Optional: fallback config
Expand Down Expand Up @@ -81,7 +82,8 @@ agents:
| --------------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `model` | string | ✓ | Model reference. Either inline (`openai/gpt-5`) or a named model from the `models` section. |
| `description` | string | ✓ | Brief description of the agent's purpose. Used by coordinators to decide delegation. |
| `instruction` | string | ✓ | System prompt that defines the agent's behavior, personality, and constraints. |
| `instruction` | string | ✓ | System prompt that defines the agent's behavior, personality, and constraints. Required unless `instruction_file` is set. |
| `instruction_file` | string | ✗ | Path to a file (relative to the config file's directory) whose contents become the agent's instruction, loaded at startup. Mutually exclusive with `instruction`. Must be a local relative path inside the config directory (absolute paths and `..` traversal are rejected). Only supported for local file-based configs, not OCI/URL sources. See [External Instruction Files](#external-instruction-files) below. |
| `sub_agents` | array | ✗ | List of agent names or external OCI references this agent can delegate to. Supports local agents, registry references (e.g., `agentcatalog/pirate`), and named references (`name:reference`). Automatically enables the `transfer_task` tool. Pin external OCI references to a digest (`name@sha256:…`) to skip the per-run registry lookup that tag references incur. See [External Sub-Agents]({{ '/concepts/multi-agent/#external-sub-agents-from-registries' | relative_url }}). |
| `toolsets` | array | ✗ | List of tool configurations. See [Tool Config]({{ '/configuration/tools/' | relative_url }}). |
| `fallback` | object | ✗ | Automatic model failover configuration. |
Expand Down Expand Up @@ -116,6 +118,41 @@ agents:

</div>

## External Instruction Files

Long system prompts can be kept in their own files instead of being inlined in
the YAML, using `instruction_file`. This separates infrastructure configuration
(models, providers, tools) from behavioral content (the prompt), which keeps
version-control diffs focused, reduces merge conflicts on shared configs, and
lets instruction content be edited without risking YAML syntax errors.

```yaml
agents:
coordinator:
model: openai/gpt-5-mini
description: Routes work between specialist agents
instruction_file: instructions/coordinator.md
sub_agents:
- writer
writer:
model: openai/gpt-5-mini
description: Drafts and edits written content
instruction_file: instructions/writer.md
```

The path is resolved relative to the config file's directory and the file's
contents are loaded as the agent's instruction when the config is loaded. Notes:

- **Mutually exclusive** with `instruction`. Setting both is an error.
- The path must be a **local relative path inside the config directory**.
Absolute paths and `..` traversal are rejected.
- Only supported for **local file-based configs**, not agents loaded from OCI
registries or URLs. When an agent is pushed with `docker agent share push`,
the file contents are inlined into the pushed artifact, so the published
agent stays self-contained.

A runnable example lives in [`examples/instruction_file.yaml`](https://github.com/docker/docker-agent/blob/main/examples/instruction_file.yaml).

## Response Cache

The response cache short-circuits the model when the same user question is asked again. The first time a question is asked, the agent calls the model normally and stores the assistant's reply. Subsequent identical questions skip the model entirely and replay the stored reply verbatim.
Expand Down
15 changes: 15 additions & 0 deletions examples/instruction_file.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
agents:
coordinator:
model: openai/gpt-5-mini
description: Routes work between specialist agents
# Instead of inlining a long prompt here, point at a Markdown file kept
# next to the config. The path is relative to this config file's directory
# and its contents are loaded as the agent's instruction at startup.
instruction_file: instructions/coordinator.md
sub_agents:
- writer

writer:
model: openai/gpt-5-mini
description: Drafts and edits written content
instruction_file: instructions/writer.md
8 changes: 8 additions & 0 deletions examples/instructions/coordinator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
You are a coordinator agent.

Your job is to understand the user's request, break it into clear steps, and
delegate writing work to the `writer` sub-agent. Keep your own responses short:
summarize what you delegated and relay the result back to the user.

Do not write long-form content yourself. Always hand drafting and editing tasks
to the `writer`.
6 changes: 6 additions & 0 deletions examples/instructions/writer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
You are a writing specialist.

Produce clear, well-structured prose tailored to the requested audience and
tone. When asked to edit, preserve the author's intent while improving clarity,
flow, and correctness. Prefer concrete examples over abstractions, and keep
paragraphs focused on a single idea.
51 changes: 51 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"log/slog"
"maps"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
Expand Down Expand Up @@ -56,6 +57,10 @@ func Load(ctx context.Context, source Source) (*latest.Config, error) {

config.Version = raw.Version

if err := resolveInstructionFiles(&config, source); err != nil {
return nil, err
}

if err := validateConfig(&config); err != nil {
return nil, err
}
Expand All @@ -65,6 +70,52 @@ func Load(ctx context.Context, source Source) (*latest.Config, error) {
return &config, nil
}

// resolveInstructionFiles replaces every agent's instruction_file reference
// with the file's contents, loaded relative to the config file's directory.
// Resolution happens once at load time so the rest of the pipeline (and any
// marshalled/pushed copy of the config) only ever sees the inlined
// Instruction; the InstructionFile field is cleared afterwards to keep the
// loaded config self-contained.
//
// The reference must be a local relative path inside the config directory:
// absolute paths and "../" traversal are rejected, and reads are confined to
// the directory with os.OpenRoot so symlinks cannot escape it. This mirrors
// the path-safety rules used by the HCL file() helper and fileSource.Read.
func resolveInstructionFiles(cfg *latest.Config, source Source) error {
parentDir := source.ParentDir()

for i := range cfg.Agents {
agent := &cfg.Agents[i]
if agent.InstructionFile == "" {
continue
}
if agent.Instruction != "" {
return fmt.Errorf("agent %q: 'instruction' and 'instruction_file' are mutually exclusive, set only one", agent.Name)
}
if parentDir == "" {
return fmt.Errorf("agent %q: 'instruction_file' is only supported for local file-based configs, not OCI/URL sources", agent.Name)
}
if !filepath.IsLocal(agent.InstructionFile) {
return fmt.Errorf("agent %q: instruction_file %q must be a local relative path inside the config directory", agent.Name, agent.InstructionFile)
}

root, err := os.OpenRoot(parentDir)
if err != nil {
return fmt.Errorf("agent %q: opening config directory %q: %w", agent.Name, parentDir, err)
}
data, err := root.ReadFile(filepath.ToSlash(agent.InstructionFile))
_ = root.Close()
if err != nil {
return fmt.Errorf("agent %q: reading instruction_file %q: %w", agent.Name, agent.InstructionFile, err)
}

agent.Instruction = string(data)
agent.InstructionFile = ""
}

return nil
}

// CheckRequiredEnvVars checks which environment variables are required by the models and tools.
//
// This allows exiting early with a proper error message instead of failing later when trying to use a model or tool.
Expand Down
182 changes: 182 additions & 0 deletions pkg/config/instruction_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package config

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

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

// writeConfigDir creates a temp directory holding an agent config file named
// agent.yaml with the given body, plus any extra files (path relative to the
// directory -> contents). It returns the directory path.
func writeConfigDir(t *testing.T, configYAML string, files map[string]string) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(configYAML), 0o644))
for name, content := range files {
full := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
}
return dir
}

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

cfgYAML := `agents:
root:
model: openai/gpt-4o
description: test agent
instruction_file: instructions/root.md
`
dir := writeConfigDir(t, cfgYAML, map[string]string{
"instructions/root.md": "You are a helpful assistant.\n",
})

cfg, err := Load(t.Context(), NewFileSource(filepath.Join(dir, "agent.yaml")))
require.NoError(t, err)

agent := cfg.Agents.First()
assert.Equal(t, "You are a helpful assistant.\n", agent.Instruction)
// The reference is cleared after resolution so the in-memory config is
// self-contained and round-trips through marshalling.
assert.Empty(t, agent.InstructionFile)
}

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

cfgYAML := `agents:
root:
model: openai/gpt-4o
description: test agent
instruction_file: instructions/missing.md
`
dir := writeConfigDir(t, cfgYAML, nil)

_, err := Load(t.Context(), NewFileSource(filepath.Join(dir, "agent.yaml")))
require.Error(t, err)
assert.Contains(t, err.Error(), "root")
assert.Contains(t, err.Error(), "instruction_file")
assert.Contains(t, err.Error(), "instructions/missing.md")
}

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

cfgYAML := `agents:
root:
model: openai/gpt-4o
description: test agent
instruction_file: ../secret.md
`
dir := writeConfigDir(t, cfgYAML, nil)
// A file outside the config directory that traversal would try to reach.
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(dir), "secret.md"), []byte("secret"), 0o644))

_, err := Load(t.Context(), NewFileSource(filepath.Join(dir, "agent.yaml")))
require.Error(t, err)
assert.Contains(t, err.Error(), "local relative path")
assert.Contains(t, err.Error(), "../secret.md")
}

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

abs := filepath.Join(t.TempDir(), "outside.md")
require.NoError(t, os.WriteFile(abs, []byte("secret"), 0o644))

cfgYAML := `agents:
root:
model: openai/gpt-4o
description: test agent
instruction_file: ` + abs + `
`
dir := writeConfigDir(t, cfgYAML, nil)

_, err := Load(t.Context(), NewFileSource(filepath.Join(dir, "agent.yaml")))
require.Error(t, err)
assert.Contains(t, err.Error(), "local relative path")
}

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

cfgYAML := `agents:
root:
model: openai/gpt-4o
description: test agent
instruction: inline prompt
instruction_file: instructions/root.md
`
dir := writeConfigDir(t, cfgYAML, map[string]string{
"instructions/root.md": "from file",
})

_, err := Load(t.Context(), NewFileSource(filepath.Join(dir, "agent.yaml")))
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
}

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

cfgYAML := `agents:
root:
model: openai/gpt-4o
description: test agent
instruction_file: instructions/root.md
`
// A bytes source has no directory to resolve the reference against.
_, err := Load(t.Context(), NewBytesSource("config.yaml", []byte(cfgYAML)))
require.Error(t, err)
assert.Contains(t, err.Error(), "local file-based configs")
}

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

cfgYAML := `agents:
root:
model: openai/gpt-4o
description: test agent
instruction_file: instructions/root.md
`
dir := writeConfigDir(t, cfgYAML, nil)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "instructions"), 0o755))

outside := filepath.Join(filepath.Dir(dir), "outside.md")
require.NoError(t, os.WriteFile(outside, []byte("secret"), 0o644))

link := filepath.Join(dir, "instructions", "root.md")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlink not supported: %v", err)
}

_, err := Load(t.Context(), NewFileSource(filepath.Join(dir, "agent.yaml")))
require.Error(t, err)
assert.Contains(t, err.Error(), "instruction_file")
}

// TestInstructionFileEmptyStringIgnored verifies that an explicit empty
// instruction_file is treated as unset rather than triggering resolution.
func TestInstructionFileEmptyStringIgnored(t *testing.T) {
t.Parallel()

cfgYAML := `agents:
root:
model: openai/gpt-4o
description: test agent
instruction: inline prompt
instruction_file: ""
`
dir := writeConfigDir(t, cfgYAML, nil)

cfg, err := Load(t.Context(), NewFileSource(filepath.Join(dir, "agent.yaml")))
require.NoError(t, err)
assert.Equal(t, "inline prompt", cfg.Agents.First().Instruction)
}
15 changes: 12 additions & 3 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,18 @@ type AgentConfig struct {
WelcomeMessage string `json:"welcome_message,omitempty"`
Toolsets []Toolset `json:"toolsets,omitempty"`
Instruction string `json:"instruction,omitempty"`
Harness *HarnessConfig `json:"harness,omitempty"`
SubAgents []string `json:"sub_agents,omitempty"`
Handoffs []string `json:"handoffs,omitempty"`
// InstructionFile names a file, relative to the config file's directory,
// whose contents are loaded into Instruction when the config is loaded.
// It keeps long behavioral prompts out of the YAML, letting infrastructure
// configuration and instruction content evolve in separate files. Mutually
// exclusive with Instruction. Only file-based config sources are supported
// (not OCI/URL/bytes sources, which have no directory to resolve against).
// The field is cleared once resolved so the in-memory config stays
// self-contained (see config.Load).
InstructionFile string `json:"instruction_file,omitempty" yaml:"instruction_file,omitempty"`
Harness *HarnessConfig `json:"harness,omitempty"`
SubAgents []string `json:"sub_agents,omitempty"`
Handoffs []string `json:"handoffs,omitempty"`
// ForceHandoff names an agent that unconditionally receives the
// conversation whenever this agent produces a final response,
// bypassing the LLM's tool-calling entirely. Unlike Handoffs (which
Expand Down
Loading
Loading