Skip to content

Commit 3351f1c

Browse files
committed
v0.49.0: add interaction_mode: off — silent agent mode
Adds "off" as a valid interaction_mode value that suppresses all per-iteration render output (iteration headers, thinking, tool calls, tool results, summary) and delivers the clean final answer to stdout. Implementation: - Add interactionMode field + SetInteractionMode() to loop Engine - Gate all 6 render call sites with e.interactionMode != "off" - Wire interactionMode from Agent Config to engine - CLI: skip rend.Start() header, print clean result to stdout - Telegram: force toolProgress="off" when interaction_mode="off" - Update /mode help, config docs, and AGENTS.md - Unit tests at loop level (render suppression) and config level (value acceptance) - Set global config: interaction_mode=off, tool_progress=new
1 parent e378bb4 commit 3351f1c

9 files changed

Lines changed: 114 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Five-layer priority chain:
118118
4. CLI flags ← Explicit invocation (highest priority)
119119
```
120120
- `${VAR}` substitution in JSON config files.
121-
- `interaction_mode` field: `engaging` | `verbose` | `enhance`.
121+
- `interaction_mode` field: `engaging` | `verbose` | `enhance` | `off`.
122122
- `max_tool_parallel`: bounded concurrency for tool execution.
123123
- `--deliver` CLI flag delivers agent response to Telegram default chat (for cron).
124124

cmd/odek/main.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1092,7 +1092,9 @@ func run(args []string) error {
10921092
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
10931093
defer cancel()
10941094

1095-
rend.Start(f.Task)
1095+
if resolved.InteractionMode != "off" {
1096+
rend.Start(f.Task)
1097+
}
10961098

10971099
// Shared agent run — capture messages for --learn mode
10981100
var allMessages []llm.Message
@@ -1186,6 +1188,11 @@ func run(args []string) error {
11861188
}
11871189
}
11881190

1191+
// ── Off mode: print clean result to stdout ──
1192+
if resolved.InteractionMode == "off" && runErr == nil && result != "" {
1193+
fmt.Println(result)
1194+
}
1195+
11891196
return nil
11901197
}
11911198

cmd/odek/telegram.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,10 @@ func handleChatMessage(
10531053
// behavior is used regardless of tool_progress (preserves existing UX).
10541054
toolProgress := resolved.ToolProgress
10551055
isEnhance := resolved.InteractionMode == "enhance"
1056+
isOff := resolved.InteractionMode == "off"
1057+
if isOff {
1058+
toolProgress = "off"
1059+
}
10561060
if isEnhance {
10571061
toolProgress = "enhance"
10581062
}

internal/config/loader.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ type CLIFlags struct {
7474
// "engaging" (default) = emoji-rich narration, progress message edited.
7575
// "enhance" = per-tool narrated messages appended, progress header kept.
7676
// "verbose" = raw tool names, args, and results.
77+
// "off" = no intermediate progress output, clean answer only.
7778
InteractionMode string
7879
}
7980

@@ -184,6 +185,7 @@ type FileConfig struct {
184185
// "engaging" (default) = emoji-rich narration, progress message edited.
185186
// "enhance" = per-tool narrated messages, progress header kept.
186187
// "verbose" = raw tool names, args, and results.
188+
// "off" = no progress output, clean answer only.
187189
InteractionMode string `json:"interaction_mode,omitempty"`
188190

189191
// ToolProgress controls per-tool progress messages for the Telegram bot.
@@ -292,6 +294,7 @@ type ResolvedConfig struct {
292294
GithubRepoUrl string
293295

294296
// InteractionMode is the resolved interaction style.
297+
// Values: "engaging" (default), "enhance", "verbose", or "off".
295298
// "engaging" (default), "enhance", or "verbose".
296299
InteractionMode string
297300

internal/config/loader_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,3 +750,11 @@ func TestLoadConfig_InteractionModeViaCLI(t *testing.T) {
750750
t.Errorf("InteractionMode = %q, want %q", cfg.InteractionMode, "verbose")
751751
}
752752
}
753+
754+
func TestLoadConfig_InteractionModeOff(t *testing.T) {
755+
// "off" should be accepted as a valid value via CLI.
756+
cfg := LoadConfig(CLIFlags{InteractionMode: "off"})
757+
if cfg.InteractionMode != "off" {
758+
t.Errorf("InteractionMode = %q, want %q", cfg.InteractionMode, "off")
759+
}
760+
}

internal/loop/loop.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ type Engine struct {
7171

7272
toolEventHandler ToolEventHandler // optional: fires during tool execution
7373

74+
// interactionMode controls how progress is surfaced to the user.
75+
// "engaging" (default), "verbose", "enhance", or "off" (silent).
76+
// When "off", all per-iteration render output is suppressed.
77+
interactionMode string
78+
7479
// narrator produces engaging, human-friendly progress messages
7580
// instead of raw tool call output. nil = verbose mode (default).
7681
narrator *narrate.Narrator
@@ -148,6 +153,10 @@ func (e *Engine) SetSkillLoader(sl SkillLoader) { e.skillLoader = sl }
148153
// message before the LLM invocation.
149154
func (e *Engine) SetEpisodeContextFunc(ef EpisodeContextFunc) { e.episodeCtx = ef }
150155

156+
// SetInteractionMode sets how progress is surfaced.
157+
// "off" suppresses all per-iteration render output except the final answer.
158+
func (e *Engine) SetInteractionMode(mode string) { e.interactionMode = mode }
159+
151160
// SetSkillVerbose controls whether skill loading shows full banners (true)
152161
// or condensed markers (false, default). Condensed saves context window space.
153162
func (e *Engine) SetSkillVerbose(verbose bool) { e.skillVerbose = verbose }
@@ -404,7 +413,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
404413
}
405414

406415
// Render iteration header (1-indexed for humans)
407-
if e.renderer != nil {
416+
if e.renderer != nil && e.interactionMode != "off" {
408417
e.renderer.Iteration(i+1, e.maxIter, 0, 0, 0, 0)
409418
}
410419

@@ -547,7 +556,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
547556
}
548557

549558
// Render turn statistics (re-draw iteration header with stats)
550-
if e.renderer != nil {
559+
if e.renderer != nil && e.interactionMode != "off" {
551560
e.renderer.Iteration(i+1, e.maxIter, latency, result.InputTokens, result.OutputTokens, 0)
552561
}
553562

@@ -563,7 +572,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
563572

564573
// No tool calls = final answer
565574
if len(result.ToolCalls) == 0 {
566-
if e.renderer != nil {
575+
if e.renderer != nil && e.interactionMode != "off" {
567576
e.renderer.FinalAnswer(result.Content)
568577
e.renderer.Summary(
569578
e.TotalInputTokens,
@@ -607,7 +616,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
607616
e.renderer.NarratorMessage(msg)
608617
}
609618
}
610-
} else if e.renderer != nil && result.Content != "" {
619+
} else if e.renderer != nil && result.Content != "" && e.interactionMode != "off" {
611620
e.renderer.Thinking(result.Content)
612621
}
613622

@@ -653,7 +662,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
653662
e.renderer.NarratorMessage(msg)
654663
}
655664
}
656-
} else if e.renderer != nil {
665+
} else if e.renderer != nil && e.interactionMode != "off" {
657666
e.renderer.ToolCall(tc.Function.Name, tc.Function.Arguments)
658667
}
659668
if e.toolEventHandler != nil {
@@ -782,7 +791,7 @@ if e.approver != nil && len(result.ToolCalls) > 1 {
782791
output := results[i].output
783792

784793
// Tool results: only shown in verbose mode.
785-
if e.narrator == nil && e.renderer != nil {
794+
if e.narrator == nil && e.renderer != nil && e.interactionMode != "off" {
786795
e.renderer.ToolResult(output)
787796
}
788797
if e.toolEventHandler != nil {

internal/loop/loop_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package loop
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -14,6 +15,7 @@ import (
1415

1516
"github.com/BackendStack21/kode/internal/danger"
1617
"github.com/BackendStack21/kode/internal/llm"
18+
"github.com/BackendStack21/kode/internal/render"
1719
"github.com/BackendStack21/kode/internal/tool"
1820
)
1921

@@ -1840,3 +1842,69 @@ func TestClassifyToolCall_Terminal(t *testing.T) {
18401842
}
18411843
}
18421844

1845+
func TestEngine_InteractionModeOff_SuppressesAllRenderOutput(t *testing.T) {
1846+
// Mock LLM: first response has thinking + tool call, second is final answer.
1847+
callCount := 0
1848+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1849+
callCount++
1850+
if callCount == 1 {
1851+
fmt.Fprint(w, `{"choices":[{"message":{"content":"Let me check.","tool_calls":[{"id":"call_1","function":{"name":"echo","arguments":"{\"text\":\"hello\"}"}}]}}]}`)
1852+
} else {
1853+
fmt.Fprint(w, `{"choices":[{"message":{"content":"Final answer"}}]}`)
1854+
}
1855+
}))
1856+
defer server.Close()
1857+
1858+
var buf bytes.Buffer
1859+
reg := tool.NewRegistry([]tool.Tool{&fakeTool{name: "echo", output: "echo output"}})
1860+
rend := render.New(&buf, false)
1861+
1862+
client := llm.New(server.URL, "sk-test", "test-model", "", 0)
1863+
engine := New(client, reg, 10, "", rend, 0)
1864+
engine.SetInteractionMode("off")
1865+
1866+
result, err := engine.Run(context.Background(), "test")
1867+
if err != nil {
1868+
t.Fatalf("Run() error: %v", err)
1869+
}
1870+
if result != "Final answer" {
1871+
t.Errorf("result = %q, want %q", result, "Final answer")
1872+
}
1873+
if buf.Len() > 0 {
1874+
t.Errorf("render output should be empty in off mode, got %d bytes: %q", buf.Len(), buf.String())
1875+
}
1876+
}
1877+
1878+
func TestEngine_InteractionModeDefault_ProducesRenderOutput(t *testing.T) {
1879+
// Same mock LLM but without SetInteractionMode("off") — render output should appear.
1880+
callCount := 0
1881+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1882+
callCount++
1883+
if callCount == 1 {
1884+
fmt.Fprint(w, `{"choices":[{"message":{"content":"Let me check.","tool_calls":[{"id":"call_1","function":{"name":"echo","arguments":"{\"text\":\"hello\"}"}}]}}]}`)
1885+
} else {
1886+
fmt.Fprint(w, `{"choices":[{"message":{"content":"Final answer"}}]}`)
1887+
}
1888+
}))
1889+
defer server.Close()
1890+
1891+
var buf bytes.Buffer
1892+
reg := tool.NewRegistry([]tool.Tool{&fakeTool{name: "echo", output: "echo output"}})
1893+
rend := render.New(&buf, false)
1894+
1895+
client := llm.New(server.URL, "sk-test", "test-model", "", 0)
1896+
engine := New(client, reg, 10, "", rend, 0)
1897+
// Default interaction mode — no SetInteractionMode, no SetNarrator = verbose mode
1898+
1899+
result, err := engine.Run(context.Background(), "test")
1900+
if err != nil {
1901+
t.Fatalf("Run() error: %v", err)
1902+
}
1903+
if result != "Final answer" {
1904+
t.Errorf("result = %q, want %q", result, "Final answer")
1905+
}
1906+
if buf.Len() == 0 {
1907+
t.Error("render output should NOT be empty in default (verbose) mode")
1908+
}
1909+
}
1910+

internal/telegram/commands.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ func modeHandler(args string) (string, error) {
139139
"*Interaction Mode:*\n" +
140140
"• `interaction_mode: engaging` — emoji-rich narration (default)\n" +
141141
"• `interaction_mode: enhance` — narrated tool summaries (persist)\n" +
142-
"• `interaction_mode: verbose` — raw tool call output\n\n" +
142+
"• `interaction_mode: verbose` — raw tool call output\n" +
143+
"• `interaction_mode: off` — no progress output, clean answer only\n\n" +
143144
"*Tool Progress (Telegram):*\n" +
144145
"• `tool_progress: all` — smart previews with throttling (default)\n" +
145146
"• `tool_progress: new` — only on tool change\n" +

odek.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ type Config struct {
114114
// after each tool invocation. Used by the WebUI for live streaming.
115115
ToolEventHandler func(event string, name string, data string)
116116

117-
// InteractionMode controls tool-call rendering: "engaging" (default), "enhance", or "verbose".
117+
// InteractionMode controls tool-call rendering: "engaging" (default), "enhance", "verbose", or "off".
118118
InteractionMode string
119119

120120
// IterationCallback, if set, is invoked after each iteration of the
@@ -560,10 +560,14 @@ func New(cfg Config) (*Agent, error) {
560560

561561
// Wire narrator for engaging/enhance interaction modes.
562562
// In verbose mode, narrator stays nil → existing renderer behavior.
563+
// In "off" mode, narrator stays nil and render output is suppressed.
563564
if cfg.InteractionMode == "" || cfg.InteractionMode == "engaging" || cfg.InteractionMode == "enhance" {
564565
engine.SetNarrator(narrate.New(true))
565566
}
566567

568+
// Wire interaction mode to the engine for render gating
569+
engine.SetInteractionMode(cfg.InteractionMode)
570+
567571
// Wire per-turn episode search — searches past session episodes
568572
// using the user's message as a query, then injects relevant summaries.
569573
// Uses recency-based ranking (no LLM) to avoid recursion in the loop.

0 commit comments

Comments
 (0)