feat(gateway): add SDK-facing tools.invoke RPC

Adds the SDK-facing tools.invoke Gateway RPC for #74705.

Reuses the /tools/invoke policy path for tool policy, deny-list, owner filtering, before-tool-call hooks, session/agent scoping, and plugin approval handling. Returns typed SDK approval/refusal/success results while preserving HTTP compatibility and uses idempotencyKey as the stable tool-call id.

Includes protocol schema exports, method scope/list registration, SDK helper/types, docs, generated Swift models, tests, and changelog credit.
This commit is contained in:
NVIDIAN
2026-05-01 01:16:53 -07:00
committed by GitHub
parent 37f8c3806a
commit ef0eb12615
24 changed files with 932 additions and 251 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP.
- macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti.
- Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc.
- Messages/docs: clarify that `BodyForAgent` is the primary inbound model text while `Body` is the legacy envelope fallback, and add Signal coverage so channel hardening patches target the real prompt path. Refs #66198. Thanks @defonota3box.
- Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok.
- BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/<guid>`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris.

View File

@@ -3830,6 +3830,100 @@ public struct ToolsEffectiveResult: Codable, Sendable {
}
}
public struct ToolsInvokeParams: Codable, Sendable {
public let name: String
public let args: [String: AnyCodable]?
public let sessionkey: String?
public let agentid: String?
public let confirm: Bool?
public let idempotencykey: String?
public init(
name: String,
args: [String: AnyCodable]?,
sessionkey: String?,
agentid: String?,
confirm: Bool?,
idempotencykey: String?)
{
self.name = name
self.args = args
self.sessionkey = sessionkey
self.agentid = agentid
self.confirm = confirm
self.idempotencykey = idempotencykey
}
private enum CodingKeys: String, CodingKey {
case name
case args
case sessionkey = "sessionKey"
case agentid = "agentId"
case confirm
case idempotencykey = "idempotencyKey"
}
}
public struct ToolsInvokeError: Codable, Sendable {
public let code: String
public let message: String
public let details: AnyCodable?
public init(
code: String,
message: String,
details: AnyCodable?)
{
self.code = code
self.message = message
self.details = details
}
private enum CodingKeys: String, CodingKey {
case code
case message
case details
}
}
public struct ToolsInvokeResult: Codable, Sendable {
public let ok: Bool
public let toolname: String
public let output: AnyCodable?
public let requiresapproval: Bool?
public let approvalid: String?
public let source: AnyCodable?
public let error: [String: AnyCodable]?
public init(
ok: Bool,
toolname: String,
output: AnyCodable?,
requiresapproval: Bool?,
approvalid: String?,
source: AnyCodable?,
error: [String: AnyCodable]?)
{
self.ok = ok
self.toolname = toolname
self.output = output
self.requiresapproval = requiresapproval
self.approvalid = approvalid
self.source = source
self.error = error
}
private enum CodingKeys: String, CodingKey {
case ok
case toolname = "toolName"
case output
case requiresapproval = "requiresApproval"
case approvalid = "approvalId"
case source
case error
}
}
public struct SkillsBinsParams: Codable, Sendable {}
public struct SkillsBinsResult: Codable, Sendable {

View File

@@ -3830,6 +3830,100 @@ public struct ToolsEffectiveResult: Codable, Sendable {
}
}
public struct ToolsInvokeParams: Codable, Sendable {
public let name: String
public let args: [String: AnyCodable]?
public let sessionkey: String?
public let agentid: String?
public let confirm: Bool?
public let idempotencykey: String?
public init(
name: String,
args: [String: AnyCodable]?,
sessionkey: String?,
agentid: String?,
confirm: Bool?,
idempotencykey: String?)
{
self.name = name
self.args = args
self.sessionkey = sessionkey
self.agentid = agentid
self.confirm = confirm
self.idempotencykey = idempotencykey
}
private enum CodingKeys: String, CodingKey {
case name
case args
case sessionkey = "sessionKey"
case agentid = "agentId"
case confirm
case idempotencykey = "idempotencyKey"
}
}
public struct ToolsInvokeError: Codable, Sendable {
public let code: String
public let message: String
public let details: AnyCodable?
public init(
code: String,
message: String,
details: AnyCodable?)
{
self.code = code
self.message = message
self.details = details
}
private enum CodingKeys: String, CodingKey {
case code
case message
case details
}
}
public struct ToolsInvokeResult: Codable, Sendable {
public let ok: Bool
public let toolname: String
public let output: AnyCodable?
public let requiresapproval: Bool?
public let approvalid: String?
public let source: AnyCodable?
public let error: [String: AnyCodable]?
public init(
ok: Bool,
toolname: String,
output: AnyCodable?,
requiresapproval: Bool?,
approvalid: String?,
source: AnyCodable?,
error: [String: AnyCodable]?)
{
self.ok = ok
self.toolname = toolname
self.output = output
self.requiresapproval = requiresapproval
self.approvalid = approvalid
self.source = source
self.error = error
}
private enum CodingKeys: String, CodingKey {
case ok
case toolname = "toolName"
case output
case requiresapproval = "requiresApproval"
case approvalid = "approvalId"
case source
case error
}
}
public struct SkillsBinsParams: Codable, Sendable {}
public struct SkillsBinsResult: Codable, Sendable {

View File

@@ -25,24 +25,24 @@ resources.
`@openclaw/sdk` ships with:
| Surface | Status | What it does |
| ------------------------- | ------- | ---------------------------------------------------------------------------- |
| `OpenClaw` | Ready | Main client entry point. Owns transport, connection, requests, and events. |
| `GatewayClientTransport` | Ready | WebSocket transport backed by the Gateway client. |
| `oc.agents` | Ready | Lists, creates, updates, deletes, and gets agent handles. |
| `Agent.run()` | Ready | Starts a Gateway `agent` run and returns a `Run`. |
| `oc.runs` | Ready | Creates, gets, waits for, cancels, and streams runs. |
| `Run.events()` | Ready | Streams normalized per-run events with replay for fast runs. |
| `Run.wait()` | Ready | Calls `agent.wait` and returns a stable `RunResult`. |
| `Run.cancel()` | Ready | Calls `sessions.abort` by run id, with session key when available. |
| `oc.sessions` | Ready | Creates, resolves, sends to, patches, compacts, and gets session handles. |
| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. |
| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. |
| `oc.tools` | Partial | Lists tool catalog and effective tools; direct tool invocation is not wired. |
| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. |
| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. |
| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. |
| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. |
| Surface | Status | What it does |
| ------------------------- | ------ | -------------------------------------------------------------------------- |
| `OpenClaw` | Ready | Main client entry point. Owns transport, connection, requests, and events. |
| `GatewayClientTransport` | Ready | WebSocket transport backed by the Gateway client. |
| `oc.agents` | Ready | Lists, creates, updates, deletes, and gets agent handles. |
| `Agent.run()` | Ready | Starts a Gateway `agent` run and returns a `Run`. |
| `oc.runs` | Ready | Creates, gets, waits for, cancels, and streams runs. |
| `Run.events()` | Ready | Streams normalized per-run events with replay for fast runs. |
| `Run.wait()` | Ready | Calls `agent.wait` and returns a stable `RunResult`. |
| `Run.cancel()` | Ready | Calls `sessions.abort` by run id, with session key when available. |
| `oc.sessions` | Ready | Creates, resolves, sends to, patches, compacts, and gets session handles. |
| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. |
| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. |
| `oc.tools` | Ready | Lists, scopes, and invokes Gateway tools through the policy pipeline. |
| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. |
| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. |
| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. |
| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. |
The SDK also exports the core types used by those surfaces:
`AgentRunParams`, `RunResult`, `RunStatus`, `OpenClawEvent`,
@@ -216,11 +216,19 @@ await oc.models.list();
await oc.models.status({ probe: false }); // calls models.authStatus
```
Tool helpers expose the Gateway catalog and effective tool view:
Tool helpers expose the Gateway catalog, effective tool view, and direct
Gateway tool invocation. `oc.tools.invoke()` returns a typed envelope instead
of throwing for policy or approval refusals.
```typescript
await oc.tools.list();
await oc.tools.effective({ sessionKey: "main" });
await oc.tools.invoke("tool-name", {
args: { input: "value" },
sessionKey: "main",
confirm: false,
idempotencyKey: "tool-call-1",
});
```
Artifact helpers expose the Gateway artifact projection for session, run, or
@@ -256,8 +264,6 @@ await oc.tasks.list();
await oc.tasks.get("task-id");
await oc.tasks.cancel("task-id");
await oc.tools.invoke("tool-name", {});
await oc.environments.list();
await oc.environments.create({});
await oc.environments.status("environment-id");

View File

@@ -443,7 +443,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
<Accordion title="Automation, skills, and tools">
- Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work.
- Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`.
- Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`, `tools.invoke`.
</Accordion>
</AccordionGroup>
@@ -501,6 +501,15 @@ enumeration of `src/gateway/server-methods/*.ts`.
caller-supplied auth or delivery context.
- The response is session-scoped and reflects what the active conversation can use right now,
including core, plugin, and channel tools.
- Operators may call `tools.invoke` (`operator.write`) to invoke one available tool through the
same gateway policy path as `/tools/invoke`.
- `name` is required. `args`, `sessionKey`, `agentId`, `confirm`, and
`idempotencyKey` are optional.
- If both `sessionKey` and `agentId` are present, the resolved session agent must match
`agentId`.
- The response is an SDK-facing envelope with `ok`, `toolName`, optional `output`, and typed
`error` fields. Approval or policy refusals return `ok:false` in the payload rather than
bypassing the gateway tool policy pipeline.
- Operators may call `skills.status` (`operator.read`) to fetch the visible
skill inventory for an agent.
- `agentId` is optional; omit it to read the default agent workspace.

View File

@@ -18,6 +18,8 @@ import type {
SessionCreateParams,
SessionSendParams,
SessionTarget,
ToolInvokeParams,
ToolInvokeResult,
} from "./types.js";
const MAX_REPLAY_RUNS = 100;
@@ -764,10 +766,15 @@ export class ToolsNamespace extends RpcNamespace {
return await this.call("effective", params);
}
async invoke(name: string, params?: unknown): Promise<unknown> {
void name;
void params;
return unsupportedGatewayApi("oc.tools.invoke");
async invoke(name: string, params?: ToolInvokeParams): Promise<ToolInvokeResult> {
return await this.call("invoke", {
name,
...(params?.args ? { args: params.args } : {}),
...(params?.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params?.agentId ? { agentId: params.agentId } : {}),
...(typeof params?.confirm === "boolean" ? { confirm: params.confirm } : {}),
...(params?.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
});
}
}

View File

@@ -100,6 +100,7 @@ async function createFakeGateway(port = 0): Promise<FakeGateway> {
"sessions.send",
"tools.catalog",
"tools.effective",
"tools.invoke",
],
events: ["agent", "sessions.changed"],
},
@@ -253,6 +254,11 @@ async function createFakeGateway(port = 0): Promise<FakeGateway> {
return;
}
if (frame.method === "tools.invoke") {
reply({ ok: true, toolName: "shell", output: { ok: true } });
return;
}
if (frame.method === "exec.approval.list") {
reply({ approvals: [] });
return;
@@ -414,6 +420,9 @@ describe("OpenClaw SDK websocket e2e", () => {
await expect(oc.tools.effective({ sessionKey: "sdk-session" })).resolves.toMatchObject({
tools: [{ name: "shell", enabled: true }],
});
await expect(
oc.tools.invoke("shell", { args: { command: "pwd" }, sessionKey: "sdk-session" }),
).resolves.toMatchObject({ ok: true, toolName: "shell", output: { ok: true } });
await expect(oc.approvals.list()).resolves.toMatchObject({ approvals: [] });
await expect(
oc.approvals.respond("approval-1", { decision: "approve" }),
@@ -437,6 +446,7 @@ describe("OpenClaw SDK websocket e2e", () => {
"models.authStatus",
"tools.catalog",
"tools.effective",
"tools.invoke",
"exec.approval.list",
"exec.approval.resolve",
]);

View File

@@ -335,9 +335,6 @@ describe("OpenClaw SDK", () => {
await expect(oc.tasks.cancel("task_123")).rejects.toThrow(
"oc.tasks.cancel is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.tools.invoke("demo")).rejects.toThrow(
"oc.tools.invoke is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.environments.list()).rejects.toThrow(
"oc.environments.list is not supported by the current OpenClaw Gateway yet",
);
@@ -353,6 +350,35 @@ describe("OpenClaw SDK", () => {
expect(transport.calls).toEqual([]);
});
it("invokes tools through the Gateway tools.invoke method", async () => {
const transport = new FakeTransport({
"tools.invoke": { ok: true, toolName: "demo", output: { value: 1 }, source: "core" },
});
const oc = new OpenClaw({ transport });
await expect(
oc.tools.invoke("demo", {
args: { mode: "test" },
sessionKey: "agent:main:main",
confirm: false,
idempotencyKey: "tools-invoke-test",
}),
).resolves.toMatchObject({ ok: true, toolName: "demo", output: { value: 1 } });
expect(transport.calls).toEqual([
{
method: "tools.invoke",
params: {
name: "demo",
args: { mode: "test" },
sessionKey: "agent:main:main",
confirm: false,
idempotencyKey: "tools-invoke-test",
},
options: undefined,
},
]);
});
it("cancels runs and checks model auth status through current Gateway methods", async () => {
const transport = new FakeTransport({
agent: { status: "accepted", runId: "run_without_session" },

View File

@@ -42,5 +42,7 @@ export type {
SessionCreateParams,
SessionSendParams,
SessionTarget,
ToolInvokeParams,
ToolInvokeResult,
WorkspaceSelection,
} from "./types.js";

View File

@@ -114,6 +114,24 @@ export type SDKError = {
details?: unknown;
};
export type ToolInvokeParams = {
args?: JsonObject;
sessionKey?: string;
agentId?: string;
confirm?: boolean;
idempotencyKey?: string;
};
export type ToolInvokeResult = {
ok: boolean;
toolName: string;
output?: unknown;
requiresApproval?: boolean;
approvalId?: string;
source?: string;
error?: SDKError;
};
export type RunResult = {
runId: string;
status: RunStatus;

View File

@@ -83,6 +83,34 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
expect(onResolution).toHaveBeenCalledWith(PluginApprovalResolutions.CANCELLED);
});
it("reports approval-required tools without opening an approval request", async () => {
runBeforeToolCallMock.mockResolvedValue({
requireApproval: {
pluginId: "test-plugin",
title: "Needs approval",
description: "Review before running",
severity: "info",
},
params: { adjusted: true },
});
const result = await runBeforeToolCallHook({
toolName: "exec",
params: { command: "ls" },
toolCallId: "call-report",
approvalMode: "report",
});
expect(result).toEqual({
blocked: true,
kind: "failure",
deniedReason: "plugin-approval",
reason: "Review before running",
params: { command: "ls" },
});
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("sends approval to gateway when NOT in embedded mode", async () => {
setEmbeddedMode(false);

View File

@@ -399,6 +399,7 @@ export async function runBeforeToolCallHook(args: {
toolCallId?: string;
ctx?: HookContext;
signal?: AbortSignal;
approvalMode?: "request" | "report";
}): Promise<HookOutcome> {
const toolName = normalizeToolName(args.toolName || "tool");
const params = args.params;
@@ -501,6 +502,18 @@ export async function runBeforeToolCallHook(args: {
};
}
if (trustedPolicyResult?.requireApproval) {
if (args.approvalMode === "report") {
return {
blocked: true,
kind: "failure",
deniedReason: "plugin-approval",
reason:
trustedPolicyResult.requireApproval.description ||
trustedPolicyResult.requireApproval.title ||
"Plugin approval required",
params,
};
}
return await requestPluginToolApproval({
approval: trustedPolicyResult.requireApproval,
toolName,
@@ -537,6 +550,18 @@ export async function runBeforeToolCallHook(args: {
}
if (hookResult?.requireApproval) {
if (args.approvalMode === "report") {
return {
blocked: true,
kind: "failure",
deniedReason: "plugin-approval",
reason:
hookResult.requireApproval.description ||
hookResult.requireApproval.title ||
"Plugin approval required",
params: policyAdjustedParams,
};
}
return await requestPluginToolApproval({
approval: hookResult.requireApproval,
toolName,

View File

@@ -33,6 +33,7 @@ describe("method scope resolution", () => {
["sessions.create", ["operator.write"]],
["sessions.send", ["operator.write"]],
["sessions.abort", ["operator.write"]],
["tools.invoke", ["operator.write"]],
["sessions.messages.subscribe", ["operator.read"]],
["sessions.messages.unsubscribe", ["operator.read"]],
["diagnostics.stability", ["operator.read"]],

View File

@@ -147,6 +147,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"voicewake.set",
"voicewake.routing.set",
"node.invoke",
"tools.invoke",
"chat.send",
"chat.abort",
"sessions.create",

View File

@@ -311,6 +311,9 @@ import {
type ToolsEffectiveParams,
ToolsEffectiveParamsSchema,
type ToolsEffectiveResult,
type ToolsInvokeParams,
ToolsInvokeParamsSchema,
type ToolsInvokeResult,
type Snapshot,
SnapshotSchema,
type StateVersion,
@@ -534,6 +537,7 @@ export const validateToolsCatalogParams = ajv.compile<ToolsCatalogParams>(ToolsC
export const validateToolsEffectiveParams = ajv.compile<ToolsEffectiveParams>(
ToolsEffectiveParamsSchema,
);
export const validateToolsInvokeParams = ajv.compile<ToolsInvokeParams>(ToolsInvokeParamsSchema);
export const validateSkillsBinsParams = ajv.compile<SkillsBinsParams>(SkillsBinsParamsSchema);
export const validateSkillsInstallParams =
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
@@ -762,6 +766,7 @@ export {
SkillsStatusParamsSchema,
ToolsCatalogParamsSchema,
ToolsEffectiveParamsSchema,
ToolsInvokeParamsSchema,
SkillsInstallParamsSchema,
SkillsSearchParamsSchema,
SkillsSearchResultSchema,
@@ -883,6 +888,8 @@ export type {
ToolsCatalogResult,
ToolsEffectiveParams,
ToolsEffectiveResult,
ToolsInvokeParams,
ToolsInvokeResult,
SkillsBinsParams,
SkillsBinsResult,
SkillsSearchParams,

View File

@@ -375,6 +375,18 @@ export const ToolsEffectiveParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ToolsInvokeParamsSchema = Type.Object(
{
name: NonEmptyString,
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
sessionKey: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
confirm: Type.Optional(Type.Boolean()),
idempotencyKey: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const ToolCatalogProfileSchema = Type.Object(
{
id: Type.Union([
@@ -467,3 +479,33 @@ export const ToolsEffectiveResultSchema = Type.Object(
},
{ additionalProperties: false },
);
export const ToolsInvokeErrorSchema = Type.Object(
{
code: NonEmptyString,
message: NonEmptyString,
details: Type.Optional(Type.Unknown()),
},
{ additionalProperties: false },
);
export const ToolsInvokeResultSchema = Type.Object(
{
ok: Type.Boolean(),
toolName: NonEmptyString,
output: Type.Optional(Type.Unknown()),
requiresApproval: Type.Optional(Type.Boolean()),
approvalId: Type.Optional(NonEmptyString),
source: Type.Optional(
Type.Union([
Type.Literal("core"),
Type.Literal("plugin"),
Type.Literal("mcp"),
Type.Literal("channel"),
Type.String(),
]),
),
error: Type.Optional(ToolsInvokeErrorSchema),
},
{ additionalProperties: false },
);

View File

@@ -48,6 +48,9 @@ import {
ToolsEffectiveGroupSchema,
ToolsEffectiveParamsSchema,
ToolsEffectiveResultSchema,
ToolsInvokeErrorSchema,
ToolsInvokeParamsSchema,
ToolsInvokeResultSchema,
} from "./agents-models-skills.js";
import {
ArtifactSummarySchema,
@@ -367,6 +370,9 @@ export const ProtocolSchemas = {
ToolsEffectiveEntry: ToolsEffectiveEntrySchema,
ToolsEffectiveGroup: ToolsEffectiveGroupSchema,
ToolsEffectiveResult: ToolsEffectiveResultSchema,
ToolsInvokeParams: ToolsInvokeParamsSchema,
ToolsInvokeError: ToolsInvokeErrorSchema,
ToolsInvokeResult: ToolsInvokeResultSchema,
SkillsBinsParams: SkillsBinsParamsSchema,
SkillsBinsResult: SkillsBinsResultSchema,
SkillsSearchParams: SkillsSearchParamsSchema,

View File

@@ -144,6 +144,8 @@ export type ToolsEffectiveParams = SchemaType<"ToolsEffectiveParams">;
export type ToolsEffectiveEntry = SchemaType<"ToolsEffectiveEntry">;
export type ToolsEffectiveGroup = SchemaType<"ToolsEffectiveGroup">;
export type ToolsEffectiveResult = SchemaType<"ToolsEffectiveResult">;
export type ToolsInvokeParams = SchemaType<"ToolsInvokeParams">;
export type ToolsInvokeResult = SchemaType<"ToolsInvokeResult">;
export type SkillsBinsParams = SchemaType<"SkillsBinsParams">;
export type SkillsBinsResult = SchemaType<"SkillsBinsResult">;
export type SkillsSearchParams = SchemaType<"SkillsSearchParams">;

View File

@@ -64,6 +64,7 @@ const BASE_METHODS = [
"models.authStatus",
"tools.catalog",
"tools.effective",
"tools.invoke",
"agents.list",
"agents.create",
"agents.update",

View File

@@ -33,6 +33,7 @@ import { systemHandlers } from "./server-methods/system.js";
import { talkHandlers } from "./server-methods/talk.js";
import { toolsCatalogHandlers } from "./server-methods/tools-catalog.js";
import { toolsEffectiveHandlers } from "./server-methods/tools-effective.js";
import { toolsInvokeHandlers } from "./server-methods/tools-invoke.js";
import { ttsHandlers } from "./server-methods/tts.js";
import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js";
import { updateHandlers } from "./server-methods/update.js";
@@ -96,6 +97,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...talkHandlers,
...toolsCatalogHandlers,
...toolsEffectiveHandlers,
...toolsInvokeHandlers,
...ttsHandlers,
...skillsHandlers,
...sessionsHandlers,

View File

@@ -0,0 +1,86 @@
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { ADMIN_SCOPE } from "../method-scopes.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateToolsInvokeParams,
type ToolsInvokeResult,
} from "../protocol/index.js";
import { invokeGatewayTool } from "../tools-invoke-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
function resolveRpcErrorCode(params: {
type: "invalid_request" | "not_found" | "tool_call_blocked" | "tool_error";
requiresApproval?: boolean;
}): string {
if (params.requiresApproval) {
return "requires_approval";
}
switch (params.type) {
case "invalid_request":
return "validation_error";
case "not_found":
return "not_found";
case "tool_call_blocked":
return "forbidden";
case "tool_error":
return "internal_error";
}
return "internal_error";
}
export const toolsInvokeHandlers: GatewayRequestHandlers = {
"tools.invoke": async ({ params, client, respond, context }) => {
if (!validateToolsInvokeParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid tools.invoke params: ${formatValidationErrors(validateToolsInvokeParams.errors)}`,
),
);
return;
}
const requestedToolName = normalizeOptionalString(params.name);
if (!requestedToolName) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid tools.invoke params: name required"),
);
return;
}
const outcome = await invokeGatewayTool({
cfg: context.getRuntimeConfig(),
input: params,
senderIsOwner: Boolean(client?.connect.scopes?.includes(ADMIN_SCOPE)),
toolCallIdPrefix: "rpc",
approvalMode: params.confirm === true ? "request" : "report",
});
if (outcome.ok) {
const payload: ToolsInvokeResult = {
ok: true,
toolName: outcome.toolName,
output: outcome.result,
source: outcome.source,
};
respond(true, payload, undefined);
return;
}
const payload: ToolsInvokeResult = {
ok: false,
toolName: outcome.toolName || requestedToolName,
...(outcome.error.requiresApproval ? { requiresApproval: true } : {}),
error: {
code: resolveRpcErrorCode(outcome.error),
message: outcome.error.message,
},
};
respond(true, payload, undefined);
},
};

View File

@@ -205,6 +205,7 @@ vi.mock("../agents/pi-tools.before-tool-call.js", () => ({
const { authorizeHttpGatewayConnect } = await import("./auth.js");
const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js");
const { toolsInvokeHandlers } = await import("./server-methods/tools-invoke.js");
let pluginHttpHandlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [];
@@ -380,6 +381,21 @@ const expectOkInvokeResponse = async (res: Response) => {
return body as { ok: boolean; result?: Record<string, unknown> };
};
const invokeToolsRpc = async (params: Record<string, unknown>, scopes = ["operator.write"]) => {
const respond = vi.fn();
await toolsInvokeHandlers["tools.invoke"]({
params,
respond: respond as never,
context: { getRuntimeConfig: () => cfg } as never,
client: { connect: { role: "operator", scopes } } as never,
req: { type: "req", id: "req-rpc-1", method: "tools.invoke" },
isWebchatConnect: () => false,
});
return respond.mock.calls[0] as
| [boolean, { ok?: boolean; toolName?: string; output?: unknown; error?: unknown }?, unknown?]
| undefined;
};
const setMainAllowedTools = (params: {
allow: string[];
gatewayAllow?: string[];
@@ -901,3 +917,101 @@ describe("POST /tools/invoke", () => {
expect(lastCreateOpenClawToolsContext?.disablePluginTools).toBe(false);
});
});
describe("tools.invoke Gateway RPC", () => {
it("invokes a tool through the SDK-facing RPC envelope", async () => {
allowAgentsListForMain();
const call = await invokeToolsRpc({
name: "agents_list",
args: {},
sessionKey: "main",
idempotencyKey: "rpc-tool-test",
});
expect(call?.[0]).toBe(true);
expect(call?.[1]).toMatchObject({
ok: true,
toolName: "agents_list",
output: { ok: true, result: [] },
source: "core",
});
expect(lastCreateOpenClawToolsContext?.allowGatewaySubagentBinding).toBe(true);
expect(hookMocks.runBeforeToolCallHook).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: "report",
toolName: "agents_list",
toolCallId: "rpc-rpc-tool-test",
ctx: expect.objectContaining({
agentId: "main",
sessionKey: "agent:main:main",
}),
}),
);
});
it("returns typed approval-needed refusal when the policy hook blocks", async () => {
setMainAllowedTools({ allow: ["tools_invoke_test"] });
hookMocks.runBeforeToolCallHook.mockResolvedValueOnce({
blocked: true,
deniedReason: "plugin-approval",
reason: "Plugin approval required",
params: { mode: "ok" },
});
const call = await invokeToolsRpc({
name: "tools_invoke_test",
args: { mode: "ok" },
sessionKey: "main",
confirm: false,
});
expect(call?.[0]).toBe(true);
expect(call?.[1]).toMatchObject({
ok: false,
toolName: "tools_invoke_test",
requiresApproval: true,
error: {
code: "requires_approval",
message: "Plugin approval required",
},
});
});
it("rejects mismatched session and agent scope", async () => {
cfg = {
agents: {
list: [
{ id: "main", default: true, tools: { allow: ["agents_list"] } },
{ id: "other", tools: { allow: ["agents_list"] } },
],
},
};
const call = await invokeToolsRpc({
name: "agents_list",
sessionKey: "agent:main:main",
agentId: "other",
});
expect(call?.[0]).toBe(true);
expect(call?.[1]).toMatchObject({
ok: false,
toolName: "agents_list",
error: {
code: "validation_error",
message: 'agent id "other" does not match session agent "main"',
},
});
});
it("rejects malformed params at the RPC boundary", async () => {
const call = await invokeToolsRpc({ name: "" });
expect(call?.[0]).toBe(false);
expect(call?.[2]).toMatchObject({
code: "INVALID_REQUEST",
message: expect.stringContaining("invalid tools.invoke params"),
});
});
});

View File

@@ -1,130 +1,18 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { runBeforeToolCallHook } from "../agents/pi-tools.before-tool-call.js";
import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js";
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js";
import { ToolInputError, type AnyAgentTool } from "../agents/tools/common.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
import { defaultSlotIdForKey } from "../plugins/slots.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import {
readJsonBodyOrError,
sendInvalidRequest,
sendJson,
sendMethodNotAllowed,
} from "./http-common.js";
import { readJsonBodyOrError, sendJson, sendMethodNotAllowed } from "./http-common.js";
import {
authorizeScopedGatewayHttpRequestOrReply,
getHeader,
resolveOpenAiCompatibleHttpOperatorScopes,
resolveOpenAiCompatibleHttpSenderIsOwner,
} from "./http-utils.js";
import { resolveGatewayScopedTools } from "./tool-resolution.js";
import { invokeGatewayTool, type ToolsInvokeInput } from "./tools-invoke-shared.js";
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
type ToolsInvokeBody = {
tool?: unknown;
action?: unknown;
args?: unknown;
sessionKey?: unknown;
dryRun?: unknown;
};
function resolveSessionKeyFromBody(body: ToolsInvokeBody): string | undefined {
if (typeof body.sessionKey === "string" && body.sessionKey.trim()) {
return body.sessionKey.trim();
}
return undefined;
}
function resolveMemoryToolDisableReasons(cfg: OpenClawConfig): string[] {
if (!process.env.VITEST) {
return [];
}
const reasons: string[] = [];
const plugins = cfg.plugins;
const slotRaw = plugins?.slots?.memory;
const slotDisabled = slotRaw === null || normalizeOptionalLowercaseString(slotRaw) === "none";
const pluginsDisabled = plugins?.enabled === false;
const defaultDisabled = isTestDefaultMemorySlotDisabled(cfg);
if (pluginsDisabled) {
reasons.push("plugins.enabled=false");
}
if (slotDisabled) {
reasons.push(slotRaw === null ? "plugins.slots.memory=null" : 'plugins.slots.memory="none"');
}
if (!pluginsDisabled && !slotDisabled && defaultDisabled) {
reasons.push("memory plugin disabled by test default");
}
return reasons;
}
function mergeActionIntoArgsIfSupported(params: {
toolSchema: unknown;
action: string | undefined;
args: Record<string, unknown>;
}): Record<string, unknown> {
const { toolSchema, action, args } = params;
if (!action) {
return args;
}
if (args.action !== undefined) {
return args;
}
// TypeBox schemas are plain objects; many tools define an `action` property.
const schemaObj = toolSchema as { properties?: Record<string, unknown> } | null;
const hasAction = Boolean(
schemaObj &&
typeof schemaObj === "object" &&
schemaObj.properties &&
"action" in schemaObj.properties,
);
if (!hasAction) {
return args;
}
return { ...args, action };
}
function getErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message || String(err);
}
if (typeof err === "string") {
return err;
}
return String(err);
}
function resolveToolInputErrorStatus(err: unknown): number | null {
if (err instanceof ToolInputError) {
const status = (err as { status?: unknown }).status;
return typeof status === "number" ? status : 400;
}
if (typeof err !== "object" || err === null || !("name" in err)) {
return null;
}
const name = (err as { name?: unknown }).name;
if (name !== "ToolInputError" && name !== "ToolAuthorizationError") {
return null;
}
const status = (err as { status?: unknown }).status;
if (typeof status === "number") {
return status;
}
return name === "ToolAuthorizationError" ? 403 : 400;
}
export async function handleToolsInvokeHttpRequest(
req: IncomingMessage,
@@ -176,42 +64,7 @@ export async function handleToolsInvokeHttpRequest(
if (bodyUnknown === undefined) {
return true;
}
const body = (bodyUnknown ?? {}) as ToolsInvokeBody;
const toolName = normalizeOptionalString(body.tool) ?? "";
if (!toolName) {
sendInvalidRequest(res, "tools.invoke requires body.tool");
return true;
}
if (process.env.VITEST && MEMORY_TOOL_NAMES.has(toolName)) {
const reasons = resolveMemoryToolDisableReasons(cfg);
if (reasons.length > 0) {
const suffix = reasons.length > 0 ? ` (${reasons.join(", ")})` : "";
sendJson(res, 400, {
ok: false,
error: {
type: "invalid_request",
message:
`memory tools are disabled in tests${suffix}. ` +
`Enable by setting plugins.slots.memory="${defaultSlotIdForKey("memory")}" (and ensure plugins.enabled is not false).`,
},
});
return true;
}
}
const action = normalizeOptionalString(body.action);
const argsRaw = body.args;
const args =
argsRaw && typeof argsRaw === "object" && !Array.isArray(argsRaw)
? (argsRaw as Record<string, unknown>)
: {};
const rawSessionKey = resolveSessionKeyFromBody(body);
const sessionKey =
!rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;
const body = (bodyUnknown ?? {}) as ToolsInvokeInput;
// Resolve message channel/account hints (optional headers) for policy inheritance.
const messageChannel = normalizeMessageChannel(
@@ -226,77 +79,20 @@ export async function handleToolsInvokeHttpRequest(
// with the correct owner context and channel-action gates (e.g. Matrix set-profile)
// work correctly for both owner and non-owner callers.
const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth);
const resolveTools = (disablePluginTools: boolean) =>
resolveGatewayScopedTools({
cfg,
sessionKey,
messageProvider: messageChannel ?? undefined,
accountId,
agentTo,
agentThreadId,
allowGatewaySubagentBinding: true,
allowMediaInvokeCommands: true,
surface: "http",
disablePluginTools,
senderIsOwner,
});
const knownCoreTool = isKnownCoreToolId(toolName);
let { agentId, tools } = resolveTools(knownCoreTool);
if (knownCoreTool && !tools.some((candidate) => candidate.name === toolName)) {
({ agentId, tools } = resolveTools(false));
}
const gatewayFiltered = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
const tool = gatewayFiltered.find((t) => t.name === toolName);
if (!tool) {
sendJson(res, 404, {
ok: false,
error: { type: "not_found", message: `Tool not available: ${toolName}` },
});
return true;
}
try {
const gatewayTool: AnyAgentTool = tool;
const toolCallId = `http-${Date.now()}`;
const toolArgs = mergeActionIntoArgsIfSupported({
toolSchema: gatewayTool.parameters,
action,
args,
});
const hookResult = await runBeforeToolCallHook({
toolName,
params: toolArgs,
toolCallId,
ctx: {
agentId,
sessionKey,
loopDetection: resolveToolLoopDetectionConfig({ cfg, agentId }),
},
});
if (hookResult.blocked) {
sendJson(res, 403, {
ok: false,
error: { type: "tool_call_blocked", message: hookResult.reason },
});
return true;
}
const result = await gatewayTool.execute?.(toolCallId, hookResult.params);
sendJson(res, 200, { ok: true, result });
} catch (err) {
const inputStatus = resolveToolInputErrorStatus(err);
if (inputStatus !== null) {
sendJson(res, inputStatus, {
ok: false,
error: { type: "tool_error", message: getErrorMessage(err) || "invalid tool arguments" },
});
return true;
}
logWarn(`tools-invoke: tool execution failed: ${String(err)}`);
sendJson(res, 500, {
ok: false,
error: { type: "tool_error", message: "tool execution failed" },
});
const outcome = await invokeGatewayTool({
cfg,
input: body,
senderIsOwner,
messageChannel: messageChannel ?? undefined,
accountId,
agentTo,
agentThreadId,
toolCallIdPrefix: "http",
});
if (outcome.ok) {
sendJson(res, outcome.status, { ok: true, result: outcome.result });
} else {
sendJson(res, outcome.status, { ok: false, error: outcome.error });
}
return true;

View File

@@ -0,0 +1,303 @@
import { getChannelAgentToolMeta } from "../agents/channel-tools.js";
import { runBeforeToolCallHook } from "../agents/pi-tools.before-tool-call.js";
import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js";
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js";
import { ToolInputError, type AnyAgentTool } from "../agents/tools/common.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
import { defaultSlotIdForKey } from "../plugins/slots.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { canonicalizeSessionKeyForAgent } from "./session-store-key.js";
import { resolveGatewayScopedTools } from "./tool-resolution.js";
const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
export type ToolsInvokeInput = {
tool?: unknown;
name?: unknown;
action?: unknown;
args?: unknown;
sessionKey?: unknown;
agentId?: unknown;
idempotencyKey?: unknown;
dryRun?: unknown;
};
export type ToolsInvokeErrorType =
| "invalid_request"
| "not_found"
| "tool_call_blocked"
| "tool_error";
export type ToolsInvokeOutcome =
| {
ok: true;
status: 200;
toolName: string;
source: "core" | "plugin" | "channel";
result: unknown;
}
| {
ok: false;
status: 400 | 403 | 404 | 500;
toolName: string;
error: {
type: ToolsInvokeErrorType;
message: string;
requiresApproval?: boolean;
};
};
function resolveSessionKey(params: { cfg: OpenClawConfig; input: ToolsInvokeInput }): string {
const rawSessionKey = normalizeOptionalString(params.input.sessionKey);
if (rawSessionKey && rawSessionKey !== "main") {
return rawSessionKey;
}
const agentId = normalizeOptionalString(params.input.agentId);
if (agentId) {
return canonicalizeSessionKeyForAgent(agentId, "main");
}
return resolveMainSessionKey(params.cfg);
}
function resolveMemoryToolDisableReasons(cfg: OpenClawConfig): string[] {
if (!process.env.VITEST) {
return [];
}
const reasons: string[] = [];
const plugins = cfg.plugins;
const slotRaw = plugins?.slots?.memory;
const slotDisabled = slotRaw === null || normalizeOptionalLowercaseString(slotRaw) === "none";
const pluginsDisabled = plugins?.enabled === false;
const defaultDisabled = isTestDefaultMemorySlotDisabled(cfg);
if (pluginsDisabled) {
reasons.push("plugins.enabled=false");
}
if (slotDisabled) {
reasons.push(slotRaw === null ? "plugins.slots.memory=null" : 'plugins.slots.memory="none"');
}
if (!pluginsDisabled && !slotDisabled && defaultDisabled) {
reasons.push("memory plugin disabled by test default");
}
return reasons;
}
function mergeActionIntoArgsIfSupported(params: {
toolSchema: unknown;
action: string | undefined;
args: Record<string, unknown>;
}): Record<string, unknown> {
const { toolSchema, action, args } = params;
if (!action || args.action !== undefined) {
return args;
}
const schemaObj = toolSchema as { properties?: Record<string, unknown> } | null;
const hasAction = Boolean(
schemaObj &&
typeof schemaObj === "object" &&
schemaObj.properties &&
"action" in schemaObj.properties,
);
return hasAction ? { ...args, action } : args;
}
function getErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message || String(err);
}
if (typeof err === "string") {
return err;
}
return String(err);
}
function resolveToolInputErrorStatus(err: unknown): number | null {
if (err instanceof ToolInputError) {
const status = (err as { status?: unknown }).status;
return typeof status === "number" ? status : 400;
}
if (typeof err !== "object" || err === null || !("name" in err)) {
return null;
}
const name = (err as { name?: unknown }).name;
if (name !== "ToolInputError" && name !== "ToolAuthorizationError") {
return null;
}
const status = (err as { status?: unknown }).status;
if (typeof status === "number") {
return status;
}
return name === "ToolAuthorizationError" ? 403 : 400;
}
function resolveToolSource(tool: AnyAgentTool): "core" | "plugin" | "channel" {
if (getPluginToolMeta(tool)) {
return "plugin";
}
if (getChannelAgentToolMeta(tool as never)) {
return "channel";
}
return "core";
}
export async function invokeGatewayTool(params: {
cfg: OpenClawConfig;
input: ToolsInvokeInput;
senderIsOwner: boolean;
messageChannel?: string;
accountId?: string;
agentTo?: string;
agentThreadId?: string;
toolCallIdPrefix: string;
approvalMode?: "request" | "report";
}): Promise<ToolsInvokeOutcome> {
const toolName = normalizeOptionalString(params.input.name ?? params.input.tool) ?? "";
if (!toolName) {
return {
ok: false,
status: 400,
toolName: "",
error: { type: "invalid_request", message: "tools.invoke requires name" },
};
}
if (process.env.VITEST && MEMORY_TOOL_NAMES.has(toolName)) {
const reasons = resolveMemoryToolDisableReasons(params.cfg);
if (reasons.length > 0) {
const suffix = ` (${reasons.join(", ")})`;
return {
ok: false,
status: 400,
toolName,
error: {
type: "invalid_request",
message:
`memory tools are disabled in tests${suffix}. ` +
`Enable by setting plugins.slots.memory="${defaultSlotIdForKey("memory")}" (and ensure plugins.enabled is not false).`,
},
};
}
}
const action = normalizeOptionalString(params.input.action);
const argsRaw = params.input.args;
const args =
argsRaw && typeof argsRaw === "object" && !Array.isArray(argsRaw)
? (argsRaw as Record<string, unknown>)
: {};
const sessionKey = resolveSessionKey({ cfg: params.cfg, input: params.input });
const resolveTools = (disablePluginTools: boolean) =>
resolveGatewayScopedTools({
cfg: params.cfg,
sessionKey,
messageProvider: params.messageChannel,
accountId: params.accountId,
agentTo: params.agentTo,
agentThreadId: params.agentThreadId,
allowGatewaySubagentBinding: true,
allowMediaInvokeCommands: true,
surface: "http",
disablePluginTools,
senderIsOwner: params.senderIsOwner,
});
const knownCoreTool = isKnownCoreToolId(toolName);
let { agentId, tools } = resolveTools(knownCoreTool);
if (knownCoreTool && !tools.some((candidate) => candidate.name === toolName)) {
({ agentId, tools } = resolveTools(false));
}
const requestedAgentId = normalizeOptionalString(params.input.agentId);
if (requestedAgentId && agentId && requestedAgentId !== agentId) {
return {
ok: false,
status: 400,
toolName,
error: {
type: "invalid_request",
message: `agent id "${requestedAgentId}" does not match session agent "${agentId}"`,
},
};
}
const tool = applyOwnerOnlyToolPolicy(tools, params.senderIsOwner).find(
(candidate) => candidate.name === toolName,
);
if (!tool) {
return {
ok: false,
status: 404,
toolName,
error: { type: "not_found", message: `Tool not available: ${toolName}` },
};
}
try {
const gatewayTool: AnyAgentTool = tool;
const idempotencyKey = normalizeOptionalString(params.input.idempotencyKey);
const toolCallId = idempotencyKey
? `${params.toolCallIdPrefix}-${idempotencyKey}`
: `${params.toolCallIdPrefix}-${Date.now()}`;
const toolArgs = mergeActionIntoArgsIfSupported({
toolSchema: gatewayTool.parameters,
action,
args,
});
const hookResult = await runBeforeToolCallHook({
toolName,
params: toolArgs,
toolCallId,
ctx: {
agentId,
sessionKey,
loopDetection: resolveToolLoopDetectionConfig({ cfg: params.cfg, agentId }),
},
approvalMode: params.approvalMode,
});
if (hookResult.blocked) {
return {
ok: false,
status: 403,
toolName,
error: {
type: "tool_call_blocked",
message: hookResult.reason,
requiresApproval: hookResult.deniedReason === "plugin-approval",
},
};
}
return {
ok: true,
status: 200,
toolName,
source: resolveToolSource(gatewayTool),
result: await gatewayTool.execute?.(toolCallId, hookResult.params),
};
} catch (err) {
const inputStatus = resolveToolInputErrorStatus(err);
if (inputStatus !== null) {
return {
ok: false,
status: inputStatus === 403 ? 403 : 400,
toolName,
error: {
type: "tool_error",
message: getErrorMessage(err) || "invalid tool arguments",
},
};
}
logWarn(`tools-invoke: tool execution failed: ${String(err)}`);
return {
ok: false,
status: 500,
toolName,
error: { type: "tool_error", message: "tool execution failed" },
};
}
}