Files
openclaw/src/plugins/host-hooks.ts

333 lines
8.8 KiB
TypeScript

import type { OperatorScope } from "../gateway/operator-scopes.js";
import type { AgentEventPayload, AgentEventStream } from "../infra/agent-events.js";
import type {
PluginHookAgentContext,
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookToolContext,
} from "./hook-types.js";
import type { PluginJsonValue } from "./host-hook-json.js";
import type {
PluginAgentTurnPrepareResult,
PluginNextTurnInjectionPlacement,
PluginNextTurnInjectionRecord,
} from "./host-hook-turn-types.js";
export { isPluginJsonValue } from "./host-hook-json.js";
export type { PluginJsonPrimitive, PluginJsonValue } from "./host-hook-json.js";
export type {
PluginAgentTurnPrepareEvent,
PluginAgentTurnPrepareResult,
PluginHeartbeatPromptContributionEvent,
PluginHeartbeatPromptContributionResult,
PluginNextTurnInjection,
PluginNextTurnInjectionEnqueueResult,
PluginNextTurnInjectionPlacement,
PluginNextTurnInjectionRecord,
} from "./host-hook-turn-types.js";
export type PluginHostCleanupReason = "disable" | "reset" | "delete" | "restart";
export type PluginSessionExtensionProjectionContext = {
sessionKey: string;
sessionId?: string;
state: PluginJsonValue | undefined;
};
export type PluginSessionExtensionRegistration = {
namespace: string;
description: string;
project?: (ctx: PluginSessionExtensionProjectionContext) => PluginJsonValue | undefined;
cleanup?: (ctx: { reason: PluginHostCleanupReason; sessionKey?: string }) => void | Promise<void>;
/**
* When set, after every successful `patchSessionExtension` the projected
* value is mirrored to `SessionEntry[<slotKey>]` so non-plugin readers
* can consume the typed slot without reaching into
* `pluginExtensions[pluginId][namespace]`.
*
* The slot is a read-only mirror: writes always go through
* `patchSessionExtension`; the host overwrites the slot value on every
* subsequent patch.
*/
sessionEntrySlotKey?: string;
/**
* Optional JSON-compatible schema describing the projected slot value.
* Purely informational at this layer; clients may use it to validate the
* mirrored slot against a contract.
*/
sessionEntrySlotSchema?: PluginJsonValue;
};
export type PluginSessionExtensionProjection = {
pluginId: string;
namespace: string;
value: PluginJsonValue;
};
export type PluginSessionExtensionPatchParams = {
key: string;
pluginId: string;
namespace: string;
value?: PluginJsonValue;
unset?: boolean;
};
export type PluginToolPolicyDecision =
| PluginHookBeforeToolCallResult
| {
allow?: boolean;
reason?: string;
};
export type PluginTrustedToolPolicyRegistration = {
id: string;
description: string;
evaluate: (
event: PluginHookBeforeToolCallEvent,
ctx: PluginHookToolContext,
) => PluginToolPolicyDecision | void | Promise<PluginToolPolicyDecision | void>;
};
export type PluginToolMetadataRegistration = {
toolName: string;
displayName?: string;
description?: string;
risk?: "low" | "medium" | "high";
tags?: string[];
};
export type PluginCommandContinuation = {
continueAgent?: boolean;
};
export type PluginControlUiDescriptor = {
id: string;
surface: "session" | "tool" | "run" | "settings";
label: string;
description?: string;
placement?: string;
schema?: PluginJsonValue;
requiredScopes?: OperatorScope[];
};
export type PluginSessionActionContext = {
pluginId: string;
actionId: string;
sessionKey?: string;
payload?: PluginJsonValue;
client?: {
connId?: string;
scopes: string[];
};
};
export type PluginSessionActionResult =
| {
ok?: true;
result?: PluginJsonValue;
reply?: PluginJsonValue;
continueAgent?: boolean;
}
| {
ok: false;
error: string;
code?: string;
details?: PluginJsonValue;
};
export type PluginSessionActionRegistration = {
id: string;
description?: string;
schema?: PluginJsonValue;
requiredScopes?: OperatorScope[];
handler: (
ctx: PluginSessionActionContext,
) => PluginSessionActionResult | void | Promise<PluginSessionActionResult | void>;
};
export type PluginRuntimeLifecycleRegistration = {
id: string;
description?: string;
cleanup?: (ctx: {
reason: PluginHostCleanupReason;
sessionKey?: string;
runId?: string;
}) => void | Promise<void>;
};
export type PluginAgentEventSubscriptionRegistration = {
id: string;
description?: string;
streams?: AgentEventStream[];
handle: (
event: AgentEventPayload,
ctx: {
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Run-context JSON reads are caller-typed by namespace.
getRunContext: <T extends PluginJsonValue = PluginJsonValue>(
namespace: string,
) => T | undefined;
setRunContext: (namespace: string, value: PluginJsonValue) => void;
clearRunContext: (namespace?: string) => void;
},
) => void | Promise<void>;
};
export type PluginAgentEventEmitParams = {
runId: string;
stream: AgentEventStream;
data: PluginJsonValue;
sessionKey?: string;
};
export type PluginAgentEventEmitResult =
| { emitted: true; stream: AgentEventStream }
| { emitted: false; reason: string };
export type PluginRunContextPatch = {
runId: string;
namespace: string;
value?: PluginJsonValue;
unset?: boolean;
};
export type PluginRunContextGetParams = {
runId: string;
namespace: string;
};
export type PluginSessionSchedulerJobRegistration = {
id: string;
sessionKey: string;
kind: string;
description?: string;
cleanup?: (ctx: {
reason: PluginHostCleanupReason;
sessionKey: string;
jobId: string;
}) => void | Promise<void>;
};
export type PluginSessionSchedulerJobHandle = {
id: string;
pluginId: string;
sessionKey: string;
kind: string;
};
export type PluginSessionAttachmentFile = {
path: string;
};
export type PluginAttachmentChannelHints = {
telegram?: {
parseMode?: "HTML";
disableNotification?: boolean;
/**
* Require host-side detection to match this MIME before forcing document delivery.
* Mismatched files are rejected before the outbound adapter is called.
*/
forceDocumentMime?: string;
};
slack?: {
threadTs?: string;
};
};
export type PluginSessionAttachmentCaptionFormat = "plain" | "html" | "markdown";
export type PluginSessionAttachmentParams = {
sessionKey: string;
files: PluginSessionAttachmentFile[];
text?: string;
threadId?: string | number;
forceDocument?: boolean;
maxBytes?: number;
captionFormat?: PluginSessionAttachmentCaptionFormat;
channelHints?: PluginAttachmentChannelHints;
};
export type PluginSessionAttachmentResult =
| {
ok: true;
channel: string;
deliveredTo: string;
count: number;
}
| { ok: false; error: string };
export type PluginSessionTurnSchedule =
| { at: string | number | Date }
| { delayMs: number }
| { cron: string; tz?: string };
type PluginSessionTurnScheduleCommonParams = {
sessionKey: string;
message: string;
agentId?: string;
deliveryMode?: "none" | "announce";
name?: string;
/** Optional cleanup tag. Reserved cron-name delimiters like `:` are rejected. */
tag?: string;
};
export type PluginSessionTurnScheduleParams =
| ({
at: string | number | Date;
deleteAfterRun?: boolean;
} & PluginSessionTurnScheduleCommonParams)
| ({
delayMs: number;
deleteAfterRun?: boolean;
} & PluginSessionTurnScheduleCommonParams)
| ({
cron: string;
tz?: string;
deleteAfterRun?: false;
} & PluginSessionTurnScheduleCommonParams);
export type PluginSessionTurnUnscheduleByTagParams = {
sessionKey: string;
tag: string;
};
export type PluginSessionTurnUnscheduleByTagResult = {
removed: number;
failed: number;
};
export function normalizePluginHostHookId(value: string | undefined): string {
return (value ?? "").trim();
}
function normalizeQueuedInjectionText(
entry: PluginNextTurnInjectionRecord,
placement: PluginNextTurnInjectionPlacement,
): string | undefined {
const candidate = entry as {
placement?: unknown;
text?: unknown;
};
if (candidate.placement !== placement || typeof candidate.text !== "string") {
return undefined;
}
const text = candidate.text.trim();
return text || undefined;
}
export function buildPluginAgentTurnPrepareContext(params: {
queuedInjections: PluginNextTurnInjectionRecord[];
}): PluginAgentTurnPrepareResult {
const prepend = params.queuedInjections
.map((entry) => normalizeQueuedInjectionText(entry, "prepend_context"))
.filter(Boolean);
const append = params.queuedInjections
.map((entry) => normalizeQueuedInjectionText(entry, "append_context"))
.filter(Boolean);
return {
...(prepend.length > 0 ? { prependContext: prepend.join("\n\n") } : {}),
...(append.length > 0 ? { appendContext: append.join("\n\n") } : {}),
};
}
export type PluginHostHookRunContext = PluginHookAgentContext;