From 3e80805d11de4eec7f015edf5c87d02a55003c5a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 21:56:20 +0100 Subject: [PATCH] feat(agents): add current-session steer command --- CHANGELOG.md | 1 + docs/tools/slash-commands.md | 3 +- docs/tools/subagents.md | 2 + src/auto-reply/commands-registry.shared.ts | 7 +- .../reply/commands-handlers.runtime.ts | 2 + .../reply/commands-steer.runtime.ts | 5 + src/auto-reply/reply/commands-steer.test.ts | 129 ++++++++++++++++++ src/auto-reply/reply/commands-steer.ts | 113 +++++++++++++++ .../reply/commands-subagents-dispatch.ts | 1 - .../reply/commands-subagents-routing.test.ts | 8 +- .../reply/commands-subagents-steer.test.ts | 2 +- .../reply/commands-subagents/shared.ts | 24 ++-- 12 files changed, 268 insertions(+), 29 deletions(-) create mode 100644 src/auto-reply/reply/commands-steer.runtime.ts create mode 100644 src/auto-reply/reply/commands-steer.test.ts create mode 100644 src/auto-reply/reply/commands-steer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d17c6b25ab0..072241c17e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Changes - Channels/streaming: add unified `streaming.mode: "progress"` drafts with auto single-word status labels and shared progress configuration across Discord, Telegram, Matrix, Slack, and Microsoft Teams. +- Agents/commands: add `/steer ` for queue-independent steering of the active current-session run without starting a new turn when the session is idle. (#76934) - Tools/BTW: add `/side` as a text and native slash-command alias for `/btw` side questions. - Agents/tools: skip optional media and PDF tool factories when the effective tool denylist already blocks them, avoiding unnecessary hot-path setup for tools that will be filtered out before model use. (#76773) Thanks @dorukardahan. - Discord/status: let explicit reaction tool calls opt into tracking subsequent tool progress on the reacted message with `trackToolCalls: true`, and use the shared tool display emoji table for status reactions. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 1b7e06b0388..691f320af22 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -145,6 +145,7 @@ Current source-of-truth: - `/model [name|#|status]` shows or sets the model. - `/models [provider] [page] [limit=|size=|all]` lists configured/auth-available providers or models for a provider; add `all` to browse that provider's full catalog. - `/queue ` manages queue behavior (`steer`, legacy `queue`, `followup`, `collect`, `steer-backlog`, `interrupt`) plus options like `debounce:0.5s cap:25 drop:summarize`; `/queue default` or `/queue reset` clears the session override. See [Command queue](/concepts/queue) and [Steering queue](/concepts/queue-steering). + - `/steer ` injects guidance into the active run for the current session, independent of `/queue` mode. It does not start a new run when the session is idle. Alias: `/tell`. @@ -174,7 +175,7 @@ Current source-of-truth: - `/unfocus` removes the current binding. - `/agents` lists thread-bound agents for the current session. - `/kill ` aborts one or all running sub-agents. - - `/steer ` sends steering to a running sub-agent. Alias: `/tell`. + - `/subagents steer ` sends steering to a running sub-agent. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index d48497b9f29..e8ecd819c6e 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -47,6 +47,8 @@ session**: /subagents spawn [--model ] [--thinking ] ``` +Use top-level `/steer ` to steer the current requester session's active run. Use `/subagents steer ` when the target is a child run. + `/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup). Use `sessions_history` for a bounded, safety-filtered recall view; inspect the transcript path on disk when you diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index ce562c1bd50..886ad1102d9 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -512,16 +512,11 @@ export function buildBuiltinChatCommands( defineChatCommand({ key: "steer", nativeName: "steer", - description: "Send guidance to a running subagent.", + description: "Send guidance to the active run in this session.", textAlias: "/steer", category: "management", tier: "standard", args: [ - { - name: "target", - description: "Label, run id, or index", - type: "string", - }, { name: "message", description: "Steering message", diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts index c2f7fe51e4e..0a7b911db24 100644 --- a/src/auto-reply/reply/commands-handlers.runtime.ts +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -31,6 +31,7 @@ import { handleStopCommand, handleUsageCommand, } from "./commands-session.js"; +import { handleSteerCommand } from "./commands-steer.js"; import { handleSubagentsCommand } from "./commands-subagents.js"; import { handleTasksCommand } from "./commands-tasks.js"; import { handleTtsCommands } from "./commands-tts.js"; @@ -56,6 +57,7 @@ export function loadCommandHandlers(): CommandHandler[] { handleStatusCommand, handleDiagnosticsCommand, handleTasksCommand, + handleSteerCommand, handleAllowlistCommand, handleApproveCommand, handleContextCommand, diff --git a/src/auto-reply/reply/commands-steer.runtime.ts b/src/auto-reply/reply/commands-steer.runtime.ts new file mode 100644 index 00000000000..f23c4bd4083 --- /dev/null +++ b/src/auto-reply/reply/commands-steer.runtime.ts @@ -0,0 +1,5 @@ +export { + isEmbeddedPiRunActive, + queueEmbeddedPiMessage, + resolveActiveEmbeddedRunSessionId, +} from "../../agents/pi-embedded-runner/runs.js"; diff --git a/src/auto-reply/reply/commands-steer.test.ts b/src/auto-reply/reply/commands-steer.test.ts new file mode 100644 index 00000000000..eb3967a5a78 --- /dev/null +++ b/src/auto-reply/reply/commands-steer.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { buildCommandTestParams } from "./commands.test-harness.js"; + +const steerRuntimeMocks = vi.hoisted(() => ({ + isEmbeddedPiRunActive: vi.fn(), + queueEmbeddedPiMessage: vi.fn(), + resolveActiveEmbeddedRunSessionId: vi.fn(), +})); + +vi.mock("./commands-steer.runtime.js", () => steerRuntimeMocks); + +const { handleSteerCommand } = await import("./commands-steer.js"); + +const baseCfg = { + commands: { text: true }, + session: { mainKey: "main", scope: "per-sender" }, +} as OpenClawConfig; + +function buildParams(commandBody: string) { + return buildCommandTestParams(commandBody, baseCfg); +} + +describe("handleSteerCommand", () => { + beforeEach(() => { + steerRuntimeMocks.isEmbeddedPiRunActive.mockReset().mockReturnValue(false); + steerRuntimeMocks.queueEmbeddedPiMessage.mockReset().mockReturnValue(true); + steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReset().mockReturnValue(undefined); + }); + + it("queues steering for the active current text-command session", async () => { + steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); + + const result = await handleSteerCommand(buildParams("/steer keep going"), true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "steered current session." }, + }); + expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith( + "agent:main:main", + ); + expect(steerRuntimeMocks.queueEmbeddedPiMessage).toHaveBeenCalledWith( + "session-active", + "keep going", + { + steeringMode: "all", + debounceMs: 0, + }, + ); + }); + + it("prefers the native command target session key over the slash-command session", async () => { + steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-target"); + + const params = buildParams("/steer check the target"); + params.ctx.CommandSource = "native"; + params.ctx.CommandTargetSessionKey = "agent:main:discord:direct:target"; + params.sessionKey = "agent:main:discord:slash:user"; + + await handleSteerCommand(params, true); + + expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith( + "agent:main:discord:direct:target", + ); + expect(steerRuntimeMocks.queueEmbeddedPiMessage).toHaveBeenCalledWith( + "session-target", + "check the target", + { + steeringMode: "all", + debounceMs: 0, + }, + ); + }); + + it("falls back to the stored session id when it is still active", async () => { + steerRuntimeMocks.isEmbeddedPiRunActive.mockReturnValue(true); + + const params = buildParams("/tell continue from state"); + params.sessionEntry = { sessionId: "stored-session-id", updatedAt: Date.now() }; + + await handleSteerCommand(params, true); + + expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith( + "agent:main:main", + ); + expect(steerRuntimeMocks.isEmbeddedPiRunActive).toHaveBeenCalledWith("stored-session-id"); + expect(steerRuntimeMocks.queueEmbeddedPiMessage).toHaveBeenCalledWith( + "stored-session-id", + "continue from state", + { + steeringMode: "all", + debounceMs: 0, + }, + ); + }); + + it("returns usage for an empty steer command", async () => { + const result = await handleSteerCommand(buildParams("/steer"), true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "Usage: /steer " }, + }); + expect(steerRuntimeMocks.queueEmbeddedPiMessage).not.toHaveBeenCalled(); + }); + + it("does not start a new run when no current session run is active", async () => { + const result = await handleSteerCommand(buildParams("/steer keep going"), true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "⚠️ No active run to steer in this session." }, + }); + expect(steerRuntimeMocks.queueEmbeddedPiMessage).not.toHaveBeenCalled(); + }); + + it("reports when the active run rejects steering injection", async () => { + steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); + steerRuntimeMocks.queueEmbeddedPiMessage.mockReturnValue(false); + + const result = await handleSteerCommand(buildParams("/steer keep going"), true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "⚠️ Current run is active but not accepting steering right now." }, + }); + }); +}); diff --git a/src/auto-reply/reply/commands-steer.ts b/src/auto-reply/reply/commands-steer.ts new file mode 100644 index 00000000000..57a3412aafb --- /dev/null +++ b/src/auto-reply/reply/commands-steer.ts @@ -0,0 +1,113 @@ +import { + resolveInternalSessionKey, + resolveMainSessionAlias, +} from "../../agents/tools/sessions-helpers.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { rejectUnauthorizedCommand } from "./command-gates.js"; +import { + isEmbeddedPiRunActive, + queueEmbeddedPiMessage, + resolveActiveEmbeddedRunSessionId, +} from "./commands-steer.runtime.js"; +import type { CommandHandler, HandleCommandsParams } from "./commands-types.js"; + +const STEER_USAGE = "Usage: /steer "; + +function parseSteerMessage(raw: string): string | null { + const match = raw.trim().match(/^\/(?:steer|tell)(?:\s+([\s\S]*))?$/i); + if (!match) { + return null; + } + return (match[1] ?? "").trim(); +} + +function resolveSteerTargetSessionKey(params: HandleCommandsParams): string | undefined { + const commandTarget = normalizeOptionalString(params.ctx.CommandTargetSessionKey); + const commandSession = normalizeOptionalString(params.sessionKey); + const raw = + params.ctx.CommandSource === "native" + ? commandTarget || commandSession + : commandSession || commandTarget; + if (!raw) { + return undefined; + } + + const { mainKey, alias } = resolveMainSessionAlias(params.cfg); + return resolveInternalSessionKey({ key: raw, alias, mainKey }); +} + +function resolveStoredSessionEntry( + params: HandleCommandsParams, + targetSessionKey: string, +): SessionEntry | undefined { + if (params.sessionStore?.[targetSessionKey]) { + return params.sessionStore[targetSessionKey]; + } + if (params.sessionKey === targetSessionKey) { + return params.sessionEntry; + } + return undefined; +} + +function resolveSteerSessionId(params: { + commandParams: HandleCommandsParams; + targetSessionKey: string; +}): string | undefined { + const activeSessionId = resolveActiveEmbeddedRunSessionId(params.targetSessionKey); + if (activeSessionId) { + return activeSessionId; + } + + const entry = resolveStoredSessionEntry(params.commandParams, params.targetSessionKey); + const sessionId = normalizeOptionalString(entry?.sessionId); + if (!sessionId || !isEmbeddedPiRunActive(sessionId)) { + return undefined; + } + return sessionId; +} + +export const handleSteerCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + + const message = parseSteerMessage(params.command.commandBodyNormalized); + if (message === null) { + return null; + } + + const unauthorized = rejectUnauthorizedCommand(params, "/steer"); + if (unauthorized) { + return unauthorized; + } + + if (!message) { + return { shouldContinue: false, reply: { text: STEER_USAGE } }; + } + + const targetSessionKey = resolveSteerTargetSessionKey(params); + if (!targetSessionKey) { + return { shouldContinue: false, reply: { text: "⚠️ No current session to steer." } }; + } + + const sessionId = resolveSteerSessionId({ commandParams: params, targetSessionKey }); + if (!sessionId) { + return { shouldContinue: false, reply: { text: "⚠️ No active run to steer in this session." } }; + } + + const steered = queueEmbeddedPiMessage(sessionId, message, { + steeringMode: "all", + debounceMs: 0, + }); + if (!steered) { + logVerbose(`steer: active session ${sessionId} rejected steering injection`); + return { + shouldContinue: false, + reply: { text: "⚠️ Current run is active but not accepting steering right now." }, + }; + } + + return { shouldContinue: false, reply: { text: "steered current session." } }; +}; diff --git a/src/auto-reply/reply/commands-subagents-dispatch.ts b/src/auto-reply/reply/commands-subagents-dispatch.ts index 74abb38b048..86c29338299 100644 --- a/src/auto-reply/reply/commands-subagents-dispatch.ts +++ b/src/auto-reply/reply/commands-subagents-dispatch.ts @@ -4,7 +4,6 @@ import type { HandleCommandsParams } from "./commands-types.js"; export { COMMAND, COMMAND_KILL, - COMMAND_STEER, resolveHandledPrefix, resolveRequesterSessionKey, resolveSubagentsAction, diff --git a/src/auto-reply/reply/commands-subagents-routing.test.ts b/src/auto-reply/reply/commands-subagents-routing.test.ts index de540e93833..2d46ff74cdb 100644 --- a/src/auto-reply/reply/commands-subagents-routing.test.ts +++ b/src/auto-reply/reply/commands-subagents-routing.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { COMMAND, COMMAND_KILL, - COMMAND_STEER, resolveHandledPrefix, resolveRequesterSessionKey, resolveSubagentsAction, @@ -79,7 +78,7 @@ describe("subagents command dispatch", () => { it("maps slash aliases to the right handled prefix", () => { expect(resolveHandledPrefix("/subagents list")).toBe(COMMAND); expect(resolveHandledPrefix("/kill 1")).toBe(COMMAND_KILL); - expect(resolveHandledPrefix("/steer 1 continue")).toBe(COMMAND_STEER); + expect(resolveHandledPrefix("/steer 1 continue")).toBeNull(); expect(resolveHandledPrefix("/unknown")).toBeNull(); }); @@ -94,10 +93,11 @@ describe("subagents command dispatch", () => { ); expect(killTokens).toEqual(["1"]); - const steerTokens = ["1", "continue"]; - expect(resolveSubagentsAction({ handledPrefix: COMMAND_STEER, restTokens: steerTokens })).toBe( + const steerTokens = ["steer", "1", "continue"]; + expect(resolveSubagentsAction({ handledPrefix: COMMAND, restTokens: steerTokens })).toBe( "steer", ); + expect(steerTokens).toEqual(["1", "continue"]); }); it("returns null for invalid /subagents actions", () => { diff --git a/src/auto-reply/reply/commands-subagents-steer.test.ts b/src/auto-reply/reply/commands-subagents-steer.test.ts index a1ca8e9e311..4127a23b881 100644 --- a/src/auto-reply/reply/commands-subagents-steer.test.ts +++ b/src/auto-reply/reply/commands-subagents-steer.test.ts @@ -7,7 +7,7 @@ import { handleSubagentsSendAction } from "./commands-subagents/action-send.js"; const buildContext = () => buildSubagentsDispatchContext({ - handledPrefix: "/steer", + handledPrefix: "/subagents", restTokens: ["1", "check", "timer.ts", "instead"], }); diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index b5ce1c94311..80acee3db94 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -32,8 +32,6 @@ export type { ChatMessage } from "../commands-subagents-text.js"; export const COMMAND = "/subagents"; export const COMMAND_KILL = "/kill"; -export const COMMAND_STEER = "/steer"; -const COMMAND_TELL = "/tell"; const COMMAND_FOCUS = "/focus"; const COMMAND_UNFOCUS = "/unfocus"; const COMMAND_AGENTS = "/agents"; @@ -171,17 +169,13 @@ export function resolveHandledPrefix(normalized: string): string | null { ? COMMAND : normalized.startsWith(COMMAND_KILL) ? COMMAND_KILL - : normalized.startsWith(COMMAND_STEER) - ? COMMAND_STEER - : normalized.startsWith(COMMAND_TELL) - ? COMMAND_TELL - : normalized.startsWith(COMMAND_FOCUS) - ? COMMAND_FOCUS - : normalized.startsWith(COMMAND_UNFOCUS) - ? COMMAND_UNFOCUS - : normalized.startsWith(COMMAND_AGENTS) - ? COMMAND_AGENTS - : null; + : normalized.startsWith(COMMAND_FOCUS) + ? COMMAND_FOCUS + : normalized.startsWith(COMMAND_UNFOCUS) + ? COMMAND_UNFOCUS + : normalized.startsWith(COMMAND_AGENTS) + ? COMMAND_AGENTS + : null; } export function resolveSubagentsAction(params: { @@ -209,7 +203,7 @@ export function resolveSubagentsAction(params: { if (params.handledPrefix === COMMAND_AGENTS) { return "agents"; } - return "steer"; + return null; } type FocusTargetResolution = { @@ -291,8 +285,6 @@ export function buildSubagentsHelp() { "- /session idle ", "- /session max-age ", "- /kill ", - "- /steer ", - "- /tell ", "", "Ids: use the list index (#), runId/session prefix, label, or full session key.", ].join("\n");