Skip to content

feat: allow instruction_file to accept a list of files#3306

Merged
dgageot merged 3 commits into
docker:mainfrom
dgageot:worktree-board-7d054332f2224ce0
Jun 30, 2026
Merged

feat: allow instruction_file to accept a list of files#3306
dgageot merged 3 commits into
docker:mainfrom
dgageot:worktree-board-7d054332f2224ce0

Conversation

@dgageot

@dgageot dgageot commented Jun 29, 2026

Copy link
Copy Markdown
Member

Agents often share a common preamble — coding conventions, tone guidelines, a project glossary — but each also needs its own role-specific instructions. Previously instruction_file accepted only a single path, so teams had to maintain one monolithic file per agent or work around the limitation with custom tooling.

This change extends instruction_file so it accepts either a single string (unchanged behaviour) or a list of strings. When multiple paths are given, their contents are concatenated in order separated by a blank line, letting you compose a shared preamble with per-agent specifics cleanly. A single-element list serialises back to scalar form, so existing configs and round-tripped YAML are unaffected. The new form is reflected in agent-schema.json (oneOf string or array), the docs, and a new example under examples/instruction_file.yaml with an accompanying examples/instructions/shared-preamble.md.

All existing path-safety rules apply to every entry in the list: absolute paths and .. traversal are rejected, and reads are confined with os.OpenRoot so symlinks cannot escape the config directory. List-form configs are local-file-only; OCI and URL sources are not supported (consistent with the existing single-file restriction). The configUsesInstructionFile OCI-push probe handles both string and list forms so pushed artifacts remain self-contained. The implementation lives in a new InstructionFiles type with custom YAML/JSON marshalers in pkg/config/latest/types.go; the resolution logic in pkg/config/config.go was refactored into a readInstructionFiles helper that uses a deferred root.Close to avoid leaking the os.Root handle. New tests cover list concatenation, missing files, per-entry traversal rejection, empty-entry rejection, and single-element scalar marshalling.

@dgageot dgageot requested a review from a team as a code owner June 29, 2026 16:30
@aheritier aheritier added area/config For configuration parsing, YAML, environment variables area/docs Documentation changes kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Jun 29, 2026

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟡 NEEDS ATTENTION

if len(f) == 1 {
return f[0], nil
}
return []string(f), nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] MarshalYAML returns empty slice for zero-length InstructionFiles, bypassing omitempty

When instruction_file: [] is parsed from YAML, UnmarshalYAML correctly falls through the scalar path (can't decode a sequence into string) and calls list(&many), which succeeds with many = []string{} — giving a non-nil empty InstructionFiles{}. This non-nil empty value later reaches MarshalYAML:

func (f InstructionFiles) MarshalYAML() (any, error) {
    if len(f) == 1 {
        return f[0], nil
    }
    return []string(f), nil  // returns []string{} for len==0 — not nil
}

In go-yaml v3, omitempty on the struct field checks whether the value equals the zero value for its type before deciding to call MarshalYAML. For a named slice type, nil is zero but a non-nil empty slice is not — so go-yaml v3 will call MarshalYAML, receive []string{}, and serialize it as instruction_file: [] in the output YAML instead of omitting the field entirely.

resolveInstructionFiles skips agents with len(agent.InstructionFile) == 0 but does not normalize an empty slice to nil, so the empty InstructionFiles{} persists through OCI packaging and share push workflows.

Fix: add an early-return guard in MarshalYAML:

func (f InstructionFiles) MarshalYAML() (any, error) {
    if len(f) == 0 {
        return nil, nil
    }
    if len(f) == 1 {
        return f[0], nil
    }
    return []string(f), nil
}

Comment thread pkg/config/config.go

parts := make([]string, 0, len(paths))
for _, path := range paths {
if !filepath.IsLocal(path) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Empty string entry in instruction_file list causes confusing error; inconsistent with scalar handling

The scalar form instruction_file: "" is silently treated as absent (the unmarshal path explicitly sets *f = nil when one == ""). But an empty string inside a list — e.g. instruction_file: ["", "file.md"] — is not filtered during unmarshal, and later trips the path-safety check:

if !filepath.IsLocal(path) {   // filepath.IsLocal("") == false in Go ≥1.20
    return "", fmt.Errorf("instruction_file %q must be a local relative path …", path)
}

This produces the error: instruction_file "" must be a local relative path inside the config directory — which correctly rejects the input but gives no indication that the problem is an empty entry rather than a traversal attempt.

The inconsistency means a user who migrates from the scalar "" (silently ignored) to the list form [""] will get an unexpected error instead of the same no-op. Consider filtering out empty strings in unmarshal (same as the scalar branch does) and/or improving the error message to distinguish an empty path from a path-traversal attempt.

@dgageot

dgageot commented Jun 29, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review! Addressed both points in 1e0b633:

  • [MEDIUM] empty slice bypassing omitempty: MarshalYAML/MarshalJSON now return nil/null for a zero-length InstructionFiles, so the omitempty tag drops the field entirely instead of emitting instruction_file: []. Verified that a config parsed from instruction_file: [] re-marshals without the field, so OCI packaging / share push stay clean.
  • [LOW] empty entry in list: empty strings are now dropped on decode (new nonEmptyInstructionFiles helper), making the list form consistent with the scalar form where instruction_file: "" is treated as absent. instruction_file: [""] is now a no-op instead of failing the path-safety check.

Added tests: TestInstructionFileEmptyMarshalsAsNull, TestInstructionFileListDropsEmptyEntries, TestInstructionFileListAllEmptyIgnored. Build, tests, and golangci-lint all pass.

@aheritier aheritier marked this pull request as draft June 29, 2026 18:02
@aheritier aheritier added the status/needs-rebase PR has merge conflicts or is out of date with main label Jun 29, 2026
@aheritier

Copy link
Copy Markdown
Contributor

👋 This PR has merge conflicts with the base branch. Please rebase or merge the latest base branch and resolve them. I've moved it to draft and added status/needs-rebase; it'll be picked back up automatically once the conflicts are cleared.

dgageot added 3 commits June 30, 2026 09:24
When several paths are listed their contents are concatenated in order,
separated by a blank line, so a shared preamble can be reused across agents.
Eliminates three manual `_ = root.Close()` call sites and prevents a
potential handle leak if an early return is added in the future.

Assisted-By: Claude
Zero-length InstructionFiles now marshals to nil/null so the omitempty
tag suppresses the field entirely. Empty strings in the list form are
stripped on decode, matching scalar behaviour and avoiding a confusing
path-safety error.

Assisted-By: Claude
@dgageot dgageot force-pushed the worktree-board-7d054332f2224ce0 branch from 1e0b633 to 2b5ab5e Compare June 30, 2026 07:25
@aheritier aheritier removed the status/needs-rebase PR has merge conflicts or is out of date with main label Jun 30, 2026
@dgageot dgageot marked this pull request as ready for review June 30, 2026 07:36

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟢 APPROVE

The implementation looks correct and well-structured. The custom YAML/JSON marshalers handle both scalar and list forms properly, the helper correctly applies path-safety rules (absolute path rejection, ... traversal, os.OpenRoot confinement) to every entry, the deferred root.Close avoids handle leaks, and the OCI push probe covers both string and list forms. Concatenation with a blank-line separator and single-element scalar round-tripping are correct.

@dgageot dgageot merged commit e07c94c into docker:main Jun 30, 2026
9 checks passed
@dgageot dgageot deleted the worktree-board-7d054332f2224ce0 branch June 30, 2026 07:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config For configuration parsing, YAML, environment variables area/docs Documentation changes kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants