diff --git a/backend/pkg/cast/chain_ast.go b/backend/pkg/cast/chain_ast.go index 6edb69994..67ceded18 100644 --- a/backend/pkg/cast/chain_ast.go +++ b/backend/pkg/cast/chain_ast.go @@ -17,7 +17,7 @@ const ( FallbackResponseContent = "the call was not handled, please try again" SummarizationToolName = "execute_task_and_return_summary" SummarizationToolArgs = `{"question": "delegate and execute the task, then return the summary of the result"}` - ToolCallIDTemplate = "call_{r:24:x}" + ToolCallIDTemplate = templates.DefaultToolCallIDTemplate // Fake reasoning signatures for different providers when summarizing content // that originally contained reasoning signatures diff --git a/backend/pkg/providers/helpers.go b/backend/pkg/providers/helpers.go index 74b55a30d..84efcd370 100644 --- a/backend/pkg/providers/helpers.go +++ b/backend/pkg/providers/helpers.go @@ -45,7 +45,7 @@ type dummyMessage struct { } func toolCallIDTemplateOrDefault(template string) string { - if template != "" { + if template != "" && templates.ValidatePatternSyntax(template) == nil { return template } diff --git a/backend/pkg/providers/helpers_test.go b/backend/pkg/providers/helpers_test.go index 262e3b2be..908bf9d33 100644 --- a/backend/pkg/providers/helpers_test.go +++ b/backend/pkg/providers/helpers_test.go @@ -583,6 +583,7 @@ func TestHelpers_ToolCallIDTemplateOrDefault_NeverYieldsAnEmptyTemplate(t *testi }{ {name: "an empty detection falls back to the default", detected: "", want: "call_{r:24:x}"}, {name: "a detected pattern is kept", detected: "toolu_{r:24:b}", want: "toolu_{r:24:b}"}, + {name: "a pattern the regexp cannot compile falls back to the default", detected: "call_{r:1001:x}", want: "call_{r:24:x}"}, } for _, tt := range tests { diff --git a/backend/pkg/providers/provider/agents.go b/backend/pkg/providers/provider/agents.go index 693b37300..89b030cb4 100644 --- a/backend/pkg/providers/provider/agents.go +++ b/backend/pkg/providers/provider/agents.go @@ -544,7 +544,12 @@ func fallbackHeuristicDetection(samples []templates.PatternSample) string { } } - return pattern.String() + template := pattern.String() + if templates.ValidatePatternSyntax(template) != nil { + return "" + } + + return template } // determineCommonCharset finds charset that covers all character sets across positions diff --git a/backend/pkg/providers/provider/agents_test.go b/backend/pkg/providers/provider/agents_test.go index 6fa96942c..2a1306958 100644 --- a/backend/pkg/providers/provider/agents_test.go +++ b/backend/pkg/providers/provider/agents_test.go @@ -1,6 +1,7 @@ package provider import ( + "strings" "testing" "pentagi/pkg/templates" @@ -43,6 +44,9 @@ func TestAgents_FallbackHeuristicDetection_BuildsThePatternTheSamplesShare(t *te {"upper case only", samples("KEY_ABCDEFGH", "KEY_ZYXWVUTS", "KEY_QPONMLKJ"), "KEY_{r:8:u}"}, {"no samples", samples(), ""}, {"a single sample is all literal", samples("test_123abc"), "test_123abc"}, + {"a random run longer than the regexp compiles yields nothing", samples( + "call_"+strings.Repeat("a", 1001), "call_"+strings.Repeat("b", 1001), "call_"+strings.Repeat("c", 1001), + ), ""}, } for _, tc := range tests { diff --git a/backend/pkg/templates/templates.go b/backend/pkg/templates/templates.go index 09e1da33b..d070d74a1 100644 --- a/backend/pkg/templates/templates.go +++ b/backend/pkg/templates/templates.go @@ -785,6 +785,13 @@ const ( var patternRegex = regexp.MustCompile(`\{r:(\d+):(d|digit|l|lower|u|upper|a|alpha|x|alnum|h|hex|H|HEX|b|base62)\}|\{f\}`) +// DefaultToolCallIDTemplate is the pattern used when a provider's own could not +// be detected or does not compile. +const DefaultToolCallIDTemplate = "call_{r:24:x}" + +// maxRandomPartLength is the largest repeat count Go's regexp compiles. +const maxRandomPartLength = 1000 + type patternPart struct { literal string isRandom bool @@ -817,8 +824,14 @@ func getCharset(name string) string { } } +// ValidatePatternSyntax reports whether pattern can be compiled and generated from. +func ValidatePatternSyntax(pattern string) error { + _, err := parsePattern(pattern) + return err +} + // parsePattern parses a pattern string into parts -func parsePattern(pattern string) []patternPart { +func parsePattern(pattern string) ([]patternPart, error) { var parts []patternPart lastIndex := 0 @@ -842,7 +855,12 @@ func parsePattern(pattern string) []patternPart { }) } else { // Parse random part - length, _ := strconv.Atoi(pattern[match[2]:match[3]]) + lengthText := pattern[match[2]:match[3]] + length, err := strconv.Atoi(lengthText) + if err != nil || length > maxRandomPartLength { + return nil, fmt.Errorf("pattern %q: random part length %s exceeds %d", + pattern, lengthText, maxRandomPartLength) + } charsetName := pattern[match[4]:match[5]] parts = append(parts, patternPart{ isRandom: true, @@ -863,7 +881,7 @@ func parsePattern(pattern string) []patternPart { }) } - return parts + return parts, nil } // generateRandomString generates a random string of specified length using the given charset @@ -889,13 +907,17 @@ func generateRandomString(length int, charset string) string { } // GenerateFromPattern generates a random string matching the given pattern template. -// This function never returns an error - it uses fallback values for invalid patterns. +// This function never returns an error - it uses fallback values for invalid patterns, +// and a pattern that does not compile yields an ID of DefaultToolCallIDTemplate. // // Pattern format: literal text with {r:LENGTH:CHARSET} for random parts and {f} for function name // Example: "toolu_{r:24:base62}" → "toolu_xK9pQw2mN5vR8tY7uI6oP3zA" // Example: "{f}:{r:1:d}" with functionName="get_number" → "get_number:0" func GenerateFromPattern(pattern string, functionName string) string { - parts := parsePattern(pattern) + parts, err := parsePattern(pattern) + if err != nil { + parts, _ = parsePattern(DefaultToolCallIDTemplate) + } var result strings.Builder for _, part := range parts { @@ -944,12 +966,11 @@ func (e *PatternValidationError) Error() string { // Pattern format: literal text with {r:LENGTH:CHARSET} for random parts and {f} for function name // Example: ValidatePattern("call_{r:24:alnum}", []PatternSample{{Value: "call_abc123..."}}) func ValidatePattern(pattern string, samples []PatternSample) error { - if len(samples) == 0 { - return nil + parts, err := parsePattern(pattern) + if err != nil { + return err } - parts := parsePattern(pattern) - // Validate each sample for _, sample := range samples { value := sample.Value @@ -981,7 +1002,10 @@ func ValidatePattern(pattern string, samples []PatternSample) error { } regexPattern := "^" + strings.Join(regexParts, "") + "$" - re := regexp.MustCompile(regexPattern) + re, err := regexp.Compile(regexPattern) + if err != nil { + return fmt.Errorf("pattern %q: %w", pattern, err) + } // Check length if len(value) != expectedLen { diff --git a/backend/pkg/templates/templates_test.go b/backend/pkg/templates/templates_test.go index 3c9a82d90..d3c7db918 100644 --- a/backend/pkg/templates/templates_test.go +++ b/backend/pkg/templates/templates_test.go @@ -175,6 +175,18 @@ func TestTemplates_ValidatePattern_NamesWhereASampleDiverges(t *testing.T) { samples: []PatternSample{{Value: "wrong_name:0", FunctionName: "get_number"}}, wantErr: []string{"pattern mismatch"}, }, + { + name: "a repeat the regexp cannot compile", + pattern: "call_{r:1001:x}", + samples: []PatternSample{{Value: "call_x"}}, + wantErr: []string{"exceeds 1000"}, + }, + { + name: "no sample excuses a repeat the regexp cannot compile", + pattern: "call_{r:1001:x}", + samples: []PatternSample{}, + wantErr: []string{"exceeds 1000"}, + }, } for _, tt := range tests { @@ -190,3 +202,32 @@ func TestTemplates_ValidatePattern_NamesWhereASampleDiverges(t *testing.T) { }) } } + +func TestTemplates_ValidatePatternSyntax_StopsAtTheLargestRepeatTheRegexpCompiles(t *testing.T) { + tests := []struct { + name string + pattern string + wantErr string + }{ + {"the largest repeat the regexp compiles", "call_{r:1000:x}", ""}, + {"one above it", "call_{r:1001:x}", "exceeds 1000"}, + {"a count that overflows int", "call_{r:99999999999999999999:x}", "exceeds 1000"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePatternSyntax(tt.pattern) + if tt.wantErr == "" { + assert.NoError(t, err) + return + } + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestTemplates_GenerateFromPattern_FallsBackToTheDefaultTemplateWhenThePatternCannotCompile(t *testing.T) { + got := GenerateFromPattern("call_{r:1001:x}", "") + + assert.Regexp(t, `^call_[a-zA-Z0-9]{24}$`, got) +}