Repository navigation
fix: exclude expanded tools from prompt diffs #957
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ece353a
cd1cd8e
f001a10
4e0e87b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import ( | |
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "maps" | ||
| "net/http" | ||
| "net/url" | ||
| "os" | ||
|
|
@@ -38,6 +39,9 @@ type directAPI struct { | |
| skills map[string]versionedSkill | ||
| manifest *syncapi.SyncManifest | ||
| requests []string | ||
| // expandTools makes each read of the variation add the content of its | ||
| // tools to model.parameters.tools, as LaunchDarkly does. | ||
| expandTools bool | ||
| } | ||
|
|
||
| type notFoundAPI struct{} | ||
|
|
@@ -152,8 +156,12 @@ func (api *directAPI) MakeRequest( | |
| case "GET": | ||
| variations := []directAPIVariation{} | ||
| if api.variation != nil { | ||
| served := *api.variation | ||
| if api.expandTools && len(served.Tools) != 0 { | ||
| served.Model = api.expandedModel(served) | ||
| } | ||
| variations = append(variations, directAPIVariation{ | ||
| Variation: *api.variation, | ||
| Variation: served, | ||
| State: api.variationState, | ||
| }) | ||
| } | ||
|
|
@@ -222,6 +230,34 @@ func (*directAPI) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, er | |
| return nil, nil | ||
| } | ||
|
|
||
| // expandedModel returns the model of the variation with the content of each | ||
| // of its tools in model.parameters.tools. | ||
| func (api *directAPI) expandedModel(variation syncdomain.Variation) map[string]any { | ||
| model := maps.Clone(variation.Model) | ||
| if model == nil { | ||
| model = map[string]any{} | ||
| } | ||
| parameters, _ := model["parameters"].(map[string]any) | ||
| parameters = maps.Clone(parameters) | ||
| if parameters == nil { | ||
| parameters = map[string]any{} | ||
| } | ||
| expanded := make([]any, 0, len(variation.Tools)) | ||
| for _, ref := range variation.Tools { | ||
| tool := api.tools[ref.Key] | ||
| description := "" | ||
| if tool.Description != nil { | ||
| description = *tool.Description | ||
| } | ||
| expanded = append(expanded, map[string]any{ | ||
| "type": "function", "name": tool.Key, "description": description, "parameters": tool.Schema, | ||
| }) | ||
| } | ||
| parameters["tools"] = expanded | ||
| model["parameters"] = parameters | ||
| return model | ||
| } | ||
|
|
||
| func TestPromptDryRunUsesOnlyExistingReadAPI(t *testing.T) { | ||
| root := initRepository(t) | ||
| baseline := variation("Baseline") | ||
|
|
@@ -272,6 +308,137 @@ func TestPromptFirstSyncAdoptsMatchingStateWithoutMutation(t *testing.T) { | |
| requireOnlyReads(t, api.requests) | ||
| } | ||
|
|
||
| func TestPromptDiffExcludesExpandedToolsFromModelParameters(t *testing.T) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems to cover only pull. think it would be worth it for a push test case? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call, added |
||
| root := initRepository(t) | ||
| description := "Search the documentation" | ||
| tool := syncdomain.Tool{ | ||
| Key: "search", | ||
| Description: &description, | ||
| Schema: map[string]any{"type": "object"}, | ||
| } | ||
| local := variation("Support") | ||
| local.Instructions = "Use the local instructions." | ||
| local.ModelConfigKey = "claude" | ||
| local.ModelConfigVersion = 2 | ||
| local.Model = map[string]any{ | ||
| "modelName": "claude-sonnet-4", | ||
| "parameters": map[string]any{ | ||
| "temperature": 0.2, | ||
| }, | ||
| } | ||
| local.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 3}} | ||
| local.Attachments = []syncdomain.Attachment{toolAttachment(tool, 3)} | ||
| writeVariation(t, root, local, false) | ||
| writeManifest(t, root, local) | ||
|
|
||
| server := local | ||
| server.Instructions = "Use the LaunchDarkly instructions." | ||
| server.Attachments = nil | ||
| server.Model = map[string]any{ | ||
| "modelName": "claude-sonnet-4", | ||
| "parameters": map[string]any{ | ||
| "temperature": 0.2, | ||
| "tools": []any{map[string]any{ | ||
| "type": "function", | ||
| "name": "search", | ||
| "description": description, | ||
| "parameters": map[string]any{"type": "object"}, | ||
| }}, | ||
| }, | ||
| } | ||
| api := &directAPI{ | ||
| variation: &server, | ||
| tools: map[string]versionedTool{ | ||
| "search": {Tool: tool, Version: 3}, | ||
| }, | ||
| } | ||
|
|
||
| stdout, _, err := runPrompt(t, root, api, "--dry-run", "--diff", "--output", "plaintext") | ||
|
|
||
| require.NoError(t, err) | ||
| assert.Contains(t, stdout, "instructions changed in LaunchDarkly") | ||
| assert.NotContains(t, stdout, "model changed in LaunchDarkly") | ||
| assert.NotContains(t, stdout, `"type": "function"`) | ||
| assert.NotContains(t, stdout, `Tool "search"`) | ||
|
|
||
| _, _, err = runPrompt(t, root, api, "--yes") | ||
| require.NoError(t, err) | ||
| resources, err := synclocal.CompileWorkspace(root) | ||
| require.NoError(t, err) | ||
| require.Len(t, resources, 1) | ||
| assert.Equal(t, map[string]any{ | ||
| "modelName": "claude-sonnet-4", | ||
| "parameters": map[string]any{ | ||
| "temperature": 0.2, | ||
| }, | ||
| }, resources[0].Variation.Model) | ||
| assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search"}}, resources[0].Variation.Tools) | ||
| } | ||
|
|
||
| // A push of a local change also ignores the expanded tools that LaunchDarkly | ||
| // returns, sends only the local model, and leaves both sides in sync. | ||
| func TestPromptPushExcludesExpandedToolsFromModelParameters(t *testing.T) { | ||
| root := initRepository(t) | ||
| description := "Search the documentation" | ||
| tool := syncdomain.Tool{Key: "search", Description: &description, Schema: map[string]any{"type": "object"}} | ||
| base := variation("Support") | ||
| base.Instructions = "Use the LaunchDarkly instructions." | ||
| base.ModelConfigKey = "claude" | ||
| base.ModelConfigVersion = 2 | ||
| base.Model = map[string]any{"modelName": "claude-sonnet-4", "parameters": map[string]any{"temperature": 0.2}} | ||
| base.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 3}} | ||
| base.Attachments = []syncdomain.Attachment{toolAttachment(tool, 3)} | ||
| local := base | ||
| local.Instructions = "Use the local instructions." | ||
| writeVariation(t, root, local, false) | ||
| writeManifest(t, root, base) | ||
|
|
||
| server := base | ||
| server.Attachments = nil | ||
| api := &directAPI{variation: &server, tools: map[string]versionedTool{"search": {Tool: tool, Version: 3}}, expandTools: true} | ||
|
|
||
| stdout, _, err := runPrompt(t, root, api, "--dry-run", "--diff", "--output", "plaintext") | ||
|
|
||
| require.NoError(t, err) | ||
| assert.Contains(t, stdout, "instructions changed") | ||
| assert.NotContains(t, stdout, "model changed") | ||
| assert.NotContains(t, stdout, `"type": "function"`) | ||
|
|
||
| _, _, err = runPrompt(t, root, api, "--yes") | ||
| require.NoError(t, err) | ||
| assert.Equal(t, local.Instructions, api.variation.Instructions) | ||
| assert.Equal(t, base.Model, api.variation.Model, "the push sends the local model without expanded tools") | ||
|
|
||
| stdout, _, err = runPrompt(t, root, api, "--dry-run", "--output", "plaintext") | ||
| require.NoError(t, err) | ||
| assert.Contains(t, stdout, "No changes.") | ||
| } | ||
|
|
||
| // LaunchDarkly keeps model parameters and custom values only as objects, so | ||
| // sync rejects any other value before a write. | ||
| func TestPromptRejectsModelValuesThatAreNotObjects(t *testing.T) { | ||
| for name, test := range map[string]struct { | ||
| model string | ||
| want string | ||
| }{ | ||
| "parameters": {" parameters: temperature=0.2\n", "model.parameters must be an object"}, | ||
| "empty parameters": {" parameters:\n", "model.parameters must be an object"}, | ||
| "custom": {" custom: [retries]\n", "model.custom must be an object"}, | ||
| } { | ||
| t.Run(name, func(t *testing.T) { | ||
| root := initRepository(t) | ||
| writeLocalFile(t, root, "configs/support/default.prompt.md", "---\nformatVersion: 1\nupsert: true\n"+ | ||
| "mode: agent\nkey: default\nname: Default\nmodel:\n modelName: gpt-5\n"+test.model+"---\n\nHelp.\n") | ||
| api := &directAPI{} | ||
|
|
||
| _, _, err := runPrompt(t, root, api, "--yes") | ||
|
|
||
| require.ErrorContains(t, err, test.want) | ||
| requireOnlyReads(t, api.requests) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestPromptFirstSyncCreatesUpsertVariation(t *testing.T) { | ||
| root := initRepository(t) | ||
| local := variation("New") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should validate user's input parameters as an object here I think.
In case someone does:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. Your example synced without an error, and ldcli sent
"parameters": "temperature=0.2"as a string. LaunchDarkly does not keep a non-object value there, so the setting was lost.Variation.Validatenow rejects a non-objectmodel.parametersormodel.customwithmodel.parameters must be an object(ormodel.custom ...) before any write.TestPromptRejectsModelValuesThatAreNotObjectscovers your example, an emptyparameters:, and a list incustom, and it failed before the fix. Added in f001a10.