Fix empty system prompt override (#143)
* Fix empty system prompt override * Complete empty system prompt overrides * Remove duplicate system prompt test --------- Co-authored-by: patriceckhart <mail@patriceckhart.com>
- commit
- 2ddba2d152dbb7053a78a3bcbfe43ec23acf64ed
- author
- Neal Lin · · 2026-08-13 14:47 UTC
- committer
- GitHub · · 2026-08-13 14:47 UTC
- parent
- 9fa3d04
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.3.42
11 files · +129 -31
M README.md +2 -2
| @@ -148,7 +148,7 @@ $ZOT_HOME/ | |||
| 148 | 148 | └── logs/ # app log files | |
| 149 | 149 | ``` | |
| 150 | 150 | ||
| 151 | - | Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity and zot-docs guidance for every run. `--system-prompt` still wins per-invocation. Custom prompts still receive appended instructions and generated context, including `AGENTS.md`, skills, auto-swarm guidance when enabled, and the date/cwd footer. Delete the file to revert to the default. | |
| 151 | + | Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity and zot-docs guidance for every run. `--system-prompt` still wins per-invocation. Pass an empty value (`--system-prompt ""`) to intentionally omit the built-in identity. Custom prompts still receive appended instructions and generated context, including `AGENTS.md`, skills, auto-swarm guidance when enabled, and the date/cwd footer. Delete the file to revert to the default. | |
| 152 | 152 | ||
| 153 | 153 | ### HTTP proxy | |
| 154 | 154 | ||
| @@ -222,7 +222,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 222 | 222 | | `--api-key <key>` | Override the API key. | | |
| 223 | 223 | | `--base-url <url>` | Override the provider base URL (tests, self-hosted). | | |
| 224 | 224 | | `--insecure` | Skip TLS certificate verification for the explicit `--base-url` endpoint or a `baseUrl` defined for a user model in `models.json` (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. | | |
| 225 | - | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | |
| 225 | + | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`; pass `""` for no built-in identity). | | |
| 226 | 226 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | |
| 227 | 227 | | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. | | |
| 228 | 228 | | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. | | |
| @@ -148,7 +148,7 @@ $ZOT_HOME/ | |||||
| 148 | └── logs/ # app log files | 148 | └── logs/ # app log files | ||
| 149 | ``` | 149 | ``` | ||
| 150 | 150 | ||||
| 151 | - | Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity and zot-docs guidance for every run. `--system-prompt` still wins per-invocation. Custom prompts still receive appended instructions and generated context, including `AGENTS.md`, skills, auto-swarm guidance when enabled, and the date/cwd footer. Delete the file to revert to the default. | 151 | + | Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity and zot-docs guidance for every run. `--system-prompt` still wins per-invocation. Pass an empty value (`--system-prompt ""`) to intentionally omit the built-in identity. Custom prompts still receive appended instructions and generated context, including `AGENTS.md`, skills, auto-swarm guidance when enabled, and the date/cwd footer. Delete the file to revert to the default. |
| 152 | 152 | ||||
| 153 | ### HTTP proxy | 153 | ### HTTP proxy | ||
| 154 | 154 | ||||
| @@ -222,7 +222,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 222 | | `--api-key <key>` | Override the API key. | | 222 | | `--api-key <key>` | Override the API key. | | ||
| 223 | | `--base-url <url>` | Override the provider base URL (tests, self-hosted). | | 223 | | `--base-url <url>` | Override the provider base URL (tests, self-hosted). | | ||
| 224 | | `--insecure` | Skip TLS certificate verification for the explicit `--base-url` endpoint or a `baseUrl` defined for a user model in `models.json` (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. | | 224 | | `--insecure` | Skip TLS certificate verification for the explicit `--base-url` endpoint or a `baseUrl` defined for a user model in `models.json` (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. | | ||
| 225 | - | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | 225 | + | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`; pass `""` for no built-in identity). | |
| 226 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | 226 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | ||
| 227 | | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. | | 227 | | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. | | ||
| 228 | | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. | | 228 | | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. | | ||
M docs/zotfiles.md +1 -1
| @@ -115,7 +115,7 @@ Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the r | |||
| 115 | 115 | } | |
| 116 | 116 | ``` | |
| 117 | 117 | ||
| 118 | - | Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. | |
| 118 | + | Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. With replacement enabled, an empty `AGENT.md` intentionally omits the built-in identity while retaining generated context. | |
| 119 | 119 | ||
| 120 | 120 | ## Bundled skills | |
| 121 | 121 | ||
| @@ -115,7 +115,7 @@ Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the r | |||||
| 115 | } | 115 | } | ||
| 116 | ``` | 116 | ``` | ||
| 117 | 117 | ||||
| 118 | - | Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. | 118 | + | Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. With replacement enabled, an empty `AGENT.md` intentionally omits the built-in identity while retaining generated context. |
| 119 | 119 | ||||
| 120 | ## Bundled skills | 120 | ## Bundled skills | ||
| 121 | 121 | ||||
M packages/agent/args.go +6 -2
| @@ -42,8 +42,11 @@ type Args struct { | |||
| 42 | 42 | inheritedAuthMethod string | |
| 43 | 43 | inheritedAccountID string | |
| 44 | 44 | ||
| 45 | - | BaseURL string // override provider base URL (for tests/self-hosted) | |
| 46 | - | SystemPrompt string | |
| 45 | + | BaseURL string // override provider base URL (for tests/self-hosted) | |
| 46 | + | SystemPrompt string | |
| 47 | + | // SystemPromptSet records whether --system-prompt was provided. An empty | |
| 48 | + | // value is an intentional override, distinct from leaving the flag absent. | |
| 49 | + | SystemPromptSet bool | |
| 47 | 50 | AppendSystemPrompt []string | |
| 48 | 51 | Reasoning string | |
| 49 | 52 | Temperature *float32 | |
| @@ -202,6 +205,7 @@ func ParseArgs(in []string) (Args, error) { | |||
| 202 | 205 | return a, err | |
| 203 | 206 | } | |
| 204 | 207 | a.SystemPrompt = v | |
| 208 | + | a.SystemPromptSet = true | |
| 205 | 209 | case "--append-system-prompt": | |
| 206 | 210 | v, err := want(&i, arg) | |
| 207 | 211 | if err != nil { | |
| @@ -42,8 +42,11 @@ type Args struct { | |||||
| 42 | inheritedAuthMethod string | 42 | inheritedAuthMethod string | ||
| 43 | inheritedAccountID string | 43 | inheritedAccountID string | ||
| 44 | 44 | ||||
| 45 | - | BaseURL string // override provider base URL (for tests/self-hosted) | 45 | + | BaseURL string // override provider base URL (for tests/self-hosted) |
| 46 | - | SystemPrompt string | 46 | + | SystemPrompt string |
| 47 | + | // SystemPromptSet records whether --system-prompt was provided. An empty | |||
| 48 | + | // value is an intentional override, distinct from leaving the flag absent. | |||
| 49 | + | SystemPromptSet bool | |||
| 47 | AppendSystemPrompt []string | 50 | AppendSystemPrompt []string | ||
| 48 | Reasoning string | 51 | Reasoning string | ||
| 49 | Temperature *float32 | 52 | Temperature *float32 | ||
| @@ -202,6 +205,7 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 202 | return a, err | 205 | return a, err | ||
| 203 | } | 206 | } | ||
| 204 | a.SystemPrompt = v | 207 | a.SystemPrompt = v | ||
| 208 | + | a.SystemPromptSet = true | |||
| 205 | case "--append-system-prompt": | 209 | case "--append-system-prompt": | ||
| 206 | v, err := want(&i, arg) | 210 | v, err := want(&i, arg) | ||
| 207 | if err != nil { | 211 | if err != nil { | ||
M packages/agent/args_test.go +13 -0
| @@ -2,6 +2,19 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | + | func TestParseArgsExplicitEmptySystemPromptIsSet(t *testing.T) { | |
| 6 | + | args, err := ParseArgs([]string{"--system-prompt", ""}) | |
| 7 | + | if err != nil { | |
| 8 | + | t.Fatalf("ParseArgs returned %v", err) | |
| 9 | + | } | |
| 10 | + | if !args.SystemPromptSet { | |
| 11 | + | t.Fatal("SystemPromptSet = false; want true for an explicitly empty flag value") | |
| 12 | + | } | |
| 13 | + | if args.SystemPrompt != "" { | |
| 14 | + | t.Fatalf("SystemPrompt = %q; want empty", args.SystemPrompt) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 5 | 18 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | |
| 6 | 19 | args, err := ParseArgs([]string{"--temperature", "0"}) | |
| 7 | 20 | if err != nil { | |
| @@ -2,6 +2,19 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | + | func TestParseArgsExplicitEmptySystemPromptIsSet(t *testing.T) { | |||
| 6 | + | args, err := ParseArgs([]string{"--system-prompt", ""}) | |||
| 7 | + | if err != nil { | |||
| 8 | + | t.Fatalf("ParseArgs returned %v", err) | |||
| 9 | + | } | |||
| 10 | + | if !args.SystemPromptSet { | |||
| 11 | + | t.Fatal("SystemPromptSet = false; want true for an explicitly empty flag value") | |||
| 12 | + | } | |||
| 13 | + | if args.SystemPrompt != "" { | |||
| 14 | + | t.Fatalf("SystemPrompt = %q; want empty", args.SystemPrompt) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 5 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | 18 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | ||
| 6 | args, err := ParseArgs([]string{"--temperature", "0"}) | 19 | args, err := ParseArgs([]string{"--temperature", "0"}) | ||
| 7 | if err != nil { | 20 | if err != nil { | ||
M packages/agent/build.go +7 -1
| @@ -63,6 +63,7 @@ type Resolved struct { | |||
| 63 | 63 | // resolve. | |
| 64 | 64 | systemAppend []string | |
| 65 | 65 | systemCustom string | |
| 66 | + | systemCustomSet bool | |
| 66 | 67 | toolDescriptions map[string]string | |
| 67 | 68 | } | |
| 68 | 69 | ||
| @@ -102,6 +103,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||
| 102 | 103 | CWD: r.CWD, | |
| 103 | 104 | Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions), | |
| 104 | 105 | Custom: r.systemCustom, | |
| 106 | + | CustomSet: r.systemCustomSet, | |
| 105 | 107 | Append: append_, | |
| 106 | 108 | ZotDocsDir: filepath.Join(ZotHome(), "docs"), | |
| 107 | 109 | }) | |
| @@ -619,14 +621,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 619 | 621 | // 2. $ZOT_HOME/SYSTEM.md (persistent user override) | |
| 620 | 622 | // 3. built-in default (defaultIdentity + defaultGuidelines) | |
| 621 | 623 | custom := args.SystemPrompt | |
| 622 | - | if custom == "" { | |
| 624 | + | customSet := args.SystemPromptSet || custom != "" | |
| 625 | + | if !customSet { | |
| 623 | 626 | custom = readUserSystemPrompt(ZotHome()) | |
| 627 | + | customSet = custom != "" | |
| 624 | 628 | } | |
| 625 | 629 | ||
| 626 | 630 | sys := BuildSystemPrompt(SystemPromptOpts{ | |
| 627 | 631 | CWD: args.CWD, | |
| 628 | 632 | Tools: summaries, | |
| 629 | 633 | Custom: custom, | |
| 634 | + | CustomSet: customSet, | |
| 630 | 635 | Append: append_, | |
| 631 | 636 | ZotDocsDir: docsDir, | |
| 632 | 637 | }) | |
| @@ -660,6 +665,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 660 | 665 | ContextFiles: contextFiles, | |
| 661 | 666 | systemAppend: append_, | |
| 662 | 667 | systemCustom: custom, | |
| 668 | + | systemCustomSet: customSet, | |
| 663 | 669 | toolDescriptions: descMapFromSummaries(summaries), | |
| 664 | 670 | }, nil | |
| 665 | 671 | } | |
| @@ -63,6 +63,7 @@ type Resolved struct { | |||||
| 63 | // resolve. | 63 | // resolve. | ||
| 64 | systemAppend []string | 64 | systemAppend []string | ||
| 65 | systemCustom string | 65 | systemCustom string | ||
| 66 | + | systemCustomSet bool | |||
| 66 | toolDescriptions map[string]string | 67 | toolDescriptions map[string]string | ||
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| @@ -102,6 +103,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||||
| 102 | CWD: r.CWD, | 103 | CWD: r.CWD, | ||
| 103 | Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions), | 104 | Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions), | ||
| 104 | Custom: r.systemCustom, | 105 | Custom: r.systemCustom, | ||
| 106 | + | CustomSet: r.systemCustomSet, | |||
| 105 | Append: append_, | 107 | Append: append_, | ||
| 106 | ZotDocsDir: filepath.Join(ZotHome(), "docs"), | 108 | ZotDocsDir: filepath.Join(ZotHome(), "docs"), | ||
| 107 | }) | 109 | }) | ||
| @@ -619,14 +621,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 619 | // 2. $ZOT_HOME/SYSTEM.md (persistent user override) | 621 | // 2. $ZOT_HOME/SYSTEM.md (persistent user override) | ||
| 620 | // 3. built-in default (defaultIdentity + defaultGuidelines) | 622 | // 3. built-in default (defaultIdentity + defaultGuidelines) | ||
| 621 | custom := args.SystemPrompt | 623 | custom := args.SystemPrompt | ||
| 622 | - | if custom == "" { | 624 | + | customSet := args.SystemPromptSet || custom != "" |
| 625 | + | if !customSet { | |||
| 623 | custom = readUserSystemPrompt(ZotHome()) | 626 | custom = readUserSystemPrompt(ZotHome()) | ||
| 627 | + | customSet = custom != "" | |||
| 624 | } | 628 | } | ||
| 625 | 629 | ||||
| 626 | sys := BuildSystemPrompt(SystemPromptOpts{ | 630 | sys := BuildSystemPrompt(SystemPromptOpts{ | ||
| 627 | CWD: args.CWD, | 631 | CWD: args.CWD, | ||
| 628 | Tools: summaries, | 632 | Tools: summaries, | ||
| 629 | Custom: custom, | 633 | Custom: custom, | ||
| 634 | + | CustomSet: customSet, | |||
| 630 | Append: append_, | 635 | Append: append_, | ||
| 631 | ZotDocsDir: docsDir, | 636 | ZotDocsDir: docsDir, | ||
| 632 | }) | 637 | }) | ||
| @@ -660,6 +665,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 660 | ContextFiles: contextFiles, | 665 | ContextFiles: contextFiles, | ||
| 661 | systemAppend: append_, | 666 | systemAppend: append_, | ||
| 662 | systemCustom: custom, | 667 | systemCustom: custom, | ||
| 668 | + | systemCustomSet: customSet, | |||
| 663 | toolDescriptions: descMapFromSummaries(summaries), | 669 | toolDescriptions: descMapFromSummaries(summaries), | ||
| 664 | }, nil | 670 | }, nil | ||
| 665 | } | 671 | } | ||
M packages/agent/build_test.go +28 -0
| @@ -98,6 +98,34 @@ func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) { | |||
| 98 | 98 | } | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | + | func TestResolveExplicitEmptySystemPromptOverridesPersistentPrompt(t *testing.T) { | |
| 102 | + | zotHome := t.TempDir() | |
| 103 | + | cwd := t.TempDir() | |
| 104 | + | t.Setenv("ZOT_HOME", zotHome) | |
| 105 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |
| 106 | + | if err := os.WriteFile(filepath.Join(zotHome, "SYSTEM.md"), []byte("persistent instructions"), 0o644); err != nil { | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | r, err := Resolve(Args{ | |
| 111 | + | Provider: "openai", | |
| 112 | + | Model: "gpt-5", | |
| 113 | + | CWD: cwd, | |
| 114 | + | SystemPromptSet: true, | |
| 115 | + | SystemPrompt: "", | |
| 116 | + | NoContextFiles: true, | |
| 117 | + | NoSkill: true, | |
| 118 | + | }, false) | |
| 119 | + | if err != nil { | |
| 120 | + | t.Fatal(err) | |
| 121 | + | } | |
| 122 | + | for _, unwanted := range []string{"persistent instructions", defaultIdentity, "Zot's own docs are installed under"} { | |
| 123 | + | if strings.Contains(r.SystemPrompt, unwanted) { | |
| 124 | + | t.Fatalf("explicit empty system prompt unexpectedly contains %q:\n%s", unwanted, r.SystemPrompt) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 101 | 129 | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | |
| 102 | 130 | // startup failure caught by the user's screenshot: the persisted | |
| 103 | 131 | // config.json points at a model id that's no longer in the active | |
| @@ -98,6 +98,34 @@ func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) { | |||||
| 98 | } | 98 | } | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | + | func TestResolveExplicitEmptySystemPromptOverridesPersistentPrompt(t *testing.T) { | |||
| 102 | + | zotHome := t.TempDir() | |||
| 103 | + | cwd := t.TempDir() | |||
| 104 | + | t.Setenv("ZOT_HOME", zotHome) | |||
| 105 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |||
| 106 | + | if err := os.WriteFile(filepath.Join(zotHome, "SYSTEM.md"), []byte("persistent instructions"), 0o644); err != nil { | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | r, err := Resolve(Args{ | |||
| 111 | + | Provider: "openai", | |||
| 112 | + | Model: "gpt-5", | |||
| 113 | + | CWD: cwd, | |||
| 114 | + | SystemPromptSet: true, | |||
| 115 | + | SystemPrompt: "", | |||
| 116 | + | NoContextFiles: true, | |||
| 117 | + | NoSkill: true, | |||
| 118 | + | }, false) | |||
| 119 | + | if err != nil { | |||
| 120 | + | t.Fatal(err) | |||
| 121 | + | } | |||
| 122 | + | for _, unwanted := range []string{"persistent instructions", defaultIdentity, "Zot's own docs are installed under"} { | |||
| 123 | + | if strings.Contains(r.SystemPrompt, unwanted) { | |||
| 124 | + | t.Fatalf("explicit empty system prompt unexpectedly contains %q:\n%s", unwanted, r.SystemPrompt) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 101 | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | 129 | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | ||
| 102 | // startup failure caught by the user's screenshot: the persisted | 130 | // startup failure caught by the user's screenshot: the persisted | ||
| 103 | // config.json points at a model id that's no longer in the active | 131 | // config.json points at a model id that's no longer in the active | ||
M packages/agent/sdk/sdk.go +27 -17
| @@ -50,9 +50,14 @@ type Config struct { | |||
| 50 | 50 | // process cwd. | |
| 51 | 51 | CWD string | |
| 52 | 52 | ||
| 53 | - | // SystemPrompt overrides the built-in system prompt. | |
| 53 | + | // SystemPrompt overrides the built-in system prompt when non-empty or when | |
| 54 | + | // SystemPromptSet is true. | |
| 54 | 55 | SystemPrompt string | |
| 55 | 56 | ||
| 57 | + | // SystemPromptSet allows an empty SystemPrompt to intentionally omit the | |
| 58 | + | // built-in identity. It is unnecessary for non-empty prompts. | |
| 59 | + | SystemPromptSet bool | |
| 60 | + | ||
| 56 | 61 | // AppendSystemPrompt is appended to the built-in (or overridden) | |
| 57 | 62 | // system prompt. Useful for project-specific instructions. | |
| 58 | 63 | AppendSystemPrompt []string | |
| @@ -105,22 +110,7 @@ type Runtime struct { | |||
| 105 | 110 | // New constructs a Runtime from cfg. Returns an error if no | |
| 106 | 111 | // credential is available for the requested provider. | |
| 107 | 112 | func New(cfg Config) (*Runtime, error) { | |
| 108 | - | args := agent.Args{ | |
| 109 | - | Mode: agent.ModeJSON, // headless | |
| 110 | - | Provider: cfg.Provider, | |
| 111 | - | Model: cfg.Model, | |
| 112 | - | CWD: cfg.CWD, | |
| 113 | - | APIKey: cfg.APIKey, | |
| 114 | - | BaseURL: cfg.BaseURL, | |
| 115 | - | SystemPrompt: cfg.SystemPrompt, | |
| 116 | - | AppendSystemPrompt: cfg.AppendSystemPrompt, | |
| 117 | - | Reasoning: cfg.Reasoning, | |
| 118 | - | Temperature: cfg.Temperature, | |
| 119 | - | MaxSteps: cfg.MaxSteps, | |
| 120 | - | Tools: cfg.Tools, | |
| 121 | - | NoTools: cfg.NoTools, | |
| 122 | - | NoSess: true, // SDK callers manage persistence themselves | |
| 123 | - | } | |
| 113 | + | args := argsFromConfig(cfg) | |
| 124 | 114 | if args.MaxSteps == 0 { | |
| 125 | 115 | args.MaxSteps = 50 | |
| 126 | 116 | } | |
| @@ -140,6 +130,26 @@ func New(cfg Config) (*Runtime, error) { | |||
| 140 | 130 | }, nil | |
| 141 | 131 | } | |
| 142 | 132 | ||
| 133 | + | func argsFromConfig(cfg Config) agent.Args { | |
| 134 | + | return agent.Args{ | |
| 135 | + | Mode: agent.ModeJSON, // headless | |
| 136 | + | Provider: cfg.Provider, | |
| 137 | + | Model: cfg.Model, | |
| 138 | + | CWD: cfg.CWD, | |
| 139 | + | APIKey: cfg.APIKey, | |
| 140 | + | BaseURL: cfg.BaseURL, | |
| 141 | + | SystemPrompt: cfg.SystemPrompt, | |
| 142 | + | SystemPromptSet: cfg.SystemPromptSet || cfg.SystemPrompt != "", | |
| 143 | + | AppendSystemPrompt: cfg.AppendSystemPrompt, | |
| 144 | + | Reasoning: cfg.Reasoning, | |
| 145 | + | Temperature: cfg.Temperature, | |
| 146 | + | MaxSteps: cfg.MaxSteps, | |
| 147 | + | Tools: cfg.Tools, | |
| 148 | + | NoTools: cfg.NoTools, | |
| 149 | + | NoSess: true, // SDK callers manage persistence themselves | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 143 | 153 | // Provider returns the active provider id. | |
| 144 | 154 | func (r *Runtime) Provider() string { r.mu.Lock(); defer r.mu.Unlock(); return r.provider } | |
| 145 | 155 | ||
| @@ -50,9 +50,14 @@ type Config struct { | |||||
| 50 | // process cwd. | 50 | // process cwd. | ||
| 51 | CWD string | 51 | CWD string | ||
| 52 | 52 | ||||
| 53 | - | // SystemPrompt overrides the built-in system prompt. | 53 | + | // SystemPrompt overrides the built-in system prompt when non-empty or when |
| 54 | + | // SystemPromptSet is true. | |||
| 54 | SystemPrompt string | 55 | SystemPrompt string | ||
| 55 | 56 | ||||
| 57 | + | // SystemPromptSet allows an empty SystemPrompt to intentionally omit the | |||
| 58 | + | // built-in identity. It is unnecessary for non-empty prompts. | |||
| 59 | + | SystemPromptSet bool | |||
| 60 | + | ||||
| 56 | // AppendSystemPrompt is appended to the built-in (or overridden) | 61 | // AppendSystemPrompt is appended to the built-in (or overridden) | ||
| 57 | // system prompt. Useful for project-specific instructions. | 62 | // system prompt. Useful for project-specific instructions. | ||
| 58 | AppendSystemPrompt []string | 63 | AppendSystemPrompt []string | ||
| @@ -105,22 +110,7 @@ type Runtime struct { | |||||
| 105 | // New constructs a Runtime from cfg. Returns an error if no | 110 | // New constructs a Runtime from cfg. Returns an error if no | ||
| 106 | // credential is available for the requested provider. | 111 | // credential is available for the requested provider. | ||
| 107 | func New(cfg Config) (*Runtime, error) { | 112 | func New(cfg Config) (*Runtime, error) { | ||
| 108 | - | args := agent.Args{ | 113 | + | args := argsFromConfig(cfg) |
| 109 | - | Mode: agent.ModeJSON, // headless | |||
| 110 | - | Provider: cfg.Provider, | |||
| 111 | - | Model: cfg.Model, | |||
| 112 | - | CWD: cfg.CWD, | |||
| 113 | - | APIKey: cfg.APIKey, | |||
| 114 | - | BaseURL: cfg.BaseURL, | |||
| 115 | - | SystemPrompt: cfg.SystemPrompt, | |||
| 116 | - | AppendSystemPrompt: cfg.AppendSystemPrompt, | |||
| 117 | - | Reasoning: cfg.Reasoning, | |||
| 118 | - | Temperature: cfg.Temperature, | |||
| 119 | - | MaxSteps: cfg.MaxSteps, | |||
| 120 | - | Tools: cfg.Tools, | |||
| 121 | - | NoTools: cfg.NoTools, | |||
| 122 | - | NoSess: true, // SDK callers manage persistence themselves | |||
| 123 | - | } | |||
| 124 | if args.MaxSteps == 0 { | 114 | if args.MaxSteps == 0 { | ||
| 125 | args.MaxSteps = 50 | 115 | args.MaxSteps = 50 | ||
| 126 | } | 116 | } | ||
| @@ -140,6 +130,26 @@ func New(cfg Config) (*Runtime, error) { | |||||
| 140 | }, nil | 130 | }, nil | ||
| 141 | } | 131 | } | ||
| 142 | 132 | ||||
| 133 | + | func argsFromConfig(cfg Config) agent.Args { | |||
| 134 | + | return agent.Args{ | |||
| 135 | + | Mode: agent.ModeJSON, // headless | |||
| 136 | + | Provider: cfg.Provider, | |||
| 137 | + | Model: cfg.Model, | |||
| 138 | + | CWD: cfg.CWD, | |||
| 139 | + | APIKey: cfg.APIKey, | |||
| 140 | + | BaseURL: cfg.BaseURL, | |||
| 141 | + | SystemPrompt: cfg.SystemPrompt, | |||
| 142 | + | SystemPromptSet: cfg.SystemPromptSet || cfg.SystemPrompt != "", | |||
| 143 | + | AppendSystemPrompt: cfg.AppendSystemPrompt, | |||
| 144 | + | Reasoning: cfg.Reasoning, | |||
| 145 | + | Temperature: cfg.Temperature, | |||
| 146 | + | MaxSteps: cfg.MaxSteps, | |||
| 147 | + | Tools: cfg.Tools, | |||
| 148 | + | NoTools: cfg.NoTools, | |||
| 149 | + | NoSess: true, // SDK callers manage persistence themselves | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 143 | // Provider returns the active provider id. | 153 | // Provider returns the active provider id. | ||
| 144 | func (r *Runtime) Provider() string { r.mu.Lock(); defer r.mu.Unlock(); return r.provider } | 154 | func (r *Runtime) Provider() string { r.mu.Lock(); defer r.mu.Unlock(); return r.provider } | ||
| 145 | 155 | ||||
A packages/agent/sdk/systemprompt_test.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | package sdk | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestArgsFromConfigPreservesExplicitEmptySystemPrompt(t *testing.T) { | |
| 6 | + | args := argsFromConfig(Config{SystemPromptSet: true}) | |
| 7 | + | if !args.SystemPromptSet { | |
| 8 | + | t.Fatal("SystemPromptSet = false; want true for an explicitly empty SDK prompt") | |
| 9 | + | } | |
| 10 | + | if args.SystemPrompt != "" { | |
| 11 | + | t.Fatalf("SystemPrompt = %q; want empty", args.SystemPrompt) | |
| 12 | + | } | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func TestArgsFromConfigTreatsNonEmptySystemPromptAsSet(t *testing.T) { | |
| 16 | + | args := argsFromConfig(Config{SystemPrompt: "custom"}) | |
| 17 | + | if !args.SystemPromptSet { | |
| 18 | + | t.Fatal("SystemPromptSet = false; want true for a non-empty SDK prompt") | |
| 19 | + | } | |
| 20 | + | } | |
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | package sdk | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestArgsFromConfigPreservesExplicitEmptySystemPrompt(t *testing.T) { | |||
| 6 | + | args := argsFromConfig(Config{SystemPromptSet: true}) | |||
| 7 | + | if !args.SystemPromptSet { | |||
| 8 | + | t.Fatal("SystemPromptSet = false; want true for an explicitly empty SDK prompt") | |||
| 9 | + | } | |||
| 10 | + | if args.SystemPrompt != "" { | |||
| 11 | + | t.Fatalf("SystemPrompt = %q; want empty", args.SystemPrompt) | |||
| 12 | + | } | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func TestArgsFromConfigTreatsNonEmptySystemPromptAsSet(t *testing.T) { | |||
| 16 | + | args := argsFromConfig(Config{SystemPrompt: "custom"}) | |||
| 17 | + | if !args.SystemPromptSet { | |||
| 18 | + | t.Fatal("SystemPromptSet = false; want true for a non-empty SDK prompt") | |||
| 19 | + | } | |||
| 20 | + | } | |||
M packages/agent/systemprompt.go +3 -2
| @@ -21,7 +21,8 @@ type ToolSummary struct { | |||
| 21 | 21 | type SystemPromptOpts struct { | |
| 22 | 22 | CWD string | |
| 23 | 23 | Tools []ToolSummary | |
| 24 | - | Custom string // if set, replaces the built-in identity and docs guidance | |
| 24 | + | Custom string // when CustomSet, replaces the built-in identity and docs guidance | |
| 25 | + | CustomSet bool // preserves an intentionally empty custom prompt | |
| 25 | 26 | Append []string // extra text appended at the end | |
| 26 | 27 | Now time.Time | |
| 27 | 28 | ZotDocsDir string | |
| @@ -58,7 +59,7 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||
| 58 | 59 | ||
| 59 | 60 | var sb strings.Builder | |
| 60 | 61 | ||
| 61 | - | if o.Custom != "" { | |
| 62 | + | if o.CustomSet || o.Custom != "" { | |
| 62 | 63 | sb.WriteString(o.Custom) | |
| 63 | 64 | } else { | |
| 64 | 65 | sb.WriteString(defaultIdentity) | |
| @@ -21,7 +21,8 @@ type ToolSummary struct { | |||||
| 21 | type SystemPromptOpts struct { | 21 | type SystemPromptOpts struct { | ||
| 22 | CWD string | 22 | CWD string | ||
| 23 | Tools []ToolSummary | 23 | Tools []ToolSummary | ||
| 24 | - | Custom string // if set, replaces the built-in identity and docs guidance | 24 | + | Custom string // when CustomSet, replaces the built-in identity and docs guidance |
| 25 | + | CustomSet bool // preserves an intentionally empty custom prompt | |||
| 25 | Append []string // extra text appended at the end | 26 | Append []string // extra text appended at the end | ||
| 26 | Now time.Time | 27 | Now time.Time | ||
| 27 | ZotDocsDir string | 28 | ZotDocsDir string | ||
| @@ -58,7 +59,7 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||||
| 58 | 59 | ||||
| 59 | var sb strings.Builder | 60 | var sb strings.Builder | ||
| 60 | 61 | ||||
| 61 | - | if o.Custom != "" { | 62 | + | if o.CustomSet || o.Custom != "" { |
| 62 | sb.WriteString(o.Custom) | 63 | sb.WriteString(o.Custom) | ||
| 63 | } else { | 64 | } else { | ||
| 64 | sb.WriteString(defaultIdentity) | 65 | sb.WriteString(defaultIdentity) | ||
M packages/agent/zotfile.go +11 -6
| @@ -151,12 +151,7 @@ func runLocalZotfile(ref string, args Args, version string) error { | |||
| 151 | 151 | agentPrompt, err := os.ReadFile(agentPath) | |
| 152 | 152 | switch { | |
| 153 | 153 | case err == nil: | |
| 154 | - | text := strings.TrimSpace(string(agentPrompt)) | |
| 155 | - | if zf.Manifest.ReplaceSystemPrompt { | |
| 156 | - | args.SystemPrompt = text | |
| 157 | - | } else { | |
| 158 | - | args.AppendSystemPrompt = append(args.AppendSystemPrompt, text) | |
| 159 | - | } | |
| 154 | + | applyZotfileAgentPrompt(&args, zf.Manifest.ReplaceSystemPrompt, string(agentPrompt)) | |
| 160 | 155 | case os.IsNotExist(err): | |
| 161 | 156 | // AGENT.md is optional; missing file leaves the system prompt unchanged. | |
| 162 | 157 | default: | |
| @@ -181,6 +176,16 @@ func runLocalZotfile(ref string, args Args, version string) error { | |||
| 181 | 176 | return runWithArgs(args, version) | |
| 182 | 177 | } | |
| 183 | 178 | ||
| 179 | + | func applyZotfileAgentPrompt(args *Args, replace bool, prompt string) { | |
| 180 | + | text := strings.TrimSpace(prompt) | |
| 181 | + | if replace { | |
| 182 | + | args.SystemPrompt = text | |
| 183 | + | args.SystemPromptSet = true | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | args.AppendSystemPrompt = append(args.AppendSystemPrompt, text) | |
| 187 | + | } | |
| 188 | + | ||
| 184 | 189 | func zotInspect(ref string) error { | |
| 185 | 190 | zf, cleanup, err := loadZotfile(ref) | |
| 186 | 191 | if cleanup != nil { | |
| @@ -151,12 +151,7 @@ func runLocalZotfile(ref string, args Args, version string) error { | |||||
| 151 | agentPrompt, err := os.ReadFile(agentPath) | 151 | agentPrompt, err := os.ReadFile(agentPath) | ||
| 152 | switch { | 152 | switch { | ||
| 153 | case err == nil: | 153 | case err == nil: | ||
| 154 | - | text := strings.TrimSpace(string(agentPrompt)) | 154 | + | applyZotfileAgentPrompt(&args, zf.Manifest.ReplaceSystemPrompt, string(agentPrompt)) |
| 155 | - | if zf.Manifest.ReplaceSystemPrompt { | |||
| 156 | - | args.SystemPrompt = text | |||
| 157 | - | } else { | |||
| 158 | - | args.AppendSystemPrompt = append(args.AppendSystemPrompt, text) | |||
| 159 | - | } | |||
| 160 | case os.IsNotExist(err): | 155 | case os.IsNotExist(err): | ||
| 161 | // AGENT.md is optional; missing file leaves the system prompt unchanged. | 156 | // AGENT.md is optional; missing file leaves the system prompt unchanged. | ||
| 162 | default: | 157 | default: | ||
| @@ -181,6 +176,16 @@ func runLocalZotfile(ref string, args Args, version string) error { | |||||
| 181 | return runWithArgs(args, version) | 176 | return runWithArgs(args, version) | ||
| 182 | } | 177 | } | ||
| 183 | 178 | ||||
| 179 | + | func applyZotfileAgentPrompt(args *Args, replace bool, prompt string) { | |||
| 180 | + | text := strings.TrimSpace(prompt) | |||
| 181 | + | if replace { | |||
| 182 | + | args.SystemPrompt = text | |||
| 183 | + | args.SystemPromptSet = true | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | args.AppendSystemPrompt = append(args.AppendSystemPrompt, text) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 184 | func zotInspect(ref string) error { | 189 | func zotInspect(ref string) error { | ||
| 185 | zf, cleanup, err := loadZotfile(ref) | 190 | zf, cleanup, err := loadZotfile(ref) | ||
| 186 | if cleanup != nil { | 191 | if cleanup != nil { | ||
M packages/agent/zotfile_test.go +11 -0
| @@ -93,6 +93,17 @@ func writeTestZotfile(t *testing.T, manifest string) string { | |||
| 93 | 93 | return dir | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | func TestApplyZotfileAgentPromptPreservesEmptyReplacement(t *testing.T) { | |
| 97 | + | args := Args{SystemPrompt: "cli prompt"} | |
| 98 | + | applyZotfileAgentPrompt(&args, true, " \n") | |
| 99 | + | if !args.SystemPromptSet { | |
| 100 | + | t.Fatal("SystemPromptSet = false; want true for an empty replacement AGENT.md") | |
| 101 | + | } | |
| 102 | + | if args.SystemPrompt != "" { | |
| 103 | + | t.Fatalf("SystemPrompt = %q; want empty", args.SystemPrompt) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 96 | 107 | func TestLoadZotfileAllowsMissingAgentMD(t *testing.T) { | |
| 97 | 108 | dir := t.TempDir() | |
| 98 | 109 | if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"zotfile":1,"name":"test"}`), 0o600); err != nil { | |
| @@ -93,6 +93,17 @@ func writeTestZotfile(t *testing.T, manifest string) string { | |||||
| 93 | return dir | 93 | return dir | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | + | func TestApplyZotfileAgentPromptPreservesEmptyReplacement(t *testing.T) { | |||
| 97 | + | args := Args{SystemPrompt: "cli prompt"} | |||
| 98 | + | applyZotfileAgentPrompt(&args, true, " \n") | |||
| 99 | + | if !args.SystemPromptSet { | |||
| 100 | + | t.Fatal("SystemPromptSet = false; want true for an empty replacement AGENT.md") | |||
| 101 | + | } | |||
| 102 | + | if args.SystemPrompt != "" { | |||
| 103 | + | t.Fatalf("SystemPrompt = %q; want empty", args.SystemPrompt) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 96 | func TestLoadZotfileAllowsMissingAgentMD(t *testing.T) { | 107 | func TestLoadZotfileAllowsMissingAgentMD(t *testing.T) { | ||
| 97 | dir := t.TempDir() | 108 | dir := t.TempDir() | ||
| 98 | if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"zotfile":1,"name":"test"}`), 0o600); err != nil { | 109 | if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"zotfile":1,"name":"test"}`), 0o600); err != nil { | ||