From 097550c2993096a8ac9ebf51ed20243b06d3f177 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 00:52:11 +0100 Subject: [PATCH] fix: centralize verbose overrides and tool stream gating --- CHANGELOG.md | 1 + docs/tools/thinking.md | 1 + src/agents/pi-embedded-subscribe.test.ts | 49 -------------- src/agents/pi-embedded-subscribe.ts | 75 +++++++++------------- src/auto-reply/reply/agent-runner.ts | 5 +- src/auto-reply/reply/directive-handling.ts | 9 +-- src/auto-reply/reply/followup-runner.ts | 5 +- src/commands/agent.ts | 17 ++--- src/cron/isolated-agent.ts | 11 ++-- src/gateway/server-chat.ts | 29 ++++++++- src/gateway/server.agent.test.ts | 65 ++++++++++++++++++- src/gateway/sessions-patch.ts | 15 ++--- src/infra/agent-events.ts | 4 ++ src/sessions/level-overrides.ts | 34 ++++++++++ ui/src/ui/views/sessions.ts | 10 ++- 15 files changed, 203 insertions(+), 127 deletions(-) create mode 100644 src/sessions/level-overrides.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 033feca68..97d6ee2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete - Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe - Control UI: persist per-session verbose off and hide tool cards unless verbose is on. (#262) — thanks @steipete +- Gateway: centralize verbose overrides and gate tool stream events at the server. (#262) — thanks @steipete - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - Sandbox: allow `session_status` tool in sandboxed sessions by default. — thanks @steipete - CLI: add `clawdbot config --section ` to jump straight into a wizard section (repeatable). diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 5f407015e..94b8cfc91 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -32,6 +32,7 @@ read_when: ## Verbose directives (/verbose or /v) - Levels: `on|full` or `off` (default). - Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state. +- `/verbose off` stores an explicit session override; clear it via the Sessions UI by choosing `inherit`. - Inline directive affects only that message; session/global defaults apply otherwise. - Send `/verbose` (or `/verbose:`) with no argument to see the current verbose level. - When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with ` : ` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting. diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index e27f3fc9d..c238b2b8b 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -1,13 +1,8 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; -import { emitAgentEvent } from "../infra/agent-events.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -vi.mock("../infra/agent-events.js", () => ({ - emitAgentEvent: vi.fn(), -})); - type StubSession = { subscribe: (fn: (evt: unknown) => void) => () => void; }; @@ -15,8 +10,6 @@ type StubSession = { type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { - const emitAgentEventMock = vi.mocked(emitAgentEvent); - it("filters to and falls back when tags are malformed", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { @@ -1474,48 +1467,6 @@ describe("subscribeEmbeddedPiSession", () => { expect(onToolResult).not.toHaveBeenCalled(); }); - it("skips tool stream events when tool verbose is off", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; - - emitAgentEventMock.mockReset(); - - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters< - typeof subscribeEmbeddedPiSession - >[0]["session"], - runId: "run-tool-events-off", - shouldEmitToolResult: () => false, - }); - - handler?.({ - type: "tool_execution_start", - toolName: "read", - toolCallId: "tool-evt-1", - args: { path: "/tmp/off.txt" }, - }); - handler?.({ - type: "tool_execution_update", - toolName: "read", - toolCallId: "tool-evt-1", - partialResult: "partial", - }); - handler?.({ - type: "tool_execution_end", - toolName: "read", - toolCallId: "tool-evt-1", - isError: false, - result: "ok", - }); - - expect(emitAgentEventMock).not.toHaveBeenCalled(); - }); - it("emits tool summaries when shouldEmitToolResult overrides verbose", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 22c58b958..a109448e0 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -368,7 +368,6 @@ export function subscribeEmbeddedPiSession(params: { const toolMetas: Array<{ toolName?: string; meta?: string }> = []; const toolMetaById = new Map(); const toolSummaryById = new Set(); - const toolEventById = new Set(); const blockReplyBreak = params.blockReplyBreak ?? "text_end"; const reasoningMode = params.reasoningMode ?? "off"; const includeReasoning = reasoningMode === "on"; @@ -590,7 +589,6 @@ export function subscribeEmbeddedPiSession(params: { toolMetas.length = 0; toolMetaById.clear(); toolSummaryById.clear(); - toolEventById.clear(); messagingToolSentTexts.length = 0; messagingToolSentTargets.length = 0; pendingMessagingTexts.clear(); @@ -642,19 +640,16 @@ export function subscribeEmbeddedPiSession(params: { ); const shouldEmitToolEvents = shouldEmitToolResult(); - if (shouldEmitToolEvents) { - toolEventById.add(toolCallId); - emitAgentEvent({ - runId: params.runId, - stream: "tool", - data: { - phase: "start", - name: toolName, - toolCallId, - args: args as Record, - }, - }); - } + emitAgentEvent({ + runId: params.runId, + stream: "tool", + data: { + phase: "start", + name: toolName, + toolCallId, + args: args as Record, + }, + }); params.onAgentEvent?.({ stream: "tool", data: { phase: "start", name: toolName, toolCallId }, @@ -710,18 +705,16 @@ export function subscribeEmbeddedPiSession(params: { const partial = (evt as AgentEvent & { partialResult?: unknown }) .partialResult; const sanitized = sanitizeToolResult(partial); - if (toolEventById.has(toolCallId)) { - emitAgentEvent({ - runId: params.runId, - stream: "tool", - data: { - phase: "update", - name: toolName, - toolCallId, - partialResult: sanitized, - }, - }); - } + emitAgentEvent({ + runId: params.runId, + stream: "tool", + data: { + phase: "update", + name: toolName, + toolCallId, + partialResult: sanitized, + }, + }); params.onAgentEvent?.({ stream: "tool", data: { @@ -768,22 +761,18 @@ export function subscribeEmbeddedPiSession(params: { } } - const shouldEmitToolEvents = toolEventById.has(toolCallId); - if (shouldEmitToolEvents) { - emitAgentEvent({ - runId: params.runId, - stream: "tool", - data: { - phase: "result", - name: toolName, - toolCallId, - meta, - isError, - result: sanitizedResult, - }, - }); - } - toolEventById.delete(toolCallId); + emitAgentEvent({ + runId: params.runId, + stream: "tool", + data: { + phase: "result", + name: toolName, + toolCallId, + meta, + isError, + result: sanitizedResult, + }, + }); params.onAgentEvent?.({ stream: "tool", data: { diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 9d443b54e..56a04b5b2 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -346,7 +346,10 @@ export async function runReplyAgent(params: { try { const runId = crypto.randomUUID(); if (sessionKey) { - registerAgentRunContext(runId, { sessionKey }); + registerAgentRunContext(runId, { + sessionKey, + verboseLevel: resolvedVerboseLevel, + }); } let runResult: Awaited>; let fallbackProvider = followupRun.run.provider; diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 056ff462c..fb5479da6 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -37,6 +37,7 @@ import { saveSessionStore, } from "../../config/sessions.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { shortenHomePath } from "../../utils.js"; import { extractModelDirective } from "../model.js"; import type { MsgContext } from "../templating.js"; @@ -853,7 +854,7 @@ export async function handleDirectiveOnly(params: { else sessionEntry.thinkingLevel = directives.thinkLevel; } if (directives.hasVerboseDirective && directives.verboseLevel) { - sessionEntry.verboseLevel = directives.verboseLevel; + applyVerboseOverride(sessionEntry, directives.verboseLevel); } if (directives.hasReasoningDirective && directives.reasoningLevel) { if (directives.reasoningLevel === "off") @@ -1027,11 +1028,7 @@ export async function persistInlineDirectives(params: { updated = true; } if (directives.hasVerboseDirective && directives.verboseLevel) { - if (directives.verboseLevel === "off") { - delete sessionEntry.verboseLevel; - } else { - sessionEntry.verboseLevel = directives.verboseLevel; - } + applyVerboseOverride(sessionEntry, directives.verboseLevel); updated = true; } if (directives.hasReasoningDirective && directives.reasoningLevel) { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index f031c9957..324494bc9 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -119,7 +119,10 @@ export function createFollowupRunner(params: { try { const runId = crypto.randomUUID(); if (queued.run.sessionKey) { - registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey }); + registerAgentRunContext(runId, { + sessionKey: queued.run.sessionKey, + verboseLevel: queued.run.verboseLevel, + }); } let autoCompactionCompleted = false; let runResult: Awaited>; diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 6ccdeaa4b..485324828 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -58,6 +58,7 @@ import { import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; import { normalizeMessageProvider, @@ -249,10 +250,6 @@ export async function agentCommand( let sessionEntry = resolvedSessionEntry; const runId = opts.runId?.trim() || sessionId; - if (sessionKey) { - registerAgentRunContext(runId, { sessionKey }); - } - if (opts.deliver === true) { const sendPolicy = resolveSendPolicy({ cfg, @@ -276,6 +273,13 @@ export async function agentCommand( persistedVerbose ?? (agentCfg?.verboseDefault as VerboseLevel | undefined); + if (sessionKey) { + registerAgentRunContext(runId, { + sessionKey, + verboseLevel: resolvedVerboseLevel, + }); + } + const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot; const skillsSnapshot = needsSkillsSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }) @@ -306,10 +310,7 @@ export async function agentCommand( if (thinkOverride === "off") delete next.thinkingLevel; else next.thinkingLevel = thinkOverride; } - if (verboseOverride) { - if (verboseOverride === "off") delete next.verboseLevel; - else next.verboseLevel = verboseOverride; - } + applyVerboseOverride(next, verboseOverride); sessionStore[sessionKey] = next; await saveSessionStore(storePath, sessionStore); } diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index e4b393daa..2ef6a3a65 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -425,8 +425,12 @@ export async function runCronIsolatedAgentTurn(params: { const sessionFile = resolveSessionTranscriptPath( cronSession.sessionEntry.sessionId, ); + const resolvedVerboseLevel = + (cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ?? + (agentCfg?.verboseDefault as "on" | "off" | undefined); registerAgentRunContext(cronSession.sessionEntry.sessionId, { sessionKey: params.sessionKey, + verboseLevel: resolvedVerboseLevel, }); const messageProvider = resolvedDelivery.provider; const claudeSessionId = cronSession.sessionEntry.claudeCliSessionId?.trim(); @@ -464,12 +468,7 @@ export async function runCronIsolatedAgentTurn(params: { provider: providerOverride, model: modelOverride, thinkLevel, - verboseLevel: - (cronSession.sessionEntry.verboseLevel as - | "on" - | "off" - | undefined) ?? - (agentCfg?.verboseDefault as "on" | "off" | undefined), + verboseLevel: resolvedVerboseLevel, timeoutMs, runId: cronSession.sessionEntry.sessionId, }); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 124e2ed24..8d418b62d 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -1,4 +1,9 @@ -import type { AgentEventPayload } from "../infra/agent-events.js"; +import { normalizeVerboseLevel } from "../auto-reply/thinking.js"; +import { + type AgentEventPayload, + getAgentRunContext, +} from "../infra/agent-events.js"; +import { loadSessionEntry } from "./session-utils.js"; import { formatForLog } from "./ws-log.js"; export type ChatRunEntry = { @@ -185,6 +190,24 @@ export function createAgentEventHandler({ bridgeSendToSession(sessionKey, "chat", payload); }; + const shouldEmitToolEvents = (runId: string, sessionKey?: string) => { + const runContext = getAgentRunContext(runId); + const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel); + if (runVerbose) return runVerbose === "on"; + if (!sessionKey) return false; + try { + const { cfg, entry } = loadSessionEntry(sessionKey); + const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel); + if (sessionVerbose) return sessionVerbose === "on"; + const defaultVerbose = normalizeVerboseLevel( + cfg.agents?.defaults?.verboseDefault, + ); + return defaultVerbose === "on"; + } catch { + return false; + } + }; + return (evt: AgentEventPayload) => { const chatLink = chatRunState.registry.peek(evt.runId); const sessionKey = @@ -192,6 +215,10 @@ export function createAgentEventHandler({ // Include sessionKey so Control UI can filter tool streams per session. const agentPayload = sessionKey ? { ...evt, sessionKey } : evt; const last = agentRunSeq.get(evt.runId) ?? 0; + if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) { + agentRunSeq.set(evt.runId, evt.seq); + return; + } if (evt.seq !== last + 1) { broadcast("agent", { runId: evt.runId, diff --git a/src/gateway/server.agent.test.ts b/src/gateway/server.agent.test.ts index 1186b5435..b1807cc50 100644 --- a/src/gateway/server.agent.test.ts +++ b/src/gateway/server.agent.test.ts @@ -785,7 +785,10 @@ describe("gateway server agent", () => { }, }); - registerAgentRunContext("run-tool-1", { sessionKey: "main" }); + registerAgentRunContext("run-tool-1", { + sessionKey: "main", + verboseLevel: "on", + }); const agentEvtP = onceMessage( ws, @@ -813,6 +816,66 @@ describe("gateway server agent", () => { await server.close(); }); + test("suppresses tool stream events when verbose is off", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + JSON.stringify( + { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: Date.now(), + verboseLevel: "off", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws, { + client: { + name: "webchat", + version: "1.0.0", + platform: "test", + mode: "webchat", + }, + }); + + registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" }); + + emitAgentEvent({ + runId: "run-tool-off", + stream: "tool", + data: { phase: "start", name: "read", toolCallId: "tool-1" }, + }); + emitAgentEvent({ + runId: "run-tool-off", + stream: "assistant", + data: { text: "hello" }, + }); + + const evt = await onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "agent" && + o.payload?.runId === "run-tool-off", + 8000, + ); + const payload = + evt.payload && typeof evt.payload === "object" + ? (evt.payload as Record) + : {}; + expect(payload.stream).toBe("assistant"); + + ws.close(); + await server.close(); + }); + test("agent.wait resolves after lifecycle end", async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 54fd41de5..31421800a 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -12,11 +12,14 @@ import { normalizeReasoningLevel, normalizeThinkLevel, normalizeUsageDisplay, - normalizeVerboseLevel, } from "../auto-reply/thinking.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { isSubagentSessionKey } from "../routing/session-key.js"; +import { + applyVerboseOverride, + parseVerboseOverride, +} from "../sessions/level-overrides.js"; import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { parseSessionLabel } from "../sessions/session-label.js"; import { @@ -103,13 +106,9 @@ export async function applySessionsPatchToStore(params: { if ("verboseLevel" in patch) { const raw = patch.verboseLevel; - if (raw === null) { - delete next.verboseLevel; - } else if (raw !== undefined) { - const normalized = normalizeVerboseLevel(String(raw)); - if (!normalized) return invalid('invalid verboseLevel (use "on"|"off")'); - next.verboseLevel = normalized; - } + const parsed = parseVerboseOverride(raw); + if (!parsed.ok) return invalid(parsed.error); + applyVerboseOverride(next, parsed.value); } if ("reasoningLevel" in patch) { diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index e61f841cf..e014d848d 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -16,6 +16,7 @@ export type AgentEventPayload = { export type AgentRunContext = { sessionKey?: string; + verboseLevel?: "off" | "on"; }; // Keep per-run counters so streams stay strictly monotonic per runId. @@ -36,6 +37,9 @@ export function registerAgentRunContext( if (context.sessionKey && existing.sessionKey !== context.sessionKey) { existing.sessionKey = context.sessionKey; } + if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) { + existing.verboseLevel = context.verboseLevel; + } } export function getAgentRunContext(runId: string) { diff --git a/src/sessions/level-overrides.ts b/src/sessions/level-overrides.ts new file mode 100644 index 000000000..dfb0d617e --- /dev/null +++ b/src/sessions/level-overrides.ts @@ -0,0 +1,34 @@ +import { + normalizeVerboseLevel, + type VerboseLevel, +} from "../auto-reply/thinking.js"; +import type { SessionEntry } from "../config/sessions.js"; + +export function parseVerboseOverride( + raw: unknown, +): + | { ok: true; value: VerboseLevel | null | undefined } + | { ok: false; error: string } { + if (raw === null) return { ok: true, value: null }; + if (raw === undefined) return { ok: true, value: undefined }; + if (typeof raw !== "string") { + return { ok: false, error: 'invalid verboseLevel (use "on"|"off")' }; + } + const normalized = normalizeVerboseLevel(raw); + if (!normalized) { + return { ok: false, error: 'invalid verboseLevel (use "on"|"off")' }; + } + return { ok: true, value: normalized }; +} + +export function applyVerboseOverride( + entry: SessionEntry, + level: VerboseLevel | null | undefined, +) { + if (level === undefined) return; + if (level === null) { + delete entry.verboseLevel; + return; + } + entry.verboseLevel = level; +} diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 1d655b1a2..32106e2ba 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -32,7 +32,11 @@ export type SessionsProps = { }; const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const; -const VERBOSE_LEVELS = ["", "off", "on"] as const; +const VERBOSE_LEVELS = [ + { value: "", label: "inherit" }, + { value: "off", label: "off (explicit)" }, + { value: "on", label: "on" }, +] as const; const REASONING_LEVELS = ["", "off", "on", "stream"] as const; export function renderSessions(props: SessionsProps) { @@ -178,8 +182,8 @@ function renderRow( onPatch(row.key, { verboseLevel: value || null }); }} > - ${VERBOSE_LEVELS.map((level) => - html``, + ${VERBOSE_LEVELS.map( + (level) => html``, )}