feat(agents): add current-session steer command

This commit is contained in:
Peter Steinberger
2026-05-03 21:56:20 +01:00
parent 66336bf7c8
commit 3e80805d11
12 changed files with 268 additions and 29 deletions

View File

@@ -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 <message>` 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.

View File

@@ -145,6 +145,7 @@ Current source-of-truth:
- `/model [name|#|status]` shows or sets the model.
- `/models [provider] [page] [limit=<n>|size=<n>|all]` lists configured/auth-available providers or models for a provider; add `all` to browse that provider's full catalog.
- `/queue <mode>` 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 <message>` 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`.
</Accordion>
<Accordion title="Discovery and status">
@@ -174,7 +175,7 @@ Current source-of-truth:
- `/unfocus` removes the current binding.
- `/agents` lists thread-bound agents for the current session.
- `/kill <id|#|all>` aborts one or all running sub-agents.
- `/steer <id|#> <message>` sends steering to a running sub-agent. Alias: `/tell`.
- `/subagents steer <id|#> <message>` sends steering to a running sub-agent.
</Accordion>
<Accordion title="Owner-only writes and admin">

View File

@@ -47,6 +47,8 @@ session**:
/subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]
```
Use top-level `/steer <message>` to steer the current requester session's active run. Use `/subagents steer <id|#> <message>` 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

View File

@@ -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",

View File

@@ -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,

View File

@@ -0,0 +1,5 @@
export {
isEmbeddedPiRunActive,
queueEmbeddedPiMessage,
resolveActiveEmbeddedRunSessionId,
} from "../../agents/pi-embedded-runner/runs.js";

View File

@@ -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 <message>" },
});
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." },
});
});
});

View File

@@ -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 <message>";
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." } };
};

View File

@@ -4,7 +4,6 @@ import type { HandleCommandsParams } from "./commands-types.js";
export {
COMMAND,
COMMAND_KILL,
COMMAND_STEER,
resolveHandledPrefix,
resolveRequesterSessionKey,
resolveSubagentsAction,

View File

@@ -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", () => {

View File

@@ -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"],
});

View File

@@ -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 <duration|off>",
"- /session max-age <duration|off>",
"- /kill <id|#|all>",
"- /steer <id|#> <message>",
"- /tell <id|#> <message>",
"",
"Ids: use the list index (#), runId/session prefix, label, or full session key.",
].join("\n");