From 0badb4364dad05a638d5218a3bf3aa9ec1cbc5ac Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 2 Jul 2026 23:00:43 +0000 Subject: [PATCH] Add prompt cache control loading --- internal/domain/domain.go | 20 ++++++-- internal/promptdef/filesystem_repository.go | 48 ++++++++++++++++--- internal/promptdef/repository_test.go | 45 +++++++++++++++++ .../testdata/empty_cache_control_type.yaml | 10 ++++ .../testdata/unknown_cache_control_field.yaml | 12 +++++ .../unsupported_cache_control_ttl.yaml | 12 +++++ .../unsupported_cache_control_type.yaml | 11 +++++ .../testdata/valid_cache_control_ttl.yaml | 14 ++++++ .../valid_cache_control_without_ttl.yaml | 13 +++++ 9 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 internal/promptdef/testdata/empty_cache_control_type.yaml create mode 100644 internal/promptdef/testdata/unknown_cache_control_field.yaml create mode 100644 internal/promptdef/testdata/unsupported_cache_control_ttl.yaml create mode 100644 internal/promptdef/testdata/unsupported_cache_control_type.yaml create mode 100644 internal/promptdef/testdata/valid_cache_control_ttl.yaml create mode 100644 internal/promptdef/testdata/valid_cache_control_without_ttl.yaml diff --git a/internal/domain/domain.go b/internal/domain/domain.go index c935a42..8a0a4d0 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -40,6 +40,19 @@ const ( ValidationSkipped ValidationStatus = "skipped" ) +// CacheControlType defines provider cache behavior for prompt content. +type CacheControlType string + +const ( + CacheControlEphemeral CacheControlType = "ephemeral" +) + +// CacheControl describes provider cache metadata attached to prompt content. +type CacheControl struct { + Type CacheControlType `yaml:"type" json:"type"` + TTL string `yaml:"ttl,omitempty" json:"ttl,omitempty"` +} + // RunRequest represents a request to generate a single artifact. type RunRequest struct { PromptID string @@ -131,9 +144,10 @@ type PromptInput struct { // PromptMessageTemplate defines a template for a chat message. type PromptMessageTemplate struct { - Role string `yaml:"role"` - Content string `yaml:"content"` - ContentFile string `yaml:"content_file"` + Role string `yaml:"role"` + Content string `yaml:"content"` + ContentFile string `yaml:"content_file"` + CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"` } // ExecutionProfile describes how and where to execute a model. diff --git a/internal/promptdef/filesystem_repository.go b/internal/promptdef/filesystem_repository.go index 422d0fc..865b73f 100644 --- a/internal/promptdef/filesystem_repository.go +++ b/internal/promptdef/filesystem_repository.go @@ -42,9 +42,15 @@ type promptInputFile struct { } type promptMessageFile struct { - Role string `yaml:"role"` - Content string `yaml:"content"` - ContentFile string `yaml:"content_file"` + Role string `yaml:"role"` + Content string `yaml:"content"` + ContentFile string `yaml:"content_file"` + CacheControl *cacheControlFile `yaml:"cache_control"` +} + +type cacheControlFile struct { + Type string `yaml:"type"` + TTL string `yaml:"ttl"` } type promptOutputContractFile struct { @@ -212,6 +218,11 @@ func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*d return nil, fmt.Errorf("message %d (%s) must set exactly one of content or content_file", i, role) } + cacheControl, err := normalizeCacheControl(msg.CacheControl) + if err != nil { + return nil, fmt.Errorf("message %d (%s) cache_control: %w", i, role, err) + } + templateContent := msg.Content resolvedContentFile := "" if hasContentFile { @@ -230,9 +241,10 @@ func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*d } templates = append(templates, domain.PromptMessageTemplate{ - Role: role, - Content: templateContent, - ContentFile: resolvedContentFile, + Role: role, + Content: templateContent, + ContentFile: resolvedContentFile, + CacheControl: cacheControl, }) } @@ -274,6 +286,30 @@ func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*d }, nil } +func normalizeCacheControl(raw *cacheControlFile) (*domain.CacheControl, error) { + if raw == nil { + return nil, nil + } + + cacheType := strings.TrimSpace(raw.Type) + if cacheType == "" { + return nil, errors.New("type is required") + } + if domain.CacheControlType(cacheType) != domain.CacheControlEphemeral { + return nil, fmt.Errorf("unsupported type %q", cacheType) + } + + ttl := strings.TrimSpace(raw.TTL) + if ttl != "" && ttl != "1h" { + return nil, fmt.Errorf("unsupported ttl %q", ttl) + } + + return &domain.CacheControl{ + Type: domain.CacheControlType(cacheType), + TTL: ttl, + }, nil +} + func isValidOutputFormat(f domain.OutputFormat) bool { switch f { case domain.FormatText, domain.FormatMarkdown, domain.FormatJSON: diff --git a/internal/promptdef/repository_test.go b/internal/promptdef/repository_test.go index b620c28..4db41c9 100644 --- a/internal/promptdef/repository_test.go +++ b/internal/promptdef/repository_test.go @@ -68,6 +68,34 @@ func TestFilesystemRepository_GetPromptDefinition(t *testing.T) { } }) + t.Run("valid cache control with ttl", func(t *testing.T) { + p, err := repo.GetPromptDefinition(ctx, "valid-cache-control-ttl", "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(p.Templates) != 2 { + t.Fatalf("expected 2 messages, got %d", len(p.Templates)) + } + assertCacheControl(t, p.Templates[0].CacheControl, domain.CacheControlEphemeral, "1h") + if p.Templates[1].CacheControl != nil { + t.Fatalf("expected second message cache control to be nil, got %#v", p.Templates[1].CacheControl) + } + }) + + t.Run("valid cache control without ttl", func(t *testing.T) { + p, err := repo.GetPromptDefinition(ctx, "valid-cache-control-without-ttl", "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(p.Templates) != 2 { + t.Fatalf("expected 2 messages, got %d", len(p.Templates)) + } + assertCacheControl(t, p.Templates[0].CacheControl, domain.CacheControlEphemeral, "") + if p.Templates[1].CacheControl != nil { + t.Fatalf("expected second message cache control to be nil, got %#v", p.Templates[1].CacheControl) + } + }) + t.Run("valid nested file-backed prompt resolves content file relative to nested YAML", func(t *testing.T) { nestedDir := filepath.Join(tmpDir, "dnd", "recap") if err := os.MkdirAll(nestedDir, 0o755); err != nil { @@ -258,6 +286,10 @@ output: {name: "invalid validation mode", id: "invalid_validation_mode", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"invalid validation mode"}}, {name: "json_schema without schema_path", id: "json_schema_without_schema_path", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"schema_path"}}, {name: "unknown input field", id: "unknown_input_field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unknown_input_setting not found"}}, + {name: "empty cache control type", id: "empty_cache_control_type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "type is required"}}, + {name: "unsupported cache control type", id: "unsupported_cache_control_type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported type"}}, + {name: "unsupported cache control ttl", id: "unsupported_cache_control_ttl", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported ttl"}}, + {name: "unknown cache control field", id: "unknown_cache_control_field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unexpected not found"}}, } for _, tc := range cases { @@ -282,6 +314,19 @@ output: }) } +func assertCacheControl(t *testing.T, got *domain.CacheControl, wantType domain.CacheControlType, wantTTL string) { + t.Helper() + if got == nil { + t.Fatal("expected cache control, got nil") + } + if got.Type != wantType { + t.Fatalf("unexpected cache control type: got %q want %q", got.Type, wantType) + } + if got.TTL != wantTTL { + t.Fatalf("unexpected cache control ttl: got %q want %q", got.TTL, wantTTL) + } +} + func writePromptTestFile(t *testing.T, path string, content string) { t.Helper() if err := os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o644); err != nil { diff --git a/internal/promptdef/testdata/empty_cache_control_type.yaml b/internal/promptdef/testdata/empty_cache_control_type.yaml new file mode 100644 index 0000000..03330ec --- /dev/null +++ b/internal/promptdef/testdata/empty_cache_control_type.yaml @@ -0,0 +1,10 @@ +id: empty-cache-control-type +version: "1.0.0" +messages: + - role: system + content: "Use cached instructions." + cache_control: {} +output: + format: markdown + validation_mode: basic + repair_attempts: 0 diff --git a/internal/promptdef/testdata/unknown_cache_control_field.yaml b/internal/promptdef/testdata/unknown_cache_control_field.yaml new file mode 100644 index 0000000..ac8193a --- /dev/null +++ b/internal/promptdef/testdata/unknown_cache_control_field.yaml @@ -0,0 +1,12 @@ +id: unknown-cache-control-field +version: "1.0.0" +messages: + - role: system + content: "Use cached instructions." + cache_control: + type: ephemeral + unexpected: true +output: + format: markdown + validation_mode: basic + repair_attempts: 0 diff --git a/internal/promptdef/testdata/unsupported_cache_control_ttl.yaml b/internal/promptdef/testdata/unsupported_cache_control_ttl.yaml new file mode 100644 index 0000000..d6f81d4 --- /dev/null +++ b/internal/promptdef/testdata/unsupported_cache_control_ttl.yaml @@ -0,0 +1,12 @@ +id: unsupported-cache-control-ttl +version: "1.0.0" +messages: + - role: system + content: "Use cached instructions." + cache_control: + type: ephemeral + ttl: 5m +output: + format: markdown + validation_mode: basic + repair_attempts: 0 diff --git a/internal/promptdef/testdata/unsupported_cache_control_type.yaml b/internal/promptdef/testdata/unsupported_cache_control_type.yaml new file mode 100644 index 0000000..875a9b0 --- /dev/null +++ b/internal/promptdef/testdata/unsupported_cache_control_type.yaml @@ -0,0 +1,11 @@ +id: unsupported-cache-control-type +version: "1.0.0" +messages: + - role: system + content: "Use cached instructions." + cache_control: + type: persistent +output: + format: markdown + validation_mode: basic + repair_attempts: 0 diff --git a/internal/promptdef/testdata/valid_cache_control_ttl.yaml b/internal/promptdef/testdata/valid_cache_control_ttl.yaml new file mode 100644 index 0000000..4136ac4 --- /dev/null +++ b/internal/promptdef/testdata/valid_cache_control_ttl.yaml @@ -0,0 +1,14 @@ +id: valid-cache-control-ttl +version: "1.0.0" +messages: + - role: system + content: "Use cached instructions." + cache_control: + type: ephemeral + ttl: 1h + - role: user + content: "Summarize the input." +output: + format: markdown + validation_mode: basic + repair_attempts: 0 diff --git a/internal/promptdef/testdata/valid_cache_control_without_ttl.yaml b/internal/promptdef/testdata/valid_cache_control_without_ttl.yaml new file mode 100644 index 0000000..48e6c37 --- /dev/null +++ b/internal/promptdef/testdata/valid_cache_control_without_ttl.yaml @@ -0,0 +1,13 @@ +id: valid-cache-control-without-ttl +version: "1.0.0" +messages: + - role: system + content: "Use cached instructions." + cache_control: + type: ephemeral + - role: user + content: "Summarize the input." +output: + format: markdown + validation_mode: basic + repair_attempts: 0