mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
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:
6 files changed
+241
-6
No files matched your search
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user