/adversary Command — Implementation Plan¶
Context¶
Users working with Keen Code want to catch bugs, security issues, and blind spots in the agent's
work without relying solely on the primary model. /adversary addresses this by invoking a
separately-configured LLM that adversarially reviews the full conversation — finding problems
rather than validating the primary agent's output. It is modelled closely on /btw as a
side-channel that never enters the main session history.
Design Summary¶
/adversary— default adversarial review (issues, bugs, risks, security)/adversary <prompt>— custom focus/adversary model— open the interactive model picker to configure the adversary model- Full conversation context (not truncated like
/btw's last-10) - Read-only tools:
read_file,glob,grep,web_fetch. Nobash,write_file,edit_file,call_mcp_tool - Rendered as a
SecondaryColor(teal) left-border block with an "adversary" chip label - Separate spinner (
spinner.Points,SecondaryColor) from main (Pulse) and btw (MiniDot) - Output is NOT added to main session history; each invocation starts fresh
- Adversary provider/model stored separately in
GlobalConfig(adversary_provider,adversary_model)
Implementation Steps¶
1. internal/config/config.go — Add adversary config fields¶
Add two fields to GlobalConfig:
AdversaryProvider string `json:"adversary_provider,omitempty"`
AdversaryModel string `json:"adversary_model,omitempty"`
Add a helper:
func ResolveAdversary(global *GlobalConfig) (*ResolvedConfig, error) {
if global.AdversaryProvider == "" || global.AdversaryModel == "" {
return nil, fmt.Errorf("adversary model not configured")
}
provCfg := global.Providers[global.AdversaryProvider]
return &ResolvedConfig{
Provider: global.AdversaryProvider,
Model: global.AdversaryModel,
APIKey: provCfg.APIKey,
BaseURL: provCfg.BaseURL,
AuthMode: AuthModeForProvider(global.AdversaryProvider),
}, nil
}
2. internal/llm/systemprompt.go — Add BuildAdversaryPrompt¶
const adversaryPrompt = `You are an adversarial critic for Keen Code.
Your job is to find problems — in code, in ideas, in plans, and in reasoning.
When the conversation involves code changes: find bugs, logic errors, security vulnerabilities,
missing edge cases, and risks the main agent overlooked. Use your read tools to inspect files
when you need evidence. Reference file:line when citing code.
When the conversation involves ideas, suggestions, or plans: challenge the assumptions, question
the rationale, surface alternatives, and identify what could go wrong. Do not simply validate
what the main agent proposed.
Be direct and specific. If nothing significant is wrong, say so briefly — do not invent problems.`
func BuildAdversaryPrompt(workingDir string) string {
return adversaryPrompt + fmt.Sprintf("\n\nWorking directory: %s", workingDir)
}
3. internal/cli/repl/appstate/state.go — Add adversary client and StreamAdversary¶
Add field to AppState:
adversaryClient llm.LLMClient
Add methods:
func (s *AppState) SetAdversaryClient(client llm.LLMClient)
func (s *AppState) IsAdversaryClientReady() bool // returns adversaryClient != nil
func (s *AppState) StreamAdversary(ctx context.Context, focus string, opts ...llm.StreamOptions) (<-chan llm.StreamEvent, error) {
// Full history — no truncation (unlike btwContext)
history := s.GetMessages()
messages := make([]llm.Message, 0, 2+len(history))
messages = append(messages, llm.Message{Role: llm.RoleSystem, Content: llm.BuildAdversaryPrompt(s.workingDir)})
messages = append(messages, history...)
instruction := "Review this conversation based on your responsibilities."
if focus != "" {
instruction = focus
}
messages = append(messages, llm.Message{Role: llm.RoleUser, Content: instruction})
readOnlyRegistry := s.toolRegistry.Without("write_file", "edit_file", "bash", "call_mcp_tool")
return s.adversaryClient.StreamChat(ctx, messages, readOnlyRegistry, llm.StreamOptions{OneShot: true})
}
4. internal/cli/repl/stream_msgs.go — Add adversary message types¶
Append after the btw types (lines 54–57):
type adversaryChunkMsg string
type adversaryDoneMsg struct{}
type adversaryErrorMsg struct{ err error }
5. internal/cli/repl/theme/styles.go — Add adversary styles¶
Append using the existing SecondaryColor:
AdversaryBorderStyle = lipgloss.NewStyle().Foreground(SecondaryColor)
AdversaryLabelStyle = lipgloss.NewStyle().Foreground(TextPrimaryColor)
AdversaryChipStyle = lipgloss.NewStyle().
Background(SecondaryColor).
Foreground(lipgloss.Color("#000000")).
Bold(true).
Padding(0, 1)
6. internal/cli/repl/commands/commands.go — Register the command¶
Add constants:
Adversary = "/adversary"
AdversaryModel = "/adversary model"
Add to both All (for help) and Suggestions (for autocomplete):
{Adversary, "Adversarially review the conversation for issues, bugs, risks, and security problems"},
{AdversaryModel, "Configure the adversary model"},
7. internal/cli/repl/repl.go — Extend replContext, replModel, initialModel¶
Declare a new struct to hold all adversary state, and add a single field to replModel:
type adversaryState struct {
streamHandler *StreamHandler
streamCancel context.CancelFunc
lines []string
focus string
showSpinner bool
spinner spinner.Model
modelSelection *replwidgets.Model
}
// in replModel
adversary adversaryState
In initialModel, initialize alongside the btw spinner (bs):
as := spinner.New()
as.Spinner = spinner.Points
as.Style = lipgloss.NewStyle().Foreground(repltheme.SecondaryColor)
In updateViewportContent, append adversary block after btw block:
if m.adversary.streamHandler != nil && m.adversary.streamHandler.IsActive() {
content.WriteString(m.renderAdversaryInline(contentWidth))
} else if m.adversary.lines != nil {
content.WriteString(m.renderAdversaryInlineFinished(contentWidth))
}
In handleSpinnerTick, add adversary spinner branch alongside the btw branch. Extend the early-return guard:
if !m.showSpinner && !m.btwShowSpinner && !m.adversary.showSpinner {
return m, nil, false
}
Add consumeAdversaryModelSelectionResult mirroring consumeModelSelectionResult, and wire it into updateNormalMode. Also add m.adversary.modelSelection key routing in handleKeyMsg mirroring the modelSelection routing block.
8. internal/cli/repl/stream_render.go — Add adversary render functions¶
Mirror the four btw render functions exactly, substituting adversary styles and the "adversary" chip label:
func renderAdversaryQuestionHeader(focus string) string
func renderAdversaryLeftBorder(line string) string
func (m *replModel) renderAdversaryInline(width int) string
func (m *replModel) renderAdversaryInlineFinished(width int) string
The adversaryFocus is shown next to the chip only when non-empty (e.g. "adversary — look for SQL injection risks"). When empty, only the chip is rendered.
9. internal/cli/repl/repl_helpers.go — Add adversary helpers¶
func waitForAdversaryEvent(ch <-chan llm.StreamEvent) tea.Cmd
// mirrors waitForBtwEvent; maps events to adversaryChunkMsg / adversaryDoneMsg / adversaryErrorMsg
func (m *replModel) cancelAdversaryStream()
// cancels context, resets m.adversary.lines, m.adversary.showSpinner
func (m *replModel) flushAdversaryToOutput()
// moves m.adversary.lines into the main output buffer, then clears it
func (m *replModel) buildAdversaryClient() error
// calls config.ResolveAdversary, then llm.NewClient, then m.appState.SetAdversaryClient
Also call m.flushAdversaryToOutput() in the same place flushBtwToOutput() is called when a new user turn begins (around repl.go:239).
10. internal/cli/repl/handlers.go — Add handleAdversaryStreamMsg¶
Mirror handleBtwStreamMsg (line 668), dispatching on adversaryChunkMsg, adversaryDoneMsg, adversaryErrorMsg. Wire into handleLLMStreamMsg immediately after the btw handler call:
if updated, cmd, handled := m.handleAdversaryStreamMsg(msg); handled {
return updated, cmd, true
}
11. internal/cli/repl/command_handlers.go — Wire /adversary¶
In handleEnterKey, intercept before dispatchCommand (same placement as the /btw intercept, line ~224):
if input == replcommands.Adversary || strings.HasPrefix(input, replcommands.Adversary+" ") {
m.history.Push(input)
m.textarea.Reset()
result, cmd := m.handleAdversaryCommand(input)
return result, cmd
}
Add handleAdversaryCommand:
func (m *replModel) handleAdversaryCommand(input string) (replModel, tea.Cmd) {
arg := strings.TrimSpace(strings.TrimPrefix(input, replcommands.Adversary))
if arg == "model" {
return m.startAdversaryModelSelection(), nil
}
if m.ctx.globalCfg.AdversaryProvider == "" || m.ctx.globalCfg.AdversaryModel == "" {
// show "Run /adversary model to configure an adversary model" in muted style
return *m, nil
}
if !m.appState.IsAdversaryClientReady() {
if err := m.buildAdversaryClient(); err != nil {
// show error
return *m, nil
}
}
m.cancelAdversaryStream() // cancel any in-flight stream
m.flushAdversaryToOutput() // persist previous result to output
ctx, cancel := context.WithCancel(context.Background())
m.adversary.streamCancel = cancel
eventCh, err := m.appState.StreamAdversary(ctx, arg)
if err != nil { /* show error, cancel, return */ }
m.adversary.lines = nil
m.adversary.focus = arg
m.adversary.streamHandler.Start(eventCh, nextLoadingText())
m.adversary.showSpinner = true
m.updateViewportContent()
m.viewport.GotoBottom()
return *m, tea.Batch(m.adversary.spinner.Tick, waitForAdversaryEvent(eventCh))
}
Add startAdversaryModelSelection:
func (m *replModel) startAdversaryModelSelection() replModel {
// Save current main model values — the widget will overwrite Active* before calling onComplete
savedProvider := m.ctx.globalCfg.ActiveProvider
savedModel := m.ctx.globalCfg.ActiveModel
onComplete := func(provider, model, apiKey string) error {
// Move widget's Active* writes into Adversary* fields
m.ctx.globalCfg.AdversaryProvider = provider
m.ctx.globalCfg.AdversaryModel = model
// Restore main model — widget overwrote these
m.ctx.globalCfg.ActiveProvider = savedProvider
m.ctx.globalCfg.ActiveModel = savedModel
if err := m.ctx.loader.Save(m.ctx.globalCfg); err != nil {
return err
}
return m.buildAdversaryClient()
}
adversaryResolved, _ := config.ResolveAdversary(m.ctx.globalCfg)
if adversaryResolved == nil {
adversaryResolved = &config.ResolvedConfig{}
}
m.adversary.modelSelection = replwidgets.New(
m.ctx.registry, m.ctx.globalCfg, m.ctx.loader, adversaryResolved, onComplete,
)
m.updateViewportContent()
m.viewport.GotoBottom()
return *m
}
Add Esc handling: when m.adversary.streamHandler.IsActive(), Esc cancels the adversary stream (checked before the btw Esc block).
Implementation Order¶
Dependencies flow in this order:
config.go— no dependenciessystemprompt.go— no dependenciesappstate/state.go— depends on 1, 2stream_msgs.go— no dependenciestheme/styles.go— no dependenciescommands/commands.go— no dependenciesstream_render.go— depends on 5repl_helpers.go— depends on 3, 4handlers.go— depends on 3, 4, 7, 8repl.go— depends on 5, 6, 7, 8, 9command_handlers.go— depends on all prior
Critical Files¶
| File | Change |
|---|---|
internal/config/config.go |
Add AdversaryProvider, AdversaryModel to GlobalConfig; add ResolveAdversary |
internal/llm/systemprompt.go |
Add BuildAdversaryPrompt |
internal/cli/repl/appstate/state.go |
Add adversaryClient, SetAdversaryClient, IsAdversaryClientReady, StreamAdversary |
internal/cli/repl/stream_msgs.go |
Add 3 adversary message types |
internal/cli/repl/theme/styles.go |
Add 3 adversary styles using SecondaryColor |
internal/cli/repl/commands/commands.go |
Register Adversary and AdversaryModel constants |
internal/cli/repl/repl.go |
Add adversaryState struct and adversary adversaryState field to replModel; update viewport, spinner tick, key routing |
internal/cli/repl/stream_render.go |
Add 4 adversary render functions |
internal/cli/repl/repl_helpers.go |
Add waitForAdversaryEvent, cancelAdversaryStream, flushAdversaryToOutput, buildAdversaryClient |
internal/cli/repl/handlers.go |
Add handleAdversaryStreamMsg; wire into handleLLMStreamMsg |
internal/cli/repl/command_handlers.go |
Add handleAdversaryCommand, startAdversaryModelSelection; wire into handleEnterKey and handleKeyMsg |
Verification¶
go build ./...— clean compilego test -race ./...— all tests pass, including existing btw and model picker tests- Config round-trip:
/adversary model→ select a provider/model → inspect~/.keen/configs.json:adversary_providerandadversary_modelwritten;active_provider/active_modelunchanged - No-config path: with
adversary_providerabsent,/adversaryprints "Run /adversary model to configure an adversary model" without crashing - Stream rendering: with adversary configured,
/adversaryrenders a teal▌left-border block with an "adversary" chip; main conversation unchanged after stream completes - Custom focus:
/adversary look for SQL injection risksuses that string as the chip label and as the final user message to the adversary model - Tool restriction: unit test that
toolRegistry.Without("write_file", "edit_file", "bash", "call_mcp_tool")produces a registry containing onlyread_file,glob,grep,web_fetch(and any MCP tools if present — confirmcall_mcp_toolexclusion is correct) - Esc cancellation: Esc while adversary is streaming cancels cleanly, no panic
- Concurrent btw:
/btwafter/adversaryruns without interfering with each other's spinner or line buffers - Main model picker regression:
/modelstill correctly updates the main provider/model and reinitializes the main LLM client after theonCompletesignature change