Skip to content

Commit b90d4a5

Browse files
committed
fix: evaluate JS template expressions independently on failure
When a slash command instruction contains multiple ${...} expressions and one references an undefined symbol (e.g. an MCP tool not available in the JS runtime), the entire template literal evaluation failed, leaving all expressions unexpanded — including valid tool calls like ${shell(...)}. Fall back to evaluating each ${...} expression individually so that successful ones are expanded while failed ones are preserved as-is. Also simplify pkg/js by merging Evaluator into Expander (removing eval.go) since both types did the same thing with different bindings. Assisted-By: docker-agent
1 parent e10701a commit b90d4a5

File tree

4 files changed

+303
-116
lines changed

4 files changed

+303
-116
lines changed

pkg/js/eval.go

Lines changed: 0 additions & 94 deletions
This file was deleted.

pkg/js/eval_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,24 @@ func TestEvaluate(t *testing.T) {
9999
tools: nil,
100100
expected: "Unclosed ${foo",
101101
},
102+
{
103+
name: "mixed known and unknown expressions",
104+
input: `Known: ${echo({msg: "hi"})}, Unknown: ${undefined_tool()}`,
105+
tools: mockTools,
106+
expected: `Known: echoed: {"msg":"hi"}, Unknown: ${undefined_tool()}`,
107+
},
108+
{
109+
name: "unknown expression preserves shell calls",
110+
input: "Lint: ${shell({cmd: \"task lint\"})}\n${unknown_tool()}",
111+
tools: mockTools,
112+
expected: "Lint: output: {\"cmd\":\"task lint\"}\n${unknown_tool()}",
113+
},
114+
{
115+
name: "nested braces in tool args",
116+
input: `${shell({cmd: "echo }"})}`,
117+
tools: mockTools,
118+
expected: `output: {"cmd":"echo }"}`,
119+
},
102120
}
103121

104122
for _, tt := range tests {

pkg/js/expand.go

Lines changed: 181 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,38 @@ package js
22

33
import (
44
"context"
5+
"encoding/json"
6+
"fmt"
7+
"log/slog"
8+
"slices"
59
"strings"
610

711
"github.com/dop251/goja"
812

913
"github.com/docker/docker-agent/pkg/config/types"
1014
"github.com/docker/docker-agent/pkg/environment"
15+
"github.com/docker/docker-agent/pkg/tools"
1116
)
1217

1318
// newVM creates a new Goja JavaScript runtime.
1419
var newVM = goja.New
1520

16-
// Expander expands JavaScript template literals in strings using environment variables.
21+
// Expander expands JavaScript template literals in strings.
22+
// It can be configured with an environment provider for ${env.X} access
23+
// and/or agent tools for ${tool({...})} calls.
1724
type Expander struct {
18-
env environment.Provider
25+
env environment.Provider
26+
tools []tools.Tool
1927
}
2028

2129
// NewJsExpander creates a new Expander with the given environment provider.
2230
func NewJsExpander(env environment.Provider) *Expander {
23-
return &Expander{
24-
env: env,
25-
}
31+
return &Expander{env: env}
32+
}
33+
34+
// NewEvaluator creates a new Expander with the given tools (for command evaluation).
35+
func NewEvaluator(agentTools []tools.Tool) *Expander {
36+
return &Expander{tools: agentTools}
2637
}
2738

2839
// dynamicLookup implements goja.DynamicObject for lazy key-value access.
@@ -37,26 +48,51 @@ func (*dynamicLookup) Has(string) bool { return true }
3748
func (*dynamicLookup) Delete(string) bool { return true }
3849
func (*dynamicLookup) Keys() []string { return nil }
3950

40-
// newEnvVM creates a new JS runtime with the 'env' dynamic object pre-bound
41-
// to the Expander's environment provider.
42-
func (exp *Expander) newEnvVM(ctx context.Context) *goja.Runtime {
51+
// newVMWithBindings creates a new JS runtime with env and tools pre-bound.
52+
func (exp *Expander) newVMWithBindings(ctx context.Context) *goja.Runtime {
4353
vm := newVM()
44-
_ = vm.Set("env", vm.NewDynamicObject(&dynamicLookup{
45-
vm: vm,
46-
lookup: func(k string) string { v, _ := exp.env.Get(ctx, k); return v },
47-
}))
54+
55+
if exp.env != nil {
56+
_ = vm.Set("env", vm.NewDynamicObject(&dynamicLookup{
57+
vm: vm,
58+
lookup: func(k string) string { v, _ := exp.env.Get(ctx, k); return v },
59+
}))
60+
}
61+
62+
for _, tool := range exp.tools {
63+
_ = vm.Set(tool.Name, createToolCaller(ctx, tool))
64+
}
65+
4866
return vm
4967
}
5068

69+
// Evaluate finds and evaluates ${...} JavaScript expressions in the input string.
70+
// args are available as the 'args' array in JavaScript.
71+
func (exp *Expander) Evaluate(ctx context.Context, input string, args []string) string {
72+
if !strings.Contains(input, "${") {
73+
return input
74+
}
75+
76+
vm := exp.newVMWithBindings(ctx)
77+
if args == nil {
78+
args = []string{}
79+
}
80+
_ = vm.Set("args", args)
81+
82+
slog.Debug("Evaluating JS template", "input", input)
83+
84+
return runExpansion(vm, input)
85+
}
86+
5187
// Expand expands JavaScript template literals using the provided values map.
52-
// The values are bound as top-level variables in the JS runtime alongside the
53-
// env object from the Expander's environment provider.
88+
// The values are bound as top-level variables in the JS runtime alongside
89+
// env and tools bindings.
5490
func (exp *Expander) Expand(ctx context.Context, text string, values map[string]string) string {
5591
if !strings.Contains(text, "${") {
5692
return text
5793
}
5894

59-
vm := exp.newEnvVM(ctx)
95+
vm := exp.newVMWithBindings(ctx)
6096
for k, v := range values {
6197
_ = vm.Set(k, v)
6298
}
@@ -70,7 +106,7 @@ func (exp *Expander) ExpandMap(ctx context.Context, kv map[string]string) map[st
70106
return nil
71107
}
72108

73-
vm := exp.newEnvVM(ctx)
109+
vm := exp.newVMWithBindings(ctx)
74110

75111
expanded := make(map[string]string, len(kv))
76112
for k, v := range kv {
@@ -85,7 +121,7 @@ func (exp *Expander) ExpandCommands(ctx context.Context, cmds types.Commands) ty
85121
return nil
86122
}
87123

88-
vm := exp.newEnvVM(ctx)
124+
vm := exp.newVMWithBindings(ctx)
89125

90126
expanded := make(types.Commands, len(cmds))
91127
for k, cmd := range cmds {
@@ -118,21 +154,144 @@ func ExpandMapFunc(values map[string]string, objName string, lookup, preprocess
118154
return resolved
119155
}
120156

157+
// createToolCaller creates a JavaScript function that calls the given tool.
158+
func createToolCaller(ctx context.Context, tool tools.Tool) func(args map[string]any) (string, error) {
159+
return func(args map[string]any) (string, error) {
160+
var toolArgs struct {
161+
Required []string `json:"required"`
162+
}
163+
164+
if err := tools.ConvertSchema(tool.Parameters, &toolArgs); err != nil {
165+
return "", err
166+
}
167+
168+
// Filter out nil values for non-required arguments
169+
nonNilArgs := make(map[string]any)
170+
for k, v := range args {
171+
if slices.Contains(toolArgs.Required, k) || v != nil {
172+
nonNilArgs[k] = v
173+
}
174+
}
175+
176+
arguments, err := json.Marshal(nonNilArgs)
177+
if err != nil {
178+
return "", err
179+
}
180+
181+
toolCall := tools.ToolCall{
182+
ID: "jseval_" + tool.Name,
183+
Type: "function",
184+
Function: tools.FunctionCall{
185+
Name: tool.Name,
186+
Arguments: string(arguments),
187+
},
188+
}
189+
190+
if tool.Handler == nil {
191+
return "", fmt.Errorf("tool '%s' has no handler", tool.Name)
192+
}
193+
194+
result, err := tool.Handler(ctx, toolCall)
195+
if err != nil {
196+
return "", err
197+
}
198+
199+
return result.Output, nil
200+
}
201+
}
202+
121203
// runExpansion executes the template string using the provided Goja runtime.
204+
// If the full template literal evaluation fails (e.g. because one expression
205+
// references an undefined variable), it falls back to evaluating each ${...}
206+
// expression independently so that successful expressions are still expanded.
122207
func runExpansion(vm *goja.Runtime, text string) string {
123208
// Escape backslashes first, then backticks
124209
escaped := strings.ReplaceAll(text, "\\", "\\\\")
125210
escaped = strings.ReplaceAll(escaped, "`", "\\`")
126211
script := "`" + escaped + "`"
127212

128213
v, err := vm.RunString(script)
129-
if err != nil {
130-
return text
214+
if err == nil {
215+
if v == nil || v.Export() == nil {
216+
return ""
217+
}
218+
return v.String()
131219
}
132220

133-
if v == nil || v.Export() == nil {
134-
return ""
221+
// Full template failed — try each ${...} expression individually.
222+
return expandExpressions(vm, text)
223+
}
224+
225+
// expandExpressions evaluates each ${...} expression in text individually,
226+
// replacing successful ones with their result and leaving failed ones as-is.
227+
func expandExpressions(vm *goja.Runtime, text string) string {
228+
var result strings.Builder
229+
i := 0
230+
for i < len(text) {
231+
// Look for ${
232+
idx := strings.Index(text[i:], "${")
233+
if idx < 0 {
234+
result.WriteString(text[i:])
235+
break
236+
}
237+
result.WriteString(text[i : i+idx])
238+
exprStart := i + idx
239+
240+
// Find matching closing brace, accounting for nested braces and strings.
241+
end := findClosingBrace(text, exprStart+2)
242+
if end < 0 {
243+
// Unclosed expression — write the rest as-is.
244+
result.WriteString(text[exprStart:])
245+
break
246+
}
247+
248+
expr := text[exprStart+2 : end] // content between ${ and }
249+
full := text[exprStart : end+1] // ${...} including delimiters
250+
251+
v, err := vm.RunString(expr)
252+
switch {
253+
case err != nil:
254+
result.WriteString(full) // keep original
255+
case v == nil || goja.IsUndefined(v) || goja.IsNull(v):
256+
// Match JS template literal behavior: null/undefined become empty string.
257+
default:
258+
result.WriteString(v.String())
259+
}
260+
i = end + 1
135261
}
262+
return result.String()
263+
}
136264

137-
return v.String()
265+
// findClosingBrace returns the index of the closing '}' for a ${...} expression
266+
// starting at pos (which points to the first character after "${").
267+
// It handles nested braces, template literals, and quoted strings.
268+
// Returns -1 if no matching brace is found.
269+
func findClosingBrace(text string, pos int) int {
270+
depth := 1
271+
var quote byte
272+
for i := pos; i < len(text) && depth > 0; i++ {
273+
ch := text[i]
274+
if quote != 0 {
275+
if ch == '\\' && i+1 < len(text) {
276+
i++ // skip escaped char
277+
continue
278+
}
279+
if ch == quote {
280+
quote = 0
281+
}
282+
continue
283+
}
284+
switch ch {
285+
case '"', '\'', '`':
286+
quote = ch
287+
case '{':
288+
depth++
289+
case '}':
290+
depth--
291+
if depth == 0 {
292+
return i
293+
}
294+
}
295+
}
296+
return -1
138297
}

0 commit comments

Comments
 (0)