Merge db88d3b463a7fa2a45943f6dfee03baae90fe214 into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
Kaspre 2026-03-21 00:28:10 -04:00 committed by GitHub
commit c4a0bfc5ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 242 additions and 0 deletions

View File

@ -87,6 +87,7 @@ describe("sessions tools", () => {
expect(schemaProp("sessions_list", "limit").type).toBe("number");
expect(schemaProp("sessions_list", "activeMinutes").type).toBe("number");
expect(schemaProp("sessions_list", "messageLimit").type).toBe("number");
expect(schemaProp("sessions_manage", "action").type).toBe("string");
expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "thinking").type).toBe("string");
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number");
@ -669,6 +670,63 @@ describe("sessions tools", () => {
expect(sendCallCount).toBe(0);
});
it("sessions_manage compacts and resets via gateway methods", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: Record<string, unknown> }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as {
method?: string;
params?: Record<string, unknown>;
};
calls.push(request);
if (request.method === "sessions.resolve") {
return { key: "main" };
}
if (request.method === "sessions.compact") {
return { ok: true, compacted: true };
}
if (request.method === "sessions.reset") {
return { ok: true, key: "main", entry: { key: "main", sessionId: "new-sess" } };
}
return {};
});
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_manage");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing sessions_manage tool");
}
const compactResult = await tool.execute("call-manage-1", {
sessionKey: "main",
action: "compact",
});
expect(compactResult.details).toMatchObject({
status: "ok",
action: "compact",
sessionKey: "main",
compacted: true,
});
const resetResult = await tool.execute("call-manage-2", {
sessionKey: "main",
action: "reset",
});
expect(resetResult.details).toMatchObject({
status: "ok",
action: "reset",
sessionKey: "main",
resetOk: true,
});
expect(
calls.some((call) => call.method === "sessions.compact" && call.params?.key === "main"),
).toBe(true);
expect(
calls.some((call) => call.method === "sessions.reset" && call.params?.key === "main"),
).toBe(true);
});
it("sessions_send resolves sessionId inputs", async () => {
const sessionId = "sess-send";
const targetKey = "agent:main:discord:channel:123";

View File

@ -20,6 +20,7 @@ import { createPdfTool } from "./tools/pdf-tool.js";
import { createSessionStatusTool } from "./tools/session-status-tool.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsManageTool } from "./tools/sessions-manage-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createSessionsYieldTool } from "./tools/sessions-yield-tool.js";
@ -197,6 +198,11 @@ export function createOpenClawTools(
sandboxed: options?.sandboxed,
config: options?.config,
}),
createSessionsManageTool({
agentSessionKey: options?.agentSessionKey,
sandboxed: options?.sandboxed,
config: options?.config,
}),
createSessionsYieldTool({
sessionId: options?.sessionId,
onYield: options?.onYield,

View File

@ -305,6 +305,7 @@ describe("createOpenClawCodingTools", () => {
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_manage",
"sessions_spawn",
"subagents",
"session_status",
@ -328,6 +329,7 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("sessions_list")).toBe(false);
expect(names.has("sessions_history")).toBe(false);
expect(names.has("sessions_send")).toBe(false);
expect(names.has("sessions_manage")).toBe(false);
expect(names.has("sessions_spawn")).toBe(false);
// Explicit subagent orchestration tool remains available (list/steer/kill with safeguards).
expect(names.has("subagents")).toBe(true);

View File

@ -35,6 +35,8 @@ const SUBAGENT_TOOL_DENY_ALWAYS = [
"memory_get",
// Direct session sends - subagents communicate through announce chain
"sessions_send",
// Session management - subagents should not compact/reset sessions
"sessions_manage",
];
/**

View File

@ -20,6 +20,7 @@ export const DEFAULT_TOOL_ALLOW = [
"image",
"sessions_list",
"sessions_history",
"sessions_manage",
"sessions_send",
"sessions_spawn",
"sessions_yield",

View File

@ -261,6 +261,7 @@ export function buildAgentSystemPrompt(params: {
sessions_list: "List other sessions (incl. sub-agents) with filters/last",
sessions_history: "Fetch history for another session/sub-agent",
sessions_send: "Send a message to another session/sub-agent",
sessions_manage: "Compact or reset a session (programmatic /compact and /new)",
sessions_spawn: acpSpawnRuntimeEnabled
? 'Spawn an isolated sub-agent or ACP coding session (runtime="acp" requires `agentId` unless `acp.defaultAgent` is configured; ACP harness ids follow acp.allowedAgents, not agents_list)'
: "Spawn an isolated sub-agent session",
@ -293,6 +294,7 @@ export function buildAgentSystemPrompt(params: {
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_manage",
"subagents",
"session_status",
"image",
@ -443,6 +445,7 @@ export function buildAgentSystemPrompt(params: {
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
"- sessions_manage: compact or reset a session",
"- subagents: list/steer/kill sub-agent runs",
'- session_status: show usage/time/model state and answer "what model are we using?"',
].join("\n"),

View File

@ -137,6 +137,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
profiles: ["coding", "messaging"],
includeInOpenClawGroup: true,
},
{
id: "sessions_manage",
label: "sessions_manage",
description: "Compact/reset session",
sectionId: "sessions",
profiles: ["coding", "messaging"],
includeInOpenClawGroup: true,
},
{
id: "sessions_spawn",
label: "sessions_spawn",

View File

@ -31,6 +31,11 @@
"title": "Session Send",
"detailKeys": ["label", "sessionKey", "agentId", "timeoutSeconds"]
},
"sessions_manage": {
"emoji": "🧰",
"title": "Session Manage",
"detailKeys": ["sessionKey", "action"]
},
"sessions_history": {
"emoji": "🧾",
"title": "Session History",

View File

@ -7,6 +7,7 @@ const MUTATING_TOOL_NAMES = new Set([
"process",
"message",
"sessions_send",
"sessions_manage",
"cron",
"gateway",
"canvas",
@ -107,6 +108,7 @@ export function isMutatingToolCall(toolName: string, args: unknown): boolean {
case "exec":
case "bash":
case "sessions_send":
case "sessions_manage":
return true;
case "process":
return action != null && PROCESS_MUTATING_ACTIONS.has(action);

View File

@ -0,0 +1,155 @@
import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import {
createAgentToAgentPolicy,
createSessionVisibilityGuard,
resolveEffectiveSessionToolsVisibility,
resolveSessionReference,
resolveSessionToolContext,
resolveVisibleSessionReference,
} from "./sessions-helpers.js";
const SESSIONS_MANAGE_ACTIONS = ["compact", "reset"] as const;
const SessionsManageToolSchema = Type.Object({
sessionKey: Type.String(),
action: stringEnum(SESSIONS_MANAGE_ACTIONS),
});
export function createSessionsManageTool(opts?: {
agentSessionKey?: string;
sandboxed?: boolean;
config?: OpenClawConfig;
}): AnyAgentTool {
return {
label: "Session Manage",
name: "sessions_manage",
description:
"Compact or reset a session by key. Use action 'compact' to compress context or 'reset' to start fresh.",
parameters: SessionsManageToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKeyParam = readStringParam(params, "sessionKey", { required: true });
const action = readStringParam(params, "action", { required: true });
if (!SESSIONS_MANAGE_ACTIONS.includes(action as (typeof SESSIONS_MANAGE_ACTIONS)[number])) {
return jsonResult({ status: "error", error: "action must be 'compact' or 'reset'" });
}
const { cfg, mainKey, alias, effectiveRequesterKey, restrictToSpawned } =
resolveSessionToolContext(opts);
// Resolve and validate the target session, same pattern as sessions_send.
const resolvedSession = await resolveSessionReference({
sessionKey: sessionKeyParam,
alias,
mainKey,
requesterInternalKey: effectiveRequesterKey,
restrictToSpawned,
});
if (!resolvedSession.ok) {
return jsonResult({ status: resolvedSession.status, error: resolvedSession.error });
}
const visibleSession = await resolveVisibleSessionReference({
resolvedSession,
requesterSessionKey: effectiveRequesterKey,
restrictToSpawned,
visibilitySessionKey: sessionKeyParam,
});
if (!visibleSession.ok) {
return jsonResult({
status: visibleSession.status,
error: visibleSession.error,
sessionKey: visibleSession.displayKey,
});
}
const resolvedKey = visibleSession.key;
const displayKey = visibleSession.displayKey;
// Enforce agent-to-agent and visibility policy.
const a2aPolicy = createAgentToAgentPolicy(cfg);
const sessionVisibility = resolveEffectiveSessionToolsVisibility({
cfg,
sandboxed: opts?.sandboxed === true,
});
// TODO: add "manage" to SessionAccessAction for accurate error messages
const visibilityGuard = await createSessionVisibilityGuard({
action: "send",
requesterSessionKey: effectiveRequesterKey,
visibility: sessionVisibility,
a2aPolicy,
});
const access = visibilityGuard.check(resolvedKey);
if (!access.allowed) {
return jsonResult({
status: access.status,
error: access.error,
sessionKey: displayKey,
});
}
// Execute the requested action.
if (action === "compact") {
try {
const result = await callGateway<{
compacted?: boolean;
reason?: string;
kept?: number;
}>({
method: "sessions.compact",
params: { key: resolvedKey },
});
return jsonResult({
status: "ok",
action,
sessionKey: displayKey,
compacted: result?.compacted === true,
...(typeof result?.reason === "string" ? { reason: result.reason } : {}),
...(typeof result?.kept === "number" ? { kept: result.kept } : {}),
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return jsonResult({
status: "error",
action,
sessionKey: displayKey,
error: msg,
});
}
}
// action === "reset"
try {
const result = await callGateway<{
ok?: boolean;
key?: string;
entry?: unknown;
}>({
method: "sessions.reset",
params: { key: resolvedKey },
});
return jsonResult({
status: "ok",
action,
sessionKey: displayKey,
resetOk: result?.ok === true,
...(typeof result?.key === "string" ? { newKey: result.key } : {}),
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return jsonResult({
status: "error",
action,
sessionKey: displayKey,
error: msg,
});
}
},
};
}