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 <djordje.lukic@docker.com>
This commit is contained in:
Djordje Lukic committed 2026-07-04 01:15:06 +02:00
1 parent 16fdef4d47
commit 493e9bbd48
6 files changed
+241 -6

No files matched your search

+62 -1
View File
@@ -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)
+6 -1
View File
@@ -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"
}
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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
+49 -1
View File
@@ -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)
+119
View File
@@ -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()