@@ -2,27 +2,38 @@ package js
22
33import (
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.
1419var 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.
1724type 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.
2230func 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 }
3748func (* dynamicLookup ) Delete (string ) bool { return true }
3849func (* 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 .
5490func (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.
122207func 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