From 493e9bbd48f3d079697adca5f20ddc0055e98e9d Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Sat, 4 Jul 2026 01:14:48 +0200 Subject: [PATCH] feat(hcl): render file() as a template when given variables file(path, vars) now parses the file as an HCL template and renders it with each key of vars exposed as a variable, replacing the need for a separate templatefile() function. file(path) is unchanged and returns the contents verbatim, keeping literal ${...} runtime snippets intact. Templates expose no functions, so a rendered file cannot recursively call file(). Signed-off-by: Djordje Lukic --- docs/configuration/hcl/index.md | 63 ++++++++++++++- examples/instructions_from_file.hcl | 7 +- examples/instructions_from_file.md | 4 +- pkg/config/hcl/hcl.go | 4 +- pkg/config/hcl/hcl_file.go | 50 +++++++++++- pkg/config/hcl/hcl_file_test.go | 119 ++++++++++++++++++++++++++++ 6 files changed, 241 insertions(+), 6 deletions(-) diff --git a/docs/configuration/hcl/index.md b/docs/configuration/hcl/index.md index cc0ed7c63..a0de8dca6 100644 --- a/docs/configuration/hcl/index.md +++ b/docs/configuration/hcl/index.md @@ -195,6 +195,66 @@ command "fix-lint" { The model will receive the literal `${shell({cmd: "task lint"})}` text. +## Loading Files with `file()` + +The `file()` function reads a UTF-8 text file and returns its contents as a string. Relative paths are resolved from the HCL config file's directory, and reads are restricted to that directory. + +This keeps long prompts out of the config: + +```hcl +agent "root" { + model = "openai/gpt-5" + description = "Coding assistant" + instruction = file("prompts/coding.md") +} +``` + +With a single argument, the file contents are returned exactly as written — any `${...}` in the file stays literal, so runtime snippets like `${shell({cmd: "..."})}` pass through untouched. + +### Rendering files as templates + +Pass an object as the second argument to render the file as an HCL template. Each key becomes a variable available inside the file: + +```hcl +agent "reviewer" { + model = "openai/gpt-5" + description = "Go reviewer" + instruction = file("prompts/reviewer.md", { + language = "Go" + strictness = "high" + }) +} + +agent "py_reviewer" { + model = "openai/gpt-5" + description = "Python reviewer" + instruction = file("prompts/reviewer.md", { + language = "Python" + strictness = "relaxed" + }) +} +``` + +With `prompts/reviewer.md` containing: + +```markdown +You review ${language} code with ${strictness} strictness. +``` + +Templates support the full HCL template syntax, including `%{ for }` and `%{ if }` directives: + +```markdown +Rules: +%{ for rule in rules ~} +- ${rule} +%{ endfor ~} +``` + +Two things to keep in mind: + +- Referencing a variable that is not in the object is an error. +- No functions are available inside templates, so a template cannot call `file()` again. If the file needs a literal `${...}` while being rendered as a template, escape it as `$${...}` inside the file. + ## Repeated Blocks Become Lists Some YAML sections are lists. In HCL, those are written as repeated blocks. @@ -231,7 +291,7 @@ The same idea applies to other list-shaped sections such as RAG `strategy` block docker-agent uses HCL as a configuration syntax, not as Terraform: - There are no modules, `locals`, or `variable` blocks. -- There are no docker-agent-specific HCL functions to call from expressions. +- The only function available in expressions is [`file()`](#loading-files-with-file); Terraform's function library (including `templatefile()`, which `file()` with a vars object replaces) is not available. - Prefer normal literal values: strings, numbers, booleans, lists, objects, and nested blocks. - After conversion, the result is validated exactly like the equivalent YAML config. @@ -243,3 +303,4 @@ See these real configs in the repository: - [`examples/pirate.hcl`](https://github.com/docker/docker-agent/blob/main/examples/pirate.hcl) - [`examples/gopher.hcl`](https://github.com/docker/docker-agent/blob/main/examples/gopher.hcl) +- [`examples/instructions_from_file.hcl`](https://github.com/docker/docker-agent/blob/main/examples/instructions_from_file.hcl) diff --git a/examples/instructions_from_file.hcl b/examples/instructions_from_file.hcl index 5a94aca22..9f9f2ba8b 100644 --- a/examples/instructions_from_file.hcl +++ b/examples/instructions_from_file.hcl @@ -4,7 +4,12 @@ agent "root" { # The file() helper reads a text file and injects its contents here. # Relative paths are resolved from this HCL file's directory. - instruction = file("instructions_from_file.md") + # Passing an object as a second argument renders the file as a template: + # each key becomes a ${key} variable inside the file. + instruction = file("instructions_from_file.md", { + audience = "developers" + style = "concise" + }) welcome_message = "Hi! My instructions were loaded from instructions_from_file.md" } diff --git a/examples/instructions_from_file.md b/examples/instructions_from_file.md index c096e3810..70fa07d20 100644 --- a/examples/instructions_from_file.md +++ b/examples/instructions_from_file.md @@ -1,7 +1,7 @@ -You are a helpful assistant. +You are a helpful assistant for ${audience}. When you answer: -- be concise +- be ${style} - prefer bullet points when listing steps - ask for clarification only if necessary - mention when you are making an assumption diff --git a/pkg/config/hcl/hcl.go b/pkg/config/hcl/hcl.go index 53cb24e96..6874256fd 100644 --- a/pkg/config/hcl/hcl.go +++ b/pkg/config/hcl/hcl.go @@ -14,7 +14,9 @@ // `${...}` interpolation, any literal `${...}` (such as // `${shell({cmd: "..."})}`) must be escaped as `$${...}`. // - The custom `file("path")` function reads a UTF-8 text file and returns -// its contents as a string. Relative paths are resolved from the HCL +// its contents as a string. An optional second argument renders the file +// as an HCL template, e.g. `file("prompt.md", { name = "Ada" })` expands +// `${name}` inside the file. Relative paths are resolved from the HCL // config file's directory. // // The converter does not validate the resulting document against the diff --git a/pkg/config/hcl/hcl_file.go b/pkg/config/hcl/hcl_file.go index 5f902b5c6..861d7ac50 100644 --- a/pkg/config/hcl/hcl_file.go +++ b/pkg/config/hcl/hcl_file.go @@ -6,29 +6,77 @@ import ( "os" "path/filepath" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" "github.com/zclconf/go-cty/cty/function" ) +// fileFunction implements `file(path)` and `file(path, vars)`. With a single +// argument the file contents are returned verbatim. With a vars object, the +// file is rendered as an HCL template with each key exposed as a variable. func fileFunction(baseDir string) function.Function { return function.New(&function.Spec{ Params: []function.Parameter{{ Name: "path", Type: cty.String, }}, + VarParam: &function.Parameter{ + Name: "vars", + Type: cty.DynamicPseudoType, + AllowDynamicType: true, + }, Type: function.StaticReturnType(cty.String), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + if len(args) > 2 { + return cty.NilVal, errors.New("file() takes a path and an optional variables object") + } path := args[0].AsString() data, readPath, err := readFileForHCL(path, baseDir) if err != nil { return cty.NilVal, fmt.Errorf("reading file %q: %w", readPath, err) } - return cty.StringVal(string(data)), nil + if len(args) == 1 { + return cty.StringVal(string(data)), nil + } + return renderTemplate(data, readPath, args[1]) }, }) } +// renderTemplate evaluates data as an HCL template with the keys of vars as +// the only available variables. No functions are exposed, so templates +// cannot recursively call file(). +func renderTemplate(data []byte, filename string, vars cty.Value) (cty.Value, error) { + t := vars.Type() + if vars.IsNull() || (!t.IsObjectType() && !t.IsMapType()) { + return cty.NilVal, errors.New(`template variables must be an object, e.g. { name = "value" }`) + } + + expr, diags := hclsyntax.ParseTemplate(data, filename, hcl.InitialPos) + if diags.HasErrors() { + return cty.NilVal, fmt.Errorf("parsing template %q: %s", filename, diags.Error()) + } + + evalCtx := &hcl.EvalContext{Variables: map[string]cty.Value{}} + for it := vars.ElementIterator(); it.Next(); { + k, v := it.Element() + evalCtx.Variables[k.AsString()] = v + } + + val, diags := expr.Value(evalCtx) + if diags.HasErrors() { + return cty.NilVal, fmt.Errorf("rendering template %q: %s", filename, diags.Error()) + } + out, err := convert.Convert(val, cty.String) + if err != nil { + return cty.NilVal, fmt.Errorf("rendering template %q: %w", filename, err) + } + return out, nil +} + func readFileForHCL(path, baseDir string) ([]byte, string, error) { if baseDir == "" { data, err := os.ReadFile(path) diff --git a/pkg/config/hcl/hcl_file_test.go b/pkg/config/hcl/hcl_file_test.go index 0e75d2843..af7785421 100644 --- a/pkg/config/hcl/hcl_file_test.go +++ b/pkg/config/hcl/hcl_file_test.go @@ -32,6 +32,125 @@ agent "root" { assert.Equal(t, "Line 1\nLine 2\n", root["instruction"]) } +func TestToYAML_FileFunctionTemplate(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "prompt.md"), []byte("You are ${name}, level ${level}.\n"), 0o644)) + + src := []byte(` +agent "root" { + instruction = file("prompt.md", { name = "Gopher", level = 3 }) + model = "auto" +} +`) + + m, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.NoError(t, err) + + items := m["agents"].(yaml.MapSlice) + root := items[0].Value.(map[string]any) + assert.Equal(t, "You are Gopher, level 3.\n", root["instruction"]) +} + +func TestToYAML_FileFunctionTemplateHasNoFunctions(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "prompt.md"), []byte("${file(\"prompt.md\")}"), 0o644)) + + src := []byte(` +agent "root" { + instruction = file("prompt.md", {}) + model = "auto" +} +`) + + _, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.Error(t, err) + assert.Contains(t, err.Error(), "rendering template") +} + +func TestToYAML_FileFunctionTemplateDirectives(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := "Rules:\n%{ for rule in rules ~}\n- ${rule}\n%{ endfor ~}\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "rules.md"), []byte(tmpl), 0o644)) + + src := []byte(` +agent "root" { + instruction = file("rules.md", { rules = ["be brief", "be kind"] }) + model = "auto" +} +`) + + m, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.NoError(t, err) + + items := m["agents"].(yaml.MapSlice) + root := items[0].Value.(map[string]any) + assert.Equal(t, "Rules:\n- be brief\n- be kind\n", root["instruction"]) +} + +func TestToYAML_FileFunctionTemplateMissingVariable(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "prompt.md"), []byte("Hello ${name} ${missing}"), 0o644)) + + src := []byte(` +agent "root" { + instruction = file("prompt.md", { name = "Gopher" }) + model = "auto" +} +`) + + _, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.Error(t, err) + assert.Contains(t, err.Error(), "rendering template") + assert.Contains(t, err.Error(), "missing") +} + +func TestToYAML_FileFunctionTemplateVarsMustBeObject(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "prompt.md"), []byte("Hello"), 0o644)) + + src := []byte(` +agent "root" { + instruction = file("prompt.md", "nope") + model = "auto" +} +`) + + _, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.Error(t, err) + assert.Contains(t, err.Error(), "template variables must be an object") +} + +func TestToYAML_FileFunctionWithoutVarsKeepsInterpolationLiteral(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "prompt.md"), []byte("Run ${shell({cmd: \"ls\"})}\n"), 0o644)) + + src := []byte(` +agent "root" { + instruction = file("prompt.md") + model = "auto" +} +`) + + m, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.NoError(t, err) + + items := m["agents"].(yaml.MapSlice) + root := items[0].Value.(map[string]any) + assert.Equal(t, "Run ${shell({cmd: \"ls\"})}\n", root["instruction"]) +} + func TestToYAML_FileFunctionMissingFile(t *testing.T) { t.Parallel()