mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 18:42:25 +00:00
feat(agents): add current-session steer command
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
src/auto-reply/reply/commands-steer.runtime.ts
Normal file
5
src/auto-reply/reply/commands-steer.runtime.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
isEmbeddedPiRunActive,
|
||||
queueEmbeddedPiMessage,
|
||||
resolveActiveEmbeddedRunSessionId,
|
||||
} from "../../agents/pi-embedded-runner/runs.js";
|
||||
129
src/auto-reply/reply/commands-steer.test.ts
Normal file
129
src/auto-reply/reply/commands-steer.test.ts
Normal 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." },
|
||||
});
|
||||
});
|
||||
});
|
||||
113
src/auto-reply/reply/commands-steer.ts
Normal file
113
src/auto-reply/reply/commands-steer.ts
Normal 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." } };
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import type { HandleCommandsParams } from "./commands-types.js";
|
||||
export {
|
||||
COMMAND,
|
||||
COMMAND_KILL,
|
||||
COMMAND_STEER,
|
||||
resolveHandledPrefix,
|
||||
resolveRequesterSessionKey,
|
||||
resolveSubagentsAction,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user