diff --git a/docs/docs.json b/docs/docs.json index 402d56aa380..b18409f6fbf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1033,6 +1033,7 @@ { "group": "Skills", "pages": [ + "tools/btw", "tools/creating-skills", "tools/slash-commands", "tools/skills", diff --git a/docs/tools/btw.md b/docs/tools/btw.md new file mode 100644 index 00000000000..38a30fcec77 --- /dev/null +++ b/docs/tools/btw.md @@ -0,0 +1,142 @@ +--- +summary: "Ephemeral side questions with /btw" +read_when: + - You want to ask a quick side question about the current session + - You are implementing or debugging BTW behavior across clients +title: "BTW Side Questions" +--- + +# BTW Side Questions + +`/btw` lets you ask a quick side question about the **current session** without +turning that question into normal conversation history. + +It is modeled after Claude Code's `/btw` behavior, but adapted to OpenClaw's +Gateway and multi-channel architecture. + +## What it does + +When you send: + +```text +/btw what changed? +``` + +OpenClaw: + +1. snapshots the current session context, +2. runs a separate **tool-less** model call, +3. answers only the side question, +4. leaves the main run alone, +5. does **not** write the BTW question or answer to session history, +6. emits the answer as a **live side result** rather than a normal assistant message. + +The important mental model is: + +- same session context +- separate one-shot side query +- no tool calls +- no future context pollution +- no transcript persistence + +## What it does not do + +`/btw` does **not**: + +- create a new durable session, +- continue the unfinished main task, +- run tools or agent tool loops, +- write BTW question/answer data to transcript history, +- appear in `chat.history`, +- survive a reload. + +It is intentionally **ephemeral**. + +## How context works + +BTW uses the current session as **background context only**. + +If the main run is currently active, OpenClaw snapshots the current message +state and includes the in-flight main prompt as background context, while +explicitly telling the model: + +- answer only the side question, +- do not resume or complete the unfinished main task, +- do not emit tool calls or pseudo-tool calls. + +That keeps BTW isolated from the main run while still making it aware of what +the session is about. + +## Delivery model + +BTW is **not** delivered as a normal assistant transcript message. + +At the Gateway protocol level: + +- normal assistant chat uses the `chat` event +- BTW uses the `chat.side_result` event + +This separation is intentional. If BTW reused the normal `chat` event path, +clients would treat it like regular conversation history. + +Because BTW uses a separate live event and is not replayed from +`chat.history`, it disappears after reload. + +## Surface behavior + +### TUI + +In TUI, BTW is rendered inline in the current session view, but it remains +ephemeral: + +- visibly distinct from a normal assistant reply +- dismissible with `Enter` or `Esc` +- not replayed on reload + +### External channels + +On channels like Telegram, WhatsApp, and Discord, BTW is delivered as a +clearly labeled one-off reply because those surfaces do not have a local +ephemeral overlay concept. + +The answer is still treated as a side result, not normal session history. + +### Control UI / web + +The Gateway emits BTW correctly as `chat.side_result`, and BTW is not included +in `chat.history`, so the persistence contract is already correct for web. + +The current Control UI still needs a dedicated `chat.side_result` consumer to +render BTW live in the browser. Until that client-side support lands, BTW is a +Gateway-level feature with full TUI and external-channel behavior, but not yet +a complete browser UX. + +## When to use BTW + +Use `/btw` when you want: + +- a quick clarification about the current work, +- a factual side answer while a long run is still in progress, +- a temporary answer that should not become part of future session context. + +Examples: + +```text +/btw what file are we editing? +/btw what does this error mean? +/btw summarize the current task in one sentence +/btw what is 17 * 19? +``` + +## When not to use BTW + +Do not use `/btw` when you want the answer to become part of the session's +future working context. + +In that case, ask normally in the main session instead of using BTW. + +## Related + +- [Slash commands](/tools/slash-commands) +- [Thinking Levels](/tools/thinking) +- [Session](/concepts/session) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 0fe5f383f24..19072342b20 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -76,7 +76,7 @@ Text + native (when enabled): - `/allowlist` (list/add/remove allowlist entries) - `/approve allow-once|allow-always|deny` (resolve exec approval prompts) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) -- `/btw ` (ask a quick side question about the current session without changing future session context) +- `/btw ` (ask an ephemeral side question about the current session without changing future session context; see [/tools/btw](/tools/btw)) - `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt) - `/whoami` (show your sender id; alias: `/id`) - `/session idle ` (manage inactivity auto-unfocus for focused thread bindings) @@ -224,3 +224,27 @@ Notes: - **`/stop`** targets the active chat session so it can abort the current run. - **Slack:** `channels.slack.slashCommand` is still supported for a single `/openclaw`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons. - Slack native exception: register `/agentstatus` (not `/status`) because Slack reserves `/status`. Text `/status` still works in Slack messages. + +## BTW side questions + +`/btw` is a quick **side question** about the current session. + +Unlike normal chat: + +- it uses the current session as background context, +- it runs as a separate **tool-less** one-shot call, +- it does not change future session context, +- it is not written to transcript history, +- it is delivered as a live side result instead of a normal assistant message. + +That makes `/btw` useful when you want a temporary clarification while the main +task keeps going. + +Example: + +```text +/btw what are we doing right now? +``` + +See [BTW Side Questions](/tools/btw) for the full behavior and client UX +details. diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 943cdd140d0..b9f3a9c19f1 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; const streamSimpleMock = vi.fn(); -const appendCustomEntryMock = vi.fn(); const buildSessionContextMock = vi.fn(); const getLeafEntryMock = vi.fn(); const branchMock = vi.fn(); @@ -13,11 +12,8 @@ const discoverModelsMock = vi.fn(); const resolveModelWithRegistryMock = vi.fn(); const getApiKeyForModelMock = vi.fn(); const requireApiKeyMock = vi.fn(); -const acquireSessionWriteLockMock = vi.fn(); const resolveSessionAuthProfileOverrideMock = vi.fn(); const getActiveEmbeddedRunSnapshotMock = vi.fn(); -const waitForEmbeddedPiRunEndMock = vi.fn(); -const diagWarnMock = vi.fn(); const diagDebugMock = vi.fn(); vi.mock("@mariozechner/pi-ai", () => ({ @@ -31,7 +27,6 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({ branch: branchMock, resetLeaf: resetLeafMock, buildSessionContext: buildSessionContextMock, - appendCustomEntry: appendCustomEntryMock, }), }, })); @@ -54,13 +49,8 @@ vi.mock("./model-auth.js", () => ({ requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args), })); -vi.mock("./session-write-lock.js", () => ({ - acquireSessionWriteLock: (...args: unknown[]) => acquireSessionWriteLockMock(...args), -})); - vi.mock("./pi-embedded-runner/runs.js", () => ({ getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args), - waitForEmbeddedPiRunEnd: (...args: unknown[]) => waitForEmbeddedPiRunEndMock(...args), })); vi.mock("./auth-profiles/session-override.js", () => ({ @@ -70,12 +60,11 @@ vi.mock("./auth-profiles/session-override.js", () => ({ vi.mock("../logging/diagnostic.js", () => ({ diagnosticLogger: { - warn: (...args: unknown[]) => diagWarnMock(...args), debug: (...args: unknown[]) => diagDebugMock(...args), }, })); -const { BTW_CUSTOM_TYPE, runBtwSideQuestion } = await import("./btw.js"); +const { runBtwSideQuestion } = await import("./btw.js"); function makeAsyncEvents(events: unknown[]) { return { @@ -99,7 +88,6 @@ function createSessionEntry(overrides: Partial = {}): SessionEntry describe("runBtwSideQuestion", () => { beforeEach(() => { streamSimpleMock.mockReset(); - appendCustomEntryMock.mockReset(); buildSessionContextMock.mockReset(); getLeafEntryMock.mockReset(); branchMock.mockReset(); @@ -110,11 +98,8 @@ describe("runBtwSideQuestion", () => { resolveModelWithRegistryMock.mockReset(); getApiKeyForModelMock.mockReset(); requireApiKeyMock.mockReset(); - acquireSessionWriteLockMock.mockReset(); resolveSessionAuthProfileOverrideMock.mockReset(); getActiveEmbeddedRunSnapshotMock.mockReset(); - waitForEmbeddedPiRunEndMock.mockReset(); - diagWarnMock.mockReset(); diagDebugMock.mockReset(); buildSessionContextMock.mockReturnValue({ @@ -128,15 +113,11 @@ describe("runBtwSideQuestion", () => { }); getApiKeyForModelMock.mockResolvedValue({ apiKey: "secret", mode: "api-key", source: "test" }); requireApiKeyMock.mockReturnValue("secret"); - acquireSessionWriteLockMock.mockResolvedValue({ - release: vi.fn().mockResolvedValue(undefined), - }); resolveSessionAuthProfileOverrideMock.mockResolvedValue("profile-1"); getActiveEmbeddedRunSnapshotMock.mockReturnValue(undefined); - waitForEmbeddedPiRunEndMock.mockResolvedValue(true); }); - it("streams blocks and persists a non-context custom entry", async () => { + it("streams blocks without persisting BTW data to disk", async () => { const onBlockReply = vi.fn().mockResolvedValue(undefined); streamSimpleMock.mockReturnValue( makeAsyncEvents([ @@ -212,17 +193,6 @@ describe("runBtwSideQuestion", () => { text: "Side answer.", btw: { question: "What changed?" }, }); - await vi.waitFor(() => { - expect(appendCustomEntryMock).toHaveBeenCalledWith( - BTW_CUSTOM_TYPE, - expect.objectContaining({ - question: "What changed?", - answer: "Side answer.", - provider: "anthropic", - model: "claude-sonnet-4-5", - }), - ); - }); }); it("returns a final payload when block streaming is unavailable", async () => { @@ -641,14 +611,7 @@ describe("runBtwSideQuestion", () => { ); }); - it("returns the BTW answer and retries transcript persistence after a session lock", async () => { - acquireSessionWriteLockMock - .mockRejectedValueOnce( - new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"), - ) - .mockResolvedValueOnce({ - release: vi.fn().mockResolvedValue(undefined), - }); + it("returns the BTW answer without appending transcript custom entries", async () => { streamSimpleMock.mockReturnValue( makeAsyncEvents([ { @@ -688,26 +651,10 @@ describe("runBtwSideQuestion", () => { }); expect(result).toEqual({ text: "323" }); - await vi.waitFor(() => { - expect(waitForEmbeddedPiRunEndMock).toHaveBeenCalledWith("session-1", 30000); - expect(appendCustomEntryMock).toHaveBeenCalledWith( - BTW_CUSTOM_TYPE, - expect.objectContaining({ - question: "What is 17 * 19?", - answer: "323", - }), - ); - }); + expect(buildSessionContextMock).toHaveBeenCalled(); }); - it("logs deferred persistence failures through the diagnostic logger", async () => { - acquireSessionWriteLockMock - .mockRejectedValueOnce( - new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"), - ) - .mockRejectedValueOnce( - new Error("session file locked (timeout 10000ms): pid=123 /tmp/session.lock"), - ); + it("does not log transcript persistence warnings because BTW no longer writes to disk", async () => { streamSimpleMock.mockReturnValue( makeAsyncEvents([ { @@ -747,11 +694,9 @@ describe("runBtwSideQuestion", () => { }); expect(result).toEqual({ text: "323" }); - await vi.waitFor(() => { - expect(diagWarnMock).toHaveBeenCalledWith( - expect.stringContaining("btw transcript persistence skipped: sessionId=session-1"), - ); - }); + expect(diagDebugMock).not.toHaveBeenCalledWith( + expect.stringContaining("btw transcript persistence skipped"), + ); }); it("excludes tool results from BTW context to avoid replaying raw tool output", async () => { diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 79ab9239479..d0f494277b1 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -21,19 +21,10 @@ import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js"; import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js"; -import { - getActiveEmbeddedRunSnapshot, - waitForEmbeddedPiRunEnd, -} from "./pi-embedded-runner/runs.js"; +import { getActiveEmbeddedRunSnapshot } from "./pi-embedded-runner/runs.js"; import { mapThinkingLevel } from "./pi-embedded-runner/utils.js"; import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; import { stripToolResultDetails } from "./session-transcript-repair.js"; -import { acquireSessionWriteLock } from "./session-write-lock.js"; - -const BTW_CUSTOM_TYPE = "openclaw:btw"; -const BTW_PERSIST_TIMEOUT_MS = 250; -const BTW_PERSIST_RETRY_WAIT_MS = 30_000; -const BTW_PERSIST_RETRY_LOCK_MS = 10_000; type SessionManagerLike = { getLeafEntry?: () => { @@ -47,97 +38,6 @@ type SessionManagerLike = { buildSessionContext: () => { messages?: unknown[] }; }; -type BtwCustomEntryData = { - timestamp: number; - question: string; - answer: string; - provider: string; - model: string; - thinkingLevel: ThinkLevel | "off"; - reasoningLevel: ReasoningLevel; - sessionKey?: string; - authProfileId?: string; - authProfileIdSource?: "auto" | "user"; - usage?: unknown; -}; - -async function appendBtwCustomEntry(params: { - sessionFile: string; - timeoutMs: number; - entry: BtwCustomEntryData; -}) { - const lock = await acquireSessionWriteLock({ - sessionFile: params.sessionFile, - timeoutMs: params.timeoutMs, - allowReentrant: false, - }); - try { - const persisted = SessionManager.open(params.sessionFile); - persisted.appendCustomEntry(BTW_CUSTOM_TYPE, params.entry); - } finally { - await lock.release(); - } -} - -function isSessionLockError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes("session file locked"); -} - -function deferBtwCustomEntryPersist(params: { - sessionId: string; - sessionFile: string; - entry: BtwCustomEntryData; -}) { - void (async () => { - try { - await waitForEmbeddedPiRunEnd(params.sessionId, BTW_PERSIST_RETRY_WAIT_MS); - await appendBtwCustomEntry({ - sessionFile: params.sessionFile, - timeoutMs: BTW_PERSIST_RETRY_LOCK_MS, - entry: params.entry, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`); - } - })(); -} - -async function persistBtwCustomEntry(params: { - sessionId: string; - sessionFile: string; - entry: BtwCustomEntryData; -}) { - try { - await appendBtwCustomEntry({ - sessionFile: params.sessionFile, - timeoutMs: BTW_PERSIST_TIMEOUT_MS, - entry: params.entry, - }); - } catch (error) { - if (!isSessionLockError(error)) { - throw error; - } - deferBtwCustomEntryPersist({ - sessionId: params.sessionId, - sessionFile: params.sessionFile, - entry: params.entry, - }); - } -} - -function persistBtwCustomEntryInBackground(params: { - sessionId: string; - sessionFile: string; - entry: BtwCustomEntryData; -}) { - void persistBtwCustomEntry(params).catch((error) => { - const message = error instanceof Error ? error.message : String(error); - diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`); - }); -} - function collectTextContent(content: Array<{ type?: string; text?: string }>): string { return content .filter((part): part is { type: "text"; text: string } => part.type === "text") @@ -347,7 +247,7 @@ export async function runBtwSideQuestion( throw new Error("No active session context."); } - const { model, authProfileId, authProfileIdSource } = await resolveRuntimeModel({ + const { model, authProfileId } = await resolveRuntimeModel({ cfg: params.cfg, provider: params.provider, model: params.model, @@ -483,31 +383,9 @@ export async function runBtwSideQuestion( throw new Error("No BTW response generated."); } - const customEntry = { - timestamp: Date.now(), - question: params.question, - answer, - provider: model.provider, - model: model.id, - thinkingLevel: params.resolvedThinkLevel ?? "off", - reasoningLevel: params.resolvedReasoningLevel, - sessionKey: params.sessionKey, - authProfileId, - authProfileIdSource, - usage: finalMessage?.usage, - } satisfies BtwCustomEntryData; - - persistBtwCustomEntryInBackground({ - sessionId, - sessionFile, - entry: customEntry, - }); - if (emittedBlocks > 0) { return undefined; } return { text: answer }; } - -export { BTW_CUSTOM_TYPE };