diff --git a/internal/runner/llm_source.go b/internal/runner/llm_source.go index 8118fc9..4df153e 100644 --- a/internal/runner/llm_source.go +++ b/internal/runner/llm_source.go @@ -52,8 +52,11 @@ type llmSource struct { verifier *verifier.Verifier client *llmclient.Client model string - logger *slog.Logger - history *actionHistory + // instructions is optional spec-level guidance appended to the system prompt + // to steer the model's bug-hunting (empty when unset). + instructions string + logger *slog.Logger + history *actionHistory // lastSource/lastReasoning describe the most recent NextAction so the runner // can stamp the trace. lastSource is "llm" only when the LLM (not setup) @@ -159,13 +162,23 @@ func (s *llmSource) buildRequest(candidates []verifier.ActionCandidate) llmclien return llmclient.Request{ Model: s.model, Messages: []llmclient.Message{ - {Role: "system", Content: []llmclient.ContentPart{llmclient.TextPart(llmSystemPrompt)}}, + {Role: "system", Content: []llmclient.ContentPart{llmclient.TextPart(s.systemPrompt())}}, {Role: "user", Content: userParts}, }, ResponseFormat: rankedResponseFormat(), } } +// systemPrompt is the base framing plus any spec-level instructions, appended as +// extra guidance so a spec can steer the model's bug-hunting without losing the +// candidate-kind semantics the base prompt establishes. +func (s *llmSource) systemPrompt() string { + if strings.TrimSpace(s.instructions) == "" { + return llmSystemPrompt + } + return llmSystemPrompt + "\n\n" + s.instructions +} + // userPrompt renders the numbered candidate list and the recent-action memory. func (s *llmSource) userPrompt(candidates []verifier.ActionCandidate) string { var builder strings.Builder diff --git a/internal/runner/llm_source_test.go b/internal/runner/llm_source_test.go index 15ad0bf..5fa32e5 100644 --- a/internal/runner/llm_source_test.go +++ b/internal/runner/llm_source_test.go @@ -141,6 +141,19 @@ func TestParseRanked(t *testing.T) { } } +func TestSystemPromptAppendsInstructions(t *testing.T) { + if got := (&llmSource{}).systemPrompt(); got != llmSystemPrompt { + t.Error("empty instructions should yield the base prompt unchanged") + } + withInstr := (&llmSource{instructions: "hunt for double submits"}).systemPrompt() + if !strings.Contains(withInstr, llmSystemPrompt) { + t.Error("system prompt must retain the base framing") + } + if !strings.Contains(withInstr, "hunt for double submits") { + t.Error("system prompt must include the spec instructions") + } +} + // fakeOpenRouter is a configurable in-process OpenRouter server. Set ranked / // reasoning before each call; it echoes them as a json_schema content body. type fakeOpenRouter struct { diff --git a/internal/runner/source.go b/internal/runner/source.go index e88f952..e5dc595 100644 --- a/internal/runner/source.go +++ b/internal/runner/source.go @@ -83,11 +83,12 @@ func pickSources(options Options) (ActionSource, ExtractorSource, error) { logger = slog.Default() } action := &llmSource{ - verifier: options.Verifier, - client: client, - model: config.Model, - logger: logger, - history: newActionHistory(llmHistorySize), + verifier: options.Verifier, + client: client, + model: config.Model, + instructions: config.Instructions, + logger: logger, + history: newActionHistory(llmHistorySize), } return action, gojaSource{verifier: options.Verifier}, nil }