diff --git a/go.mod b/go.mod index 688f1516..8ab92c60 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.47.3 github.com/aws/aws-sdk-go-v2/config v1.33.5 + github.com/aws/aws-sdk-go-v2/credentials v1.20.5 github.com/aws/aws-sdk-go-v2/service/devopsagent v1.17.0 github.com/aws/aws-sdk-go-v2/service/iam v1.64.0 github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 @@ -46,6 +47,10 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + github.com/tree-sitter/go-tree-sitter v0.25.0 + github.com/tree-sitter/tree-sitter-javascript v0.23.1 + github.com/tree-sitter/tree-sitter-python v0.25.0 + github.com/tree-sitter/tree-sitter-typescript v0.23.2 go.uber.org/mock v0.6.0 golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa golang.org/x/term v0.40.0 @@ -57,7 +62,6 @@ require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.20.5 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect @@ -99,6 +103,7 @@ require ( github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-pointer v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.17 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect diff --git a/go.sum b/go.sum index 36c0a364..eaa54bab 100644 --- a/go.sum +++ b/go.sum @@ -215,6 +215,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= +github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -317,6 +319,34 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU= +github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU= +github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c= +github.com/tree-sitter/tree-sitter-c v0.23.4/go.mod h1:MkI5dOiIpeN94LNjeCp8ljXN/953JCwAby4bClMr6bw= +github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w= +github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8= +github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg= +github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ= +github.com/tree-sitter/tree-sitter-go v0.23.4 h1:yt5KMGnTHS+86pJmLIAZMWxukr8W7Ae1STPvQUuNROA= +github.com/tree-sitter/tree-sitter-go v0.23.4/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040= +github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc= +github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og= +github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig= +github.com/tree-sitter/tree-sitter-java v0.23.5/go.mod h1:NRKlI8+EznxA7t1Yt3xtraPk1Wzqh3GAIC46wxvc320= +github.com/tree-sitter/tree-sitter-javascript v0.23.1 h1:1fWupaRC0ArlHJ/QJzsfQ3Ibyopw7ZfQK4xXc40Zveo= +github.com/tree-sitter/tree-sitter-javascript v0.23.1/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs= +github.com/tree-sitter/tree-sitter-json v0.24.8 h1:tV5rMkihgtiOe14a9LHfDY5kzTl5GNUYe6carZBn0fQ= +github.com/tree-sitter/tree-sitter-json v0.24.8/go.mod h1:F351KK0KGvCaYbZ5zxwx/gWWvZhIDl0eMtn+1r+gQbo= +github.com/tree-sitter/tree-sitter-php v0.23.11 h1:iHewsLNDmznh8kgGyfWfujsZxIz1YGbSd2ZTEM0ZiP8= +github.com/tree-sitter/tree-sitter-php v0.23.11/go.mod h1:T/kbfi+UcCywQfUNAJnGTN/fMSUjnwPXA8k4yoIks74= +github.com/tree-sitter/tree-sitter-python v0.25.0 h1:O6XD9v8U1LOcRc3cNj9nM7XufrtEBezE6VrpRrHZDf0= +github.com/tree-sitter/tree-sitter-python v0.25.0/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM= +github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV6z8Qw8ai+72bYo= +github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA= +github.com/tree-sitter/tree-sitter-rust v0.23.2 h1:6AtoooCW5GqNrRpfnvl0iUhxTAZEovEmLKDbyHlfw90= +github.com/tree-sitter/tree-sitter-rust v0.23.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI= +github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU= +github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= diff --git a/internal/sync/reference/adapter_conformance_test.go b/internal/sync/reference/adapter_conformance_test.go index 0eee2569..dbf6b8e7 100644 --- a/internal/sync/reference/adapter_conformance_test.go +++ b/internal/sync/reference/adapter_conformance_test.go @@ -35,6 +35,12 @@ func TestRegisteredFormatsReadAndWriteRealisticSources(t *testing.T) { id: syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"}, want: "Explain the refund policy and escalation rules.", update: "Explain refunds and escalation rules.", }, + { + name: "Python agent", fixture: "python/agents.py", format: "python", + id: syncdomain.VariationID("project", "support", "default"), + selectPath: `agents[id="support"]`, fieldMap: map[string]string{"prompt": "instructions"}, + want: "Ask for an order ID before discussing an order.", update: "Ask for an order ID and region.", + }, { name: "JSON tool", fixture: "json/mcp.json", format: "json", id: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup_order"}, diff --git a/internal/sync/reference/code.go b/internal/sync/reference/code.go new file mode 100644 index 00000000..62bb0cbb --- /dev/null +++ b/internal/sync/reference/code.go @@ -0,0 +1,239 @@ +package reference + +import ( + "fmt" + "strconv" + "strings" + "time" + "unicode/utf8" + + sitter "github.com/tree-sitter/go-tree-sitter" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +const maxSourceBytes = 4 << 20 + +func parseTree(document Document, language *sitter.Language) (*sitter.Tree, error) { + if !utf8.Valid(document.Data) { + return nil, fmt.Errorf("%s: source is not valid UTF-8", document.Path) + } + if len(document.Data) > maxSourceBytes { + return nil, fmt.Errorf("%s: source exceeds %d bytes", document.Path, maxSourceBytes) + } + parser := sitter.NewParser() + defer parser.Close() + if err := parser.SetLanguage(language); err != nil { + return nil, fmt.Errorf("%s: set parser language: %w", document.Path, err) + } + deadline := time.Now().Add(2 * time.Second) + tree := parser.ParseWithOptions(func(offset int, _ sitter.Point) []byte { + if offset >= len(document.Data) { + return nil + } + return document.Data[offset:] + }, nil, &sitter.ParseOptions{ + ProgressCallback: func(sitter.ParseState) bool { + return time.Now().After(deadline) + }, + }) + if tree == nil { + return nil, fmt.Errorf("%s: parser timed out", document.Path) + } + if tree.RootNode().HasError() { + tree.Close() + return nil, fmt.Errorf("%s: source contains syntax errors", document.Path) + } + return tree, nil +} + +func logicalRoot(collections map[string][]*sourceNode) *sourceNode { + fields := make(map[string]*sourceNode, len(collections)) + for name, items := range collections { + fields[name] = &sourceNode{items: items, collection: true} + } + return &sourceNode{fields: fields} +} + +func logicalResource(id string, fields map[string]*sourceNode) *sourceNode { + fields["id"] = &sourceNode{value: id} + return &sourceNode{fields: fields} +} + +func walkTree(root *sitter.Node, visit func(*sitter.Node)) { + stack := []*sitter.Node{root} + for len(stack) != 0 { + node := stack[len(stack)-1] + stack = stack[:len(stack)-1] + visit(node) + for index := node.NamedChildCount(); index > 0; index-- { + stack = append(stack, node.NamedChild(index-1)) + } + } +} + +func namedChildren(node *sitter.Node) []*sitter.Node { + if node == nil { + return nil + } + children := make([]*sitter.Node, 0, node.NamedChildCount()) + for index := uint(0); index < node.NamedChildCount(); index++ { + child := node.NamedChild(index) + if child.Kind() != "comment" { + children = append(children, child) + } + } + return children +} + +func nodeText(node *sitter.Node, source []byte) string { + if node == nil { + return "" + } + return string(source[node.StartByte():node.EndByte()]) +} + +func messageListFromCollection(collection *sourceNode) *sourceNode { + if collection == nil || collection.items == nil { + return nil + } + result := &sourceNode{ + items: collection.items, collection: true, start: collection.start, end: collection.end, + capability: CapabilityDirect, reason: "message structure is read-only", + } + var messages []syncdomain.Message + for _, item := range collection.items { + fields := item.fields + if fields == nil && len(item.items) == 2 { + fields = map[string]*sourceNode{"role": item.items[0], "content": item.items[1]} + item.fields = fields + } + role, roleOK := fields["role"].value.(string) + content, contentOK := fields["content"].value.(string) + if !roleOK || !contentOK { + result.capability = CapabilityReadOnly + result.reason = "dynamic message content is read-only" + return result + } + messages = append(messages, syncdomain.Message{ + Role: normalizeMessageRole(role), Content: content, + }) + } + result.value = messages + return result +} + +func decodePythonString(raw []byte) (string, string, error) { + text := string(raw) + quoteAt := strings.IndexAny(text, `"'`) + if quoteAt < 0 { + return "", "", fmt.Errorf("unsupported Python string") + } + prefix := strings.ToLower(text[:quoteAt]) + if strings.ContainsAny(prefix, "fbr") || strings.Trim(prefix, "u") != "" { + return "", "", fmt.Errorf("Python string prefix %q is read-only", prefix) + } + quote := text[quoteAt : quoteAt+1] + if strings.HasPrefix(text[quoteAt:], quote+quote+quote) { + quote = quote + quote + quote + } + if len(text) < quoteAt+2*len(quote) || !strings.HasSuffix(text, quote) { + return "", "", fmt.Errorf("invalid Python string") + } + value, err := decodeEscapes(text[quoteAt+len(quote):len(text)-len(quote)], true) + return value, quote, err +} + +func encodePythonString(value, quote string) []byte { + body := encodeEscapes(value, quote[0], len(quote) == 3) + if len(quote) == 3 { + body = strings.ReplaceAll(body, quote, `\`+quote) + } + return []byte(quote + body + quote) +} + +func decodeEscapes(text string, python bool) (string, error) { + var result strings.Builder + for offset := 0; offset < len(text); { + if text[offset] != '\\' { + r, width := utf8.DecodeRuneInString(text[offset:]) + result.WriteRune(r) + offset += width + continue + } + offset++ + if offset == len(text) { + return "", fmt.Errorf("string ends with a backslash") + } + escape := text[offset] + offset++ + switch escape { + case '\\', '\'', '"', '`': + result.WriteByte(escape) + case 'n': + result.WriteByte('\n') + case 'r': + result.WriteByte('\r') + case 't': + result.WriteByte('\t') + case 'b': + result.WriteByte('\b') + case 'f': + result.WriteByte('\f') + case 'v': + result.WriteByte('\v') + case '\n': + case 'x', 'u', 'U': + digits := map[byte]int{'x': 2, 'u': 4, 'U': 8}[escape] + if !python && escape == 'U' { + return "", fmt.Errorf("TypeScript does not support \\U escapes") + } + if offset+digits > len(text) { + return "", fmt.Errorf("short hexadecimal escape") + } + value, err := strconv.ParseUint(text[offset:offset+digits], 16, 32) + if err != nil || value > utf8.MaxRune || value >= 0xd800 && value <= 0xdfff { + return "", fmt.Errorf("invalid Unicode escape") + } + result.WriteRune(rune(value)) + offset += digits + default: + if !python { + return "", fmt.Errorf("unsupported TypeScript escape \\%c", escape) + } + result.WriteByte('\\') + result.WriteByte(escape) + } + } + return result.String(), nil +} + +func encodeEscapes(value string, quote byte, multiline bool) string { + var result strings.Builder + for _, character := range value { + switch character { + case '\\': + result.WriteString(`\\`) + case '\n': + if multiline { + result.WriteByte('\n') + } else { + result.WriteString(`\n`) + } + case '\r': + result.WriteString(`\r`) + case '\t': + result.WriteString(`\t`) + case rune(quote): + result.WriteByte('\\') + result.WriteRune(character) + default: + if character < 0x20 { + fmt.Fprintf(&result, `\u%04x`, character) + } else { + result.WriteRune(character) + } + } + } + return result.String() +} diff --git a/internal/sync/reference/code_inspector_test.go b/internal/sync/reference/code_inspector_test.go new file mode 100644 index 00000000..4e47e447 --- /dev/null +++ b/internal/sync/reference/code_inspector_test.go @@ -0,0 +1,287 @@ +package reference + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestPythonQualifiedConstantsAndDictionaryMembers(t *testing.T) { + source := fixtureFile(t, "python/settings.py") + tests := []struct { + name string + selectPath string + fieldMap map[string]string + wantPrompt string + wantModel string + }{ + { + name: "class attribute", selectPath: `constants[id="SupportSettings.instructions"]`, + fieldMap: map[string]string{"prompt": "$"}, + wantPrompt: "Ask for an order ID.\nKeep the answer concise.", + }, + { + name: "function local", selectPath: `constants[id="build.prompt"]`, + fieldMap: map[string]string{"prompt": "$"}, + wantPrompt: "Function-local prompt.", + }, + { + name: "nested dictionary", selectPath: `constants[id="CONFIG"].agents.refunds`, + fieldMap: map[string]string{"prompt": "prompt", "model": "model"}, + wantPrompt: "Explain the refund policy.", wantModel: "gpt-5", + }, + { + name: "enum dictionary key", selectPath: `constants[id="PROMPTS"]["Language.EN"]`, + fieldMap: map[string]string{"prompt": "$"}, + wantPrompt: "Answer in English.", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "settings.py", Format: "python", Select: test.selectPath, Map: test.fieldMap, + } + projection, err := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference) + require.NoError(t, err) + variation := projection.(syncdomain.VariationProjection) + require.NotNil(t, variation.Instructions) + assert.Equal(t, test.wantPrompt, *variation.Instructions) + if test.wantModel != "" { + require.NotNil(t, variation.Model) + assert.Equal(t, test.wantModel, *variation.Model) + } + + next := "Updated prompt 🌍" + desired := syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeAgent, Instructions: &next, + } + if variation.Model != nil { + model := "gpt-5.1" + desired.Model = &model + } + result, err := UpdateSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference, desired, + ) + require.NoError(t, err) + updated := applyConformanceEdits(t, source, directEdits(t, result)) + reparsed, err := ParseSource( + sourceBundle(reference.File, updated), id, syncdomain.VariationModeAgent, reference, + ) + require.NoError(t, err) + assert.Equal(t, next, *reparsed.(syncdomain.VariationProjection).Instructions) + }) + } +} + +func TestPythonConstantLiteralKinds(t *testing.T) { + source := fixtureFile(t, "python/settings.py") + root, err := parseSourceDocument(Document{Path: "settings.py", Data: source}, "python") + require.NoError(t, err) + tests := []struct { + path string + want any + }{ + {path: `constants[id="SupportSettings.temperature"]`, want: 0.2}, + {path: `constants[id="SupportSettings.enabled"]`, want: true}, + {path: `constants[id="build.retries"]`, want: 3.0}, + {path: `constants[id="MODELS"]`, want: []any{"gpt-5", "claude-sonnet"}}, + {path: `constants[id="CONFIG"].agents.refunds.model`, want: "gpt-5"}, + } + for _, test := range tests { + node, err := resolveSourcePath(root, test.path) + require.NoError(t, err) + assert.Equal(t, test.want, node.value) + } +} + +func TestCodeIndexesSelectAndUpdateConstants(t *testing.T) { + tests := []struct { + name, format, source string + }{ + { + name: "Python", format: "python", + source: "VALUES = [{\"name\": \"first\", \"prompt\": \"Keep\"}, {\"name\": \"second\", \"prompt\": \"Old\"}]\n", + }, + } + id := syncdomain.VariationID("project", "config", "default") + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reference := syncdomain.Reference{ + File: "values", Format: test.format, Select: `constants[id="VALUES"][1]`, + Map: map[string]string{"prompt": "prompt"}, + } + projection, selections, err := ParseSourceWithSelections( + sourceBundle(reference.File, []byte(test.source)), + id, syncdomain.VariationModeAgent, reference, + ) + require.NoError(t, err) + assert.Equal(t, "Old", *projection.(syncdomain.VariationProjection).Instructions) + require.Len(t, selections, 1) + + next := "Updated" + result, err := UpdateSource( + sourceBundle(reference.File, []byte(test.source)), + id, syncdomain.VariationModeAgent, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &next}, + ) + require.NoError(t, err) + updated := applyConformanceEdits(t, []byte(test.source), directEdits(t, result)) + assert.Contains(t, string(updated), "Keep") + assert.Contains(t, string(updated), "Updated") + }) + } +} + +func TestDynamicDictionaryMemberDoesNotBlockLiteralModelEdit(t *testing.T) { + source := fixtureFile(t, "python/settings.py") + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "settings.py", Format: "python", Select: `constants[id="CONFIG"].agents.support`, + Map: map[string]string{"prompt": "prompt", "model": "model"}, + } + + projection, err := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference) + require.NoError(t, err) + state, ok := syncdomain.ProjectionFieldState(projection, syncdomain.FieldPrompt) + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + assert.False(t, state.Known) + + model := "gpt-5.1" + result, err := UpdateSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Model: &model}, + ) + require.NoError(t, err) + update := result.Fields[syncdomain.FieldModel] + assert.Equal(t, CapabilityDirect, update.Capability) + assert.Len(t, update.Edits, 1) + updated := applyConformanceEdits(t, source, update.Edits) + assert.Contains(t, string(updated), `"model": "gpt-5.1"`) + assert.Contains(t, string(updated), `f"Use {DEFAULT_MODEL} for support."`) +} + +func TestPythonLangChainPromptFormsAndDocstring(t *testing.T) { + source := fixtureFile(t, "python/langchain_prompts.py") + tests := []struct { + name string + selectPath string + mapPath string + mode syncdomain.VariationMode + }{ + {name: "from messages tuples", selectPath: `prompts[id="chat"]`, mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + {name: "from messages constant", selectPath: `prompts[id="shared_chat"]`, mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + {name: "constructor message objects", selectPath: `prompts[id="chat_constructor"]`, mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + {name: "from template", selectPath: `prompts[id="simple"]`, mapPath: "template", mode: syncdomain.VariationModeAgent}, + {name: "template keyword", selectPath: `prompts[id="simple_keyword"]`, mapPath: "template", mode: syncdomain.VariationModeAgent}, + {name: "inline chain", selectPath: `prompts[id="chain"]`, mapPath: "messages", mode: syncdomain.VariationModeCompletion}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + field := "prompt" + if test.mode == syncdomain.VariationModeCompletion { + field = "messages" + } + reference := syncdomain.Reference{ + File: "langchain_prompts.py", Format: "python", Select: test.selectPath, + Map: map[string]string{field: test.mapPath}, + } + projection, err := ParseSource(sourceBundle(reference.File, source), id, test.mode, reference) + require.NoError(t, err) + desired := projection + switch value := projection.(type) { + case syncdomain.VariationProjection: + if value.Messages != nil { + messages := append([]syncdomain.Message{}, (*value.Messages)...) + messages[len(messages)-1].Content = "Updated content." + value.Messages = &messages + } else { + text := "Updated template." + value.Instructions = &text + } + desired = value + } + result, err := UpdateSource(sourceBundle(reference.File, source), id, test.mode, reference, desired) + require.NoError(t, err) + updated := applyConformanceEdits(t, source, directEdits(t, result)) + _, err = ParseSource(sourceBundle(reference.File, updated), id, test.mode, reference) + require.NoError(t, err) + }) + } + + toolID := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup_order"} + toolRef := syncdomain.Reference{ + File: "langchain_prompts.py", Format: "python", Select: `tools[id="lookup_order"]`, + Map: map[string]string{"description": "description", "schema": "schema"}, + } + projection, err := ParseSource(sourceBundle(toolRef.File, source), toolID, "", toolRef) + require.NoError(t, err) + tool := projection.(syncdomain.ToolProjection) + assert.Equal(t, "Look up one order and return its status.", *tool.Description) + schemaState, ok := syncdomain.ProjectionFieldState(tool, syncdomain.FieldSchema) + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, schemaState.Capability) + description := "Look up an order and its current status." + result, err := UpdateSource( + sourceBundle(toolRef.File, source), toolID, "", toolRef, + syncdomain.ToolProjection{Description: &description}, + ) + require.NoError(t, err) + updated := applyConformanceEdits(t, source, directEdits(t, result)) + assert.Contains(t, string(updated), `"""Look up an order and its current status."""`) +} + +func TestAmbiguousInlineChainWritesNothing(t *testing.T) { + source := fixtureFile(t, "python/langchain_prompts.py") + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "langchain_prompts.py", Format: "python", Select: `prompts[id="ambiguous"]`, + Map: map[string]string{"messages": "messages"}, + } + + _, err := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeCompletion, reference) + + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorPathAmbiguous, problem.Code) +} + +func TestCompletionMessagesEditContentButRejectStructure(t *testing.T) { + source := fixtureFile(t, "python/messages.py") + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "messages.py", Format: "python", Select: `agents[id="completion"]`, + Map: map[string]string{"messages": "messages"}, + } + projection, err := ParseSource(sourceBundle(reference.File, source), id, syncdomain.VariationModeCompletion, reference) + require.NoError(t, err) + messages := append([]syncdomain.Message{}, (*projection.(syncdomain.VariationProjection).Messages)...) + messages[0].Content = "Updated system prompt." + messages[1].Content = "Updated user prompt." + + result, err := UpdateSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeCompletion, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeCompletion, Messages: &messages}, + ) + require.NoError(t, err) + update := result.Fields[syncdomain.FieldMessages] + assert.Equal(t, CapabilityDirect, update.Capability) + assert.Len(t, update.Edits, 2) + updated := applyConformanceEdits(t, source, update.Edits) + assert.Contains(t, string(updated), `"content": "Updated system prompt."`) + assert.Contains(t, string(updated), `"content": "Updated user prompt."`) + + messages = append(messages, syncdomain.Message{Role: syncdomain.RoleAssistant, Content: "Extra"}) + result, err = UpdateSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeCompletion, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeCompletion, Messages: &messages}, + ) + require.NoError(t, err) + assert.Equal(t, CapabilityReadOnly, result.Fields[syncdomain.FieldMessages].Capability) + assert.Contains(t, result.Fields[syncdomain.FieldMessages].Reason, "add or remove messages by hand") +} diff --git a/internal/sync/reference/defensive_test.go b/internal/sync/reference/defensive_test.go index 8ae4bcaf..7832f38e 100644 --- a/internal/sync/reference/defensive_test.go +++ b/internal/sync/reference/defensive_test.go @@ -41,3 +41,32 @@ func TestMarkdownProfilesRejectUnsafeMarkers(t *testing.T) { ) require.Error(t, err) } + +func TestPlaceholderTranslationRejectsAmbiguousForms(t *testing.T) { + for _, value := range []string{"{{name}}", "{name", "{1name}", "literal }"} { + _, err := placeholdersToLaunchDarkly(value, bracePlaceholder) + require.Error(t, err) + } + for _, value := range []string{"{{name", "{{1name}}", "literal {"} { + _, err := placeholdersFromLaunchDarkly(value, bracePlaceholder) + require.Error(t, err) + } +} + +func TestPythonAdapterRejectsDynamicPrompt(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "agent.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + } + projection, err := ParseSource( + sourceBundle("agent.py", []byte("from agents import Agent\nsupport = Agent(instructions=load_prompt())\n")), + id, + syncdomain.VariationModeAgent, + reference, + ) + require.NoError(t, err) + state, ok := syncdomain.ProjectionFieldState(projection, syncdomain.FieldPrompt) + require.True(t, ok) + require.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) +} diff --git a/internal/sync/reference/framework_conformance_test.go b/internal/sync/reference/framework_conformance_test.go new file mode 100644 index 00000000..d9877d17 --- /dev/null +++ b/internal/sync/reference/framework_conformance_test.go @@ -0,0 +1,191 @@ +package reference + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type frameworkCase struct { + name, fixture, format, collection, id, sourcePath, want string + kind syncdomain.Kind + mode syncdomain.VariationMode +} + +func TestFrameworkInspectorsReadAndWriteStaticFields(t *testing.T) { + tests := []frameworkCase{ + {name: "OpenAI Python responses", fixture: "python/frameworks_tier1.py", format: "python", collection: "prompts", id: "openai_response", sourcePath: "instructions", want: "Answer in one paragraph.", kind: syncdomain.KindVariation}, + {name: "Anthropic Python", fixture: "python/frameworks_tier1.py", format: "python", collection: "prompts", id: "anthropic_message", sourcePath: "system", want: "Follow the support policy.", kind: syncdomain.KindVariation}, + {name: "OpenAI Agents Python", fixture: "python/frameworks_tier1.py", format: "python", collection: "agents", id: "support_agent", sourcePath: "instructions", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "LangGraph", fixture: "python/frameworks_tier1.py", format: "python", collection: "agents", id: "react_agent", sourcePath: "prompt", want: "Use the support tools.", kind: syncdomain.KindVariation}, + {name: "OpenAI function tool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "lookup_order", sourcePath: "description", want: "Look up one order.", kind: syncdomain.KindTool}, + {name: "LangChain tool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "refund_order", sourcePath: "description", want: "Refund one order.", kind: syncdomain.KindTool}, + {name: "StructuredTool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "structured", sourcePath: "description", want: "Look up one order with a structured tool.", kind: syncdomain.KindTool}, + {name: "Agent as tool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "support_tool", sourcePath: "tool_description", want: "Ask the support agent.", kind: syncdomain.KindTool}, + {name: "FastMCP tool", fixture: "python/frameworks_tier1.py", format: "python", collection: "tools", id: "mcp_lookup", sourcePath: "description", want: "Look up one order through MCP.", kind: syncdomain.KindTool}, + {name: "CrewAI agent", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "crew_researcher", sourcePath: "goal", want: "Find the relevant support policy.", kind: syncdomain.KindVariation}, + {name: "CrewAI task", fixture: "python/frameworks_tier2.py", format: "python", collection: "prompts", id: "crew_task", sourcePath: "description", want: "Research the refund request.", kind: syncdomain.KindVariation}, + {name: "Pydantic AI", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "pydantic_agent", sourcePath: "instructions", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "Google ADK", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "adk_agent", sourcePath: "instruction", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "Google LlmAgent", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "llm_agent", sourcePath: "instruction", want: "Explain refunds.", kind: syncdomain.KindVariation}, + {name: "AWS Strands", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "strands_agent", sourcePath: "system_prompt", want: "Follow the support policy.", kind: syncdomain.KindVariation}, + {name: "AutoGen", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "autogen_agent", sourcePath: "system_message", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "LlamaIndex prompt", fixture: "python/frameworks_tier2.py", format: "python", collection: "prompts", id: "llama_prompt", sourcePath: "template", want: "Explain {{topic}}.", kind: syncdomain.KindVariation}, + {name: "LlamaIndex tool", fixture: "python/frameworks_tier2.py", format: "python", collection: "tools", id: "llama_tool", sourcePath: "description", want: "Look up one order.", kind: syncdomain.KindTool}, + {name: "LlamaIndex agent", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "llama_agent", sourcePath: "system_prompt", want: "Use the support tools.", kind: syncdomain.KindVariation}, + {name: "Semantic Kernel agent", fixture: "python/frameworks_tier2.py", format: "python", collection: "agents", id: "semantic_agent", sourcePath: "instructions", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "Semantic Kernel tool", fixture: "python/frameworks_tier2.py", format: "python", collection: "tools", id: "semantic_lookup", sourcePath: "description", want: "Look up one order with Semantic Kernel.", kind: syncdomain.KindTool}, + {name: "Pydantic AI tool", fixture: "python/frameworks_tier2.py", format: "python", collection: "tools", id: "pydantic_lookup", sourcePath: "description", want: "Look up one order with Pydantic AI.", kind: syncdomain.KindTool}, + {name: "Strands tool", fixture: "python/frameworks_tier2.py", format: "python", collection: "tools", id: "strands_lookup", sourcePath: "description", want: "Look up one order with Strands.", kind: syncdomain.KindTool}, + {name: "OpenAI TypeScript responses", fixture: "typescript/frameworks.ts", format: "typescript", collection: "prompts", id: "openaiResponse", sourcePath: "instructions", want: "Answer in one paragraph.", kind: syncdomain.KindVariation}, + {name: "Anthropic TypeScript", fixture: "typescript/frameworks.ts", format: "typescript", collection: "prompts", id: "anthropicMessage", sourcePath: "system", want: "Follow the support policy.", kind: syncdomain.KindVariation}, + {name: "Vercel generateText", fixture: "typescript/frameworks.ts", format: "typescript", collection: "prompts", id: "generated", sourcePath: "system", want: "Follow the support policy.", kind: syncdomain.KindVariation}, + {name: "Vercel tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "vercelLookup", sourcePath: "description", want: "Look up one order.", kind: syncdomain.KindTool}, + {name: "OpenAI Agents JS", fixture: "typescript/frameworks.ts", format: "typescript", collection: "agents", id: "supportAgent", sourcePath: "instructions", want: "Ask for an order ID.", kind: syncdomain.KindVariation}, + {name: "OpenAI Agents JS tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "openaiLookup", sourcePath: "description", want: "Look up one order with OpenAI Agents.", kind: syncdomain.KindTool}, + {name: "LangChain JS tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "langchainLookup", sourcePath: "description", want: "Look up one order with LangChain.", kind: syncdomain.KindTool}, + {name: "MCP TypeScript tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "mcp_lookup", sourcePath: "description", want: "Look up one order through MCP.", kind: syncdomain.KindTool}, + {name: "MCP TypeScript prompt", fixture: "typescript/frameworks.ts", format: "typescript", collection: "prompts", id: "mcp_prompt", sourcePath: "description", want: "Build one MCP prompt.", kind: syncdomain.KindVariation}, + {name: "Mastra agent", fixture: "typescript/frameworks.ts", format: "typescript", collection: "agents", id: "mastraAgent", sourcePath: "instructions", want: "Ask for an order ID with Mastra.", kind: syncdomain.KindVariation}, + {name: "Mastra tool", fixture: "typescript/frameworks.ts", format: "typescript", collection: "tools", id: "mastraLookup", sourcePath: "description", want: "Look up one order with Mastra.", kind: syncdomain.KindTool}, + } + for _, test := range tests { + if test.format != "python" { + continue + } + t.Run(test.name, func(t *testing.T) { + source := fixtureFile(t, test.fixture) + mode := test.mode + if mode == "" { + mode = syncdomain.VariationModeAgent + } + field := "prompt" + id := syncdomain.VariationID("project", "config", "default") + if test.kind == syncdomain.KindTool { + field = "description" + id = syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: test.id} + } + reference := syncdomain.Reference{ + File: test.fixture, Format: test.format, + Select: test.collection + `[id="` + test.id + `"]`, + Map: map[string]string{field: test.sourcePath}, + } + projection, err := ParseSource(sourceBundle(test.fixture, source), id, mode, reference) + require.NoError(t, err) + assert.Equal(t, test.want, primaryText(t, projection)) + update := "Updated " + test.want + result, err := UpdateSource( + sourceBundle(test.fixture, source), id, mode, reference, + projectionWithText(projection, update), + ) + require.NoError(t, err) + updated := applyConformanceEdits(t, source, directEdits(t, result)) + reparsed, err := ParseSource(sourceBundle(test.fixture, updated), id, mode, reference) + require.NoError(t, err) + assert.Equal(t, update, primaryText(t, reparsed)) + }) + } +} + +func TestFrameworkMessageListsAndBareCallIDs(t *testing.T) { + messageTests := []struct { + name, fixture, format, id string + }{ + {name: "OpenAI Python chat", fixture: "python/frameworks_tier1.py", format: "python", id: "openai_chat"}, + {name: "Anthropic Python messages", fixture: "python/frameworks_tier1.py", format: "python", id: "anthropic_message"}, + {name: "OpenAI TypeScript chat", fixture: "typescript/frameworks.ts", format: "typescript", id: "openaiChat"}, + {name: "Anthropic TypeScript messages", fixture: "typescript/frameworks.ts", format: "typescript", id: "anthropicMessage"}, + {name: "Vercel streamText messages", fixture: "typescript/frameworks.ts", format: "typescript", id: "streamed"}, + } + for _, test := range messageTests { + if test.format != "python" { + continue + } + t.Run(test.name, func(t *testing.T) { + source := fixtureFile(t, test.fixture) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: test.fixture, Format: test.format, Select: `prompts[id="` + test.id + `"]`, + Map: map[string]string{"messages": "messages"}, + } + projection, err := ParseSource( + sourceBundle(test.fixture, source), id, syncdomain.VariationModeCompletion, reference, + ) + require.NoError(t, err) + messages := append([]syncdomain.Message{}, (*projection.(syncdomain.VariationProjection).Messages)...) + require.NotEmpty(t, messages) + messages[0].Content = "Updated framework message." + result, err := UpdateSource( + sourceBundle(test.fixture, source), id, syncdomain.VariationModeCompletion, reference, + syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeCompletion, Messages: &messages, + }, + ) + require.NoError(t, err) + assert.Equal(t, CapabilityDirect, result.Fields[syncdomain.FieldMessages].Capability) + applyConformanceEdits(t, source, result.Fields[syncdomain.FieldMessages].Edits) + }) + } + + bareTests := []struct { + name, fixture, format, id, path, want string + }{ + {name: "OpenAI Python bare call", fixture: "python/frameworks_tier1.py", format: "python", id: "bare_calls.openai.responses.create", path: "instructions", want: "Bare response prompt."}, + {name: "Anthropic Python bare call", fixture: "python/frameworks_tier1.py", format: "python", id: "bare_calls.anthropic.messages.create", path: "system", want: "Bare Anthropic prompt."}, + {name: "OpenAI TypeScript bare call", fixture: "typescript/frameworks.ts", format: "typescript", id: "bareCalls.openai.responses.create", path: "instructions", want: "Bare OpenAI response."}, + {name: "Anthropic TypeScript bare call", fixture: "typescript/frameworks.ts", format: "typescript", id: "bareCalls.anthropic.messages.create", path: "system", want: "Bare Anthropic message."}, + } + for _, test := range bareTests { + if test.format != "python" { + continue + } + t.Run(test.name, func(t *testing.T) { + source := fixtureFile(t, test.fixture) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: test.fixture, Format: test.format, Select: `prompts[id="` + test.id + `"]`, + Map: map[string]string{"prompt": test.path}, + } + projection, err := ParseSource(sourceBundle(test.fixture, source), id, syncdomain.VariationModeAgent, reference) + require.NoError(t, err) + assert.Equal(t, test.want, *projection.(syncdomain.VariationProjection).Instructions) + }) + } +} + +func TestFrameworkSchemasAndFunctionBuiltPromptsStayReadOnly(t *testing.T) { + tests := []struct { + name, fixture, format, selectPath, field, path string + kind syncdomain.Kind + }{ + {name: "Pydantic type hints", fixture: "python/frameworks_tier2.py", format: "python", selectPath: `tools[id="pydantic_lookup"]`, field: "schema", path: "schema", kind: syncdomain.KindTool}, + {name: "FastMCP prompt function", fixture: "python/frameworks_tier1.py", format: "python", selectPath: `prompts[id="mcp_prompt"]`, field: "prompt", path: "template", kind: syncdomain.KindVariation}, + {name: "Pydantic system prompt function", fixture: "python/frameworks_tier2.py", format: "python", selectPath: `prompts[id="pydantic_system_prompt"]`, field: "prompt", path: "template", kind: syncdomain.KindVariation}, + {name: "LangChain Zod schema", fixture: "typescript/frameworks.ts", format: "typescript", selectPath: `tools[id="langchainLookup"]`, field: "schema", path: "schema", kind: syncdomain.KindTool}, + {name: "MCP Zod schema", fixture: "typescript/frameworks.ts", format: "typescript", selectPath: `tools[id="mcp_lookup"]`, field: "schema", path: "schema", kind: syncdomain.KindTool}, + } + for _, test := range tests { + if test.format != "python" { + continue + } + t.Run(test.name, func(t *testing.T) { + source := fixtureFile(t, test.fixture) + id := syncdomain.VariationID("project", "config", "default") + if test.kind == syncdomain.KindTool { + id = syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "tool"} + } + reference := syncdomain.Reference{ + File: test.fixture, Format: test.format, Select: test.selectPath, + Map: map[string]string{test.field: test.path}, + } + projection, err := ParseSource(sourceBundle(test.fixture, source), id, syncdomain.VariationModeAgent, reference) + require.NoError(t, err) + state, ok := syncdomain.ProjectionFieldState(projection, syncdomain.Field(test.field)) + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + }) + } +} diff --git a/internal/sync/reference/import_test.go b/internal/sync/reference/import_test.go new file mode 100644 index 00000000..89c017c5 --- /dev/null +++ b/internal/sync/reference/import_test.go @@ -0,0 +1,88 @@ +package reference + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestImportedLiteralReadAndWrite(t *testing.T) { + tests := []struct { + name, format, primaryPath, dependencyPath string + }{ + {name: "Python relative import", format: "python", primaryPath: "app/agent.py", dependencyPath: "app/prompts.py"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixtureRoot := "imports/python/" + if test.format == "typescript" { + fixtureRoot = "imports/typescript/" + } + primary := fixtureFile(t, fixtureRoot+test.primaryPath) + dependency := fixtureFile(t, fixtureRoot+test.dependencyPath) + bundle := SourceBundle{ + Primary: Document{Path: test.primaryPath, Data: primary}, + Dependencies: map[string]Document{ + test.dependencyPath: {Path: test.dependencyPath, Data: dependency}, + }, + } + paths, err := DependencyPaths(test.format, bundle.Primary) + require.NoError(t, err) + assert.Contains(t, paths, test.dependencyPath) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: test.primaryPath, Format: test.format, Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + } + projection, err := ParseSource(bundle, id, syncdomain.VariationModeAgent, reference) + require.NoError(t, err) + assert.Equal(t, + "Ask for an order ID before discussing an order.", + *projection.(syncdomain.VariationProjection).Instructions, + ) + + prompt := "Ask for an order ID and region." + result, err := UpdateSource( + bundle, id, syncdomain.VariationModeAgent, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &prompt}, + ) + require.NoError(t, err) + edits := directEdits(t, result) + require.Len(t, edits, 1) + assert.Equal(t, test.dependencyPath, edits[0].File) + updated := applyConformanceEdits(t, dependency, edits) + bundle.Dependencies[test.dependencyPath] = Document{Path: test.dependencyPath, Data: updated} + reparsed, err := ParseSource(bundle, id, syncdomain.VariationModeAgent, reference) + require.NoError(t, err) + assert.Equal(t, prompt, *reparsed.(syncdomain.VariationProjection).Instructions) + assert.Equal(t, primary, bundle.Primary.Data) + }) + } +} + +func TestUnresolvedImportIsReadOnlyWithSourceHint(t *testing.T) { + source := []byte(`from agents import Agent +from third_party.prompts import SUPPORT_PROMPT +support = Agent(instructions=SUPPORT_PROMPT, model="gpt-5") +`) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "agent.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + } + + projection, err := ParseSource( + SourceBundle{Primary: Document{Path: "agent.py", Data: source}}, + id, syncdomain.VariationModeAgent, reference, + ) + + require.NoError(t, err) + state, ok := syncdomain.ProjectionFieldState(projection, syncdomain.FieldPrompt) + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + assert.Equal(t, syncdomain.ErrorImportUnresolved, state.Code) + assert.NotEmpty(t, state.File) +} diff --git a/internal/sync/reference/placeholder.go b/internal/sync/reference/placeholder.go new file mode 100644 index 00000000..8cf6bea0 --- /dev/null +++ b/internal/sync/reference/placeholder.go @@ -0,0 +1,189 @@ +package reference + +import ( + "fmt" + "strings" + "unicode" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type placeholderStyle string + +const ( + bracePlaceholder placeholderStyle = "brace" + dollarPlaceholder placeholderStyle = "dollar" +) + +func translatePlaceholderNode(node *sourceNode, style placeholderStyle) *sourceNode { + if node == nil { + return nil + } + value, ok := node.value.(string) + if !ok { + return node + } + translated, err := placeholdersToLaunchDarkly(value, style) + if err != nil { + result := *node + result.value = nil + result.capability = CapabilityReadOnly + result.reason = err.Error() + result.code = syncdomain.ErrorPlaceholderAmbiguous + result.encode = nil + return &result + } + result := *node + result.value = translated + encode := node.encode + result.encode = func(value any) ([]byte, error) { + text, ok := value.(string) + if !ok { + return nil, mapTypeError("source", "a string") + } + sourceText, err := placeholdersFromLaunchDarkly(text, style) + if err != nil { + return nil, err + } + return encode(sourceText) + } + return &result +} + +func translateMessagePlaceholders(node *sourceNode, style placeholderStyle) *sourceNode { + if node == nil || node.items == nil { + return node + } + result := *node + result.items = make([]*sourceNode, len(node.items)) + var messages []syncdomain.Message + for index, item := range node.items { + copied := *item + copied.fields = make(map[string]*sourceNode, len(item.fields)) + for key, value := range item.fields { + copied.fields[key] = value + } + copied.fields["content"] = translatePlaceholderNode(item.fields["content"], style) + result.items[index] = &copied + role, roleOK := copied.fields["role"].value.(string) + content, contentOK := copied.fields["content"].value.(string) + if !roleOK || !contentOK { + result.value = nil + result.capability = CapabilityReadOnly + result.reason = copied.fields["content"].reason + result.code = copied.fields["content"].code + return &result + } + messages = append(messages, syncdomain.Message{ + Role: normalizeMessageRole(role), Content: content, + }) + } + result.value = messages + return &result +} + +func translateFieldPlaceholders( + fields map[string]*sourceNode, + style placeholderStyle, + names ...string, +) map[string]*sourceNode { + for _, name := range names { + if node := fields[name]; node != nil { + if name == "messages" { + fields[name] = translateMessagePlaceholders(node, style) + } else { + fields[name] = translatePlaceholderNode(node, style) + } + } + } + return fields +} + +func placeholdersToLaunchDarkly(value string, style placeholderStyle) (string, error) { + var output strings.Builder + for index := 0; index < len(value); { + start, end, name, ok, err := nextSourcePlaceholder(value, index, style) + if err != nil { + return "", err + } + if !ok { + output.WriteString(value[index:]) + break + } + output.WriteString(value[index:start]) + output.WriteString("{{" + name + "}}") + index = end + } + return output.String(), nil +} + +func nextSourcePlaceholder(value string, offset int, style placeholderStyle) (int, int, string, bool, error) { + token := "{" + if style == dollarPlaceholder { + token = "${" + } + index := strings.Index(value[offset:], token) + if index < 0 { + if style == bracePlaceholder && strings.ContainsAny(value[offset:], "{}") { + return 0, 0, "", false, fmt.Errorf("literal or escaped braces make placeholder translation ambiguous") + } + return 0, 0, "", false, nil + } + index += offset + if style == bracePlaceholder && (strings.HasPrefix(value[index:], "{{") || index > 0 && value[index-1] == '{') { + return 0, 0, "", false, fmt.Errorf("escaped braces make placeholder translation ambiguous") + } + nameStart := index + len(token) + closeAt := strings.IndexByte(value[nameStart:], '}') + if closeAt < 0 { + return 0, 0, "", false, fmt.Errorf("placeholder is not closed") + } + closeAt += nameStart + name := value[nameStart:closeAt] + if !validPlaceholderName(name) { + return 0, 0, "", false, fmt.Errorf("placeholder %q is ambiguous", name) + } + return index, closeAt + 1, name, true, nil +} + +func placeholdersFromLaunchDarkly(value string, style placeholderStyle) (string, error) { + var output strings.Builder + for index := 0; index < len(value); { + start := strings.Index(value[index:], "{{") + if start < 0 { + if strings.ContainsAny(value[index:], "{}") { + return "", fmt.Errorf("literal braces make placeholder translation ambiguous") + } + output.WriteString(value[index:]) + break + } + start += index + output.WriteString(value[index:start]) + closeAt := strings.Index(value[start+2:], "}}") + if closeAt < 0 { + return "", fmt.Errorf("LaunchDarkly placeholder is not closed") + } + closeAt += start + 2 + name := value[start+2 : closeAt] + if !validPlaceholderName(name) { + return "", fmt.Errorf("LaunchDarkly placeholder %q is ambiguous", name) + } + if style == dollarPlaceholder { + output.WriteString("${" + name + "}") + } else { + output.WriteString("{" + name + "}") + } + index = closeAt + 2 + } + return output.String(), nil +} + +func validPlaceholderName(name string) bool { + for index, character := range name { + if character == '_' || unicode.IsLetter(character) || index > 0 && (unicode.IsDigit(character) || character == '.' || character == '-') { + continue + } + return false + } + return name != "" +} diff --git a/internal/sync/reference/placeholder_test.go b/internal/sync/reference/placeholder_test.go new file mode 100644 index 00000000..ac7308d5 --- /dev/null +++ b/internal/sync/reference/placeholder_test.go @@ -0,0 +1,65 @@ +package reference + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestLangChainPlaceholderTranslationRoundTrip(t *testing.T) { + source := []byte(`from langchain_core.prompts import PromptTemplate +prompt = PromptTemplate.from_template("Hello {customer}.") +`) + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "prompt.py", Format: "python", Select: `prompts[id="prompt"]`, + Map: map[string]string{"prompt": "template"}, + } + projection, err := ParseSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference, + ) + require.NoError(t, err) + assert.Equal(t, "Hello {{customer}}.", *projection.(syncdomain.VariationProjection).Instructions) + + next := "Welcome {{customer_name}}." + result, err := UpdateSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference, + syncdomain.VariationProjection{Mode: syncdomain.VariationModeAgent, Instructions: &next}, + ) + require.NoError(t, err) + updated := applyConformanceEdits(t, source, directEdits(t, result)) + assert.Contains(t, string(updated), `"Welcome {customer_name}."`) +} + +func TestAmbiguousPlaceholdersAreReadOnly(t *testing.T) { + tests := []struct { + name, template string + }{ + {name: "escaped braces", template: `Use {{literal}} braces.`}, + {name: "JSON example", template: `Return {"status":"ok"}.`}, + {name: "format conversion", template: `Show {value!r}.`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := []byte("from langchain_core.prompts import PromptTemplate\nprompt = PromptTemplate.from_template(" + + strconv.Quote(test.template) + ")\n") + id := syncdomain.VariationID("project", "config", "default") + reference := syncdomain.Reference{ + File: "prompt.py", Format: "python", Select: `prompts[id="prompt"]`, + Map: map[string]string{"prompt": "template"}, + } + projection, err := ParseSource( + sourceBundle(reference.File, source), id, syncdomain.VariationModeAgent, reference, + ) + require.NoError(t, err) + state, ok := syncdomain.ProjectionFieldState(projection, syncdomain.FieldPrompt) + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + assert.Equal(t, syncdomain.ErrorPlaceholderAmbiguous, state.Code) + }) + } +} diff --git a/internal/sync/reference/python.go b/internal/sync/reference/python.go new file mode 100644 index 00000000..4d1563d4 --- /dev/null +++ b/internal/sync/reference/python.go @@ -0,0 +1,781 @@ +package reference + +import ( + "encoding/json" + "fmt" + "path" + "strconv" + "strings" + + sitter "github.com/tree-sitter/go-tree-sitter" + python "github.com/tree-sitter/tree-sitter-python/bindings/go" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type pythonAdapter struct{} + +func (pythonAdapter) Format() string { return "python" } +func (pythonAdapter) Inspect(bundle SourceBundle) (Inspection, error) { + root, err := parsePythonBundle(bundle) + return Inspection{root: root}, err +} +func (pythonAdapter) Defaults(syncdomain.Kind) (string, map[string]string) { return "", nil } +func (pythonAdapter) Dependencies(document Document) ([]string, error) { + tree, err := parseTree(document, sitter.NewLanguage(python.Language())) + if err != nil { + return nil, err + } + defer tree.Close() + var dependencies []string + for module := range pythonImportedModules(tree.RootNode(), document.Data) { + dependencies = append(dependencies, pythonModulePaths(document.Path, module)...) + } + return dependencies, nil +} + +func init() { mustRegister(pythonAdapter{}) } + +func parsePythonBundle(bundle SourceBundle) (*sourceNode, error) { + return parsePython(bundle.Primary, pythonImportedValues(bundle)) +} + +func parsePython(document Document, imported map[string]*sourceNode) (*sourceNode, error) { + tree, err := parseTree(document, sitter.NewLanguage(python.Language())) + if err != nil { + return nil, err + } + defer tree.Close() + + aliases := pythonImports(tree.RootNode(), document.Data) + instances := pythonInstances(tree.RootNode(), document.Data, aliases) + constants, constantResources := pythonConstants(tree.RootNode(), document.Data) + for name, value := range imported { + constants[name] = value + } + collections := map[string][]*sourceNode{"agents": {}, "prompts": {}, "tools": {}, "constants": {}} + collections["constants"] = constantResources + walkTree(tree.RootNode(), func(node *sitter.Node) { + if node.Kind() != "assignment" { + return + } + name := node.ChildByFieldName("left") + value := node.ChildByFieldName("right") + if name == nil || value == nil || name.Kind() != "identifier" { + return + } + id := pythonQualifiedID(node, nodeText(name, document.Data), document.Data) + if collection, resource, ok := pythonFrameworkCall( + value, id, aliases, instances, document.Data, constants, + ); ok { + collections[collection] = append(collections[collection], resource) + } + for _, call := range pythonPromptCalls(value, aliases, document.Data) { + fields := pythonPromptFields(call, aliases, document.Data, constants) + collections["prompts"] = append(collections["prompts"], logicalResource(id, fields)) + } + }) + walkTree(tree.RootNode(), func(node *sitter.Node) { + if node.Kind() != "call" || pythonAssignedCall(node) { + return + } + if collection, resource, ok := pythonFrameworkCall( + node, "", aliases, instances, document.Data, constants, + ); ok { + collections[collection] = append(collections[collection], resource) + } + }) + walkTree(tree.RootNode(), func(node *sitter.Node) { + if node.Kind() != "decorated_definition" { + return + } + collection := pythonDecoratedCollection(node, aliases, instances, document.Data) + if collection == "" { + return + } + var function *sitter.Node + for _, child := range namedChildren(node) { + if child.Kind() == "function_definition" { + function = child + break + } + } + if function == nil { + return + } + name := function.ChildByFieldName("name") + if name == nil { + return + } + id := nodeText(name, document.Data) + fields := map[string]*sourceNode{} + if body := function.ChildByFieldName("body"); body != nil && body.NamedChildCount() > 0 { + first := body.NamedChild(0) + if first.Kind() == "expression_statement" && first.NamedChildCount() == 1 { + docstring := first.NamedChild(0) + if docstring.Kind() == "string" { + fields["description"] = pythonValue(docstring, document.Data, constants) + } + } + } + for _, child := range namedChildren(node) { + if child.Kind() != "decorator" || child.NamedChildCount() == 0 { + continue + } + decorator := child.NamedChild(0) + if decorator.Kind() == "call" { + for key, value := range pythonKeywordFields(decorator, document.Data, constants) { + fields[key] = value + } + } + } + if parameters := function.ChildByFieldName("parameters"); parameters != nil && parameters.NamedChildCount() != 0 { + fields["schema"] = readOnlyNode( + int(parameters.StartByte()), int(parameters.EndByte()), + "schema inferred from type hints is read-only", + ) + } + if collection == "prompts" { + body := function.ChildByFieldName("body") + fields["template"] = readOnlyNode( + int(body.StartByte()), int(body.EndByte()), + "prompt built by a function is read-only", + ) + } + collections[collection] = append(collections[collection], logicalResource(id, fields)) + }) + return logicalRoot(collections), nil +} + +func pythonAssignedCall(call *sitter.Node) bool { + for parent := call.Parent(); parent != nil; parent = parent.Parent() { + if parent.Kind() == "assignment" { + return true + } + if parent.Kind() == "expression_statement" || parent.Kind() == "return_statement" { + return false + } + } + return false +} + +func pythonPromptCalls(node *sitter.Node, aliases map[string]string, source []byte) []*sitter.Node { + var calls []*sitter.Node + walkTree(node, func(candidate *sitter.Node) { + if candidate.Kind() != "call" { + return + } + callable := nodeText(candidate.ChildByFieldName("function"), source) + owner, method, qualified := strings.Cut(callable, ".") + if isPythonPromptKind(aliases[callable]) || + (qualified && isPythonPromptKind(aliases[owner]) && (method == "from_messages" || method == "from_template")) { + calls = append(calls, candidate) + } + }) + return calls +} + +func pythonPromptFields( + call *sitter.Node, + aliases map[string]string, + source []byte, + constants map[string]*sourceNode, +) map[string]*sourceNode { + callable := nodeText(call.ChildByFieldName("function"), source) + owner, method, qualified := strings.Cut(callable, ".") + arguments := pythonPositionalArguments(call) + if qualified && isPythonPromptKind(aliases[owner]) { + if len(arguments) == 0 { + return map[string]*sourceNode{} + } + if method == "from_messages" { + return translateFieldPlaceholders( + map[string]*sourceNode{"messages": pythonMessages(arguments[0], source, constants)}, + bracePlaceholder, "messages", + ) + } + if method == "from_template" { + return translateFieldPlaceholders( + map[string]*sourceNode{"template": pythonValue(arguments[0], source, constants)}, + bracePlaceholder, "template", + ) + } + } + fields := pythonKeywordFields(call, source, constants) + if len(arguments) != 0 { + if aliases[callable] == "prompt-template" { + fields["template"] = pythonValue(arguments[0], source, constants) + } else if _, exists := fields["messages"]; !exists { + fields["messages"] = pythonMessages(arguments[0], source, constants) + } + } + return translateFieldPlaceholders(fields, bracePlaceholder, "template", "messages") +} + +func isPythonPromptKind(kind string) bool { + return kind == "prompt-template" || kind == "chat-prompt" +} + +func pythonPositionalArguments(call *sitter.Node) []*sitter.Node { + var result []*sitter.Node + for _, argument := range namedChildren(call.ChildByFieldName("arguments")) { + if argument.Kind() != "keyword_argument" { + result = append(result, argument) + } + } + return result +} + +func pythonImports(root *sitter.Node, source []byte) map[string]string { + aliases := map[string]string{} + walkTree(root, func(node *sitter.Node) { + if node.Kind() != "import_from_statement" { + return + } + text := nodeText(node, source) + from, imported, ok := strings.Cut(text, " import ") + if !ok || !strings.HasPrefix(from, "from ") { + return + } + module := strings.TrimSpace(strings.TrimPrefix(from, "from ")) + if allowed := pythonAllowedImports(module); allowed != nil { + addPythonImportAliases(aliases, imported, allowed) + } + }) + return aliases +} + +type pythonImportBinding struct { + module string + name string + alias string +} + +func pythonImportBindings(root *sitter.Node, source []byte) []pythonImportBinding { + var bindings []pythonImportBinding + walkTree(root, func(node *sitter.Node) { + if node.Kind() != "import_from_statement" { + return + } + text := nodeText(node, source) + from, imported, ok := strings.Cut(text, " import ") + if !ok || !strings.HasPrefix(from, "from ") { + return + } + module := strings.TrimSpace(strings.TrimPrefix(from, "from ")) + if pythonAllowedImports(module) != nil { + return + } + for _, item := range strings.Split(strings.Trim(imported, "()"), ",") { + parts := strings.Fields(strings.TrimSpace(item)) + if len(parts) == 0 { + continue + } + alias := parts[0] + if len(parts) == 3 && parts[1] == "as" { + alias = parts[2] + } + bindings = append(bindings, pythonImportBinding{ + module: module, name: parts[0], alias: alias, + }) + } + }) + return bindings +} + +func pythonImportedModules(root *sitter.Node, source []byte) map[string]struct{} { + modules := map[string]struct{}{} + for _, binding := range pythonImportBindings(root, source) { + modules[binding.module] = struct{}{} + } + return modules +} + +func pythonModulePaths(primary, module string) []string { + directory := path.Dir(primary) + dots := len(module) - len(strings.TrimLeft(module, ".")) + module = strings.TrimLeft(module, ".") + if dots == 0 { + directory = "." + } else { + for level := 1; level < dots; level++ { + directory = path.Dir(directory) + } + } + modulePath := path.Join(directory, strings.ReplaceAll(module, ".", "/")) + return []string{modulePath + ".py", path.Join(modulePath, "__init__.py")} +} + +func pythonImportedValues(bundle SourceBundle) map[string]*sourceNode { + result := map[string]*sourceNode{} + tree, err := parseTree(bundle.Primary, sitter.NewLanguage(python.Language())) + if err != nil { + return result + } + defer tree.Close() + cache := map[string]map[string]*sourceNode{} + for _, binding := range pythonImportBindings(tree.RootNode(), bundle.Primary.Data) { + candidates := pythonModulePaths(bundle.Primary.Path, binding.module) + var document Document + for _, candidate := range candidates { + if value, ok := bundle.Dependencies[candidate]; ok { + document = value + break + } + } + if document.Path == "" { + result[binding.alias] = unresolvedImportNode(binding.module, binding.name, candidates[0]) + continue + } + constants := cache[document.Path] + if constants == nil { + dependencyTree, parseErr := parseTree(document, sitter.NewLanguage(python.Language())) + if parseErr != nil { + result[binding.alias] = unresolvedImportNode(binding.module, binding.name, document.Path) + continue + } + constants, _ = pythonConstants(dependencyTree.RootNode(), document.Data) + dependencyTree.Close() + for _, value := range constants { + markSourceFile(value, document.Path) + } + cache[document.Path] = constants + } + value := constants[binding.name] + if value == nil { + value = unresolvedImportNode(binding.module, binding.name, document.Path) + } + result[binding.alias] = value + } + return result +} + +func unresolvedImportNode(module, name, file string) *sourceNode { + node := readOnlyNode(0, 0, fmt.Sprintf("imported value %s.%s cannot be resolved statically", module, name)) + node.code = syncdomain.ErrorImportUnresolved + node.file = file + return node +} + +func addPythonImportAliases(target map[string]string, imported string, allowed map[string]string) { + for _, item := range strings.Split(strings.Trim(imported, "()"), ",") { + parts := strings.Fields(strings.TrimSpace(item)) + if len(parts) == 0 { + continue + } + kind, ok := allowed[parts[0]] + if !ok { + continue + } + alias := parts[0] + if len(parts) == 3 && parts[1] == "as" { + alias = parts[2] + } + target[alias] = kind + } +} + +func pythonConstants(root *sitter.Node, source []byte) (map[string]*sourceNode, []*sourceNode) { + counts := map[string]int{} + values := map[string]*sourceNode{} + var resources []*sourceNode + walkTree(root, func(node *sitter.Node) { + if node.Kind() != "assignment" { + return + } + name := node.ChildByFieldName("left") + valueNode := node.ChildByFieldName("right") + if name == nil || valueNode == nil || name.Kind() != "identifier" { + return + } + id := pythonQualifiedID(node, nodeText(name, source), source) + value := pythonValue(valueNode, source, nil) + if value.value == nil && value.fields == nil && value.items == nil { + return + } + counts[id]++ + values[id] = value + resources = append(resources, constantResource(id, value)) + }) + mutated := map[string]bool{} + walkTree(root, func(node *sitter.Node) { + switch node.Kind() { + case "assignment", "augmented_assignment", "named_expression": + name := node.ChildByFieldName("left") + if name == nil { + name = node.ChildByFieldName("name") + } + if rootName := pythonMutationRoot(name, source); rootName != "" { + if name.Kind() != "identifier" || node.Kind() != "assignment" { + mutated[pythonQualifiedID(node, rootName, source)] = true + } + } + case "call": + function := node.ChildByFieldName("function") + if function == nil { + return + } + text := nodeText(function, source) + rootName, method, ok := strings.Cut(text, ".") + if ok && (method == "append" || method == "extend" || method == "update" || + method == "insert" || method == "pop" || method == "clear") { + mutated[pythonQualifiedID(node, rootName, source)] = true + } + } + }) + for id, count := range counts { + if count != 1 || mutated[id] { + values[id] = nil + } + } + for _, resource := range resources { + id := resource.fields["id"].value.(string) + if mutated[id] { + markReadOnly(resource, "mutated constants are read-only") + } + } + return values, resources +} + +func pythonMutationRoot(node *sitter.Node, source []byte) string { + if node == nil { + return "" + } + text := nodeText(node, source) + if index := strings.IndexAny(text, ".["); index >= 0 { + text = text[:index] + } + return strings.TrimSpace(text) +} + +func pythonQualifiedID(node *sitter.Node, name string, source []byte) string { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + if parent.Kind() != "function_definition" && parent.Kind() != "class_definition" { + continue + } + scope := parent.ChildByFieldName("name") + if scope != nil { + return nodeText(scope, source) + "." + name + } + } + return name +} + +func constantResource(id string, value *sourceNode) *sourceNode { + fields := make(map[string]*sourceNode, len(value.fields)+1) + for key, child := range value.fields { + fields[key] = child + } + fields["id"] = &sourceNode{value: id} + return &sourceNode{ + value: value.value, fields: fields, items: value.items, collection: value.collection, + start: value.start, end: value.end, file: value.file, capability: value.capability, + reason: value.reason, code: value.code, encode: value.encode, + } +} + +func pythonKeywordFields(call *sitter.Node, source []byte, constants map[string]*sourceNode) map[string]*sourceNode { + fields := map[string]*sourceNode{} + arguments := call.ChildByFieldName("arguments") + for _, argument := range namedChildren(arguments) { + if argument.Kind() != "keyword_argument" { + continue + } + name := argument.ChildByFieldName("name") + value := argument.ChildByFieldName("value") + if name == nil || value == nil { + continue + } + key := nodeText(name, source) + if value.Kind() == "call" && key == "model_settings" && + strings.HasSuffix(nodeText(value.ChildByFieldName("function"), source), "ModelSettings") { + fields[key] = &sourceNode{fields: pythonKeywordFields(value, source, constants)} + continue + } + if key == "messages" { + fields[key] = pythonMessages(value, source, constants) + continue + } + fields[key] = pythonValue(value, source, constants) + } + return fields +} + +func pythonValue(node *sitter.Node, source []byte, constants map[string]*sourceNode) *sourceNode { + if node == nil { + return readOnlyNode(0, 0, "value is missing") + } + start, end := int(node.StartByte()), int(node.EndByte()) + switch node.Kind() { + case "string": + value, quote, err := decodePythonString(source[start:end]) + if err != nil { + return readOnlyNode(start, end, err.Error()) + } + return stringNode(value, start, end, func(text string) ([]byte, error) { + return encodePythonString(text, quote), nil + }) + case "integer", "float": + value, err := strconv.ParseFloat(string(source[start:end]), 64) + if err != nil { + return readOnlyNode(start, end, "number is not representable") + } + return numberNode(value, start, end, func(number float64) ([]byte, error) { + return []byte(strconv.FormatFloat(number, 'g', -1, 64)), nil + }) + case "true", "false": + value := node.Kind() == "true" + return &sourceNode{ + value: value, start: start, end: end, capability: CapabilityDirect, + encode: func(next any) ([]byte, error) { + value, ok := next.(bool) + if !ok { + return nil, mapTypeError("source", "a boolean") + } + if value { + return []byte("True"), nil + } + return []byte("False"), nil + }, + } + case "identifier": + switch string(source[start:end]) { + case "True", "False": + value := string(source[start:end]) == "True" + return &sourceNode{ + value: value, start: start, end: end, capability: CapabilityDirect, + encode: func(next any) ([]byte, error) { + value, ok := next.(bool) + if !ok { + return nil, mapTypeError("source", "a boolean") + } + if value { + return []byte("True"), nil + } + return []byte("False"), nil + }, + } + } + if constants != nil { + if value := constants[string(source[start:end])]; value != nil && value.end < start { + return value + } + if wildcard := constants["*"]; wildcard != nil { + value := *wildcard + value.reason = fmt.Sprintf("star import cannot resolve %s statically", nodeText(node, source)) + return &value + } + } + case "dictionary": + return pythonDictionary(node, source, constants) + case "list", "tuple": + return pythonSequence(node, source, constants) + } + return readOnlyNode(start, end, "dynamic expression has no safe inverse") +} + +func pythonDictionary(node *sitter.Node, source []byte, constants map[string]*sourceNode) *sourceNode { + start, end := int(node.StartByte()), int(node.EndByte()) + object := map[string]any{} + fields := map[string]*sourceNode{} + complete := true + for _, item := range namedChildren(node) { + if item.Kind() != "pair" { + fields[nodeText(item, source)] = readOnlyNode( + int(item.StartByte()), int(item.EndByte()), "dictionary entry is dynamic", + ) + continue + } + keyNode := item.ChildByFieldName("key") + valueNode := item.ChildByFieldName("value") + key := pythonValue(keyNode, source, constants) + text, ok := key.value.(string) + if !ok { + text = nodeText(keyNode, source) + } + value := pythonValue(valueNode, source, constants) + fields[text] = value + if value.value != nil { + object[text] = value.value + } else { + complete = false + } + } + result := &sourceNode{ + value: object, fields: fields, start: start, end: end, capability: CapabilityPatch, + reason: "dictionary changes require patch review", + encode: func(value any) ([]byte, error) { return json.Marshal(value) }, + } + if !complete { + result.value = nil + result.capability = CapabilityReadOnly + result.reason = "dictionary contains dynamic values" + result.encode = nil + } + return result +} + +func pythonSequence(node *sitter.Node, source []byte, constants map[string]*sourceNode) *sourceNode { + start, end := int(node.StartByte()), int(node.EndByte()) + values := make([]any, 0, node.NamedChildCount()) + items := make([]*sourceNode, 0, node.NamedChildCount()) + complete := true + for _, item := range namedChildren(node) { + value := pythonValue(item, source, constants) + items = append(items, value) + values = append(values, value.value) + if value.value == nil { + complete = false + } + } + result := &sourceNode{ + value: values, items: items, collection: true, start: start, end: end, capability: CapabilityPatch, + reason: "collection changes require patch review", + encode: func(value any) ([]byte, error) { return json.Marshal(value) }, + } + if !complete { + result.value = nil + result.capability = CapabilityReadOnly + result.reason = "collection contains dynamic values" + result.encode = nil + } + return result +} + +func pythonMessages(node *sitter.Node, source []byte, constants map[string]*sourceNode) *sourceNode { + if node != nil && node.Kind() == "identifier" && constants != nil { + if messages := messageListFromCollection(constants[nodeText(node, source)]); messages != nil { + return messages + } + } + if node == nil || node.Kind() != "list" && node.Kind() != "tuple" { + if node == nil { + return readOnlyNode(0, 0, "messages are missing") + } + return readOnlyNode(int(node.StartByte()), int(node.EndByte()), "messages are not a literal list") + } + start, end := int(node.StartByte()), int(node.EndByte()) + var items []*sourceNode + var messages []syncdomain.Message + complete := true + for _, child := range namedChildren(node) { + item := pythonMessage(child, source, constants) + items = append(items, item) + role, roleOK := item.fields["role"].value.(string) + content, contentOK := item.fields["content"].value.(string) + if !roleOK || !contentOK { + complete = false + continue + } + role = normalizeMessageRole(role) + messages = append(messages, syncdomain.Message{Role: role, Content: content}) + } + result := &sourceNode{ + items: items, collection: true, start: start, end: end, capability: CapabilityDirect, + reason: "message structure is read-only", + } + if complete { + result.value = messages + } else { + result.capability = CapabilityReadOnly + result.reason = "dynamic message content is read-only" + } + return result +} + +func normalizeMessageRole(role string) string { + switch role { + case "human": + return syncdomain.RoleUser + case "ai": + return syncdomain.RoleAssistant + default: + return role + } +} + +func pythonMessage(node *sitter.Node, source []byte, constants map[string]*sourceNode) *sourceNode { + fields := map[string]*sourceNode{} + switch node.Kind() { + case "tuple", "list": + children := namedChildren(node) + if len(children) == 2 { + fields["role"] = pythonValue(children[0], source, constants) + fields["content"] = pythonValue(children[1], source, constants) + } + case "dictionary": + dictionary := pythonDictionary(node, source, constants) + fields = dictionary.fields + case "call": + function := nodeText(node.ChildByFieldName("function"), source) + role := "" + switch { + case strings.HasSuffix(function, "SystemMessage"): + role = syncdomain.RoleSystem + case strings.HasSuffix(function, "HumanMessage"): + role = syncdomain.RoleUser + case strings.HasSuffix(function, "AIMessage"): + role = syncdomain.RoleAssistant + } + if role != "" { + fields["role"] = &sourceNode{value: role} + arguments := pythonPositionalArguments(node) + if len(arguments) != 0 { + fields["content"] = pythonValue(arguments[0], source, constants) + } else if content := pythonKeywordFields(node, source, constants)["content"]; content != nil { + fields["content"] = content + } + } + } + if fields["role"] == nil { + fields["role"] = readOnlyNode(int(node.StartByte()), int(node.EndByte()), "message role is not static") + } + if fields["content"] == nil { + fields["content"] = readOnlyNode(int(node.StartByte()), int(node.EndByte()), "message content is not static") + } + return &sourceNode{fields: fields} +} + +func pythonDecoratedCollection( + node *sitter.Node, + aliases, instances map[string]string, + source []byte, +) string { + for _, child := range namedChildren(node) { + if child.Kind() != "decorator" || child.NamedChildCount() == 0 { + continue + } + value := child.NamedChild(0) + if value.Kind() == "call" { + value = value.ChildByFieldName("function") + } + if value == nil { + continue + } + callable := nodeText(value, source) + if kind := aliases[callable]; kind == "tool-decorator" || kind == "kernel-tool-decorator" { + return "tools" + } + receiver, method, qualified := strings.Cut(callable, ".") + if !qualified { + continue + } + switch instances[receiver] { + case "fastmcp": + if method == "tool" { + return "tools" + } + if method == "prompt" { + return "prompts" + } + case "pydantic-agent": + if method == "tool" { + return "tools" + } + if method == "system_prompt" || method == "instructions" { + return "prompts" + } + } + } + return "" +} diff --git a/internal/sync/reference/python_frameworks.go b/internal/sync/reference/python_frameworks.go new file mode 100644 index 00000000..2a9384e7 --- /dev/null +++ b/internal/sync/reference/python_frameworks.go @@ -0,0 +1,204 @@ +package reference + +import ( + "strings" + + sitter "github.com/tree-sitter/go-tree-sitter" +) + +type pythonImportRule struct { + module string + symbols map[string]string +} + +var pythonFrameworkAllowlist = []pythonImportRule{ + {module: "agents", symbols: map[string]string{"Agent": "openai-agent", "function_tool": "tool-decorator"}}, + {module: "openai", symbols: map[string]string{"OpenAI": "openai-client"}}, + {module: "anthropic", symbols: map[string]string{"Anthropic": "anthropic-client"}}, + {module: "langchain_core.prompts", symbols: map[string]string{ + "PromptTemplate": "prompt-template", "ChatPromptTemplate": "chat-prompt", + }}, + {module: "langchain_core.tools", symbols: map[string]string{ + "tool": "tool-decorator", "StructuredTool": "structured-tool", + }}, + {module: "langgraph.prebuilt", symbols: map[string]string{"create_react_agent": "react-agent"}}, + {module: "crewai", symbols: map[string]string{"Agent": "crewai-agent", "Task": "crewai-task"}}, + {module: "pydantic_ai", symbols: map[string]string{"Agent": "pydantic-agent"}}, + {module: "google.adk.agents", symbols: map[string]string{"Agent": "adk-agent", "LlmAgent": "adk-agent"}}, + {module: "strands", symbols: map[string]string{"Agent": "strands-agent"}}, + {module: "strands.tools", symbols: map[string]string{"tool": "tool-decorator"}}, + {module: "autogen_agentchat.agents", symbols: map[string]string{"AssistantAgent": "autogen-agent"}}, + {module: "llama_index.core", symbols: map[string]string{"PromptTemplate": "prompt-template"}}, + {module: "llama_index.core.tools", symbols: map[string]string{"FunctionTool": "function-tool"}}, + {module: "llama_index.core.agent", symbols: map[string]string{"ReActAgent": "llama-react-agent"}}, + {module: "semantic_kernel.agents", symbols: map[string]string{"ChatCompletionAgent": "semantic-agent"}}, + {module: "semantic_kernel.functions", symbols: map[string]string{"kernel_function": "kernel-tool-decorator"}}, + {module: "mcp.server.fastmcp", symbols: map[string]string{"FastMCP": "fastmcp"}}, +} + +type pythonCallRule struct { + collection string + positional []string +} + +var pythonCallRules = map[string]pythonCallRule{ + "openai-agent": {collection: "agents", positional: []string{"name"}}, + "crewai-agent": {collection: "agents", positional: []string{"role", "goal", "backstory"}}, + "crewai-task": {collection: "prompts", positional: []string{"description", "expected_output"}}, + "pydantic-agent": {collection: "agents", positional: []string{"model"}}, + "adk-agent": {collection: "agents", positional: []string{"name", "model", "instruction", "description"}}, + "strands-agent": {collection: "agents", positional: []string{"system_prompt", "tools"}}, + "autogen-agent": {collection: "agents", positional: []string{"name"}}, + "semantic-agent": {collection: "agents", positional: []string{"name", "instructions"}}, + "react-agent": {collection: "agents", positional: []string{"model", "tools", "prompt"}}, + "llama-react-agent": {collection: "agents", positional: []string{"tools"}}, + "prompt": {collection: "prompts", positional: []string{"template"}}, + "function-tool": {collection: "tools", positional: []string{"function"}}, + "structured-tool": {collection: "tools", positional: []string{"function"}}, + "agent-as-tool": {collection: "tools"}, + "openai-request": {collection: "prompts"}, + "anthropic-request": {collection: "prompts"}, +} + +func pythonAllowedImports(module string) map[string]string { + for _, rule := range pythonFrameworkAllowlist { + if module == rule.module { + return rule.symbols + } + } + for _, rule := range pythonFrameworkAllowlist { + if strings.HasPrefix(module, rule.module+".") { + return rule.symbols + } + } + return nil +} + +func pythonInstances(root *sitter.Node, source []byte, aliases map[string]string) map[string]string { + instances := map[string]string{} + walkTree(root, func(node *sitter.Node) { + if node.Kind() != "assignment" { + return + } + name := node.ChildByFieldName("left") + value := node.ChildByFieldName("right") + if name == nil || value == nil || name.Kind() != "identifier" || value.Kind() != "call" { + return + } + if kind := aliases[nodeText(value.ChildByFieldName("function"), source)]; kind != "" { + instances[nodeText(name, source)] = kind + } + }) + return instances +} + +func pythonFrameworkCall( + call *sitter.Node, + id string, + aliases, instances map[string]string, + source []byte, + constants map[string]*sourceNode, +) (collection string, resource *sourceNode, ok bool) { + if call == nil || call.Kind() != "call" { + return "", nil, false + } + callable := nodeText(call.ChildByFieldName("function"), source) + kind := aliases[callable] + receiver, method, qualified := strings.Cut(callable, ".") + if qualified { + ownerKind := instances[receiver] + if ownerKind == "" { + ownerKind = aliases[receiver] + } + switch ownerKind { + case "openai-client": + if method == "chat.completions.create" || method == "responses.create" { + kind = "openai-request" + } + case "anthropic-client": + if method == "messages.create" { + kind = "anthropic-request" + } + case "openai-agent", "pydantic-agent": + if method == "as_tool" { + kind = "agent-as-tool" + } + case "function-tool": + if method == "from_defaults" { + kind = "function-tool" + } + case "structured-tool": + if method == "from_function" { + kind = "structured-tool" + } + case "llama-react-agent": + if method == "from_tools" { + kind = "llama-react-agent" + } + } + } + if kind == "" || isPythonPromptKind(kind) || strings.Contains(kind, "decorator") || + kind == "openai-client" || kind == "anthropic-client" || kind == "fastmcp" { + return "", nil, false + } + fields := pythonKeywordFields(call, source, constants) + positional := pythonPositionalArguments(call) + rule := pythonCallRules[kind] + for index, name := range rule.positional { + if index < len(positional) { + fields[name] = pythonValue(positional[index], source, constants) + } + } + switch kind { + case "openai-request": + rule.collection = "prompts" + case "anthropic-request": + rule.collection = "prompts" + case "agent-as-tool", "function-tool", "structured-tool": + rule.collection = "tools" + } + if rule.collection == "" { + return "", nil, false + } + switch rule.collection { + case "agents": + fields = translateFieldPlaceholders( + fields, bracePlaceholder, + "instructions", "instruction", "prompt", "system_prompt", "system_message", "goal", "backstory", + ) + case "prompts": + fields = translateFieldPlaceholders( + fields, bracePlaceholder, + "instructions", "prompt", "system", "system_prompt", "description", "messages", + ) + } + if name := staticSourceString(fields["name"]); name != "" && id == "" { + id = name + } + if name := staticSourceString(fields["tool_name"]); name != "" && id == "" { + id = name + } + if id == "" { + id = pythonQualifiedCallID(call, callable, source) + } + return rule.collection, logicalResource(id, fields), true +} + +func pythonQualifiedCallID(call *sitter.Node, callable string, source []byte) string { + for parent := call.Parent(); parent != nil; parent = parent.Parent() { + if parent.Kind() == "function_definition" { + if name := parent.ChildByFieldName("name"); name != nil { + return nodeText(name, source) + "." + callable + } + } + } + return callable +} + +func staticSourceString(node *sourceNode) string { + if node == nil { + return "" + } + value, _ := node.value.(string) + return value +} diff --git a/internal/sync/reference/source_test.go b/internal/sync/reference/source_test.go index 73dbc2ad..d86431ea 100644 --- a/internal/sync/reference/source_test.go +++ b/internal/sync/reference/source_test.go @@ -2,6 +2,7 @@ package reference import ( "testing" + "unicode/utf8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -118,3 +119,20 @@ func FuzzPathParserNeverPanics(f *testing.F) { _ = ParsePath(path) }) } + +func FuzzPythonStringRoundTrip(f *testing.F) { + for _, value := range []string{"plain", "quotes ' \" and slash \\", "line one\nline two", "Unicode 🌍"} { + f.Add(value) + } + f.Fuzz(func(t *testing.T, value string) { + if !utf8.ValidString(value) { + t.Skip() + } + for _, quote := range []string{"'", `"`, `"""`} { + encoded := encodePythonString(value, quote) + decoded, _, err := decodePythonString(encoded) + require.NoError(t, err) + require.Equal(t, value, decoded) + } + }) +} diff --git a/internal/sync/reference/testdata/imports/python/app/agent.py b/internal/sync/reference/testdata/imports/python/app/agent.py new file mode 100644 index 00000000..18574dd8 --- /dev/null +++ b/internal/sync/reference/testdata/imports/python/app/agent.py @@ -0,0 +1,8 @@ +from agents import Agent +from .prompts import SUPPORT_PROMPT + +support = Agent( + name="Support", + instructions=SUPPORT_PROMPT, + model="gpt-5", +) diff --git a/internal/sync/reference/testdata/imports/python/app/prompts.py b/internal/sync/reference/testdata/imports/python/app/prompts.py new file mode 100644 index 00000000..598d6533 --- /dev/null +++ b/internal/sync/reference/testdata/imports/python/app/prompts.py @@ -0,0 +1 @@ +SUPPORT_PROMPT = "Ask for an order ID before discussing an order." diff --git a/internal/sync/reference/testdata/python/agents.py b/internal/sync/reference/testdata/python/agents.py new file mode 100644 index 00000000..373e5111 --- /dev/null +++ b/internal/sync/reference/testdata/python/agents.py @@ -0,0 +1,22 @@ +from agents import Agent, ModelSettings +from langchain_core.prompts import PromptTemplate + +# Shared text stays in this module. +SUMMARY = """Summarize the support case. +Keep the answer short.""" + +support = Agent( + name="Support", + instructions="Ask for an order ID before discussing an order.", + model="gpt-5", + model_settings=ModelSettings(temperature=0.2), +) + +refunds = Agent( + name='Refunds', + instructions='Explain the refund policy.', + model='gpt-5', + model_settings=ModelSettings(temperature=0.1), +) + +summary = PromptTemplate(template=SUMMARY) diff --git a/internal/sync/reference/testdata/python/frameworks_tier1.py b/internal/sync/reference/testdata/python/frameworks_tier1.py new file mode 100644 index 00000000..db41aaac --- /dev/null +++ b/internal/sync/reference/testdata/python/frameworks_tier1.py @@ -0,0 +1,81 @@ +from anthropic import Anthropic +from agents import Agent, function_tool +from langchain_core.tools import StructuredTool, tool +from langgraph.prebuilt import create_react_agent +from mcp.server.fastmcp import FastMCP +from openai import OpenAI + +openai = OpenAI() +anthropic = Anthropic() +mcp = FastMCP("support") + +openai_chat = openai.chat.completions.create( + model="gpt-5", + messages=[{"role": "system", "content": "Answer support questions."}], + tools=[{"type": "function", "name": "lookup"}], +) + +openai_response = openai.responses.create( + model="gpt-5", + instructions="Answer in one paragraph.", +) + +anthropic_message = anthropic.messages.create( + model="claude-sonnet", + system="Follow the support policy.", + messages=[{"role": "user", "content": "Help with an order."}], + tools=[{"name": "lookup", "description": "Look up an order.", "input_schema": {"type": "object"}}], +) + +support_agent = Agent( + name="Support", + instructions="Ask for an order ID.", + model="gpt-5", +) + +react_agent = create_react_agent( + "gpt-5", + [lookup_order], + prompt="Use the support tools.", +) + + +@function_tool +def lookup_order(order_id: str) -> str: + """Look up one order.""" + return order_id + + +@tool +def refund_order(order_id: str) -> str: + """Refund one order.""" + return order_id + + +structured = StructuredTool.from_function( + lookup_order, + name="structured_lookup", + description="Look up one order with a structured tool.", +) + +support_tool = support_agent.as_tool( + tool_name="support_tool", + tool_description="Ask the support agent.", +) + + +@mcp.tool() +def mcp_lookup(order_id: str) -> str: + """Look up one order through MCP.""" + return order_id + + +@mcp.prompt() +def mcp_prompt(topic: str) -> str: + """Build one support prompt.""" + return f"Explain {topic}" + + +def bare_calls(): + openai.responses.create(model="gpt-5", instructions="Bare response prompt.") + anthropic.messages.create(model="claude-sonnet", system="Bare Anthropic prompt.", messages=[]) diff --git a/internal/sync/reference/testdata/python/frameworks_tier2.py b/internal/sync/reference/testdata/python/frameworks_tier2.py new file mode 100644 index 00000000..2b7eb423 --- /dev/null +++ b/internal/sync/reference/testdata/python/frameworks_tier2.py @@ -0,0 +1,83 @@ +from autogen_agentchat.agents import AssistantAgent +from crewai import Agent as CrewAgent, Task +from google.adk.agents import Agent as ADKAgent, LlmAgent +from llama_index.core import PromptTemplate +from llama_index.core.agent import ReActAgent +from llama_index.core.tools import FunctionTool +from pydantic_ai import Agent as PydanticAgent +from semantic_kernel.agents import ChatCompletionAgent +from semantic_kernel.functions import kernel_function +from strands import Agent as StrandsAgent +from strands.tools import tool + +crew_researcher = CrewAgent( + role="Support researcher", + goal="Find the relevant support policy.", + backstory="You maintain support documentation.", +) +crew_task = Task( + description="Research the refund request.", + expected_output="A policy summary.", +) + +pydantic_agent = PydanticAgent( + "openai:gpt-5", + system_prompt="Follow the support policy.", + instructions="Ask for an order ID.", +) + +adk_agent = ADKAgent( + name="support", + model="gemini-2.5-pro", + instruction="Ask for an order ID.", + description="Support agent", +) +llm_agent = LlmAgent("refunds", "gemini-2.5-pro", "Explain refunds.", "Refund agent") + +strands_agent = StrandsAgent( + system_prompt="Follow the support policy.", + tools=[lookup_order], +) + +autogen_agent = AssistantAgent( + name="support", + system_message="Ask for an order ID.", +) + +llama_prompt = PromptTemplate("Explain {topic}.") +llama_tool = FunctionTool.from_defaults( + lookup_order, + name="llama_lookup", + description="Look up one order.", +) +llama_agent = ReActAgent.from_tools( + [lookup_order], + system_prompt="Use the support tools.", +) + +semantic_agent = ChatCompletionAgent( + name="support", + instructions="Ask for an order ID.", +) + + +@pydantic_agent.tool +def pydantic_lookup(order_id: str) -> str: + """Look up one order with Pydantic AI.""" + return order_id + + +@pydantic_agent.system_prompt +def pydantic_system_prompt() -> str: + return "Built at runtime." + + +@tool +def strands_lookup(order_id: str) -> str: + """Look up one order with Strands.""" + return order_id + + +@kernel_function(description="Look up one order with Semantic Kernel.") +def semantic_lookup(order_id: str) -> str: + return order_id diff --git a/internal/sync/reference/testdata/python/langchain_prompts.py b/internal/sync/reference/testdata/python/langchain_prompts.py new file mode 100644 index 00000000..8ad724f7 --- /dev/null +++ b/internal/sync/reference/testdata/python/langchain_prompts.py @@ -0,0 +1,37 @@ +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.prompts import ChatPromptTemplate, PromptTemplate +from langchain_core.tools import tool + +SHARED_MESSAGES = [ + ("system", "Use shared instructions."), + ("human", "{question}"), +] + +chat = ChatPromptTemplate.from_messages([ + ("system", "You route support requests."), + ("human", "Help with {request}."), +]) +shared_chat = ChatPromptTemplate.from_messages(SHARED_MESSAGES) + +chat_constructor = ChatPromptTemplate([ + SystemMessage("You summarize support cases."), + HumanMessage("Summarize {case}."), +]) + +simple = PromptTemplate.from_template("Explain {topic}.") +simple_keyword = PromptTemplate(template="Answer in one paragraph.") +chain = ChatPromptTemplate.from_messages([ + ("system", "Use the company policy."), + ("human", "{question}"), +]) | llm + +ambiguous = ( + ChatPromptTemplate.from_messages([("system", "First.")]) + | ChatPromptTemplate.from_messages([("system", "Second.")]) +) + + +@tool +def lookup_order(order_id: str) -> str: + """Look up one order and return its status.""" + return order_id diff --git a/internal/sync/reference/testdata/python/messages.py b/internal/sync/reference/testdata/python/messages.py new file mode 100644 index 00000000..32f2202e --- /dev/null +++ b/internal/sync/reference/testdata/python/messages.py @@ -0,0 +1,11 @@ +from agents import Agent + +completion = Agent( + name="Completion", + messages=[ + {"role": "system", "content": "You answer support questions."}, + {"role": "user", "content": "Where is order {order_id}?"}, + {"role": "assistant", "content": "I will look up the order."}, + ], + model="gpt-5", +) diff --git a/internal/sync/reference/testdata/python/settings.py b/internal/sync/reference/testdata/python/settings.py new file mode 100644 index 00000000..c7021dbe --- /dev/null +++ b/internal/sync/reference/testdata/python/settings.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from enum import Enum + +from app.models import DEFAULT_MODEL + + +class Language(Enum): + EN = "en" + FR = "fr" + + +@dataclass +class SupportSettings: + instructions = """Ask for an order ID. +Keep the answer concise.""" + temperature = 0.2 + enabled = True + + +CONFIG = { + "agents": { + "support": { + "prompt": f"Use {DEFAULT_MODEL} for support.", + "model": "gpt-5", + }, + "refunds": { + "prompt": "Explain the refund policy.", + "model": "gpt-5", + }, + } +} + +PROMPTS = { + Language.EN: "Answer in English.", + Language.FR: "Answer in French.", +} + +MODELS = ["gpt-5", 'claude-sonnet'] + + +def build(): + prompt = "Function-local prompt." + retries = 3 + return prompt, retries