diff --git a/docs/start/faq.md b/docs/start/faq.md index 0dbc1e7a5..f9b096be3 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -603,6 +603,7 @@ Quick reference (send these in chat): | `/activation mention\|always` | Group activation (owner-only) | | `/think ` | Set thinking level (off\|minimal\|low\|medium\|high) | | `/verbose on\|off` | Toggle verbose mode | +| `/reasoning on\|off` | Toggle reasoning visibility | | `/elevated on\|off` | Toggle elevated bash mode (approved senders only) | | `/model ` | Switch AI model (see below) | | `/queue ` | Queue mode (see below) | diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index d558af97a..7a9eddd9b 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -40,6 +40,7 @@ Text + native (when enabled): - `/reset` or `/new` - `/think ` (aliases: `/thinking`, `/t`) - `/verbose on|off` (alias: `/v`) +- `/reasoning on|off` (alias: `/reason`) - `/elevated on|off` (alias: `/elev`) - `/model ` - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`) diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index a35eb2864..86ebb6f5a 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -34,6 +34,12 @@ read_when: - Inline directive affects only that message; session/global defaults apply otherwise. - 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. +## Reasoning visibility (/reasoning) +- Levels: `on|off`. +- Directive-only message toggles whether thinking blocks are shown as italic text in replies. +- When enabled, any model-provided reasoning content is appended as a separate italic block. +- Alias: `/reason`. + ## Related - Elevated mode docs live in [`docs/elevated.md`](/tools/elevated). diff --git a/docs/web/tui.md b/docs/web/tui.md index 2a668160e..3c83c0a43 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -51,6 +51,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `/model ` (or `/model list`, `/models`) - `/think ` - `/verbose ` +- `/reasoning ` - `/elevated ` - `/elev ` - `/activation ` diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 91e3105e1..e18f3aebb 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -13,7 +13,11 @@ import { type Skill, } from "@mariozechner/pi-coding-agent"; import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js"; -import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; +import type { + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "../auto-reply/thinking.js"; import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import type { ClawdbotConfig } from "../config/config.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; @@ -53,7 +57,11 @@ import { type BlockReplyChunking, subscribeEmbeddedPiSession, } from "./pi-embedded-subscribe.js"; -import { extractAssistantText } from "./pi-embedded-utils.js"; +import { + extractAssistantText, + extractAssistantThinking, + formatReasoningMarkdown, +} from "./pi-embedded-utils.js"; import { toToolDefinitions } from "./pi-tool-definition-adapter.js"; import { createClawdbotCodingTools } from "./pi-tools.js"; import { resolveSandboxContext } from "./sandbox.js"; @@ -575,6 +583,7 @@ export async function runEmbeddedPiAgent(params: { authProfileId?: string; thinkLevel?: ThinkLevel; verboseLevel?: VerboseLevel; + reasoningLevel?: ReasoningLevel; bashElevated?: BashElevatedDefaults; timeoutMs: number; runId: string; @@ -846,6 +855,7 @@ export async function runEmbeddedPiAgent(params: { session, runId: params.runId, verboseLevel: params.verboseLevel, + includeReasoning: params.reasoningLevel === "on", shouldEmitToolResult: params.shouldEmitToolResult, onToolResult: params.onToolResult, onBlockReply: params.onBlockReply, @@ -1064,10 +1074,22 @@ export async function runEmbeddedPiAgent(params: { } } + const fallbackText = lastAssistant + ? (() => { + const base = extractAssistantText(lastAssistant); + if (params.reasoningLevel !== "on") return base; + const thinking = extractAssistantThinking(lastAssistant); + const formatted = thinking + ? formatReasoningMarkdown(thinking) + : ""; + if (!formatted) return base; + return base ? `${base}\n\n${formatted}` : formatted; + })() + : ""; for (const text of assistantTexts.length ? assistantTexts - : lastAssistant - ? [extractAssistantText(lastAssistant)] + : fallbackText + ? [fallbackText] : []) { const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text); if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index e87ff74e8..9b9169a69 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -10,6 +10,8 @@ import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; import { extractAssistantText, + extractAssistantThinking, + formatReasoningMarkdown, inferToolMetaFromArgs, } from "./pi-embedded-utils.js"; @@ -85,6 +87,7 @@ export function subscribeEmbeddedPiSession(params: { session: AgentSession; runId: string; verboseLevel?: "off" | "on"; + includeReasoning?: boolean; shouldEmitToolResult?: () => boolean; onToolResult?: (payload: { text?: string; @@ -120,6 +123,7 @@ export function subscribeEmbeddedPiSession(params: { let pendingCompactionRetry = 0; let compactionRetryResolve: (() => void) | undefined; let compactionRetryPromise: Promise | null = null; + let lastReasoningSent: string | undefined; const ensureCompactionPromise = () => { if (!compactionRetryPromise) { @@ -216,6 +220,24 @@ export function subscribeEmbeddedPiSession(params: { }); }; + const extractThinkingFromText = (text: string): string => { + if (!text || !THINKING_TAG_RE.test(text)) return ""; + THINKING_TAG_RE.lastIndex = 0; + let result = ""; + let lastIndex = 0; + let inThinking = false; + for (const match of text.matchAll(THINKING_TAG_RE)) { + const idx = match.index ?? 0; + if (inThinking) { + result += text.slice(lastIndex, idx); + } + const tag = match[0].toLowerCase(); + inThinking = !tag.includes("/"); + lastIndex = idx + match[0].length; + } + return result.trim(); + }; + const resetForCompactionRetry = () => { assistantTexts.length = 0; toolMetas.length = 0; @@ -244,6 +266,7 @@ export function subscribeEmbeddedPiSession(params: { blockChunker?.reset(); lastStreamedAssistant = undefined; lastBlockReplyText = undefined; + lastReasoningSent = undefined; assistantTextBaseline = assistantTexts.length; } } @@ -470,19 +493,26 @@ export function subscribeEmbeddedPiSession(params: { if (evt.type === "message_end") { const msg = (evt as AgentEvent & { message: AgentMessage }).message; if (msg?.role === "assistant") { + const assistantMessage = msg as AssistantMessage; + const rawText = extractAssistantText(assistantMessage); const cleaned = params.enforceFinalTag - ? stripThinkingSegments( - stripUnpairedThinkingTags( - extractAssistantText(msg as AssistantMessage), - ), - ) - : stripThinkingSegments( - extractAssistantText(msg as AssistantMessage), - ); - const text = + ? stripThinkingSegments(stripUnpairedThinkingTags(rawText)) + : stripThinkingSegments(rawText); + const baseText = params.enforceFinalTag && cleaned ? (extractFinalText(cleaned)?.trim() ?? cleaned) : cleaned; + const rawThinking = params.includeReasoning + ? extractAssistantThinking(assistantMessage) || + extractThinkingFromText(rawText) + : ""; + const formattedReasoning = rawThinking + ? formatReasoningMarkdown(rawThinking) + : ""; + const text = + baseText && formattedReasoning + ? `${baseText}\n\n${formattedReasoning}` + : baseText || formattedReasoning; const addedDuringMessage = assistantTexts.length > assistantTextBaseline; @@ -516,6 +546,16 @@ export function subscribeEmbeddedPiSession(params: { } } } + const onBlockReply = params.onBlockReply; + const shouldEmitReasoningBlock = + Boolean(formattedReasoning) && + Boolean(onBlockReply) && + formattedReasoning !== lastReasoningSent && + (blockReplyBreak === "text_end" || Boolean(blockChunker)); + if (shouldEmitReasoningBlock && formattedReasoning && onBlockReply) { + lastReasoningSent = formattedReasoning; + void onBlockReply({ text: formattedReasoning }); + } deltaBuffer = ""; blockBuffer = ""; blockChunker?.reset(); diff --git a/src/agents/pi-embedded-utils.ts b/src/agents/pi-embedded-utils.ts index a68956c7b..c7698682b 100644 --- a/src/agents/pi-embedded-utils.ts +++ b/src/agents/pi-embedded-utils.ts @@ -19,6 +19,32 @@ export function extractAssistantText(msg: AssistantMessage): string { return blocks.join("\n").trim(); } +export function extractAssistantThinking(msg: AssistantMessage): string { + if (!Array.isArray(msg.content)) return ""; + const blocks = msg.content + .map((block) => { + if (!block || typeof block !== "object") return ""; + const record = block as unknown as Record; + if (record.type === "thinking" && typeof record.thinking === "string") { + return record.thinking.trim(); + } + return ""; + }) + .filter(Boolean); + return blocks.join("\n").trim(); +} + +export function formatReasoningMarkdown(text: string): string { + const trimmed = text.trim(); + if (!trimmed) return ""; + const lines = trimmed.split(/\r?\n/); + const wrapped = lines + .map((line) => line.trim()) + .map((line) => (line ? `_${line}_` : "")) + .filter((line) => line.length > 0); + return wrapped.length > 0 ? [`_Reasoning:_`, ...wrapped].join("\n") : ""; +} + export function inferToolMetaFromArgs( toolName: string, args: unknown, diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 44219f66d..175369024 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -79,6 +79,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [ textAliases: ["/verbose", "/v"], acceptsArgs: true, }, + { + key: "reasoning", + nativeName: "reasoning", + description: "Toggle reasoning visibility.", + textAliases: ["/reasoning", "/reason"], + acceptsArgs: true, + }, { key: "elevated", nativeName: "elevated", diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 55004ce9c..1a55ba317 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -15,6 +15,7 @@ import { drainSystemEvents } from "../infra/system-events.js"; import { extractElevatedDirective, extractQueueDirective, + extractReasoningDirective, extractReplyToTag, extractThinkDirective, extractVerboseDirective, @@ -99,6 +100,12 @@ describe("directive parsing", () => { expect(res.verboseLevel).toBe("on"); }); + it("matches reasoning directive", () => { + const res = extractReasoningDirective("/reasoning on please"); + expect(res.hasDirective).toBe(true); + expect(res.reasoningLevel).toBe("on"); + }); + it("matches elevated with leading space", () => { const res = extractElevatedDirective(" please /elevated on now"); expect(res.hasDirective).toBe(true); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 40a26c8f7..2f17d9e64 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -66,6 +66,7 @@ import type { MsgContext, TemplateContext } from "./templating.js"; import { type ElevatedLevel, normalizeThinkLevel, + type ReasoningLevel, type ThinkLevel, type VerboseLevel, } from "./thinking.js"; @@ -75,6 +76,7 @@ import type { GetReplyOptions, ReplyPayload } from "./types.js"; export { extractElevatedDirective, + extractReasoningDirective, extractThinkDirective, extractVerboseDirective, } from "./reply/directives.js"; @@ -288,6 +290,9 @@ export async function getReplyFromConfig( hasVerboseDirective: false, verboseLevel: undefined, rawVerboseLevel: undefined, + hasReasoningDirective: false, + reasoningLevel: undefined, + rawReasoningLevel: undefined, hasElevatedDirective: false, elevatedLevel: undefined, rawElevatedLevel: undefined, @@ -310,6 +315,7 @@ export async function getReplyFromConfig( const hasDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || + parsedDirectives.hasReasoningDirective || parsedDirectives.hasElevatedDirective || parsedDirectives.hasStatusDirective || parsedDirectives.hasModelDirective || @@ -327,6 +333,7 @@ export async function getReplyFromConfig( ...parsedDirectives, hasThinkDirective: false, hasVerboseDirective: false, + hasReasoningDirective: false, hasStatusDirective: false, hasModelDirective: false, hasQueueDirective: false, @@ -377,6 +384,10 @@ export async function getReplyFromConfig( (directives.verboseLevel as VerboseLevel | undefined) ?? (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? (agentCfg?.verboseDefault as VerboseLevel | undefined); + const resolvedReasoningLevel: ReasoningLevel = + (directives.reasoningLevel as ReasoningLevel | undefined) ?? + (sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? + "off"; const resolvedElevatedLevel = elevatedAllowed ? ((directives.elevatedLevel as ElevatedLevel | undefined) ?? (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? @@ -542,6 +553,7 @@ export async function getReplyFromConfig( defaultGroupActivation: () => defaultActivation, resolvedThinkLevel, resolvedVerboseLevel: resolvedVerboseLevel ?? "off", + resolvedReasoningLevel, resolvedElevatedLevel, resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, provider, @@ -734,6 +746,7 @@ export async function getReplyFromConfig( authProfileId, thinkLevel: resolvedThinkLevel, verboseLevel: resolvedVerboseLevel, + reasoningLevel: resolvedReasoningLevel, elevatedLevel: resolvedElevatedLevel, bashElevated: { enabled: elevatedEnabled, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 490b696cd..602c58f28 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -221,6 +221,7 @@ export async function runReplyAgent(params: { authProfileId: followupRun.run.authProfileId, thinkLevel: followupRun.run.thinkLevel, verboseLevel: followupRun.run.verboseLevel, + reasoningLevel: followupRun.run.reasoningLevel, bashElevated: followupRun.run.bashElevated, timeoutMs: followupRun.run.timeoutMs, runId, diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 030fa902e..27d62facf 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -41,7 +41,12 @@ import { formatTokenCount, } from "../status.js"; import type { MsgContext } from "../templating.js"; -import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; +import type { + ElevatedLevel, + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; import type { InlineDirectives } from "./directive-handling.js"; @@ -202,6 +207,7 @@ export async function handleCommands(params: { defaultGroupActivation: () => "always" | "mention"; resolvedThinkLevel?: ThinkLevel; resolvedVerboseLevel: VerboseLevel; + resolvedReasoningLevel: ReasoningLevel; resolvedElevatedLevel?: ElevatedLevel; resolveDefaultThinkingLevel: () => Promise; provider: string; @@ -226,6 +232,7 @@ export async function handleCommands(params: { defaultGroupActivation, resolvedThinkLevel, resolvedVerboseLevel, + resolvedReasoningLevel, resolvedElevatedLevel, resolveDefaultThinkingLevel, provider, @@ -405,6 +412,7 @@ export async function handleCommands(params: { resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), resolvedVerbose: resolvedVerboseLevel, + resolvedReasoning: resolvedReasoningLevel, resolvedElevated: resolvedElevatedLevel, modelAuth: resolveModelAuthLabel(provider, cfg), webLinked, diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 7b3c240ae..b567c4bc8 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -32,9 +32,11 @@ import type { ReplyPayload } from "../types.js"; import { type ElevatedLevel, extractElevatedDirective, + extractReasoningDirective, extractStatusDirective, extractThinkDirective, extractVerboseDirective, + type ReasoningLevel, type ThinkLevel, type VerboseLevel, } from "./directives.js"; @@ -155,6 +157,9 @@ export type InlineDirectives = { hasVerboseDirective: boolean; verboseLevel?: VerboseLevel; rawVerboseLevel?: string; + hasReasoningDirective: boolean; + reasoningLevel?: ReasoningLevel; + rawReasoningLevel?: string; hasElevatedDirective: boolean; elevatedLevel?: ElevatedLevel; rawElevatedLevel?: string; @@ -188,12 +193,18 @@ export function parseInlineDirectives(body: string): InlineDirectives { rawLevel: rawVerboseLevel, hasDirective: hasVerboseDirective, } = extractVerboseDirective(thinkCleaned); + const { + cleaned: reasoningCleaned, + reasoningLevel, + rawLevel: rawReasoningLevel, + hasDirective: hasReasoningDirective, + } = extractReasoningDirective(verboseCleaned); const { cleaned: elevatedCleaned, elevatedLevel, rawLevel: rawElevatedLevel, hasDirective: hasElevatedDirective, - } = extractElevatedDirective(verboseCleaned); + } = extractElevatedDirective(reasoningCleaned); const { cleaned: statusCleaned, hasDirective: hasStatusDirective } = extractStatusDirective(elevatedCleaned); const { @@ -225,6 +236,9 @@ export function parseInlineDirectives(body: string): InlineDirectives { hasVerboseDirective, verboseLevel, rawVerboseLevel, + hasReasoningDirective, + reasoningLevel, + rawReasoningLevel, hasElevatedDirective, elevatedLevel, rawElevatedLevel, @@ -257,6 +271,7 @@ export function isDirectiveOnly(params: { if ( !directives.hasThinkDirective && !directives.hasVerboseDirective && + !directives.hasReasoningDirective && !directives.hasElevatedDirective && !directives.hasModelDirective && !directives.hasQueueDirective @@ -367,6 +382,11 @@ export async function handleDirectiveOnly(params: { text: `Unrecognized verbose level "${directives.rawVerboseLevel ?? ""}". Valid levels: off, on.`, }; } + if (directives.hasReasoningDirective && !directives.reasoningLevel) { + return { + text: `Unrecognized reasoning level "${directives.rawReasoningLevel ?? ""}". Valid levels: on, off.`, + }; + } if (directives.hasElevatedDirective && !directives.elevatedLevel) { return { text: `Unrecognized elevated level "${directives.rawElevatedLevel ?? ""}". Valid levels: off, on.`, @@ -476,6 +496,11 @@ export async function handleDirectiveOnly(params: { if (directives.verboseLevel === "off") delete sessionEntry.verboseLevel; else sessionEntry.verboseLevel = directives.verboseLevel; } + if (directives.hasReasoningDirective && directives.reasoningLevel) { + if (directives.reasoningLevel === "off") + delete sessionEntry.reasoningLevel; + else sessionEntry.reasoningLevel = directives.reasoningLevel; + } if (directives.hasElevatedDirective && directives.elevatedLevel) { if (directives.elevatedLevel === "off") delete sessionEntry.elevatedLevel; else sessionEntry.elevatedLevel = directives.elevatedLevel; @@ -533,6 +558,13 @@ export async function handleDirectiveOnly(params: { : `${SYSTEM_MARK} Verbose logging enabled.`, ); } + if (directives.hasReasoningDirective && directives.reasoningLevel) { + parts.push( + directives.reasoningLevel === "off" + ? `${SYSTEM_MARK} Reasoning visibility disabled.` + : `${SYSTEM_MARK} Reasoning visibility enabled.`, + ); + } if (directives.hasElevatedDirective && directives.elevatedLevel) { parts.push( directives.elevatedLevel === "off" @@ -634,6 +666,14 @@ export async function persistInlineDirectives(params: { } updated = true; } + if (directives.hasReasoningDirective && directives.reasoningLevel) { + if (directives.reasoningLevel === "off") { + delete sessionEntry.reasoningLevel; + } else { + sessionEntry.reasoningLevel = directives.reasoningLevel; + } + updated = true; + } if ( directives.hasElevatedDirective && directives.elevatedLevel && diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index bf3fee257..f1eacaccb 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -1,6 +1,8 @@ +import type { ReasoningLevel } from "../thinking.js"; import { type ElevatedLevel, normalizeElevatedLevel, + normalizeReasoningLevel, normalizeThinkLevel, normalizeVerboseLevel, type ThinkLevel, @@ -74,6 +76,28 @@ export function extractElevatedDirective(body?: string): { }; } +export function extractReasoningDirective(body?: string): { + cleaned: string; + reasoningLevel?: ReasoningLevel; + rawLevel?: string; + hasDirective: boolean; +} { + if (!body) return { cleaned: "", hasDirective: false }; + const match = body.match( + /(?:^|\s)\/(?:reasoning|reason)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i, + ); + const reasoningLevel = normalizeReasoningLevel(match?.[1]); + const cleaned = match + ? body.replace(match[0], "").replace(/\s+/g, " ").trim() + : body.trim(); + return { + cleaned, + reasoningLevel, + rawLevel: match?.[1], + hasDirective: !!match, + }; +} + export function extractStatusDirective(body?: string): { cleaned: string; hasDirective: boolean; @@ -89,4 +113,4 @@ export function extractStatusDirective(body?: string): { }; } -export type { ElevatedLevel, ThinkLevel, VerboseLevel }; +export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 6086b7f40..026fc0e80 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -131,6 +131,7 @@ export function createFollowupRunner(params: { authProfileId: queued.run.authProfileId, thinkLevel: queued.run.thinkLevel, verboseLevel: queued.run.verboseLevel, + reasoningLevel: queued.run.reasoningLevel, bashElevated: queued.run.bashElevated, timeoutMs: queued.run.timeoutMs, runId, diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index e2b089144..f1939ba59 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -4,7 +4,12 @@ import type { ClawdbotConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { defaultRuntime } from "../../runtime.js"; import type { OriginatingChannelType } from "../templating.js"; -import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "./directives.js"; +import type { + ElevatedLevel, + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "./directives.js"; import { isRoutableChannel } from "./route-reply.js"; export type QueueMode = | "steer" @@ -54,6 +59,7 @@ export type FollowupRun = { authProfileId?: string; thinkLevel?: ThinkLevel; verboseLevel?: VerboseLevel; + reasoningLevel?: ReasoningLevel; elevatedLevel?: ElevatedLevel; bashElevated?: { enabled: boolean; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index bec2aa262..7166c7d63 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -20,7 +20,12 @@ import { type SessionScope, } from "../config/sessions.js"; import { shortenHomePath } from "../utils.js"; -import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "./thinking.js"; +import type { + ElevatedLevel, + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "./thinking.js"; type AgentConfig = NonNullable; @@ -34,6 +39,7 @@ type StatusArgs = { groupActivation?: "mention" | "always"; resolvedThink?: ThinkLevel; resolvedVerbose?: VerboseLevel; + resolvedReasoning?: ReasoningLevel; resolvedElevated?: ElevatedLevel; modelAuth?: string; now?: number; @@ -173,6 +179,7 @@ export function buildStatusMessage(args: StatusArgs): string { const thinkLevel = args.resolvedThink ?? args.agent?.thinkingDefault ?? "off"; const verboseLevel = args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off"; + const reasoningLevel = args.resolvedReasoning ?? "off"; const elevatedLevel = args.resolvedElevated ?? args.sessionEntry?.elevatedLevel ?? @@ -241,8 +248,8 @@ export function buildStatusMessage(args: StatusArgs): string { )}${entry?.abortedLastRun ? " • last run aborted" : ""}`; const optionsLine = runtime.sandboxed - ? `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | elevated=${elevatedLevel}` - : `Options: thinking=${thinkLevel} | verbose=${verboseLevel}`; + ? `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | reasoning=${reasoningLevel} | elevated=${elevatedLevel}` + : `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | reasoning=${reasoningLevel}`; const modelLabel = model ? `${provider}/${model}` : "unknown"; @@ -273,6 +280,6 @@ export function buildHelpMessage(): string { return [ "ℹ️ Help", "Shortcuts: /new reset | /compact [instructions] | /restart relink", - "Options: /think | /verbose on|off | /elevated on|off | /model ", + "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model ", ].join("\n"); } diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 72890de36..5764e1775 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -1,8 +1,20 @@ import { describe, expect, it } from "vitest"; -import { normalizeThinkLevel } from "./thinking.js"; +import { normalizeReasoningLevel, normalizeThinkLevel } from "./thinking.js"; describe("normalizeThinkLevel", () => { it("accepts mid as medium", () => { expect(normalizeThinkLevel("mid")).toBe("medium"); }); }); + +describe("normalizeReasoningLevel", () => { + it("accepts on/off", () => { + expect(normalizeReasoningLevel("on")).toBe("on"); + expect(normalizeReasoningLevel("off")).toBe("off"); + }); + + it("accepts show/hide", () => { + expect(normalizeReasoningLevel("show")).toBe("on"); + expect(normalizeReasoningLevel("hide")).toBe("off"); + }); +}); diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index d2a3be1e5..c1e3fb580 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -1,6 +1,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; export type VerboseLevel = "off" | "on"; export type ElevatedLevel = "off" | "on"; +export type ReasoningLevel = "off" | "on"; // Normalize user-provided thinking level strings to the canonical enum. export function normalizeThinkLevel( @@ -55,3 +56,31 @@ export function normalizeElevatedLevel( if (["on", "true", "yes", "1"].includes(key)) return "on"; return undefined; } + +// Normalize reasoning visibility flags used to toggle reasoning exposure. +export function normalizeReasoningLevel( + raw?: string | null, +): ReasoningLevel | undefined { + if (!raw) return undefined; + const key = raw.toLowerCase(); + if ( + [ + "off", + "false", + "no", + "0", + "hide", + "hidden", + "disable", + "disabled", + ].includes(key) + ) + return "off"; + if ( + ["on", "true", "yes", "1", "show", "visible", "enable", "enabled"].includes( + key, + ) + ) + return "on"; + return undefined; +} diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 7c91ecfa2..4bd2b7c76 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -26,6 +26,7 @@ type SessionRow = { abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + reasoningLevel?: string; groupActivation?: string; inputTokens?: number; outputTokens?: number; @@ -99,6 +100,7 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => { const flags = [ row.thinkingLevel ? `think:${row.thinkingLevel}` : null, row.verboseLevel ? `verbose:${row.verboseLevel}` : null, + row.reasoningLevel ? `reasoning:${row.reasoningLevel}` : null, row.groupActivation ? `activation:${row.groupActivation}` : null, row.systemSent ? "system" : null, row.abortedLastRun ? "aborted" : null, @@ -147,6 +149,7 @@ function toRows(store: Record): SessionRow[] { abortedLastRun: entry?.abortedLastRun, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, groupActivation: entry?.groupActivation, inputTokens: entry?.inputTokens, outputTokens: entry?.outputTokens, diff --git a/src/commands/status.ts b/src/commands/status.ts index 96fdcdf7c..2d5db34c1 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -33,6 +33,7 @@ export type SessionStatus = { age: number | null; thinkingLevel?: string; verboseLevel?: string; + reasoningLevel?: string; elevatedLevel?: string; systemSent?: boolean; abortedLastRun?: boolean; @@ -111,6 +112,7 @@ export async function getStatusSummary(): Promise { age, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, elevatedLevel: entry?.elevatedLevel, systemSent: entry?.systemSent, abortedLastRun: entry?.abortedLastRun, @@ -198,6 +200,9 @@ const buildFlags = (entry: SessionEntry): string[] => { const verbose = entry?.verboseLevel; if (typeof verbose === "string" && verbose.length > 0) flags.push(`verbose:${verbose}`); + const reasoning = entry?.reasoningLevel; + if (typeof reasoning === "string" && reasoning.length > 0) + flags.push(`reasoning:${reasoning}`); const elevated = entry?.elevatedLevel; if (typeof elevated === "string" && elevated.length > 0) flags.push(`elevated:${elevated}`); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 5b297ec01..e3f499438 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -40,6 +40,7 @@ export type SessionEntry = { chatType?: SessionChatType; thinkingLevel?: string; verboseLevel?: string; + reasoningLevel?: string; elevatedLevel?: string; providerOverride?: string; modelOverride?: string; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index f58652eb2..46ce67c74 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -323,6 +323,7 @@ export const SessionsPatchParamsSchema = Type.Object( key: NonEmptyString, thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index c062bfea1..d772a88a5 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -20,6 +20,7 @@ import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { normalizeElevatedLevel, + normalizeReasoningLevel, normalizeThinkLevel, normalizeVerboseLevel, } from "../auto-reply/thinking.js"; @@ -434,6 +435,26 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } } + if ("reasoningLevel" in p) { + const raw = p.reasoningLevel; + if (raw === null) { + delete next.reasoningLevel; + } else if (raw !== undefined) { + const normalized = normalizeReasoningLevel(String(raw)); + if (!normalized) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid reasoningLevel: ${String(raw)}`, + }, + }; + } + if (normalized === "off") delete next.reasoningLevel; + else next.reasoningLevel = normalized; + } + } + if ("elevatedLevel" in p) { const raw = p.elevatedLevel; if (raw === null) { @@ -602,6 +623,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { abortedLastRun: false, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, @@ -986,6 +1008,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { updatedAt: now, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, systemSent: entry?.systemSent, lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, @@ -1125,6 +1148,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { updatedAt: now, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, lastProvider: entry?.lastProvider, @@ -1207,6 +1231,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { updatedAt: now, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, lastProvider: entry?.lastProvider, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 6cddd0c01..cab2009e0 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -82,6 +82,7 @@ export const agentHandlers: GatewayRequestHandlers = { updatedAt: now, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, skillsSnapshot: entry?.skillsSnapshot, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 05104323e..9bef65084 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -200,6 +200,7 @@ export const chatHandlers: GatewayRequestHandlers = { updatedAt: now, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, lastProvider: entry?.lastProvider, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 24074ae07..a209fb396 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -17,6 +17,7 @@ import { } from "../../agents/pi-embedded.js"; import { normalizeGroupActivation } from "../../auto-reply/group-activation.js"; import { + normalizeReasoningLevel, normalizeThinkLevel, normalizeVerboseLevel, } from "../../auto-reply/thinking.js"; @@ -211,6 +212,28 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } + if ("reasoningLevel" in p) { + const raw = p.reasoningLevel; + if (raw === null) { + delete next.reasoningLevel; + } else if (raw !== undefined) { + const normalized = normalizeReasoningLevel(String(raw)); + if (!normalized) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'invalid reasoningLevel (use "on"|"off")', + ), + ); + return; + } + if (normalized === "off") delete next.reasoningLevel; + else next.reasoningLevel = normalized; + } + } + if ("model" in p) { const raw = p.model; if (raw === null) { @@ -370,6 +393,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { abortedLastRun: false, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index e299ad0e4..3aa1aca4d 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -43,6 +43,7 @@ export type GatewaySessionRow = { abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + reasoningLevel?: string; elevatedLevel?: string; sendPolicy?: "allow" | "deny"; inputTokens?: number; @@ -441,6 +442,7 @@ export function listSessionsFromStore(params: { abortedLastRun: entry?.abortedLastRun, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, elevatedLevel: entry?.elevatedLevel, sendPolicy: entry?.sendPolicy, inputTokens: entry?.inputTokens, diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 032b04ce6..8e7a81046 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -2,6 +2,7 @@ import type { SlashCommand } from "@mariozechner/pi-tui"; const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"]; const VERBOSE_LEVELS = ["on", "off"]; +const REASONING_LEVELS = ["on", "off"]; const ELEVATED_LEVELS = ["on", "off"]; const ACTIVATION_LEVELS = ["mention", "always"]; const TOGGLE = ["on", "off"]; @@ -53,6 +54,14 @@ export function getSlashCommands(): SlashCommand[] { (value) => ({ value, label: value }), ), }, + { + name: "reasoning", + description: "Set reasoning on/off", + getArgumentCompletions: (prefix) => + REASONING_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map( + (value) => ({ value, label: value }), + ), + }, { name: "elevated", description: "Set elevated on/off", @@ -103,6 +112,7 @@ export function helpText(): string { "/model (or /models)", "/think ", "/verbose ", + "/reasoning ", "/elevated ", "/elev ", "/activation ", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 1fe903050..67c448702 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -40,6 +40,7 @@ export type GatewaySessionList = { updatedAt?: number | null; thinkingLevel?: string; verboseLevel?: string; + reasoningLevel?: string; sendPolicy?: string; model?: string; contextTokens?: number | null; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 9259f2ad8..f81012669 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -45,6 +45,7 @@ type AgentEvent = { type SessionInfo = { thinkingLevel?: string; verboseLevel?: string; + reasoningLevel?: string; model?: string; contextTokens?: number | null; totalTokens?: number | null; @@ -167,10 +168,11 @@ export async function runTui(opts: TuiOptions) { ); const think = sessionInfo.thinkingLevel ?? "off"; const verbose = sessionInfo.verboseLevel ?? "off"; + const reasoning = sessionInfo.reasoningLevel ?? "off"; const deliver = deliverDefault ? "on" : "off"; footer.setText( theme.dim( - `${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | ${tokens} | deliver ${deliver}`, + `${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | reasoning ${reasoning} | ${tokens} | deliver ${deliver}`, ), ); }; @@ -198,6 +200,7 @@ export async function runTui(opts: TuiOptions) { sessionInfo = { thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, model: entry?.model ?? result.defaults?.model ?? undefined, contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens, totalTokens: entry?.totalTokens ?? null, @@ -586,6 +589,22 @@ export async function runTui(opts: TuiOptions) { chatLog.addSystem(`verbose failed: ${String(err)}`); } break; + case "reasoning": + if (!args) { + chatLog.addSystem("usage: /reasoning "); + break; + } + try { + await client.patchSession({ + key: currentSessionKey, + reasoningLevel: args, + }); + chatLog.addSystem(`reasoning set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`reasoning failed: ${String(err)}`); + } + break; case "elevated": if (!args) { chatLog.addSystem("usage: /elevated "); diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 7611a93d3..8a0cd5854 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -226,6 +226,7 @@ export type GatewaySessionRow = { abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + reasoningLevel?: string; elevatedLevel?: string; inputTokens?: number; outputTokens?: number; @@ -251,6 +252,7 @@ export type SessionsPatchResult = { updatedAt?: number; thinkingLevel?: string; verboseLevel?: string; + reasoningLevel?: string; elevatedLevel?: string; }; };