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
16 changes: 13 additions & 3 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
},
"flavors": {
"type": "object",
"description": "Named YAML patches applied on top of the rest of the document when enabled at run time (e.g. 'docker agent run --flavor <name>'). Each flavor is a partial config document merged with JSON Merge Patch semantics: objects merge recursively, scalars and arrays replace, null deletes a key. A key ending in '+' appends its items to the existing array (e.g. 'toolsets+:'); a key ending in '-' removes entries — from an object by key name, from an array by scalar equality or partial object match (e.g. 'toolsets-: [{type: shell}]'). Enabled flavors are applied in the order they were requested; requested flavors not defined here are ignored.",
"description": "Named YAML patches applied on top of the rest of the document when enabled at run time (e.g. 'docker agent run --flavor <name>'). Each flavor is a partial config document merged with JSON Merge Patch semantics: objects merge recursively, scalars and arrays replace, null deletes a key. A key ending in '+' appends its items to the existing array (e.g. 'toolsets+:'); a scalar existing value is promoted to a one-element array first, so 'instruction+:' extends a string instruction. A key ending in '-' removes entries — from an object by key name, from an array by scalar equality or partial object match (e.g. 'toolsets-: [{type: shell}]'). Enabled flavors are applied in the order they were requested; requested flavors not defined here are ignored.",
"additionalProperties": {
"type": ["object", "null"]
}
Expand Down Expand Up @@ -604,8 +604,18 @@
}
},
"instruction": {
"type": "string",
"description": "Instructions for the agent"
"description": "Instructions for the agent (the system prompt). Accepts a single string or a list of strings; list items are concatenated in order, separated by a blank line.",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"instruction_file": {
"description": "Path(s) to a file or files, relative to the config file's directory, whose contents become the agent's instruction. Accepts a single string or a list of strings; when several files are given their contents are concatenated in order, separated by a blank line. Loaded at startup. Mutually exclusive with 'instruction'. Each 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 OCI/URL sources.",
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ agents:
agent_name:
model: string # Required: model reference
description: string # Required: what this agent does
instruction: string # Required (unless instruction_file): system prompt
instruction: string | [list] # Required (unless instruction_file): system prompt; a list is joined by blank lines
instruction_file: string | [list] # Optional: load the system prompt from one or more files 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)
Expand Down Expand Up @@ -88,7 +88,7 @@ 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. Required unless `instruction_file` is set. |
| `instruction` | string \| array | ✓ | System prompt that defines the agent's behavior, personality, and constraints. Accepts a single string or a list of strings; list items are concatenated in order, separated by a blank line (handy for a shared preamble, or for flavors to append to with `instruction+`). Required unless `instruction_file` is set. |
| `instruction_file` | string \| array | ✗ | Path(s) to a file or files (relative to the config file's directory) whose contents become the agent's instruction, loaded at startup. Accepts a single path or a list; multiple files are concatenated in order, separated by a blank line. Mutually exclusive with `instruction`. Each 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 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., `myorg/agent:tag`), 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/index.md#external-sub-agents-from-registries). |
| `toolsets` | array | ✗ | List of tool configurations. See [Tool Config](../tools/index.md). |
Expand Down
30 changes: 29 additions & 1 deletion docs/configuration/flavors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ semantics, with two extensions for arrays:
| Object | Merged recursively into the existing object. |
| Scalar or array | Replaces the existing value. |
| `null` | Deletes the key. |
| Key ending in `+` | Appends the items to the existing array. |
| Key ending in `+` | Appends the items to the existing array (a scalar is promoted to a one-element array first). |
| Key ending in `-` | Removes matching entries from an array or object. |

### Merging and replacing
Expand Down Expand Up @@ -111,6 +111,33 @@ flavors:

With `--flavor with-shell` the root agent gets both `think` and `shell`.

### Appending to an instruction

An agent `instruction` may be a list of strings, joined by blank lines. Since
`+` promotes a scalar to a one-element array, a flavor can extend the system
prompt without repeating it:

```yaml
agents:
root:
instruction: You are a helpful assistant.

flavors:
terse:
agents:
root:
instruction+:
- Answer in one sentence.
```

With `--flavor terse` the instruction becomes:

```text
You are a helpful assistant.

Answer in one sentence.
```

### Removing entries

Suffix the key with `-`. Each item in the patch value selects what to remove:
Expand Down Expand Up @@ -166,6 +193,7 @@ flavors "with-shell" {

- Flavors require config schema version 13 or later; older versions reject
the `flavors` key with a hint to bump the top-level `version` field.
Appending to a string with `+` (e.g. `instruction+`) requires version 15.
- Patches apply before validation, so a flavored config is validated exactly
like a hand-written one.
- `docker agent push` publishes the raw document, `flavors` section included,
Expand Down
6 changes: 3 additions & 3 deletions docs/configuration/overview/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,13 +311,13 @@ agents:

When you load an older config, Docker Agent automatically migrates it to the latest schema. It's recommended to include the version to ensure consistent behavior.

If you use a config key that requires a newer schema version, Docker Agent will fail with a strict-parse error and include a hint like:
If you use a config key or value syntax that requires a newer schema version, Docker Agent will fail with a strict-parse error and include a hint like:

```text
hint: this key is supported by config version 12; update the top-level 'version' field (currently 11)
hint: this syntax is supported by config version 12; update the top-level 'version' field (currently 11)
```

Bump the `version` field as directed to enable the new key.
Bump the `version` field as directed to enable the new syntax.

## Metadata Section

Expand Down
16 changes: 12 additions & 4 deletions examples/flavors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
# docker agent run examples/flavors.yaml --flavor cheap --flavor verbose
#
# Objects merge recursively, scalars and arrays replace, and a null value
# deletes a key. A key ending in '+' appends to an existing array; a key
# ending in '-' removes entries (from an object by key name, from an array by
# value or partial match). Requested flavors the file does not define are
# ignored.
# deletes a key. A key ending in '+' appends to an existing array (a scalar is
# promoted to a one-element array first); a key ending in '-' removes entries
# (from an object by key name, from an array by value or partial match).
# Requested flavors the file does not define are ignored.

agents:
root:
Expand Down Expand Up @@ -48,6 +48,14 @@ flavors:
toolsets+:
- type: shell

# 'key+' also works on a string: the instruction becomes a list, which is
# joined back into one prompt with blank lines between the parts.
terse:
agents:
root:
instruction+:
- Answer in one sentence.

# Remove entries with the 'key-' suffix: array elements are matched by
# value or partial object match.
no-think:
Expand Down
9 changes: 7 additions & 2 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,14 @@ func parseCurrentVersion(data []byte, version string) (any, error) {
// the smallest version that parses the config successfully. Best-effort: a
// newer version may accept the config for unrelated reasons (laxer schema),
// so the original unknown-field error is always shown before the hint.
// newerVersionHint returns a hint when a parse error is caused by a key or a
// value shape that a newer config version accepts (an unknown field, or a type
// mismatch such as a list where an older schema only takes a string), so the
// user is pointed at the `version` bump instead of a generic YAML error.
func newerVersionHint(data []byte, version string, parseErr error) string {
var unknownField *yaml.UnknownFieldError
if !errors.As(parseErr, &unknownField) {
var typeErr *yaml.TypeError
if !errors.As(parseErr, &unknownField) && !errors.As(parseErr, &typeErr) {
return ""
}

Expand All @@ -234,7 +239,7 @@ func newerVersionHint(data []byte, version string, parseErr error) string {
for _, n := range newer {
v := strconv.Itoa(n)
if _, err := parsers[v](data); err == nil {
return fmt.Sprintf("hint: this key is supported by config version %s; update the top-level 'version' field (currently %s)", v, version)
return fmt.Sprintf("hint: this syntax is supported by config version %s; update the top-level 'version' field (currently %s)", v, version)
}
}

Expand Down
29 changes: 19 additions & 10 deletions pkg/config/flavors.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ import (
// recursively, scalars and sequences replace the previous value, and an
// explicit null deletes the key. As extensions, a mapping key ending in `+`
// appends its sequence to the existing one instead of replacing it (e.g.
// `toolsets+:` adds entries to an agent's toolsets), and a key ending in `-`
// removes entries: from a mapping by key name, from a sequence by scalar
// equality or mapping subset-match. The `+`/`-` suffixes are reserved inside
// flavor patches; keys ending in them cannot be set literally. The `flavors`
// section itself is left in place; the latest schema carries it so parsing
// still succeeds.
// `toolsets+:` adds entries to an agent's toolsets; a scalar base such as a
// string `instruction` is promoted to a one-element sequence first), and a key
// ending in `-` removes entries: from a mapping by key name, from a sequence
// by scalar equality or mapping subset-match. The `+`/`-` suffixes are
// reserved inside flavor patches; keys ending in them cannot be set literally.
// The `flavors` section itself is left in place; the latest schema carries it
// so parsing still succeeds.
func applyFlavors(ctx context.Context, data []byte, enabled []string) ([]byte, error) {
if len(enabled) == 0 {
return data, nil
Expand Down Expand Up @@ -140,7 +141,9 @@ func mergePatch(base, patch any) (any, error) {

// appendPatch handles a `key+` patch entry: it appends the patch sequence to
// the base sequence under key, creating the sequence when key is absent or
// null. Both sides must be sequences.
// null. A scalar base is promoted to a one-element sequence first, so
// `instruction+:` can extend a plain-string instruction (the agent decoder
// joins a list of strings back into one). The patch value must be a sequence.
func appendPatch(out yaml.MapSlice, key string, value any) (yaml.MapSlice, error) {
items, ok := value.([]any)
if !ok {
Expand All @@ -152,9 +155,15 @@ func appendPatch(out yaml.MapSlice, key string, value any) (yaml.MapSlice, error
if idx < 0 {
return append(out, yaml.MapItem{Key: key, Value: items}), nil
}
existing, ok := out[idx].Value.([]any)
if !ok && out[idx].Value != nil {
return nil, fmt.Errorf("append key %q: existing value for %q is not a sequence", key+"+", key)
var existing []any
switch base := out[idx].Value.(type) {
case nil:
case []any:
existing = base
case yaml.MapSlice:
return nil, fmt.Errorf("append key %q: existing value for %q is a mapping, not a sequence", key+"+", key)
default:
existing = []any{base}
}
out[idx].Value = append(slices.Clone(existing), items...)
return out, nil
Expand Down
33 changes: 31 additions & 2 deletions pkg/config/flavors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,15 @@ flavors:
root:
model+:
- extra
mapping-append:
agents+:
- extra
more-instruction:
agents:
root:
instruction+:
- |
Also: be brief.
`

func TestFlavorsAppendToArray(t *testing.T) {
Expand Down Expand Up @@ -203,11 +212,31 @@ func TestFlavorsAppendRequiresSequencePatch(t *testing.T) {
require.ErrorContains(t, err, `append key "toolsets+": value must be a sequence`)
}

func TestFlavorsAppendRequiresSequenceBase(t *testing.T) {
func TestFlavorsAppendPromotesScalarBase(t *testing.T) {
t.Parallel()

// The patch itself succeeds ("openai/gpt-5" becomes ["openai/gpt-5",
// "extra"]); the resulting document then fails validation like any
// hand-written config with a list where a string belongs.
_, err := loadFlavored(t, appendConfig, "scalar-append")
require.ErrorContains(t, err, `existing value for "model" is not a sequence`)
require.ErrorContains(t, err, "model")
require.NotContains(t, err.Error(), `applying flavor`)
}

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

_, err := loadFlavored(t, appendConfig, "mapping-append")
require.ErrorContains(t, err, `applying flavor "mapping-append"`)
require.ErrorContains(t, err, `existing value for "agents" is a mapping, not a sequence`)
}

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

cfg, err := loadFlavored(t, appendConfig, "more-instruction")
require.NoError(t, err)
assert.Equal(t, "hello\n\nAlso: be brief.\n", cfg.Agents.First().Instruction)
}

const removeConfig = `agents:
Expand Down
58 changes: 58 additions & 0 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ func (c *Agents) UnmarshalYAML(unmarshal func(any) error) error {
return errors.New("agent name must be a string")
}

if err := joinInstructionList(item.Value); err != nil {
return fmt.Errorf("agent %s: %w", name, err)
}

valueBytes, err := yaml.Marshal(item.Value)
if err != nil {
return fmt.Errorf("failed to marshal agent config for %s: %w", name, err)
Expand All @@ -337,6 +341,60 @@ func (c *Agents) UnmarshalYAML(unmarshal func(any) error) error {
return nil
}

// joinInstructionList lets `instruction` be written as a list of strings
// (`instruction: [preamble, rules]`) as well as a single string. The parts are
// joined by a blank line, like `instruction_file` does with several files, so
// AgentConfig.Instruction stays a plain string for every consumer. The list
// form is also what a flavor's `instruction+:` patch produces when it appends
// to a scalar instruction. fields is the raw agent mapping, decoded either as
// a map or as an ordered yaml.MapSlice; anything else is left untouched.
func joinInstructionList(fields any) error {
switch fields := fields.(type) {
case map[string]any:
joined, err := joinInstructionValue(fields["instruction"])
if err != nil {
return err
}
if joined != nil {
fields["instruction"] = *joined
}
case yaml.MapSlice:
for i, field := range fields {
if field.Key != "instruction" {
continue
}
joined, err := joinInstructionValue(field.Value)
if err != nil {
return err
}
if joined != nil {
fields[i].Value = *joined
}
}
}
return nil
}

// joinInstructionValue returns the blank-line-joined string when value is a
// list of strings, nil when it is anything else (left for the regular decoder
// to handle), and an error when the list holds a non-string.
func joinInstructionValue(value any) (*string, error) {
list, ok := value.([]any)
if !ok {
return nil, nil
}
parts := make([]string, 0, len(list))
for _, v := range list {
s, ok := v.(string)
if !ok {
return nil, errors.New("instruction must be a string or a list of strings")
}
parts = append(parts, s)
}
joined := strings.Join(parts, "\n\n")
return &joined, nil
}

func (c Agents) MarshalYAML() (any, error) {
mapSlice := make(yaml.MapSlice, 0, len(c))

Expand Down
Loading
Loading