mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:10:51 +00:00
feat: add user input blocking lifecycle gates (#75035)
Summary: - The PR adds a `before_agent_run` plugin hook with pass/block decisions, redacted blocked-turn persistence, diagnostics/docs/changelog updates, and focused runner, gateway, session, and plugin tests. - Reproducibility: not applicable. as a feature PR rather than a current-main bug report. Current main lacks ` ... un`, while the PR head adds source coverage and copied live Gateway/WebChat log proof for the new behavior. Automerge notes: - PR branch already contained follow-up commit before automerge: fix: trim before agent hook PR scope - PR branch already contained follow-up commit before automerge: fix: keep before-agent blocks redacted - PR branch already contained follow-up commit before automerge: fix: keep runtime context out of model prompt - PR branch already contained follow-up commit before automerge: docs: refresh config baseline after rebase - PR branch already contained follow-up commit before automerge: fix: align blocked turn clients with redacted content - PR branch already contained follow-up commit before automerge: fix: remove out-of-scope client block UI changes Validation: - ClawSweeper review passed for head767e46fde8. - Required merge gates passed before the squash merge. Prepared head SHA:767e46fde8Review: https://github.com/openclaw/openclaw/pull/75035#issuecomment-4351843275 Co-authored-by: Jesse Merhi <jessejmerhi@gmail.com> Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
@@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Developer tooling: add checked-in VS Code Gateway debugging configs and an opt-in `OUTPUT_SOURCE_MAPS=1` source-map build path for breakpoints in TypeScript source. (#45710) Thanks @SwissArmyBud.
|
||||
- Managed proxy: add `proxy.loopbackMode` for Gateway loopback control-plane traffic, allowing operators to keep the default Gateway loopback bypass, force loopback Gateway traffic through the proxy, or block it. (#77018) Thanks @jesse-merhi.
|
||||
- Telegram/native commands: show the current thinking level above the `/think` level picker so users can see the active setting before changing it. (#78278) Thanks @obviyus.
|
||||
- Plugins/hooks: add a `before_agent_run` pass/block gate that can stop a user prompt before model submission while preserving a redacted transcript entry for the user, and clarify that raw conversation hooks require `hooks.allowConversationAccess=true`. (#75035) Thanks @jesse-merhi.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
5dd302a20b8a6347425617323d0ad7875f9b7631acd3ed3935cfaaf7708a32dd config-baseline.json
|
||||
d192d678668712b81cc2e76ddcb6420893ab5144944ccb830b290019d6a717a4 config-baseline.core.json
|
||||
da2ba9afd1062db1fafe81fb42e39db4ad65995a5e56caef4057a9954c2c386b config-baseline.json
|
||||
f860a7d43d3bd15379d8c3dfccbc6fcbf47b9bec8d8b67b29dd7313946905645 config-baseline.core.json
|
||||
cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json
|
||||
6871e789b74722e4ff2c877940dac256c232433ae26b305fc6ca782b90662097 config-baseline.plugin.json
|
||||
2fee9c16a60d074fac428b4ad14c38ad3ca7febefacfad819f741a820101326e config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ce3eef3355f00b88eba1dd54731f932a1ffff9dee64cb19402d7d89b2c363681 plugin-sdk-api-baseline.json
|
||||
28eb08edb11108d80ec5d5bd12c97108495b064a4d6dd5ca3ecc01d12c2d4c42 plugin-sdk-api-baseline.jsonl
|
||||
fe1d0d9bde4ab216a92e4fce8c01d699601c3f033f6449352382ff902b129a6f plugin-sdk-api-baseline.json
|
||||
15eb9a88276f066cddb645f072f57f25f1ff59174cf2277b6014ca2565dbe09a plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -275,7 +275,7 @@ For runtime hook debugging:
|
||||
|
||||
- `openclaw plugins inspect <id> --runtime --json` shows registered hooks and diagnostics from a module-loaded inspection pass. Runtime inspection never installs dependencies; use `openclaw doctor --fix` to clean legacy dependency state or recover missing downloadable plugins that are referenced by config.
|
||||
- `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway, service/process hints, config path, and RPC health.
|
||||
- Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_agent_finalize`, `agent_end`) require `plugins.entries.<id>.hooks.allowConversationAccess=true`.
|
||||
- Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, `agent_end`) require `plugins.entries.<id>.hooks.allowConversationAccess=true`.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
|
||||
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
|
||||
- `plugins.entries.<id>.env`: plugin-scoped env var map.
|
||||
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories.
|
||||
- `plugins.entries.<id>.hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`.
|
||||
- `plugins.entries.<id>.hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, and `agent_end`.
|
||||
- `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
|
||||
- `plugins.entries.<id>.subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model.
|
||||
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
|
||||
|
||||
@@ -104,6 +104,7 @@ observation-only.
|
||||
- `agent_turn_prepare` - consume queued plugin turn injections and add same-turn context before prompt hooks
|
||||
- `before_prompt_build` - add dynamic context or system-prompt text before the model call
|
||||
- `before_agent_start` - compatibility-only combined phase; prefer the two hooks above
|
||||
- **`before_agent_run`** - inspect the final prompt and session messages before model submission and optionally block the run
|
||||
- **`before_agent_reply`** - short-circuit the model turn with a synthetic reply or silence
|
||||
- **`before_agent_finalize`** - inspect the natural final answer and request one more model pass
|
||||
- `agent_end` - observe final messages, success state, and run duration
|
||||
@@ -232,6 +233,22 @@ Use the phase-specific hooks for new plugins:
|
||||
`before_agent_start` remains for compatibility. Prefer the explicit hooks above
|
||||
so your plugin does not depend on a legacy combined phase.
|
||||
|
||||
`before_agent_run` runs after prompt construction and before any model input,
|
||||
including prompt-local image loading and `llm_input` observation. It receives
|
||||
the current user input as `prompt`, plus loaded session history in `messages`
|
||||
and the active system prompt. Return `{ outcome: "block", reason, message? }`
|
||||
to stop the run before the model can read the prompt. `reason` is internal;
|
||||
`message` is the user-facing replacement. The only supported outcomes are
|
||||
`pass` and `block`; unsupported decision shapes fail closed.
|
||||
|
||||
When a run is blocked, OpenClaw stores only the replacement text in
|
||||
`message.content` plus non-sensitive block metadata such as the blocking plugin
|
||||
id and timestamp. The original user text is not retained in transcript or future
|
||||
context. Internal block reasons are treated as sensitive and excluded from
|
||||
transcript, history, broadcast, log, and diagnostics payloads. Observability
|
||||
should use sanitized fields such as blocker id, outcome, timestamp, or a safe
|
||||
category.
|
||||
|
||||
`before_agent_start` and `agent_end` include `event.runId` when OpenClaw can
|
||||
identify the active run. The same value is also available on `ctx.runId`.
|
||||
Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
|
||||
@@ -280,8 +297,9 @@ type BeforeAgentFinalizeRetry = {
|
||||
equivalent finalize decisions, and `maxAttempts` caps how many extra passes the
|
||||
host will allow before continuing with the natural final answer.
|
||||
|
||||
Non-bundled plugins that need `llm_input`, `llm_output`,
|
||||
`before_agent_finalize`, or `agent_end` must set:
|
||||
Non-bundled plugins that need raw conversation hooks (`before_model_resolve`,
|
||||
`before_agent_reply`, `llm_input`, `llm_output`, `before_agent_finalize`,
|
||||
`agent_end`, or `before_agent_run`) must set:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -406,8 +406,9 @@ do not run in live chat traffic, check these first:
|
||||
containers, PID 1 may only be a supervisor; restart or signal the child
|
||||
`openclaw gateway run` process.
|
||||
- Use `openclaw plugins inspect <id> --runtime --json` to confirm hook registrations and
|
||||
diagnostics. Non-bundled conversation hooks such as `llm_input`,
|
||||
`llm_output`, `before_agent_finalize`, and `agent_end` need
|
||||
diagnostics. Non-bundled conversation hooks such as `before_model_resolve`,
|
||||
`before_agent_reply`, `before_agent_run`, `llm_input`, `llm_output`,
|
||||
`before_agent_finalize`, and `agent_end` need
|
||||
`plugins.entries.<id>.hooks.allowConversationAccess=true`.
|
||||
- For model switching, prefer `before_model_resolve`. It runs before model
|
||||
resolution for agent turns; `llm_output` only runs after a model attempt
|
||||
|
||||
@@ -646,6 +646,34 @@ describe("diagnostics-otel service", () => {
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("records hook-blocked run metrics with safe blocker originator", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitDiagnosticEvent({
|
||||
type: "run.completed",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
outcome: "blocked",
|
||||
blockedBy: "policy-plugin",
|
||||
durationMs: 100,
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
expect(telemetryState.histograms.get("openclaw.run.duration_ms")?.record).toHaveBeenCalledWith(
|
||||
100,
|
||||
expect.objectContaining({
|
||||
"openclaw.outcome": "blocked",
|
||||
"openclaw.blocked_by": "policy-plugin",
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(telemetryState)).not.toContain("matched secret prompt");
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("honors disabled traces when an OpenTelemetry SDK is preloaded", async () => {
|
||||
process.env.OPENCLAW_OTEL_PRELOADED = "1";
|
||||
const service = createDiagnosticsOtelService();
|
||||
|
||||
@@ -1665,6 +1665,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
if (evt.channel) {
|
||||
attrs["openclaw.channel"] = evt.channel;
|
||||
}
|
||||
if (evt.blockedBy) {
|
||||
attrs["openclaw.blocked_by"] = lowCardinalityAttr(evt.blockedBy, "unknown");
|
||||
}
|
||||
durationHistogram.record(evt.durationMs, attrs);
|
||||
if (!tracesEnabled) {
|
||||
return;
|
||||
@@ -1673,6 +1676,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
"openclaw.outcome": evt.outcome,
|
||||
};
|
||||
addRunAttrs(spanAttrs, evt);
|
||||
if (evt.blockedBy) {
|
||||
spanAttrs["openclaw.blocked_by"] = lowCardinalityAttr(evt.blockedBy, "unknown");
|
||||
}
|
||||
if (evt.errorCategory) {
|
||||
spanAttrs["openclaw.errorCategory"] = lowCardinalityAttr(evt.errorCategory, "other");
|
||||
}
|
||||
|
||||
@@ -43,6 +43,37 @@ describe("diagnostics-prometheus service", () => {
|
||||
expect(rendered).not.toContain("session-should-not-export");
|
||||
});
|
||||
|
||||
it("records hook-blocked run metrics with safe blocker originator only", () => {
|
||||
const store = __test__.createPrometheusMetricStore();
|
||||
|
||||
__test__.recordDiagnosticEvent(
|
||||
store,
|
||||
{
|
||||
...baseEvent(),
|
||||
type: "run.completed",
|
||||
runId: "run-should-not-export",
|
||||
sessionKey: "session-should-not-export",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
channel: "slack",
|
||||
trigger: "message",
|
||||
durationMs: 250,
|
||||
outcome: "blocked",
|
||||
blockedBy: "policy-plugin",
|
||||
},
|
||||
trusted,
|
||||
);
|
||||
|
||||
const rendered = __test__.renderPrometheusMetrics(store);
|
||||
|
||||
expect(rendered).toContain(
|
||||
'openclaw_run_completed_total{blocked_by="policy-plugin",channel="slack",model="gpt-5.4",outcome="blocked",provider="openai",trigger="message"} 1',
|
||||
);
|
||||
expect(rendered).not.toContain("run-should-not-export");
|
||||
expect(rendered).not.toContain("session-should-not-export");
|
||||
expect(rendered).not.toContain("matched secret prompt");
|
||||
});
|
||||
|
||||
it("drops untrusted plugin-emitted diagnostic events", () => {
|
||||
const store = __test__.createPrometheusMetricStore();
|
||||
|
||||
|
||||
@@ -276,6 +276,7 @@ function renderPrometheusMetrics(store: PrometheusMetricStore): string {
|
||||
}
|
||||
|
||||
function runLabels(evt: {
|
||||
blockedBy?: string;
|
||||
channel?: string;
|
||||
model?: string;
|
||||
outcome?: string;
|
||||
@@ -283,6 +284,7 @@ function runLabels(evt: {
|
||||
trigger?: string;
|
||||
}): LabelSet {
|
||||
return {
|
||||
...(evt.blockedBy ? { blocked_by: lowCardinalityLabel(evt.blockedBy) } : {}),
|
||||
channel: lowCardinalityLabel(evt.channel),
|
||||
model: lowCardinalityLabel(evt.model),
|
||||
outcome: lowCardinalityLabel(evt.outcome, "unknown"),
|
||||
|
||||
@@ -58,7 +58,19 @@ function createSessionFile(params?: { history?: Array<{ role: "user"; content: s
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-hooks-"));
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", dir);
|
||||
const sessionFile = path.join(dir, "agents", "main", "sessions", "s1.jsonl");
|
||||
const storePath = path.join(path.dirname(sessionFile), "sessions.json");
|
||||
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:main:main": {
|
||||
sessionId: "s1",
|
||||
sessionFile,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
sessionFile,
|
||||
`${JSON.stringify({
|
||||
@@ -87,7 +99,7 @@ function createSessionFile(params?: { history?: Array<{ role: "user"; content: s
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
return { dir, sessionFile };
|
||||
return { dir, sessionFile, storePath };
|
||||
}
|
||||
|
||||
function buildPreparedContext(params?: {
|
||||
@@ -620,6 +632,100 @@ describe("runCliAgent reliability", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks CLI runs before llm_input and model execution when before_agent_run blocks", async () => {
|
||||
supervisorSpawnMock.mockClear();
|
||||
const hookRunner = {
|
||||
hasHooks: vi.fn((hookName: string) =>
|
||||
["before_agent_run", "llm_input", "agent_end"].includes(hookName),
|
||||
),
|
||||
runBeforeAgentRun: vi.fn(async () => ({
|
||||
pluginId: "policy-plugin",
|
||||
decision: {
|
||||
outcome: "block" as const,
|
||||
reason: "matched secret prompt: secret prompt",
|
||||
message: "The agent cannot read this message.",
|
||||
},
|
||||
})),
|
||||
runLlmInput: vi.fn(async () => undefined),
|
||||
runAgentEnd: vi.fn(async () => undefined),
|
||||
};
|
||||
setHookRunnerForTest(hookRunner);
|
||||
const { dir, sessionFile } = createSessionFile({
|
||||
history: [{ role: "user", content: "earlier context" }],
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runPreparedCliAgent({
|
||||
...buildPreparedContext({ sessionKey: "agent:main:main", runId: "run-blocked-cli" }),
|
||||
params: {
|
||||
...buildPreparedContext({ sessionKey: "agent:main:main", runId: "run-blocked-cli" })
|
||||
.params,
|
||||
agentId: "main",
|
||||
sessionFile,
|
||||
workspaceDir: dir,
|
||||
prompt: "secret prompt",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.payloads).toEqual([
|
||||
{
|
||||
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
isError: true,
|
||||
},
|
||||
]);
|
||||
expect(result.meta.livenessState).toBe("blocked");
|
||||
expect(supervisorSpawnMock).not.toHaveBeenCalled();
|
||||
expect(hookRunner.runLlmInput).not.toHaveBeenCalled();
|
||||
expect(hookRunner.runBeforeAgentRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "secret prompt",
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({ role: "user", content: "earlier context" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runId: "run-blocked-cli",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(hookRunner.runAgentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error:
|
||||
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content:
|
||||
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(JSON.stringify(hookRunner.runAgentEnd.mock.calls)).not.toContain("secret prompt");
|
||||
|
||||
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
|
||||
const blockedLine = JSON.parse(lines[lines.length - 1]);
|
||||
expect(blockedLine.message.content[0].text).toBe(
|
||||
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
);
|
||||
expect(JSON.stringify(blockedLine)).not.toContain("secret prompt");
|
||||
expect(JSON.stringify(blockedLine)).not.toContain("matched secret prompt");
|
||||
expect(blockedLine.message.__openclaw.beforeAgentRunBlocked).toMatchObject({
|
||||
blockedBy: "policy-plugin",
|
||||
});
|
||||
expect(blockedLine.message.__openclaw.beforeAgentRunBlocked).not.toHaveProperty("reason");
|
||||
expect(Object.hasOwn(blockedLine.message.__openclaw, "beforeAgentRunBlocked")).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit llm_output when the CLI run returns no assistant text", async () => {
|
||||
const hookRunner = {
|
||||
hasHooks: vi.fn((hookName: string) => hookName === "llm_output"),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
|
||||
import { resolveBlockMessage } from "../plugins/hook-decision-types.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js";
|
||||
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
|
||||
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
|
||||
import { buildAgentHookContext } from "./harness/hook-context.js";
|
||||
import { buildAgentHookConversationMessages } from "./harness/hook-history.js";
|
||||
import {
|
||||
runAgentHarnessAgentEndHook,
|
||||
@@ -15,6 +19,12 @@ import {
|
||||
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
|
||||
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/cli-runner");
|
||||
|
||||
function flushSessionManagerFile(sessionManager: SessionManager): void {
|
||||
(sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
|
||||
}
|
||||
|
||||
function buildHandledReplyPayloads(reply?: ReplyPayload) {
|
||||
const normalized = reply ?? { text: SILENT_REPLY_TOKEN };
|
||||
return [
|
||||
@@ -127,8 +137,9 @@ export async function runPreparedCliAgent(
|
||||
const hasLlmInputHooks = hookRunner?.hasHooks("llm_input") === true;
|
||||
const hasLlmOutputHooks = hookRunner?.hasHooks("llm_output") === true;
|
||||
const hasAgentEndHooks = hookRunner?.hasHooks("agent_end") === true;
|
||||
const hasBeforeAgentRunHooks = hookRunner?.hasHooks("before_agent_run") === true;
|
||||
const historyMessages =
|
||||
hasLlmInputHooks || hasAgentEndHooks
|
||||
hasLlmInputHooks || hasAgentEndHooks || hasBeforeAgentRunHooks
|
||||
? await loadCliSessionHistoryMessages({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
@@ -175,6 +186,88 @@ export async function runPreparedCliAgent(
|
||||
durationMs: Date.now() - context.started,
|
||||
});
|
||||
|
||||
const buildBlockedAgentEndEvent = (message: string) => ({
|
||||
messages: buildAgentHookConversationMessages({
|
||||
historyMessages,
|
||||
currentTurnMessages: [buildCliHookUserMessage(message)],
|
||||
}),
|
||||
success: false,
|
||||
error: message,
|
||||
durationMs: Date.now() - context.started,
|
||||
});
|
||||
|
||||
const buildBlockedBeforeAgentRunResult = (message: string): EmbeddedPiRunResult => ({
|
||||
payloads: [{ text: message, isError: true }],
|
||||
meta: {
|
||||
durationMs: Date.now() - context.started,
|
||||
finalAssistantVisibleText: message,
|
||||
finalAssistantRawText: message,
|
||||
livenessState: "blocked",
|
||||
error: {
|
||||
kind: "hook_block",
|
||||
message,
|
||||
},
|
||||
systemPromptReport: context.systemPromptReport,
|
||||
executionTrace: {
|
||||
winnerProvider: params.provider,
|
||||
winnerModel: context.modelId,
|
||||
attempts: [
|
||||
{
|
||||
provider: params.provider,
|
||||
model: context.modelId,
|
||||
result: "error",
|
||||
reason: "before_agent_run blocked the run",
|
||||
},
|
||||
],
|
||||
fallbackUsed: false,
|
||||
runner: "cli",
|
||||
},
|
||||
requestShaping: {
|
||||
...(params.thinkLevel ? { thinking: params.thinkLevel } : {}),
|
||||
...(context.effectiveAuthProfileId ? { authMode: "auth-profile" } : {}),
|
||||
},
|
||||
completion: {
|
||||
finishReason: "blocked",
|
||||
stopReason: "blocked",
|
||||
refusal: true,
|
||||
},
|
||||
agentMeta: {
|
||||
sessionId: params.sessionId ?? "",
|
||||
provider: params.provider,
|
||||
model: context.modelId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const persistBlockedBeforeAgentRun = async (block: {
|
||||
message: string;
|
||||
pluginId: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
const nowMs = Date.now();
|
||||
const sessionManager = SessionManager.open(params.sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: block.message }],
|
||||
timestamp: nowMs,
|
||||
idempotencyKey: `hook-block:before_agent_run:user:${params.runId}`,
|
||||
__openclaw: {
|
||||
beforeAgentRunBlocked: {
|
||||
blockedBy: block.pluginId,
|
||||
blockedAt: nowMs,
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof sessionManager.appendMessage>[0]);
|
||||
flushSessionManagerFile(sessionManager);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`before_agent_run block: failed to persist redacted CLI user message: ${formatErrorMessage(
|
||||
err,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toCliRunFailure = (error: unknown): never => {
|
||||
if (isFailoverError(error)) {
|
||||
throw error;
|
||||
@@ -304,6 +397,60 @@ export async function runPreparedCliAgent(
|
||||
|
||||
// Try with the provided CLI session ID first
|
||||
try {
|
||||
if (hasBeforeAgentRunHooks && hookRunner) {
|
||||
let beforeRunResult:
|
||||
| Awaited<ReturnType<NonNullable<typeof hookRunner>["runBeforeAgentRun"]>>
|
||||
| undefined;
|
||||
try {
|
||||
beforeRunResult = await hookRunner.runBeforeAgentRun(
|
||||
{
|
||||
prompt: params.prompt,
|
||||
systemPrompt: context.systemPrompt,
|
||||
messages: buildAgentHookConversationMessages({
|
||||
historyMessages,
|
||||
currentTurnMessages: [],
|
||||
}),
|
||||
channelId: hookContext.channelId,
|
||||
accountId: params.agentAccountId,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
},
|
||||
buildAgentHookContext(hookContext),
|
||||
);
|
||||
} catch {
|
||||
const blockMessage = resolveBlockMessage(
|
||||
{ outcome: "block", reason: "before_agent_run hook failed" },
|
||||
{ blockedBy: "before_agent_run" },
|
||||
);
|
||||
await persistBlockedBeforeAgentRun({
|
||||
message: blockMessage,
|
||||
pluginId: "before_agent_run",
|
||||
});
|
||||
runAgentHarnessAgentEndHook({
|
||||
event: buildBlockedAgentEndEvent(blockMessage),
|
||||
ctx: hookContext,
|
||||
hookRunner,
|
||||
});
|
||||
return buildBlockedBeforeAgentRunResult(blockMessage);
|
||||
}
|
||||
|
||||
const beforeRunDecision = beforeRunResult?.decision;
|
||||
if (beforeRunDecision?.outcome === "block") {
|
||||
const blockMessage = resolveBlockMessage(beforeRunDecision, {
|
||||
blockedBy: beforeRunResult?.pluginId ?? "unknown",
|
||||
});
|
||||
await persistBlockedBeforeAgentRun({
|
||||
message: blockMessage,
|
||||
pluginId: beforeRunResult?.pluginId ?? "unknown",
|
||||
});
|
||||
runAgentHarnessAgentEndHook({
|
||||
event: buildBlockedAgentEndEvent(blockMessage),
|
||||
ctx: hookContext,
|
||||
hookRunner,
|
||||
});
|
||||
return buildBlockedBeforeAgentRunResult(blockMessage);
|
||||
}
|
||||
}
|
||||
|
||||
runAgentHarnessLlmInputHook({
|
||||
event: llmInputEvent,
|
||||
ctx: hookContext,
|
||||
|
||||
@@ -698,6 +698,28 @@ describe("runWithModelFallback", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps before_agent_run hook blocks out of empty-result fallback", () => {
|
||||
const runResult: EmbeddedPiRunResult = {
|
||||
payloads: [{ text: "Blocked by before-run policy.", isError: true }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
livenessState: "blocked",
|
||||
error: {
|
||||
kind: "hook_block",
|
||||
message: "Blocked by before-run policy.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
classifyEmbeddedPiRunResultForModelFallback({
|
||||
provider: "atlassian-ai-gateway-openai",
|
||||
model: "gpt-5.5-2026-04-23",
|
||||
result: runResult,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("uses harness-owned terminal classification for GPT-5 fallback", () => {
|
||||
const runResult: EmbeddedPiRunResult = {
|
||||
payloads: [],
|
||||
|
||||
@@ -18,6 +18,9 @@ function isEmbeddedPiRunResult(value: unknown): value is EmbeddedPiRunResult {
|
||||
}
|
||||
|
||||
function hasDeliberateSilentTerminalReply(result: EmbeddedPiRunResult): boolean {
|
||||
if (result.meta.error?.kind === "hook_block") {
|
||||
return true;
|
||||
}
|
||||
return [result.meta.finalAssistantRawText, result.meta.finalAssistantVisibleText].some(
|
||||
(text) => typeof text === "string" && isSilentReplyPayloadText(text),
|
||||
);
|
||||
|
||||
@@ -49,6 +49,36 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
|
||||
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
|
||||
});
|
||||
|
||||
it("emits the before_agent_run hook block message as the agent payload", async () => {
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
makeAttemptResult({
|
||||
assistantTexts: [],
|
||||
promptError: new Error("Blocked by before-run policy."),
|
||||
promptErrorSource: "hook:before_agent_run",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
...overflowBaseRunParams,
|
||||
runId: "run-before-agent-run-hook-block",
|
||||
});
|
||||
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(result.payloads).toEqual([{ text: "Blocked by before-run policy.", isError: true }]);
|
||||
expect(result.meta).toMatchObject({
|
||||
finalAssistantVisibleText: "Blocked by before-run policy.",
|
||||
finalAssistantRawText: "Blocked by before-run policy.",
|
||||
finalPromptText: undefined,
|
||||
livenessState: "blocked",
|
||||
error: { kind: "hook_block", message: "Blocked by before-run policy." },
|
||||
});
|
||||
expect(result.meta?.error).toEqual({
|
||||
kind: "hook_block",
|
||||
message: "Blocked by before-run policy.",
|
||||
});
|
||||
expect(result.meta?.livenessState).toBe("blocked");
|
||||
});
|
||||
|
||||
it("warns before retrying when an incomplete turn already sent a message", async () => {
|
||||
mockedClassifyFailoverReason.mockReturnValue(null);
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
|
||||
@@ -1826,6 +1826,38 @@ export async function runEmbeddedPiAgent(
|
||||
};
|
||||
}
|
||||
|
||||
if (promptErrorSource === "hook:before_agent_run" && !aborted) {
|
||||
const errorText = formatErrorMessage(promptError);
|
||||
const replayInvalid = resolveReplayInvalidForAttempt();
|
||||
attempt.setTerminalLifecycleMeta?.({
|
||||
replayInvalid,
|
||||
livenessState: "blocked",
|
||||
});
|
||||
return {
|
||||
payloads: [{ text: errorText, isError: true }],
|
||||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
agentMeta: buildErrorAgentMeta({
|
||||
sessionId: sessionIdUsed,
|
||||
provider,
|
||||
model: model.id,
|
||||
contextTokens: ctxInfo.tokens,
|
||||
usageAccumulator,
|
||||
lastRunPromptUsage,
|
||||
lastAssistant: sessionLastAssistant,
|
||||
lastTurnTotal,
|
||||
}),
|
||||
systemPromptReport: attempt.systemPromptReport,
|
||||
finalAssistantVisibleText: errorText,
|
||||
finalAssistantRawText: errorText,
|
||||
finalPromptText: undefined,
|
||||
replayInvalid,
|
||||
livenessState: "blocked",
|
||||
error: { kind: "hook_block", message: errorText },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (promptError && !aborted && promptErrorSource !== "compaction") {
|
||||
// Normalize wrapped errors (e.g. abort-wrapped RESOURCE_EXHAUSTED) into
|
||||
// FailoverError so rate-limit classification works even for nested shapes.
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
resolveUnknownToolGuardThreshold,
|
||||
shouldRunLlmOutputHooksForAttempt,
|
||||
resolveAttemptToolPolicyMessageProvider,
|
||||
resolvePromptBuildHookResult,
|
||||
resolvePromptModeForSession,
|
||||
@@ -149,6 +150,43 @@ describe("normalizeMessagesForLlmBoundary", () => {
|
||||
expect.arrayContaining([expect.objectContaining({ customType: "other-extension-context" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only safe blocked metadata at the LLM boundary", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
},
|
||||
],
|
||||
timestamp: 1,
|
||||
__openclaw: {
|
||||
beforeAgentRunBlocked: {
|
||||
blockedBy: "policy-plugin",
|
||||
blockedAt: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const output = normalizeMessagesForLlmBoundary(
|
||||
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
|
||||
) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(output[0]?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
},
|
||||
]);
|
||||
expect(output[0]).toHaveProperty("__openclaw.beforeAgentRunBlocked");
|
||||
expect(output[0]).not.toHaveProperty("__openclaw.beforeAgentRunBlocked.reason");
|
||||
expect(JSON.stringify(output)).not.toContain("secret prompt");
|
||||
expect(JSON.stringify(output)).not.toContain("matched secret prompt");
|
||||
expect(input[0]).toHaveProperty("__openclaw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAttemptToolPolicyMessageProvider", () => {
|
||||
@@ -166,6 +204,16 @@ describe("resolveAttemptToolPolicyMessageProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRunLlmOutputHooksForAttempt", () => {
|
||||
it("skips llm_output after before_agent_run blocks before model submission", () => {
|
||||
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: "hook:before_agent_run" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: "prompt" })).toBe(true);
|
||||
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: null })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePromptBuildHookResult", () => {
|
||||
function createLegacyOnlyHookRunner() {
|
||||
return {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
|
||||
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
|
||||
import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugin-metadata-snapshot.js";
|
||||
import { buildAgentHookContextChannelFields } from "../../../plugins/hook-agent-context.js";
|
||||
import { resolveBlockMessage } from "../../../plugins/hook-decision-types.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import {
|
||||
extractModelCompat,
|
||||
@@ -335,6 +336,7 @@ import {
|
||||
} from "./preemptive-compaction.js";
|
||||
import {
|
||||
buildCurrentTurnPromptContextSuffix,
|
||||
buildRuntimeContextSystemContext,
|
||||
queueRuntimeContextForNextTurn,
|
||||
resolveRuntimeContextPromptParts,
|
||||
} from "./runtime-context-prompt.js";
|
||||
@@ -493,6 +495,29 @@ export function normalizeMessagesForLlmBoundary(messages: AgentMessage[]): Agent
|
||||
return stripHistoricalRuntimeContextCustomMessages(normalized);
|
||||
}
|
||||
|
||||
function cloneHookMessages(messages: AgentMessage[]): AgentMessage[] {
|
||||
return messages.map((message) => structuredClone(message));
|
||||
}
|
||||
|
||||
function sessionMessagesContainIdempotencyKey(
|
||||
messages: AgentMessage[],
|
||||
idempotencyKey: string,
|
||||
): boolean {
|
||||
return messages.some(
|
||||
(message) =>
|
||||
typeof (message as { idempotencyKey?: unknown }).idempotencyKey === "string" &&
|
||||
(message as { idempotencyKey?: unknown }).idempotencyKey === idempotencyKey,
|
||||
);
|
||||
}
|
||||
|
||||
function flushSessionManagerFile(sessionManager: ReturnType<typeof guardSessionManager>): void {
|
||||
(sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
|
||||
}
|
||||
|
||||
export function shouldRunLlmOutputHooksForAttempt(params: { promptErrorSource: string | null }) {
|
||||
return params.promptErrorSource !== "hook:before_agent_run";
|
||||
}
|
||||
|
||||
function isMidTurnPrecheckAssistantError(message: AgentMessage | undefined): boolean {
|
||||
if (!message || message.role !== "assistant") {
|
||||
return false;
|
||||
@@ -711,8 +736,14 @@ export async function runEmbeddedAttempt(
|
||||
let timedOutDuringToolExecution = false;
|
||||
let promptError: unknown = null;
|
||||
let emitDiagnosticRunCompleted:
|
||||
| ((outcome: "completed" | "aborted" | "error", err?: unknown) => void)
|
||||
| ((
|
||||
outcome: "completed" | "aborted" | "blocked" | "error",
|
||||
err?: unknown,
|
||||
extra?: { blockedBy?: string },
|
||||
) => void)
|
||||
| undefined;
|
||||
let beforeAgentRunBlocked = false;
|
||||
let beforeAgentRunBlockedBy: string | undefined;
|
||||
try {
|
||||
const skillsSnapshotForRun =
|
||||
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? undefined : params.skillsSnapshot;
|
||||
@@ -775,7 +806,7 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
const diagnosticRunStartedAt = Date.now();
|
||||
let diagnosticRunCompleted = false;
|
||||
emitDiagnosticRunCompleted = (outcome, err) => {
|
||||
emitDiagnosticRunCompleted = (outcome, err, extra) => {
|
||||
if (diagnosticRunCompleted) {
|
||||
return;
|
||||
}
|
||||
@@ -785,7 +816,8 @@ export async function runEmbeddedAttempt(
|
||||
...diagnosticRunBase,
|
||||
durationMs: Date.now() - diagnosticRunStartedAt,
|
||||
outcome,
|
||||
...(err ? { errorCategory: diagnosticErrorCategory(err) } : {}),
|
||||
...(extra?.blockedBy ? { blockedBy: extra.blockedBy } : {}),
|
||||
...(err && outcome !== "blocked" ? { errorCategory: diagnosticErrorCategory(err) } : {}),
|
||||
});
|
||||
};
|
||||
const corePluginToolStages = createEmbeddedRunStageTracker();
|
||||
@@ -2496,7 +2528,7 @@ export async function runEmbeddedAttempt(
|
||||
|
||||
const activeSessionManager = sessionManager;
|
||||
let preflightRecovery: EmbeddedRunAttemptResult["preflightRecovery"];
|
||||
let promptErrorSource: "prompt" | "compaction" | "precheck" | null = null;
|
||||
let promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
|
||||
const handleMidTurnPrecheckRequest = (request: MidTurnPrecheckRequest) => {
|
||||
const logMidTurnPrecheck = (route: string, extra?: string) => {
|
||||
log.warn(
|
||||
@@ -2659,25 +2691,6 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
}
|
||||
|
||||
const googlePromptCacheStreamFn = await prepareGooglePromptCacheStreamFn({
|
||||
apiKey: await resolveEmbeddedAgentApiKey({
|
||||
provider: params.provider,
|
||||
resolvedApiKey: params.resolvedApiKey,
|
||||
authStorage: params.authStorage,
|
||||
}),
|
||||
extraParams: effectiveExtraParams,
|
||||
model: params.model,
|
||||
modelId: params.modelId,
|
||||
provider: params.provider,
|
||||
sessionManager,
|
||||
signal: runAbortController.signal,
|
||||
streamFn: activeSession.agent.streamFn,
|
||||
systemPrompt: systemPromptText,
|
||||
});
|
||||
if (googlePromptCacheStreamFn) {
|
||||
activeSession.agent.streamFn = googlePromptCacheStreamFn;
|
||||
}
|
||||
|
||||
const routingSummary = describeProviderRequestRoutingSummary({
|
||||
provider: params.provider,
|
||||
api: params.model.api,
|
||||
@@ -2689,11 +2702,6 @@ export async function runEmbeddedAttempt(
|
||||
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId} ` +
|
||||
routingSummary,
|
||||
);
|
||||
cacheTrace?.recordStage("prompt:before", {
|
||||
prompt: effectivePrompt,
|
||||
messages: activeSession.messages,
|
||||
});
|
||||
|
||||
// Repair orphaned trailing user messages so new prompts don't violate role ordering.
|
||||
const leafEntry = isRawModelRun ? null : sessionManager.getLeafEntry();
|
||||
if (leafEntry?.type === "message" && leafEntry.message.role === "user") {
|
||||
@@ -2776,40 +2784,182 @@ export async function runEmbeddedAttempt(
|
||||
systemPromptText = runtimeSystemPrompt;
|
||||
}
|
||||
}
|
||||
const runtimeContextForHook = promptSubmission.runtimeOnly
|
||||
? undefined
|
||||
: promptSubmission.runtimeContext?.trim();
|
||||
const runtimeSystemPromptForHook = runtimeContextForHook
|
||||
? composeSystemPromptWithHookContext({
|
||||
baseSystemPrompt: systemPromptText,
|
||||
appendSystemContext: buildRuntimeContextSystemContext(runtimeContextForHook),
|
||||
})
|
||||
: undefined;
|
||||
const systemPromptForHook = runtimeSystemPromptForHook ?? systemPromptText;
|
||||
|
||||
const persistBlockedBeforeAgentRun = async (block: {
|
||||
message: string;
|
||||
pluginId: string;
|
||||
}): Promise<boolean> => {
|
||||
const idempotencyKey = `hook-block:before_agent_run:user:${params.runId}`;
|
||||
if (sessionMessagesContainIdempotencyKey(activeSession.messages, idempotencyKey)) {
|
||||
return true;
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const redactedUserMessage = {
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: block.message }],
|
||||
timestamp: nowMs,
|
||||
idempotencyKey,
|
||||
__openclaw: {
|
||||
beforeAgentRunBlocked: {
|
||||
blockedBy: block.pluginId,
|
||||
blockedAt: nowMs,
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
activeSessionManager.appendMessage(
|
||||
redactedUserMessage as Parameters<typeof activeSessionManager.appendMessage>[0],
|
||||
);
|
||||
flushSessionManagerFile(activeSessionManager);
|
||||
activeSession.agent.state.messages =
|
||||
activeSessionManager.buildSessionContext().messages;
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`before_agent_run block: failed to persist redacted user message: ${
|
||||
(err as Error)?.message ?? String(err)
|
||||
}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (hookRunner?.hasHooks("before_agent_run")) {
|
||||
const beforeRunMessages = cloneHookMessages(
|
||||
normalizeMessagesForLlmBoundary(activeSession.messages),
|
||||
);
|
||||
let beforeRunResult:
|
||||
| Awaited<ReturnType<NonNullable<typeof hookRunner>["runBeforeAgentRun"]>>
|
||||
| undefined;
|
||||
try {
|
||||
beforeRunResult = await hookRunner.runBeforeAgentRun(
|
||||
{
|
||||
prompt: promptForModel,
|
||||
systemPrompt: systemPromptForHook,
|
||||
messages: beforeRunMessages,
|
||||
channelId: hookCtx.channelId,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
senderId: params.senderId ?? undefined,
|
||||
senderIsOwner: params.senderIsOwner ?? undefined,
|
||||
},
|
||||
hookCtx,
|
||||
);
|
||||
} catch {
|
||||
log.warn("before_agent_run hook failed; blocking request");
|
||||
beforeAgentRunBlocked = true;
|
||||
beforeAgentRunBlockedBy = "before_agent_run";
|
||||
await persistBlockedBeforeAgentRun({
|
||||
message: resolveBlockMessage(
|
||||
{ outcome: "block", reason: "before_agent_run hook failed" },
|
||||
{ blockedBy: "before_agent_run" },
|
||||
),
|
||||
pluginId: "before_agent_run",
|
||||
});
|
||||
promptError = new Error(
|
||||
resolveBlockMessage(
|
||||
{ outcome: "block", reason: "before_agent_run hook failed" },
|
||||
{ blockedBy: "before_agent_run" },
|
||||
),
|
||||
);
|
||||
promptErrorSource = "hook:before_agent_run";
|
||||
skipPromptSubmission = true;
|
||||
}
|
||||
const beforeRunDecision = beforeRunResult?.decision;
|
||||
const beforeRunPluginId = beforeRunResult?.pluginId ?? "unknown";
|
||||
if (beforeRunDecision?.outcome === "block") {
|
||||
beforeAgentRunBlocked = true;
|
||||
beforeAgentRunBlockedBy = beforeRunPluginId;
|
||||
const blockReplacementMsg = resolveBlockMessage(beforeRunDecision, {
|
||||
blockedBy: beforeRunPluginId,
|
||||
});
|
||||
log.warn(`before_agent_run hook blocked by ${beforeRunPluginId}`);
|
||||
await persistBlockedBeforeAgentRun({
|
||||
message: blockReplacementMsg,
|
||||
pluginId: beforeRunPluginId,
|
||||
});
|
||||
promptError = new Error(blockReplacementMsg);
|
||||
promptErrorSource = "hook:before_agent_run";
|
||||
skipPromptSubmission = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipPromptSubmission) {
|
||||
const googlePromptCacheStreamFn = await prepareGooglePromptCacheStreamFn({
|
||||
apiKey: await resolveEmbeddedAgentApiKey({
|
||||
provider: params.provider,
|
||||
resolvedApiKey: params.resolvedApiKey,
|
||||
authStorage: params.authStorage,
|
||||
}),
|
||||
extraParams: effectiveExtraParams,
|
||||
model: params.model,
|
||||
modelId: params.modelId,
|
||||
provider: params.provider,
|
||||
sessionManager,
|
||||
signal: runAbortController.signal,
|
||||
streamFn: activeSession.agent.streamFn,
|
||||
systemPrompt: systemPromptText,
|
||||
});
|
||||
if (googlePromptCacheStreamFn) {
|
||||
activeSession.agent.streamFn = googlePromptCacheStreamFn;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect and load images referenced in the visible prompt for vision-capable models.
|
||||
// Images are prompt-local only (pi-like behavior).
|
||||
const imageResult = await detectAndLoadPromptImages({
|
||||
prompt: promptSubmission.prompt,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
model: params.model,
|
||||
existingImages: params.images,
|
||||
imageOrder: params.imageOrder,
|
||||
maxBytes: MAX_IMAGE_BYTES,
|
||||
maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx,
|
||||
workspaceOnly: effectiveFsWorkspaceOnly,
|
||||
// Enforce sandbox path restrictions when sandbox is enabled
|
||||
sandbox:
|
||||
sandbox?.enabled && sandbox?.fsBridge
|
||||
? { root: sandbox.workspaceDir, bridge: sandbox.fsBridge }
|
||||
: undefined,
|
||||
});
|
||||
const imageResult = skipPromptSubmission
|
||||
? {
|
||||
images: [],
|
||||
detectedRefs: [],
|
||||
loadedCount: 0,
|
||||
skippedCount: 0,
|
||||
}
|
||||
: await detectAndLoadPromptImages({
|
||||
prompt: promptSubmission.prompt,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
model: params.model,
|
||||
existingImages: params.images,
|
||||
imageOrder: params.imageOrder,
|
||||
maxBytes: MAX_IMAGE_BYTES,
|
||||
maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx,
|
||||
workspaceOnly: effectiveFsWorkspaceOnly,
|
||||
// Enforce sandbox path restrictions when sandbox is enabled
|
||||
sandbox:
|
||||
sandbox?.enabled && sandbox?.fsBridge
|
||||
? { root: sandbox.workspaceDir, bridge: sandbox.fsBridge }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
cacheTrace?.recordStage("prompt:images", {
|
||||
prompt: promptForModel,
|
||||
messages: activeSession.messages,
|
||||
note: `images: prompt=${imageResult.images.length}`,
|
||||
});
|
||||
trajectoryRecorder?.recordEvent("context.compiled", {
|
||||
systemPrompt: systemPromptText,
|
||||
prompt: promptForModel,
|
||||
messages: activeSession.messages,
|
||||
tools: toTrajectoryToolDefinitions(effectiveTools),
|
||||
imagesCount: imageResult.images.length,
|
||||
streamStrategy,
|
||||
transport: effectiveAgentTransport,
|
||||
transcriptLeafId,
|
||||
});
|
||||
if (!skipPromptSubmission) {
|
||||
cacheTrace?.recordStage("prompt:before", {
|
||||
prompt: promptForModel,
|
||||
messages: activeSession.messages,
|
||||
});
|
||||
cacheTrace?.recordStage("prompt:images", {
|
||||
prompt: promptForModel,
|
||||
messages: activeSession.messages,
|
||||
note: `images: prompt=${imageResult.images.length}`,
|
||||
});
|
||||
trajectoryRecorder?.recordEvent("context.compiled", {
|
||||
systemPrompt: systemPromptForHook,
|
||||
prompt: promptForModel,
|
||||
messages: activeSession.messages,
|
||||
tools: toTrajectoryToolDefinitions(effectiveTools),
|
||||
imagesCount: imageResult.images.length,
|
||||
streamStrategy,
|
||||
transport: effectiveAgentTransport,
|
||||
transcriptLeafId,
|
||||
});
|
||||
}
|
||||
|
||||
const promptSkipReason = skipPromptSubmission
|
||||
? null
|
||||
@@ -2880,7 +3030,7 @@ export async function runEmbeddedAttempt(
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRawModelRun && hookRunner?.hasHooks("llm_input")) {
|
||||
if (!skipPromptSubmission && !isRawModelRun && hookRunner?.hasHooks("llm_input")) {
|
||||
hookRunner
|
||||
.runLlmInput(
|
||||
{
|
||||
@@ -2888,9 +3038,11 @@ export async function runEmbeddedAttempt(
|
||||
sessionId: params.sessionId,
|
||||
provider: params.provider,
|
||||
model: params.modelId,
|
||||
systemPrompt: systemPromptText,
|
||||
prompt: effectivePrompt,
|
||||
historyMessages: activeSession.messages,
|
||||
systemPrompt: systemPromptForHook,
|
||||
prompt: promptForModel,
|
||||
historyMessages: cloneHookMessages(
|
||||
normalizeMessagesForLlmBoundary(activeSession.messages),
|
||||
),
|
||||
imagesCount: imageResult.images.length,
|
||||
},
|
||||
{
|
||||
@@ -2909,22 +3061,24 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
}
|
||||
|
||||
const preemptiveCompaction = shouldPreemptivelyCompactBeforePrompt({
|
||||
messages: activeSession.messages,
|
||||
...(contextEnginePromptAuthority === "preassembly_may_overflow"
|
||||
? { unwindowedMessages: unwindowedContextEngineMessagesForPrecheck }
|
||||
: {}),
|
||||
systemPrompt: systemPromptText,
|
||||
prompt: effectivePrompt,
|
||||
contextTokenBudget,
|
||||
reserveTokens,
|
||||
toolResultMaxChars: resolveLiveToolResultMaxChars({
|
||||
contextWindowTokens: contextTokenBudget,
|
||||
cfg: params.config,
|
||||
agentId: sessionAgentId,
|
||||
}),
|
||||
});
|
||||
if (preemptiveCompaction.route === "truncate_tool_results_only") {
|
||||
const preemptiveCompaction = skipPromptSubmission
|
||||
? null
|
||||
: shouldPreemptivelyCompactBeforePrompt({
|
||||
messages: activeSession.messages,
|
||||
...(contextEnginePromptAuthority === "preassembly_may_overflow"
|
||||
? { unwindowedMessages: unwindowedContextEngineMessagesForPrecheck }
|
||||
: {}),
|
||||
systemPrompt: systemPromptForHook,
|
||||
prompt: promptForModel,
|
||||
contextTokenBudget,
|
||||
reserveTokens,
|
||||
toolResultMaxChars: resolveLiveToolResultMaxChars({
|
||||
contextWindowTokens: contextTokenBudget,
|
||||
cfg: params.config,
|
||||
agentId: sessionAgentId,
|
||||
}),
|
||||
});
|
||||
if (preemptiveCompaction?.route === "truncate_tool_results_only") {
|
||||
const toolResultMaxChars = resolveLiveToolResultMaxChars({
|
||||
contextWindowTokens: contextTokenBudget,
|
||||
cfg: params.config,
|
||||
@@ -2969,7 +3123,7 @@ export async function runEmbeddedAttempt(
|
||||
skipPromptSubmission = true;
|
||||
}
|
||||
}
|
||||
if (preemptiveCompaction.shouldCompact) {
|
||||
if (preemptiveCompaction?.shouldCompact) {
|
||||
preflightRecovery =
|
||||
preemptiveCompaction.route === "compact_then_truncate"
|
||||
? { route: "compact_then_truncate" }
|
||||
@@ -3001,7 +3155,7 @@ export async function runEmbeddedAttempt(
|
||||
finalPromptText = promptForModel;
|
||||
trajectoryRecorder?.recordEvent("prompt.submitted", {
|
||||
prompt: promptForModel,
|
||||
systemPrompt: systemPromptText,
|
||||
systemPrompt: systemPromptForHook,
|
||||
messages: activeSession.messages,
|
||||
imagesCount: imageResult.images.length,
|
||||
});
|
||||
@@ -3014,10 +3168,9 @@ export async function runEmbeddedAttempt(
|
||||
if (promptSubmission.runtimeOnly) {
|
||||
await abortable(activeSession.prompt(promptForModel));
|
||||
} else {
|
||||
const runtimeContext = promptSubmission.runtimeContext?.trim();
|
||||
await queueRuntimeContextForNextTurn({
|
||||
session: activeSession,
|
||||
runtimeContext,
|
||||
runtimeContext: runtimeContextForHook,
|
||||
});
|
||||
|
||||
// Only pass images option if there are actually images to pass
|
||||
@@ -3435,7 +3588,10 @@ export async function runEmbeddedAttempt(
|
||||
}
|
||||
}
|
||||
|
||||
if (hookRunner?.hasHooks("llm_output")) {
|
||||
if (
|
||||
hookRunner?.hasHooks("llm_output") &&
|
||||
shouldRunLlmOutputHooksForAttempt({ promptErrorSource })
|
||||
) {
|
||||
hookRunner
|
||||
.runLlmOutput(
|
||||
{
|
||||
@@ -3642,12 +3798,19 @@ export async function runEmbeddedAttempt(
|
||||
cleanupError = err;
|
||||
}
|
||||
emitDiagnosticRunCompleted?.(
|
||||
cleanupError || promptError
|
||||
cleanupError
|
||||
? "error"
|
||||
: aborted || timedOut || idleTimedOut || timedOutDuringCompaction
|
||||
? "aborted"
|
||||
: "completed",
|
||||
: beforeAgentRunBlocked
|
||||
? "blocked"
|
||||
: promptError
|
||||
? "error"
|
||||
: aborted || timedOut || idleTimedOut || timedOutDuringCompaction
|
||||
? "aborted"
|
||||
: "completed",
|
||||
cleanupError ?? promptError,
|
||||
beforeAgentRunBlocked
|
||||
? { blockedBy: beforeAgentRunBlockedBy ?? "before_agent_run" }
|
||||
: undefined,
|
||||
);
|
||||
if (cleanupError) {
|
||||
await Promise.reject(cleanupError);
|
||||
|
||||
@@ -71,9 +71,10 @@ export type EmbeddedRunAttemptResult = {
|
||||
* this must not be retried as a fresh prompt or the same tool turn can replay.
|
||||
* - "precheck": pre-prompt overflow recovery intentionally short-circuited the prompt so the
|
||||
* outer run loop can recover via compaction/truncation before any model call is made.
|
||||
* - "hook:before_agent_run": a lifecycle hook blocked the run before the prompt was sent.
|
||||
* - null: no promptError.
|
||||
*/
|
||||
promptErrorSource: "prompt" | "compaction" | "precheck" | null;
|
||||
promptErrorSource: "prompt" | "compaction" | "precheck" | "hook:before_agent_run" | null;
|
||||
preflightRecovery?:
|
||||
| {
|
||||
route: Exclude<PreemptiveCompactionRoute, "fits">;
|
||||
|
||||
@@ -140,7 +140,8 @@ export type EmbeddedPiRunMeta = {
|
||||
| "compaction_failure"
|
||||
| "role_ordering"
|
||||
| "image_size"
|
||||
| "retry_limit";
|
||||
| "retry_limit"
|
||||
| "hook_block";
|
||||
message: string;
|
||||
};
|
||||
failureSignal?: EmbeddedRunFailureSignal;
|
||||
|
||||
@@ -960,6 +960,33 @@ describe("before_tool_call requireApproval handling", () => {
|
||||
expect(onResolution).toHaveBeenCalledWith("allow-once");
|
||||
});
|
||||
|
||||
it("allows allow-always decisions for tool approvals", async () => {
|
||||
const onResolution = vi.fn();
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Needs durable approval",
|
||||
description: "Check this durable approval",
|
||||
onResolution,
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-allow-always", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({
|
||||
id: "server-id-allow-always",
|
||||
decision: "allow-always",
|
||||
});
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: { command: "echo ok" },
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ blocked: false, params: { command: "echo ok" } });
|
||||
expect(onResolution).toHaveBeenCalledWith("allow-always");
|
||||
});
|
||||
|
||||
it("does not await onResolution before returning approval outcome", async () => {
|
||||
const onResolution = vi.fn(() => new Promise<void>(() => {}));
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ export type ToolOutcomeObservation = {
|
||||
|
||||
export type ToolOutcomeObserver = (observation: ToolOutcomeObservation) => void;
|
||||
|
||||
export function isAbortSignalCancellation(err: unknown, signal?: AbortSignal): boolean {
|
||||
if (!signal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
if (err === signal.reason) {
|
||||
return true;
|
||||
}
|
||||
return err instanceof Error && err.name === "AbortError";
|
||||
}
|
||||
|
||||
export type HookContext = {
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
@@ -47,6 +57,7 @@ export type HookContext = {
|
||||
sessionId?: string;
|
||||
runId?: string;
|
||||
trace?: DiagnosticTraceContext;
|
||||
channelId?: string;
|
||||
loopDetection?: ToolLoopDetectionConfig;
|
||||
onToolOutcome?: ToolOutcomeObserver;
|
||||
};
|
||||
@@ -114,19 +125,6 @@ function mergeParamsWithApprovalOverrides(
|
||||
return originalParams;
|
||||
}
|
||||
|
||||
function isAbortSignalCancellation(err: unknown, signal?: AbortSignal): boolean {
|
||||
if (!signal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
if (err === signal.reason) {
|
||||
return true;
|
||||
}
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function unwrapErrorCause(err: unknown): unknown {
|
||||
try {
|
||||
if (!(err instanceof Error)) {
|
||||
@@ -180,6 +178,7 @@ async function requestPluginToolApproval(params: {
|
||||
title: approval.title,
|
||||
description: approval.description,
|
||||
severity: approval.severity,
|
||||
allowedDecisions: approval.allowedDecisions,
|
||||
toolName: params.toolName,
|
||||
toolCallId: params.toolCallId,
|
||||
agentId: params.ctx?.agentId,
|
||||
@@ -504,6 +503,7 @@ export async function runBeforeToolCallHook(args: {
|
||||
...(args.ctx?.runId && { runId: args.ctx.runId }),
|
||||
...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }),
|
||||
...(args.toolCallId && { toolCallId: args.toolCallId }),
|
||||
...(args.ctx?.channelId && { channelId: args.ctx.channelId }),
|
||||
};
|
||||
const trustedPolicyResult = await runTrustedToolPolicies(
|
||||
{
|
||||
|
||||
@@ -56,6 +56,7 @@ export type ReplyPayloadMetadata = {
|
||||
* assistant source replies are message-tool-only; sendPolicy deny still wins.
|
||||
*/
|
||||
deliverDespiteSourceReplySuppression?: boolean;
|
||||
beforeAgentRunBlocked?: boolean;
|
||||
};
|
||||
|
||||
const replyPayloadMetadata = new WeakMap<object, ReplyPayloadMetadata>();
|
||||
|
||||
@@ -1690,6 +1690,104 @@ describe("runReplyAgent claude-cli routing", () => {
|
||||
expect(result).toMatchObject({ text: "ok" });
|
||||
});
|
||||
|
||||
it("does not leak hook-blocked CLI input in raw trace payloads", async () => {
|
||||
runCliAgentMock.mockResolvedValueOnce({
|
||||
payloads: [
|
||||
{
|
||||
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
error: {
|
||||
kind: "hook_block",
|
||||
message:
|
||||
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
},
|
||||
agentMeta: {
|
||||
provider: "claude-cli",
|
||||
model: "opus-4.5",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const typing = createMockTypingController();
|
||||
const sessionCtx = {
|
||||
Provider: "webchat",
|
||||
OriginatingTo: "session:1",
|
||||
AccountId: "primary",
|
||||
MessageSid: "msg",
|
||||
CommandBody: "secret hitl prompt",
|
||||
RawBody: "secret hitl prompt",
|
||||
BodyForAgent: "secret hitl prompt",
|
||||
Body: "secret hitl prompt",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
traceLevel: "raw",
|
||||
} as SessionEntry;
|
||||
const followupRun = {
|
||||
prompt: "secret hitl prompt",
|
||||
summaryLine: "secret hitl prompt",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
agentId: "main",
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "webchat",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: createCliBackendTestConfig(),
|
||||
skillsSnapshot: {},
|
||||
traceAuthorized: true,
|
||||
provider: "claude-cli",
|
||||
model: "opus-4.5",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as unknown as FollowupRun;
|
||||
|
||||
const result = await runReplyAgent({
|
||||
commandBody: "secret hitl prompt",
|
||||
followupRun,
|
||||
queueKey: "main",
|
||||
resolvedQueue,
|
||||
shouldSteer: false,
|
||||
shouldFollowup: false,
|
||||
isActive: false,
|
||||
isStreaming: false,
|
||||
typing,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
sessionStore: { main: sessionEntry },
|
||||
defaultModel: "claude-cli/opus-4.5",
|
||||
resolvedVerboseLevel: "off",
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
shouldInjectGroupIntro: false,
|
||||
typingMode: "instant",
|
||||
});
|
||||
|
||||
const texts = Array.isArray(result)
|
||||
? result.map((payload) => payload.text ?? "").join("\n")
|
||||
: (result?.text ?? "");
|
||||
expect(texts).toContain(
|
||||
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
);
|
||||
expect(texts).not.toContain("secret hitl prompt");
|
||||
});
|
||||
|
||||
it("uses the selected CLI runtime for canonical Anthropic models", async () => {
|
||||
runCliAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "ok" }],
|
||||
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
buildFallbackNotice,
|
||||
resolveFallbackTransition,
|
||||
} from "../fallback-state.js";
|
||||
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
|
||||
import {
|
||||
markReplyPayloadForSourceSuppressionDelivery,
|
||||
setReplyPayloadMetadata,
|
||||
} from "../reply-payload.js";
|
||||
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
@@ -93,6 +96,12 @@ import type { TypingController } from "./typing.js";
|
||||
|
||||
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
||||
|
||||
function markBeforeAgentRunBlockedPayloads(payloads: ReplyPayload[]): ReplyPayload[] {
|
||||
return payloads.map((payload) =>
|
||||
setReplyPayloadMetadata(payload, { beforeAgentRunBlocked: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function buildInlinePluginStatusPayload(params: {
|
||||
entry: SessionEntry | undefined;
|
||||
includeTraceLines: boolean;
|
||||
@@ -1699,14 +1708,17 @@ export async function runReplyAgent(params: {
|
||||
}
|
||||
}
|
||||
const prefixPayloads = [...verboseNotices];
|
||||
const rawUserText =
|
||||
runResult.meta?.finalPromptText ??
|
||||
sessionCtx.CommandBody ??
|
||||
sessionCtx.RawBody ??
|
||||
sessionCtx.BodyForAgent ??
|
||||
sessionCtx.Body;
|
||||
const rawAssistantText =
|
||||
runResult.meta?.finalAssistantRawText ?? runResult.meta?.finalAssistantVisibleText;
|
||||
const isHookBlockedRun = runResult.meta?.error?.kind === "hook_block";
|
||||
const rawUserText = isHookBlockedRun
|
||||
? runResult.meta?.finalPromptText
|
||||
: (runResult.meta?.finalPromptText ??
|
||||
sessionCtx.CommandBody ??
|
||||
sessionCtx.RawBody ??
|
||||
sessionCtx.BodyForAgent ??
|
||||
sessionCtx.Body);
|
||||
const rawAssistantText = isHookBlockedRun
|
||||
? undefined
|
||||
: (runResult.meta?.finalAssistantRawText ?? runResult.meta?.finalAssistantVisibleText);
|
||||
const traceAuthorized = followupRun.run.traceAuthorized === true;
|
||||
const executionTrace = mergeExecutionTrace({
|
||||
fallbackAttempts,
|
||||
@@ -1838,6 +1850,9 @@ export async function runReplyAgent(params: {
|
||||
if (responseUsageLine) {
|
||||
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
|
||||
}
|
||||
if (isHookBlockedRun) {
|
||||
finalPayloads = markBeforeAgentRunBlockedPayloads(finalPayloads);
|
||||
}
|
||||
|
||||
// Capture only policy-visible final payloads in session store to support
|
||||
// durable delivery retries. Hidden reasoning, message-tool-only replies,
|
||||
|
||||
@@ -4581,6 +4581,38 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves hook-blocked metadata when source delivery is message-tool-only", async () => {
|
||||
setNoAbort();
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
const dispatcher = createDispatcher();
|
||||
const blockedReply = setReplyPayloadMetadata(
|
||||
{ text: "Your message could not be sent: blocked by policy-plugin", isError: true },
|
||||
{ beforeAgentRunBlocked: true },
|
||||
);
|
||||
const replyResolver = vi.fn(async () => blockedReply satisfies ReplyPayload);
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({ SessionKey: "test:session" }),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
replyOptions: {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(false);
|
||||
expect(result.beforeAgentRunBlocked).toBe(true);
|
||||
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers marked runtime failure notices in message-tool-only mode", async () => {
|
||||
setNoAbort();
|
||||
sessionStoreMocks.currentEntry = {
|
||||
|
||||
@@ -95,7 +95,10 @@ import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js";
|
||||
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
|
||||
import { resolveOriginMessageProvider } from "./origin-routing.js";
|
||||
import { resolveReplyRoutingDecision } from "./routing-policy.js";
|
||||
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
|
||||
import {
|
||||
isExplicitSourceReplyCommand,
|
||||
resolveSourceReplyVisibilityPolicy,
|
||||
} from "./source-reply-delivery-mode.js";
|
||||
import { resolveRunTypingPolicy } from "./typing-policy.js";
|
||||
|
||||
const routeReplyRuntimeLoader = createLazyImportLoader(() => import("./route-reply.runtime.js"));
|
||||
@@ -711,7 +714,7 @@ export async function dispatchReplyFromConfig(
|
||||
const prefersMessageToolDelivery =
|
||||
params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" ||
|
||||
(params.replyOptions?.sourceReplyDeliveryMode === undefined &&
|
||||
ctx.CommandSource !== "native" &&
|
||||
!isExplicitSourceReplyCommand(ctx) &&
|
||||
(chatType === "group" || chatType === "channel"
|
||||
? effectiveVisibleReplies !== "automatic"
|
||||
: effectiveVisibleReplies === "message_tool"));
|
||||
@@ -1516,6 +1519,9 @@ export async function dispatchReplyFromConfig(
|
||||
}
|
||||
|
||||
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
|
||||
const beforeAgentRunBlocked = replies.some(
|
||||
(reply) => getReplyPayloadMetadata(reply)?.beforeAgentRunBlocked === true,
|
||||
);
|
||||
|
||||
let queuedFinal = false;
|
||||
let routedFinalCount = 0;
|
||||
@@ -1619,7 +1625,11 @@ export async function dispatchReplyFromConfig(
|
||||
pluginFallbackReason ? { reason: pluginFallbackReason } : undefined,
|
||||
);
|
||||
markIdle("message_completed");
|
||||
return attachSourceReplyDeliveryMode({ queuedFinal, counts });
|
||||
return attachSourceReplyDeliveryMode({
|
||||
queuedFinal,
|
||||
counts,
|
||||
...(beforeAgentRunBlocked ? { beforeAgentRunBlocked } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (inboundDedupeClaim.status === "claimed") {
|
||||
if (inboundDedupeReplayUnsafe) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type DispatchFromConfigResult = {
|
||||
counts: Record<ReplyDispatchKind, number>;
|
||||
failedCounts?: Partial<Record<ReplyDispatchKind, number>>;
|
||||
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
|
||||
beforeAgentRunBlocked?: boolean;
|
||||
};
|
||||
|
||||
export type DispatchFromConfigParams = {
|
||||
|
||||
@@ -97,13 +97,23 @@ describe("resolveSourceReplyDeliveryMode", () => {
|
||||
expect(
|
||||
resolveSourceReplyDeliveryMode({
|
||||
cfg: emptyConfig,
|
||||
ctx: { ChatType: "group", CommandSource: "text", CommandAuthorized: true },
|
||||
ctx: {
|
||||
ChatType: "group",
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
CommandBody: "/status",
|
||||
},
|
||||
}),
|
||||
).toBe("automatic");
|
||||
expect(
|
||||
resolveSourceReplyDeliveryMode({
|
||||
cfg: emptyConfig,
|
||||
ctx: { ChatType: "group", CommandSource: "text" },
|
||||
ctx: {
|
||||
ChatType: "group",
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: false,
|
||||
CommandBody: "/status",
|
||||
},
|
||||
}),
|
||||
).toBe("message_tool_only");
|
||||
});
|
||||
@@ -192,7 +202,12 @@ describe("resolveSourceReplyVisibilityPolicy", () => {
|
||||
it("keeps native and authorized text command replies visible in groups", () => {
|
||||
for (const ctx of [
|
||||
{ ChatType: "group", CommandSource: "native" },
|
||||
{ ChatType: "group", CommandSource: "text", CommandAuthorized: true },
|
||||
{
|
||||
ChatType: "group",
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
CommandBody: "/status",
|
||||
},
|
||||
] as const) {
|
||||
expect(
|
||||
resolveSourceReplyVisibilityPolicy({
|
||||
|
||||
@@ -6,9 +6,17 @@ import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
|
||||
export type SourceReplyDeliveryModeContext = {
|
||||
ChatType?: string;
|
||||
CommandAuthorized?: boolean;
|
||||
CommandBody?: string;
|
||||
CommandSource?: "text" | "native";
|
||||
};
|
||||
|
||||
export function isExplicitSourceReplyCommand(ctx: SourceReplyDeliveryModeContext): boolean {
|
||||
if (ctx.CommandSource === "native") {
|
||||
return true;
|
||||
}
|
||||
return ctx.CommandSource === "text" && ctx.CommandAuthorized === true;
|
||||
}
|
||||
|
||||
export function resolveSourceReplyDeliveryMode(params: {
|
||||
cfg: OpenClawConfig;
|
||||
ctx: SourceReplyDeliveryModeContext;
|
||||
@@ -21,10 +29,7 @@ export function resolveSourceReplyDeliveryMode(params: {
|
||||
? "automatic"
|
||||
: params.requested;
|
||||
}
|
||||
if (
|
||||
params.ctx.CommandSource === "native" ||
|
||||
(params.ctx.CommandSource === "text" && params.ctx.CommandAuthorized === true)
|
||||
) {
|
||||
if (isExplicitSourceReplyCommand(params.ctx)) {
|
||||
return "automatic";
|
||||
}
|
||||
const chatType = normalizeChatType(params.ctx.ChatType);
|
||||
|
||||
@@ -1242,7 +1242,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"plugins.entries.*.hooks.allowPromptInjection":
|
||||
"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
|
||||
"plugins.entries.*.hooks.allowConversationAccess":
|
||||
"Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.",
|
||||
"Controls whether this plugin may read raw conversation content from typed hooks such as `before_agent_run`, `before_model_resolve`, `before_agent_reply`, `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.",
|
||||
"plugins.entries.*.hooks.timeoutMs":
|
||||
"Default timeout in milliseconds for this plugin's typed hooks, capped at 600000. Use this to bound slow plugin hooks without changing plugin code; per-hook values in hooks.timeouts take precedence.",
|
||||
"plugins.entries.*.hooks.timeouts":
|
||||
|
||||
@@ -4,7 +4,9 @@ export type PluginEntryConfig = {
|
||||
/** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */
|
||||
allowPromptInjection?: boolean;
|
||||
/**
|
||||
* Controls access to raw conversation content from llm_input/llm_output/agent_end hooks.
|
||||
* Controls access to raw conversation content from conversation hooks including
|
||||
* before_agent_run, before_model_resolve, before_agent_reply, llm_input, llm_output,
|
||||
* before_agent_finalize, and agent_end.
|
||||
* Non-bundled plugins must opt in explicitly; bundled plugins stay allowed unless disabled.
|
||||
*/
|
||||
allowConversationAccess?: boolean;
|
||||
|
||||
@@ -47,7 +47,9 @@ const mockState = vi.hoisted(() => ({
|
||||
};
|
||||
}>,
|
||||
dispatchError: null as Error | null,
|
||||
dispatchErrorAfterAgentRunStart: null as Error | null,
|
||||
triggerAgentRunStart: false,
|
||||
onAfterAgentRunStart: null as (() => void) | null,
|
||||
agentRunId: "run-agent-1",
|
||||
sessionEntry: {} as Record<string, unknown>,
|
||||
lastDispatchCtx: undefined as MsgContext | undefined,
|
||||
@@ -69,6 +71,8 @@ const mockState = vi.hoisted(() => ({
|
||||
sandboxWorkspace: null as { workspaceDir: string; containerWorkdir?: string } | null,
|
||||
stageSandboxMediaError: null as Error | null,
|
||||
stagedRelativePaths: null as string[] | null,
|
||||
hasBeforeAgentRunHooks: false,
|
||||
dispatchBlockedByBeforeAgentRun: false,
|
||||
// `unstagedSources` lets tests simulate partial staging failure: absolute
|
||||
// source paths listed here are excluded from the returned `staged` map even
|
||||
// though ctx still carries their rewritten paths. This mirrors how the real
|
||||
@@ -176,6 +180,10 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
|
||||
}
|
||||
if (mockState.triggerAgentRunStart) {
|
||||
params.replyOptions?.onAgentRunStart?.(mockState.agentRunId);
|
||||
mockState.onAfterAgentRunStart?.();
|
||||
}
|
||||
if (mockState.dispatchErrorAfterAgentRunStart) {
|
||||
throw mockState.dispatchErrorAfterAgentRunStart;
|
||||
}
|
||||
if (mockState.dispatchedReplies.length > 0) {
|
||||
for (const reply of mockState.dispatchedReplies) {
|
||||
@@ -194,7 +202,12 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
|
||||
}
|
||||
params.dispatcher.markComplete();
|
||||
await params.dispatcher.waitForIdle();
|
||||
return { ok: true };
|
||||
return {
|
||||
ok: true,
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
...(mockState.dispatchBlockedByBeforeAgentRun ? { beforeAgentRunBlocked: true } : {}),
|
||||
};
|
||||
},
|
||||
),
|
||||
}));
|
||||
@@ -212,6 +225,13 @@ vi.mock("../../infra/outbound/session-binding-service.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => ({
|
||||
hasHooks: (hookName: string) =>
|
||||
hookName === "before_agent_run" && mockState.hasBeforeAgentRunHooks,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../sessions/transcript-events.js", () => ({
|
||||
emitSessionTranscriptUpdate: vi.fn(
|
||||
(update: {
|
||||
@@ -501,8 +521,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
mockState.finalPayload = null;
|
||||
mockState.dispatchedReplies = [];
|
||||
mockState.dispatchError = null;
|
||||
mockState.dispatchErrorAfterAgentRunStart = null;
|
||||
mockState.mainSessionKey = "main";
|
||||
mockState.triggerAgentRunStart = false;
|
||||
mockState.onAfterAgentRunStart = null;
|
||||
mockState.agentRunId = "run-agent-1";
|
||||
mockState.sessionEntry = {};
|
||||
mockState.lastDispatchCtx = undefined;
|
||||
@@ -523,6 +545,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
mockState.stagedRelativePaths = null;
|
||||
mockState.unstagedSources = null;
|
||||
mockState.deleteMediaBufferCalls = [];
|
||||
mockState.hasBeforeAgentRunHooks = false;
|
||||
mockState.dispatchBlockedByBeforeAgentRun = false;
|
||||
});
|
||||
|
||||
it("registers tool-event recipients for clients advertising tool-events capability", async () => {
|
||||
@@ -2058,6 +2082,92 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
expect(finalBroadcast).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit pre-gate user transcript content when before_agent_run hooks are registered", async () => {
|
||||
createTranscriptFixture("openclaw-chat-send-user-transcript-before-run-gate-");
|
||||
mockState.finalText = "ok";
|
||||
mockState.triggerAgentRunStart = true;
|
||||
mockState.hasBeforeAgentRunHooks = true;
|
||||
let userUpdateCountAtAgentStart = 0;
|
||||
mockState.onAfterAgentRunStart = () => {
|
||||
userUpdateCountAtAgentStart = mockState.emittedTranscriptUpdates.filter(
|
||||
(update) =>
|
||||
typeof update.message === "object" &&
|
||||
update.message !== null &&
|
||||
(update.message as { role?: unknown }).role === "user",
|
||||
).length;
|
||||
};
|
||||
const respond = vi.fn();
|
||||
const context = createChatContext();
|
||||
|
||||
await runNonStreamingChatSend({
|
||||
context,
|
||||
respond,
|
||||
idempotencyKey: "idem-user-transcript-before-run-gate",
|
||||
message: "secret prompt that may be blocked",
|
||||
expectBroadcast: false,
|
||||
});
|
||||
|
||||
expect(userUpdateCountAtAgentStart).toBe(0);
|
||||
const userUpdates = mockState.emittedTranscriptUpdates.filter(
|
||||
(update) =>
|
||||
typeof update.message === "object" &&
|
||||
update.message !== null &&
|
||||
(update.message as { role?: unknown }).role === "user",
|
||||
);
|
||||
expect(userUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not emit raw user transcript content when before_agent_run blocks without a persisted marker", async () => {
|
||||
createTranscriptFixture("openclaw-chat-send-user-transcript-blocked-live-signal-");
|
||||
mockState.finalText = "The agent cannot read this message.";
|
||||
mockState.triggerAgentRunStart = true;
|
||||
mockState.hasBeforeAgentRunHooks = true;
|
||||
mockState.dispatchBlockedByBeforeAgentRun = true;
|
||||
const respond = vi.fn();
|
||||
const context = createChatContext();
|
||||
|
||||
await runNonStreamingChatSend({
|
||||
context,
|
||||
respond,
|
||||
idempotencyKey: "idem-user-transcript-blocked-live-signal",
|
||||
message: "secret prompt blocked before persistence",
|
||||
expectBroadcast: false,
|
||||
});
|
||||
|
||||
const userUpdates = mockState.emittedTranscriptUpdates.filter(
|
||||
(update) =>
|
||||
typeof update.message === "object" &&
|
||||
update.message !== null &&
|
||||
(update.message as { role?: unknown }).role === "user",
|
||||
);
|
||||
expect(userUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not emit live user transcript content when before_agent_run hooks are present and the agent fails", async () => {
|
||||
createTranscriptFixture("openclaw-chat-send-user-transcript-gate-pass-error-");
|
||||
mockState.triggerAgentRunStart = true;
|
||||
mockState.hasBeforeAgentRunHooks = true;
|
||||
mockState.dispatchErrorAfterAgentRunStart = new Error("model unavailable");
|
||||
const respond = vi.fn();
|
||||
const context = createChatContext();
|
||||
|
||||
await runNonStreamingChatSend({
|
||||
context,
|
||||
respond,
|
||||
idempotencyKey: "idem-user-transcript-gate-pass-error",
|
||||
message: "prompt allowed before model error",
|
||||
expectBroadcast: false,
|
||||
});
|
||||
|
||||
const userUpdates = mockState.emittedTranscriptUpdates.filter(
|
||||
(update) =>
|
||||
typeof update.message === "object" &&
|
||||
update.message !== null &&
|
||||
(update.message as { role?: unknown }).role === "user",
|
||||
);
|
||||
expect(userUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("adds persisted media paths to the user transcript update", async () => {
|
||||
createTranscriptFixture("openclaw-chat-send-user-transcript-images-");
|
||||
mockState.finalText = "ok";
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "../../media/store.js";
|
||||
import { createChannelMessageReplyPipeline } from "../../plugin-sdk/channel-message.js";
|
||||
import { isPluginOwnedSessionBindingRecord } from "../../plugins/conversation-binding.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
|
||||
@@ -2258,6 +2259,8 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
const deliveredReplies: Array<{ payload: ReplyPayload; kind: "block" | "final" }> = [];
|
||||
let appendedWebchatAgentMedia = false;
|
||||
let userTranscriptUpdatePromise: Promise<void> | null = null;
|
||||
let agentRunStarted = false;
|
||||
const hasBeforeAgentRunGate = getGlobalHookRunner()?.hasHooks("before_agent_run") === true;
|
||||
const emitUserTranscriptUpdate = async () => {
|
||||
if (userTranscriptUpdatePromise) {
|
||||
await userTranscriptUpdatePromise;
|
||||
@@ -2432,16 +2435,6 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
});
|
||||
|
||||
// Surface accepted inbound turns immediately so transcript subscribers
|
||||
// (gateway watchers, MCP bridges, external channel backends) do not wait
|
||||
// on model startup, completion, or failure paths before seeing the user turn.
|
||||
void emitUserTranscriptUpdate().catch((transcriptErr) => {
|
||||
context.logGateway.warn(
|
||||
`webchat eager user transcript update failed: ${formatForLog(transcriptErr)}`,
|
||||
);
|
||||
});
|
||||
|
||||
let agentRunStarted = false;
|
||||
void dispatchInboundMessage({
|
||||
ctx,
|
||||
cfg,
|
||||
@@ -2453,7 +2446,9 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
imageOrder: imageOrder.length > 0 ? imageOrder : undefined,
|
||||
onAgentRunStart: (runId) => {
|
||||
agentRunStarted = true;
|
||||
void emitUserTranscriptUpdate();
|
||||
if (!hasBeforeAgentRunGate) {
|
||||
void emitUserTranscriptUpdate();
|
||||
}
|
||||
const connId = typeof client?.connId === "string" ? client.connId : undefined;
|
||||
const wantsToolEvents = hasGatewayClientCap(
|
||||
client?.connect?.caps,
|
||||
@@ -2654,8 +2649,12 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
void emitUserTranscriptUpdate();
|
||||
} else if (!hasBeforeAgentRunGate) {
|
||||
await emitUserTranscriptUpdate().catch((transcriptErr) => {
|
||||
context.logGateway.warn(
|
||||
`webchat user transcript update failed after agent run: ${formatForLog(transcriptErr)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!context.chatAbortedRuns.has(clientRunId)) {
|
||||
setGatewayDedupeEntry({
|
||||
@@ -2669,13 +2668,17 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch(async (err) => {
|
||||
void rewriteUserTranscriptMedia().catch((rewriteErr) => {
|
||||
context.logGateway.warn(
|
||||
`webchat transcript media rewrite failed after error: ${formatForLog(rewriteErr)}`,
|
||||
);
|
||||
});
|
||||
void emitUserTranscriptUpdate().catch((transcriptErr) => {
|
||||
const emitAfterError =
|
||||
agentRunStarted && hasBeforeAgentRunGate
|
||||
? Promise.resolve()
|
||||
: emitUserTranscriptUpdate();
|
||||
await emitAfterError.catch((transcriptErr) => {
|
||||
context.logGateway.warn(
|
||||
`webchat user transcript update failed after error: ${formatForLog(transcriptErr)}`,
|
||||
);
|
||||
|
||||
@@ -275,7 +275,9 @@ export class SessionHistorySseState {
|
||||
this.target.sessionId,
|
||||
this.target.storePath,
|
||||
this.target.sessionFile,
|
||||
resolveSessionHistoryTailReadOptions(this.limit),
|
||||
{
|
||||
...resolveSessionHistoryTailReadOptions(this.limit),
|
||||
},
|
||||
);
|
||||
return {
|
||||
rawMessages: snapshot.messages,
|
||||
|
||||
@@ -286,6 +286,97 @@ describe("session.message websocket events", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("strips blocked original content from live session.message events", async () => {
|
||||
const storePath = await createSessionStoreFile();
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
storePath,
|
||||
});
|
||||
const transcriptPath = path.join(path.dirname(storePath), "sess-main.jsonl");
|
||||
await fs.writeFile(
|
||||
transcriptPath,
|
||||
JSON.stringify({ type: "session", version: 1, id: "sess-main" }) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await withOperatorSessionSubscriber(async (ws) => {
|
||||
const { messageEvent } = await emitTranscriptUpdateAndCollectEvents({
|
||||
ws,
|
||||
sessionKey: "agent:main:main",
|
||||
sessionFile: transcriptPath,
|
||||
messageId: "blocked-1",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
beforeAgentRunBlocked: { blockedBy: "policy-plugin", blockedAt: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = messageEvent.payload as {
|
||||
message?: { content?: unknown; __openclaw?: { beforeAgentRunBlocked?: unknown } };
|
||||
};
|
||||
expect(payload.message?.content).toEqual([
|
||||
{ type: "text", text: "The agent cannot read this message." },
|
||||
]);
|
||||
expect(JSON.stringify(payload.message)).not.toContain("secret blocked prompt");
|
||||
expect(JSON.stringify(payload.message)).not.toContain("contains protected content");
|
||||
});
|
||||
});
|
||||
|
||||
test("broadcasts redacted blocked user appends to live session listeners", async () => {
|
||||
const storePath = await createSessionStoreFile();
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
storePath,
|
||||
});
|
||||
|
||||
await withOperatorSessionSubscriber(async (ws) => {
|
||||
const messageEventPromise = waitForSessionMessageEvent(ws, "agent:main:main");
|
||||
emitSessionTranscriptUpdate({
|
||||
sessionFile: path.join(path.dirname(storePath), "sess-main.jsonl"),
|
||||
sessionKey: "agent:main:main",
|
||||
messageId: "blocked-message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
beforeAgentRunBlocked: {
|
||||
blockedBy: "policy-plugin",
|
||||
blockedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messageEvent = await messageEventPromise;
|
||||
const payload = messageEvent.payload as {
|
||||
message?: {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
__openclaw?: { beforeAgentRunBlocked?: unknown };
|
||||
};
|
||||
};
|
||||
expect(payload.message?.role).toBe("user");
|
||||
expect(payload.message?.content).toEqual([
|
||||
{ type: "text", text: "The agent cannot read this message." },
|
||||
]);
|
||||
expect(JSON.stringify(payload.message)).not.toContain("secret blocked prompt");
|
||||
expect(JSON.stringify(payload.message)).not.toContain("contains protected content");
|
||||
});
|
||||
});
|
||||
|
||||
test("includes live usage metadata on session.message and sessions.changed transcript events", async () => {
|
||||
const storePath = await createSessionStoreFile();
|
||||
await writeSessionStore({
|
||||
|
||||
@@ -29,6 +29,32 @@ import {
|
||||
resolveSessionTranscriptCandidates,
|
||||
} from "./session-utils.fs.js";
|
||||
|
||||
function buildSessionAssistantMessage(text: string, timestamp: number) {
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text }],
|
||||
api: "openai",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
stopReason: "stop" as const,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function registerTempSessionStore(
|
||||
prefix: string,
|
||||
assignPaths: (tmpDir: string, storePath: string) => void,
|
||||
@@ -51,6 +77,30 @@ function writeTranscript(tmpDir: string, sessionId: string, lines: unknown[]): s
|
||||
return transcriptPath;
|
||||
}
|
||||
|
||||
function appendBlockedUserMessageWithSessionManager(params: {
|
||||
sessionFile: string;
|
||||
originalText?: string;
|
||||
redactedText: string;
|
||||
pluginId: string;
|
||||
idempotencyKey?: string;
|
||||
}): string {
|
||||
const sessionManager = SessionManager.open(params.sessionFile, path.dirname(params.sessionFile));
|
||||
const messageId = sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: params.redactedText }],
|
||||
timestamp: Date.now(),
|
||||
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
|
||||
__openclaw: {
|
||||
beforeAgentRunBlocked: {
|
||||
blockedBy: params.pluginId,
|
||||
blockedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof sessionManager.appendMessage>[0]);
|
||||
(sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
|
||||
return messageId;
|
||||
}
|
||||
|
||||
function buildBasicSessionTranscript(
|
||||
sessionId: string,
|
||||
userText = "Hello world",
|
||||
@@ -1047,6 +1097,29 @@ describe("readSessionMessages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps legacy messages when a mixed transcript lacks a complete branch tree", () => {
|
||||
const sessionId = "mixed-legacy-tree-session";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
{ type: "session", version: 1, id: sessionId },
|
||||
{ type: "message", id: "legacy-user", message: { role: "user", content: "legacy hello" } },
|
||||
{
|
||||
type: "message",
|
||||
id: "tree-assistant",
|
||||
parentId: "legacy-user",
|
||||
message: { role: "assistant", content: "tree hello" },
|
||||
},
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8");
|
||||
|
||||
const out = readSessionMessages(sessionId, storePath);
|
||||
|
||||
expect(out.map((message) => (message as { content?: unknown }).content)).toEqual([
|
||||
"legacy hello",
|
||||
"tree hello",
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
sessionId: "cross-agent-default-root",
|
||||
@@ -1081,6 +1154,215 @@ describe("readSessionMessages", () => {
|
||||
expect((out[0] as { __openclaw?: { seq?: number } }).__openclaw?.seq).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("reads only the active SessionManager branch after a transcript rewrite", () => {
|
||||
const sessionId = "branched-session";
|
||||
const sessionManager = SessionManager.create(tmpDir, tmpDir);
|
||||
const decoratedPrompt = 'Sender (untrusted metadata):\n```json\n{"label":"ui"}\n```\n\nhello';
|
||||
const visiblePrompt = "hello";
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: decoratedPrompt }],
|
||||
timestamp: 1,
|
||||
});
|
||||
sessionManager.appendMessage(buildSessionAssistantMessage("old answer", 2));
|
||||
|
||||
const decoratedUser = sessionManager
|
||||
.getBranch()
|
||||
.find((entry) => entry.type === "message" && entry.message.role === "user");
|
||||
expect(decoratedUser?.type).toBe("message");
|
||||
if (decoratedUser?.parentId) {
|
||||
sessionManager.branch(decoratedUser.parentId);
|
||||
} else {
|
||||
sessionManager.resetLeaf();
|
||||
}
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: visiblePrompt }],
|
||||
timestamp: 1,
|
||||
});
|
||||
sessionManager.appendMessage(buildSessionAssistantMessage("old answer", 2));
|
||||
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
expect(sessionFile).toBeTruthy();
|
||||
|
||||
const out = readSessionMessages(sessionId, storePath, sessionFile ?? undefined);
|
||||
|
||||
expect(
|
||||
out.map((message) => ({
|
||||
role: (message as { role?: string }).role,
|
||||
content: (message as { content?: unknown }).content,
|
||||
})),
|
||||
).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: visiblePrompt }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "old answer" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps compaction markers when reading only the active SessionManager branch", () => {
|
||||
const sessionId = "branched-session-with-compaction";
|
||||
const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
{
|
||||
type: "session",
|
||||
version: 1,
|
||||
id: sessionId,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "user-old",
|
||||
parentId: null,
|
||||
message: { role: "user", content: "old prompt", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-old",
|
||||
parentId: "user-old",
|
||||
message: { role: "assistant", content: "old answer", timestamp: 2 },
|
||||
},
|
||||
{
|
||||
type: "compaction",
|
||||
id: "comp-1",
|
||||
timestamp: "2026-02-07T00:00:00.000Z",
|
||||
summary: "Compacted history",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "user-active",
|
||||
parentId: null,
|
||||
message: { role: "user", content: "active prompt", timestamp: 3 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-active",
|
||||
parentId: "user-active",
|
||||
message: { role: "assistant", content: "active answer", timestamp: 4 },
|
||||
},
|
||||
];
|
||||
fs.writeFileSync(sessionFile, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8");
|
||||
|
||||
const out = readSessionMessages(sessionId, storePath, sessionFile);
|
||||
|
||||
expect(
|
||||
out.map((message) => ({
|
||||
role: (message as { role?: string }).role,
|
||||
content: (message as { content?: unknown }).content,
|
||||
kind: (message as { __openclaw?: { kind?: string } }).__openclaw?.kind,
|
||||
})),
|
||||
).toEqual([
|
||||
{ role: "system", content: [{ type: "text", text: "Compaction" }], kind: "compaction" },
|
||||
{ role: "user", content: "active prompt", kind: undefined },
|
||||
{ role: "assistant", content: "active answer", kind: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps blocked hook messages on the current active branch", async () => {
|
||||
const sessionId = "blocked-hook-branch-session";
|
||||
const sessionKey = "agent:main:explicit:blocked-hook-branch";
|
||||
const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: 1,
|
||||
sessionFile,
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
sessionFile,
|
||||
[
|
||||
{ type: "session", version: 1, id: sessionId },
|
||||
{
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
message: { role: "user", content: "hello", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
parentId: "user-1",
|
||||
message: { role: "assistant", content: "hi", timestamp: 2 },
|
||||
},
|
||||
]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const messageId = appendBlockedUserMessageWithSessionManager({
|
||||
sessionFile,
|
||||
originalText: "[hitl:block] hello",
|
||||
redactedText: "Blocked by HITL test hook.",
|
||||
pluginId: "hitl-test-hooks",
|
||||
});
|
||||
|
||||
expect(messageId).toBeTruthy();
|
||||
const out = readSessionMessages(sessionId, storePath, sessionFile);
|
||||
expect(
|
||||
out.map((message) => ({
|
||||
role: (message as { role?: string }).role,
|
||||
text: (message as { content?: string | Array<{ text?: string }> }).content,
|
||||
})),
|
||||
).toEqual([
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: "hi" },
|
||||
{ role: "user", text: [{ type: "text", text: "Blocked by HITL test hook." }] },
|
||||
]);
|
||||
expect(JSON.stringify(out)).not.toContain("[hitl:block] hello");
|
||||
expect(JSON.stringify(out)).not.toContain("matched original");
|
||||
});
|
||||
|
||||
test("keeps repeated blocked hook messages together in a new session", async () => {
|
||||
const sessionKey = "agent:main:explicit:repeated-blocked-hook";
|
||||
const sessionManager = SessionManager.create(tmpDir, tmpDir);
|
||||
const sessionId = sessionManager.getSessionId();
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
if (!sessionFile) {
|
||||
throw new Error("expected SessionManager.create to return a session file");
|
||||
}
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: 1,
|
||||
sessionFile,
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
appendBlockedUserMessageWithSessionManager({
|
||||
sessionFile,
|
||||
originalText: "[hitl:block] first",
|
||||
redactedText: "Blocked by HITL test hook.",
|
||||
pluginId: "hitl-test-hooks",
|
||||
});
|
||||
appendBlockedUserMessageWithSessionManager({
|
||||
sessionFile,
|
||||
originalText: "[hitl:block] second",
|
||||
redactedText: "Blocked again by HITL test hook.",
|
||||
pluginId: "hitl-test-hooks",
|
||||
});
|
||||
|
||||
const out = readSessionMessages(sessionId, storePath, sessionFile);
|
||||
expect(
|
||||
out.map((message) => ({
|
||||
role: (message as { role?: string }).role,
|
||||
text: (message as { content?: Array<{ text?: string }> }).content?.[0]?.text,
|
||||
})),
|
||||
).toEqual([
|
||||
{ role: "user", text: "Blocked by HITL test hook." },
|
||||
{ role: "user", text: "Blocked again by HITL test hook." },
|
||||
]);
|
||||
expect(JSON.stringify(out)).not.toContain("[hitl:block] first");
|
||||
expect(JSON.stringify(out)).not.toContain("[hitl:block] second");
|
||||
expect(JSON.stringify(out)).not.toContain("matched original");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readSessionPreviewItemsFromTranscript", () => {
|
||||
|
||||
@@ -360,8 +360,10 @@ function selectBoundedActiveTailRecords(entries: TailTranscriptRecord[]): TailTr
|
||||
const byId = new Map<string, TailTranscriptRecord>();
|
||||
let leafId: string | undefined;
|
||||
for (const entry of entries) {
|
||||
if (tailRecordHasTreeLink(entry) && entry.id) {
|
||||
if (entry.id) {
|
||||
byId.set(entry.id, entry);
|
||||
}
|
||||
if (tailRecordHasTreeLink(entry) && entry.id) {
|
||||
leafId = entry.id;
|
||||
}
|
||||
}
|
||||
@@ -384,7 +386,18 @@ function selectBoundedActiveTailRecords(entries: TailTranscriptRecord[]): TailTr
|
||||
selected.push(entry);
|
||||
currentId = entry.parentId ?? undefined;
|
||||
}
|
||||
return selected.toReversed();
|
||||
const activeBranch = selected.toReversed();
|
||||
const firstActiveRecord = activeBranch[0];
|
||||
const firstActiveIndex = firstActiveRecord ? entries.indexOf(firstActiveRecord) : -1;
|
||||
if (firstActiveIndex > 0) {
|
||||
for (let index = firstActiveIndex - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
if (entry?.record.type === "compaction") {
|
||||
return [entry, ...activeBranch];
|
||||
}
|
||||
}
|
||||
}
|
||||
return activeBranch;
|
||||
}
|
||||
|
||||
function readTranscriptRecords(filePath: string): TailTranscriptRecord[] {
|
||||
|
||||
@@ -100,7 +100,7 @@ vi.mock("./session-history-state.js", () => ({
|
||||
history: { items: [], nextCursor: null, messages: [] },
|
||||
}),
|
||||
SessionHistorySseState: {
|
||||
fromRawSnapshot: () => ({
|
||||
fromRawSnapshot: (_params: unknown) => ({
|
||||
snapshot: () => ({ items: [], nextCursor: null, messages: [] }),
|
||||
appendInlineMessage: ({ message, messageId }: { message: unknown; messageId?: string }) => ({
|
||||
message,
|
||||
|
||||
@@ -388,8 +388,9 @@ export type DiagnosticRunStartedEvent = DiagnosticRunBaseEvent & {
|
||||
export type DiagnosticRunCompletedEvent = DiagnosticRunBaseEvent & {
|
||||
type: "run.completed";
|
||||
durationMs: number;
|
||||
outcome: "completed" | "aborted" | "error";
|
||||
outcome: "completed" | "aborted" | "blocked" | "error";
|
||||
errorCategory?: string;
|
||||
blockedBy?: string;
|
||||
};
|
||||
|
||||
export type DiagnosticHarnessRunPhase = "prepare" | "start" | "send" | "resolve" | "cleanup";
|
||||
|
||||
81
src/plugins/hook-decision-types.test.ts
Normal file
81
src/plugins/hook-decision-types.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BLOCK_MESSAGE_PREFIX,
|
||||
type HookDecision,
|
||||
type HookDecisionBlock,
|
||||
mergeHookDecisions,
|
||||
isHookDecision,
|
||||
resolveBlockMessage,
|
||||
} from "./hook-decision-types.js";
|
||||
|
||||
describe("HookDecision helpers", () => {
|
||||
describe("isHookDecision", () => {
|
||||
it("recognizes supported outcomes", () => {
|
||||
expect(isHookDecision({ outcome: "pass" })).toBe(true);
|
||||
expect(isHookDecision({ outcome: "block", reason: "policy" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-decision values", () => {
|
||||
expect(isHookDecision(null)).toBe(false);
|
||||
expect(isHookDecision(undefined)).toBe(false);
|
||||
expect(isHookDecision("pass")).toBe(false);
|
||||
expect(isHookDecision({ block: true })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "ask", reason: "check" })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "invalid" })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "pass", message: "typo" })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "pass", reason: "typo" })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "block" })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "block", reason: "" })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "block", reason: "policy", message: "" })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "block", reason: "policy", message: 3 })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "block", reason: "policy", ask: true })).toBe(false);
|
||||
expect(isHookDecision({ outcome: "block", reason: "policy", metadata: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeHookDecisions", () => {
|
||||
const passDecision: HookDecision = { outcome: "pass" };
|
||||
const blockDecision: HookDecision = { outcome: "block", reason: "policy" };
|
||||
|
||||
it("uses most-restrictive-wins ordering", () => {
|
||||
expect(mergeHookDecisions(undefined, passDecision)).toBe(passDecision);
|
||||
expect(mergeHookDecisions(passDecision, blockDecision)).toBe(blockDecision);
|
||||
expect(mergeHookDecisions(blockDecision, passDecision)).toBe(blockDecision);
|
||||
});
|
||||
|
||||
it("keeps the first decision when outcomes have the same severity", () => {
|
||||
const secondBlock: HookDecision = { outcome: "block", reason: "second" };
|
||||
|
||||
expect(mergeHookDecisions(passDecision, { outcome: "pass" })).toBe(passDecision);
|
||||
expect(mergeHookDecisions(blockDecision, secondBlock)).toBe(blockDecision);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlockMessage", () => {
|
||||
it("returns explicit or default block messages", () => {
|
||||
const explicit: HookDecisionBlock = {
|
||||
outcome: "block",
|
||||
reason: "policy",
|
||||
message: "Please rephrase your request.",
|
||||
};
|
||||
const fallback: HookDecisionBlock = {
|
||||
outcome: "block",
|
||||
reason: "policy",
|
||||
};
|
||||
|
||||
expect(resolveBlockMessage(explicit)).toBe(
|
||||
`${BLOCK_MESSAGE_PREFIX}: Please rephrase your request.`,
|
||||
);
|
||||
expect(resolveBlockMessage(fallback)).toBe(`${BLOCK_MESSAGE_PREFIX}: blocked`);
|
||||
expect(resolveBlockMessage(fallback, { blockedBy: "policy-plugin" })).toBe(
|
||||
`${BLOCK_MESSAGE_PREFIX}: blocked by policy-plugin`,
|
||||
);
|
||||
expect(resolveBlockMessage(explicit, { blockedBy: "policy-plugin" })).toBe(
|
||||
`${BLOCK_MESSAGE_PREFIX}: Please rephrase your request. (blocked by policy-plugin)`,
|
||||
);
|
||||
expect(resolveBlockMessage({ ...explicit, message: " " })).toBe(
|
||||
`${BLOCK_MESSAGE_PREFIX}: blocked`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
113
src/plugins/hook-decision-types.ts
Normal file
113
src/plugins/hook-decision-types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Structured decision returned by gate/policy hooks.
|
||||
* Core is outcome-agnostic — it handles the mechanics of each outcome
|
||||
* without knowing *why* the decision was made.
|
||||
*/
|
||||
export type HookDecision = HookDecisionPass | HookDecisionBlock;
|
||||
|
||||
/** Content is fine. Proceed normally. */
|
||||
export type HookDecisionPass = {
|
||||
outcome: "pass";
|
||||
};
|
||||
|
||||
/** Prefix for user-facing replacement messages when a `block` decision stops a request. */
|
||||
export const BLOCK_MESSAGE_PREFIX = "Your message could not be sent";
|
||||
|
||||
/**
|
||||
* Content is blocked. `reason` is internal plugin-local detail; core must not log,
|
||||
* persist, broadcast, or expose it verbatim. `message` is user-facing detail.
|
||||
*/
|
||||
export type HookDecisionBlock = {
|
||||
outcome: "block";
|
||||
/** Internal plugin-local reason. Do not log, persist, broadcast, or expose verbatim. */
|
||||
reason: string;
|
||||
/** Optional user-facing detail included in the block response envelope. */
|
||||
message?: string;
|
||||
/** Plugin-defined category for analytics (e.g. "violence", "pii", "cost_limit"). */
|
||||
category?: string;
|
||||
/** Opaque metadata for the plugin's own use. Core does not interpret it. */
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function resolveBlockMessage(
|
||||
decision: HookDecisionBlock,
|
||||
params: { blockedBy?: string } = {},
|
||||
): string {
|
||||
const message = typeof decision.message === "string" ? decision.message.trim() : "";
|
||||
const blockedBy = params.blockedBy?.trim();
|
||||
if (message) {
|
||||
return blockedBy
|
||||
? `${BLOCK_MESSAGE_PREFIX}: ${message} (blocked by ${blockedBy})`
|
||||
: `${BLOCK_MESSAGE_PREFIX}: ${message}`;
|
||||
}
|
||||
return blockedBy
|
||||
? `${BLOCK_MESSAGE_PREFIX}: blocked by ${blockedBy}`
|
||||
: `${BLOCK_MESSAGE_PREFIX}: blocked`;
|
||||
}
|
||||
|
||||
/** Outcome severity for most-restrictive-wins merging. Higher = more restrictive. */
|
||||
export const HOOK_DECISION_SEVERITY: Record<HookDecision["outcome"], number> = {
|
||||
pass: 0,
|
||||
block: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge two HookDecisions using most-restrictive-wins semantics.
|
||||
* `block > pass`
|
||||
*/
|
||||
export function mergeHookDecisions(a: HookDecision | undefined, b: HookDecision): HookDecision {
|
||||
if (!a) {
|
||||
return b;
|
||||
}
|
||||
return HOOK_DECISION_SEVERITY[b.outcome] > HOOK_DECISION_SEVERITY[a.outcome] ? b : a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: does this object look like a HookDecision (has `outcome` field)?
|
||||
*/
|
||||
export function isHookDecision(value: unknown): value is HookDecision {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
const v = value as Record<string, unknown>;
|
||||
const keys = Object.keys(v);
|
||||
if (v.outcome === "pass") {
|
||||
return keys.length === 1;
|
||||
}
|
||||
if (v.outcome !== "block") {
|
||||
return false;
|
||||
}
|
||||
const allowedBlockKeys = new Set(["outcome", "reason", "message", "category", "metadata"]);
|
||||
if (keys.some((key) => !allowedBlockKeys.has(key))) {
|
||||
return false;
|
||||
}
|
||||
if (typeof v.reason !== "string" || !v.reason.trim()) {
|
||||
return false;
|
||||
}
|
||||
if ("message" in v && (typeof v.message !== "string" || !v.message.trim())) {
|
||||
return false;
|
||||
}
|
||||
if ("category" in v && (typeof v.category !== "string" || !v.category.trim())) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
"metadata" in v &&
|
||||
(typeof v.metadata !== "object" || v.metadata === null || Array.isArray(v.metadata))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Outcomes valid for input gates (before_agent_run). */
|
||||
export type InputGateDecision = HookDecisionPass | HookDecisionBlock;
|
||||
|
||||
/**
|
||||
* A gate hook decision paired with the pluginId that produced it.
|
||||
* Returned by gate hook runners so callers can
|
||||
* attribute blocked entries and audit events to the originating plugin.
|
||||
*/
|
||||
export type GateHookResult<TDecision extends HookDecision = HookDecision> = {
|
||||
decision: TDecision;
|
||||
pluginId: string;
|
||||
};
|
||||
365
src/plugins/hook-lifecycle-gates.test.ts
Normal file
365
src/plugins/hook-lifecycle-gates.test.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalHookRunnerRegistry } from "./hook-registry.types.js";
|
||||
import type { PluginHookRegistration, PluginHookAgentContext } from "./hook-types.js";
|
||||
import { createHookRunner } from "./hooks.js";
|
||||
|
||||
function makeRegistry(hooks: PluginHookRegistration[] = []): GlobalHookRunnerRegistry {
|
||||
return {
|
||||
hooks: [],
|
||||
typedHooks: hooks,
|
||||
plugins: [],
|
||||
};
|
||||
}
|
||||
|
||||
const ctx: PluginHookAgentContext = {
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "session-1",
|
||||
sessionId: "sid-1",
|
||||
};
|
||||
|
||||
describe("before_agent_run hook", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns undefined when no handlers registered", async () => {
|
||||
const runner = createHookRunner(makeRegistry());
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "hello", messages: [] }, ctx);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns pass when handler returns pass", async () => {
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "test",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () => ({ outcome: "pass" as const }),
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "hello", messages: [] }, ctx);
|
||||
expect(result?.decision).toEqual({ outcome: "pass" });
|
||||
expect(result?.pluginId).toBe("test");
|
||||
});
|
||||
|
||||
it("returns block when handler returns block (with `message`)", async () => {
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "test",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () => ({
|
||||
outcome: "block" as const,
|
||||
reason: "unsafe content",
|
||||
message: "I can't process that.",
|
||||
category: "violence",
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "bad stuff", messages: [] }, ctx);
|
||||
expect(result?.decision.outcome).toBe("block");
|
||||
if (result?.decision.outcome === "block") {
|
||||
expect(result.decision.reason).toBe("unsafe content");
|
||||
expect(result.decision.message).toBe("I can't process that.");
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks when one of multiple handlers passes and a later handler blocks", async () => {
|
||||
const calls: string[] = [];
|
||||
const passHandler = vi.fn(async () => {
|
||||
calls.push("pass-plugin");
|
||||
return { outcome: "pass" as const };
|
||||
});
|
||||
const blockHandler = vi.fn(async () => {
|
||||
calls.push("block-plugin");
|
||||
return {
|
||||
outcome: "block" as const,
|
||||
reason: "blocked",
|
||||
};
|
||||
});
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "pass-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: passHandler,
|
||||
source: "test",
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
pluginId: "block-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: blockHandler,
|
||||
source: "test",
|
||||
priority: 5,
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
|
||||
|
||||
expect(result?.decision.outcome).toBe("block");
|
||||
expect(result?.pluginId).toBe("block-plugin");
|
||||
expect(passHandler).toHaveBeenCalledTimes(1);
|
||||
expect(blockHandler).toHaveBeenCalledTimes(1);
|
||||
expect(calls).toEqual(["pass-plugin", "block-plugin"]);
|
||||
});
|
||||
|
||||
it("short-circuits when the first of multiple handlers blocks", async () => {
|
||||
const blockHandler = vi.fn(async () => ({
|
||||
outcome: "block" as const,
|
||||
reason: "blocked",
|
||||
}));
|
||||
const passHandler = vi.fn(async () => ({ outcome: "pass" as const }));
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "block-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: blockHandler,
|
||||
source: "test",
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
pluginId: "pass-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: passHandler,
|
||||
source: "test",
|
||||
priority: 5,
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
|
||||
|
||||
expect(result?.decision.outcome).toBe("block");
|
||||
expect(result?.pluginId).toBe("block-plugin");
|
||||
expect(blockHandler).toHaveBeenCalledTimes(1);
|
||||
expect(passHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats void handler returns as pass (no effect)", async () => {
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "void-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () => undefined,
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
|
||||
// void => undefined result (no decision)
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed on invalid handler results", async () => {
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "invalid-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () => ({ block: true }) as never,
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
|
||||
expect(result).toEqual({
|
||||
decision: {
|
||||
outcome: "block",
|
||||
reason: "before_agent_run returned an invalid decision",
|
||||
},
|
||||
pluginId: "invalid-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on null handler results", async () => {
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "null-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () => null as never,
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
|
||||
expect(result).toEqual({
|
||||
decision: {
|
||||
outcome: "block",
|
||||
reason: "before_agent_run returned an invalid decision",
|
||||
},
|
||||
pluginId: "null-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on malformed block decisions", async () => {
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "malformed-block-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () => ({ outcome: "block" }) as never,
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
|
||||
expect(result).toEqual({
|
||||
decision: {
|
||||
outcome: "block",
|
||||
reason: "before_agent_run returned an invalid decision",
|
||||
},
|
||||
pluginId: "malformed-block-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when handlers throw", async () => {
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "throwing-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () => {
|
||||
throw new Error("policy unavailable");
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
await expect(runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx)).rejects.toThrow(
|
||||
"before_agent_run handler from throwing-plugin failed: policy unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when handlers exceed the default timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "hanging-plugin",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () => await new Promise<never>(() => {}),
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const resultPromise = runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
|
||||
const rejection = expect(resultPromise).rejects.toThrow(
|
||||
"before_agent_run handler from hanging-plugin failed: timed out after 15000ms",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("receives the correct event payload", async () => {
|
||||
let receivedEvent: unknown;
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "test",
|
||||
hookName: "before_agent_run",
|
||||
handler: async (event: unknown) => {
|
||||
receivedEvent = event;
|
||||
return { outcome: "pass" as const };
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
await runner.runBeforeAgentRun(
|
||||
{
|
||||
prompt: "hello world",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
channelId: "discord",
|
||||
senderId: "user-123",
|
||||
senderIsOwner: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
const event = receivedEvent as Record<string, unknown>;
|
||||
expect(event.prompt).toBe("hello world");
|
||||
expect(event.channelId).toBe("discord");
|
||||
expect(event.senderId).toBe("user-123");
|
||||
expect(event.senderIsOwner).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("before_agent_run invalid ask outcome", () => {
|
||||
it("fails closed when handler returns ask", async () => {
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "test",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () =>
|
||||
({
|
||||
outcome: "ask",
|
||||
reason: "needs approval",
|
||||
title: "Review Required",
|
||||
description: "This prompt requires human review.",
|
||||
}) as never,
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "hello", messages: [] }, ctx);
|
||||
expect(result?.decision).toEqual({
|
||||
outcome: "block",
|
||||
reason: "before_agent_run returned an invalid decision",
|
||||
});
|
||||
expect(result?.pluginId).toBe("test");
|
||||
});
|
||||
|
||||
it("short-circuits unsupported ask decisions", async () => {
|
||||
let secondHandlerCalled = false;
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "plugin-a",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () =>
|
||||
({
|
||||
outcome: "ask" as const,
|
||||
reason: "check",
|
||||
title: "Check",
|
||||
description: "Check this.",
|
||||
}) as never,
|
||||
source: "test",
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
pluginId: "plugin-b",
|
||||
hookName: "before_agent_run",
|
||||
handler: async () => {
|
||||
secondHandlerCalled = true;
|
||||
return { outcome: "pass" as const };
|
||||
},
|
||||
source: "test",
|
||||
priority: 5,
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
|
||||
expect(result?.decision.outcome).toBe("block");
|
||||
expect(result?.pluginId).toBe("plugin-a");
|
||||
expect(secondHandlerCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("before_tool_call channelId forwarding", () => {
|
||||
it("passes channelId through to before_tool_call handlers", async () => {
|
||||
let receivedCtx: unknown;
|
||||
const registry = makeRegistry([
|
||||
{
|
||||
pluginId: "test",
|
||||
hookName: "before_tool_call",
|
||||
handler: async (_event: unknown, ctx: unknown) => {
|
||||
receivedCtx = ctx;
|
||||
return undefined;
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const runner = createHookRunner(registry);
|
||||
await runner.runBeforeToolCall(
|
||||
{ toolName: "exec", params: {} },
|
||||
{ toolName: "exec", channelId: "discord", sessionKey: "s1" },
|
||||
);
|
||||
expect((receivedCtx as { channelId?: string }).channelId).toBe("discord");
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry):
|
||||
},
|
||||
catchErrors: true,
|
||||
failurePolicyByHook: {
|
||||
before_agent_run: "fail-closed",
|
||||
before_tool_call: "fail-closed",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
PluginHookBeforePromptBuildEvent,
|
||||
PluginHookBeforePromptBuildResult,
|
||||
} from "./hook-before-agent-start.types.js";
|
||||
import type { InputGateDecision } from "./hook-decision-types.js";
|
||||
import type {
|
||||
PluginHookInboundClaimContext,
|
||||
PluginHookInboundClaimEvent,
|
||||
@@ -103,7 +104,8 @@ export type PluginHookName =
|
||||
| "cron_changed"
|
||||
| "before_dispatch"
|
||||
| "reply_dispatch"
|
||||
| "before_install";
|
||||
| "before_install"
|
||||
| "before_agent_run";
|
||||
|
||||
export const PLUGIN_HOOK_NAMES = [
|
||||
"before_model_resolve",
|
||||
@@ -141,6 +143,7 @@ export const PLUGIN_HOOK_NAMES = [
|
||||
"before_dispatch",
|
||||
"reply_dispatch",
|
||||
"before_install",
|
||||
"before_agent_run",
|
||||
] as const satisfies readonly PluginHookName[];
|
||||
|
||||
type MissingPluginHookNames = Exclude<PluginHookName, (typeof PLUGIN_HOOK_NAMES)[number]>;
|
||||
@@ -168,10 +171,13 @@ export const isPromptInjectionHookName = (hookName: PluginHookName): boolean =>
|
||||
promptInjectionHookNameSet.has(hookName);
|
||||
|
||||
export const CONVERSATION_HOOK_NAMES = [
|
||||
"before_model_resolve",
|
||||
"before_agent_reply",
|
||||
"llm_input",
|
||||
"llm_output",
|
||||
"before_agent_finalize",
|
||||
"agent_end",
|
||||
"before_agent_run",
|
||||
] as const satisfies readonly PluginHookName[];
|
||||
|
||||
export type ConversationHookName = (typeof CONVERSATION_HOOK_NAMES)[number];
|
||||
@@ -259,6 +265,8 @@ export type PluginHookLlmOutputEvent = {
|
||||
* `resolvedRef` so provider/model consumers keep a stable parse contract.
|
||||
*/
|
||||
harnessId?: string;
|
||||
/** The original user prompt that produced this output. */
|
||||
prompt?: string;
|
||||
assistantTexts: string[];
|
||||
lastAssistant?: unknown;
|
||||
usage?: {
|
||||
@@ -408,6 +416,7 @@ export type PluginHookToolContext = {
|
||||
getSessionExtension?: <T extends PluginJsonValue = PluginJsonValue>(
|
||||
namespace: string,
|
||||
) => T | undefined;
|
||||
channelId?: string;
|
||||
};
|
||||
|
||||
export type PluginHookBeforeToolCallEvent = {
|
||||
@@ -438,6 +447,7 @@ export type PluginHookBeforeToolCallResult = {
|
||||
severity?: "info" | "warning" | "critical";
|
||||
timeoutMs?: number;
|
||||
timeoutBehavior?: "allow" | "deny";
|
||||
allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
|
||||
pluginId?: string;
|
||||
onResolution?: (decision: PluginApprovalResolution) => Promise<void> | void;
|
||||
};
|
||||
@@ -802,6 +812,31 @@ export type PluginHookBeforeInstallResult = {
|
||||
blockReason?: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// before_agent_run — Lifecycle Gate Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Event payload for the before_agent_run gate hook. */
|
||||
export type PluginHookBeforeAgentRunEvent = {
|
||||
/** The user's message that triggered this run. */
|
||||
prompt: string;
|
||||
/** Loaded session history before the current prompt is submitted. */
|
||||
messages: unknown[];
|
||||
/** Active system prompt prepared for this run. */
|
||||
systemPrompt?: string;
|
||||
/** Account identity when available. */
|
||||
accountId?: string;
|
||||
/** Channel the message came from. */
|
||||
channelId?: string;
|
||||
/** Sender identity when available. */
|
||||
senderId?: string;
|
||||
/** Whether the sender is an owner. */
|
||||
senderIsOwner?: boolean;
|
||||
};
|
||||
|
||||
/** Result type for before_agent_run. Returns pass/block or void (= pass). */
|
||||
export type PluginHookBeforeAgentRunResult = InputGateDecision | void;
|
||||
|
||||
export type PluginHookHandlerMap = {
|
||||
agent_turn_prepare: (
|
||||
event: PluginAgentTurnPrepareEvent,
|
||||
@@ -950,6 +985,10 @@ export type PluginHookHandlerMap = {
|
||||
event: PluginHookBeforeInstallEvent,
|
||||
ctx: PluginHookBeforeInstallContext,
|
||||
) => Promise<PluginHookBeforeInstallResult | void> | PluginHookBeforeInstallResult | void;
|
||||
before_agent_run: (
|
||||
event: PluginHookBeforeAgentRunEvent,
|
||||
ctx: PluginHookAgentContext,
|
||||
) => Promise<PluginHookBeforeAgentRunResult> | PluginHookBeforeAgentRunResult;
|
||||
};
|
||||
|
||||
export type PluginHookRegistration<K extends PluginHookName = PluginHookName> = {
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
* Plugin Hook Runner
|
||||
*
|
||||
* Provides utilities for executing plugin lifecycle hooks with proper
|
||||
* error handling, priority ordering, and async support.
|
||||
* error handling and priority ordering.
|
||||
*/
|
||||
|
||||
import { formatHookErrorForLog } from "../hooks/fire-and-forget.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { concatOptionalTextSegments } from "../shared/text/join-segments.js";
|
||||
import {
|
||||
type GateHookResult,
|
||||
type InputGateDecision,
|
||||
isHookDecision,
|
||||
} from "./hook-decision-types.js";
|
||||
import type { GlobalHookRunnerRegistry, HookRunnerRegistry } from "./hook-registry.types.js";
|
||||
import type {
|
||||
PluginHookAfterCompactionEvent,
|
||||
@@ -45,6 +50,7 @@ import type {
|
||||
PluginAgentTurnPrepareResult,
|
||||
PluginHeartbeatPromptContributionEvent,
|
||||
PluginHeartbeatPromptContributionResult,
|
||||
PluginHookBeforeAgentRunEvent,
|
||||
PluginHookCronChangedEvent,
|
||||
PluginHookGatewayCronDeliveryStatus,
|
||||
PluginHookGatewayCronJobState,
|
||||
@@ -118,6 +124,7 @@ export type {
|
||||
PluginHookToolContext,
|
||||
PluginHookBeforeToolCallEvent,
|
||||
PluginHookBeforeToolCallResult,
|
||||
PluginHookBeforeAgentRunEvent,
|
||||
PluginHookAfterToolCallEvent,
|
||||
PluginHookToolResultPersistContext,
|
||||
PluginHookToolResultPersistEvent,
|
||||
@@ -184,6 +191,7 @@ const DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, numbe
|
||||
agent_end: 30_000,
|
||||
};
|
||||
const DEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, number>> = {
|
||||
before_agent_run: 15_000,
|
||||
before_prompt_build: 15_000,
|
||||
};
|
||||
|
||||
@@ -193,6 +201,7 @@ type ModifyingHookPolicy<K extends PluginHookName, TResult> = {
|
||||
next: TResult,
|
||||
registration: PluginHookRegistration<K>,
|
||||
) => TResult;
|
||||
mergeNullResults?: boolean;
|
||||
shouldStop?: (result: TResult) => boolean;
|
||||
terminalLabel?: string;
|
||||
onTerminal?: (params: { hookName: K; pluginId: string; result: TResult }) => void;
|
||||
@@ -252,7 +261,10 @@ export function createHookRunner(
|
||||
) {
|
||||
const logger = options.logger;
|
||||
const catchErrors = options.catchErrors ?? true;
|
||||
const failurePolicyByHook = options.failurePolicyByHook ?? {};
|
||||
const failurePolicyByHook = {
|
||||
before_agent_run: "fail-closed",
|
||||
...options.failurePolicyByHook,
|
||||
} satisfies Partial<Record<PluginHookName, HookFailurePolicy>>;
|
||||
const voidHookTimeoutMsByHook = {
|
||||
...DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK,
|
||||
...options.voidHookTimeoutMsByHook,
|
||||
@@ -577,7 +589,9 @@ export function createHookRunner(
|
||||
const timeoutMs = getModifyingHookTimeoutMs(hookName, hook);
|
||||
const handlerResult = timeoutMs ? await withHookTimeout(promise, timeoutMs) : await promise;
|
||||
|
||||
if (handlerResult !== undefined && handlerResult !== null) {
|
||||
const shouldMergeResult =
|
||||
handlerResult !== undefined && (handlerResult !== null || policy.mergeNullResults);
|
||||
if (shouldMergeResult) {
|
||||
if (policy.mergeResults) {
|
||||
result = policy.mergeResults(result, handlerResult, hook);
|
||||
} else {
|
||||
@@ -1050,7 +1064,57 @@ export function createHookRunner(
|
||||
return runVoidHook("message_sent", event, ctx);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
/**
|
||||
* Run before_agent_run gate hook.
|
||||
* Fires after session resolution and workspace preparation, before model inference.
|
||||
* Returns the most-restrictive pass/block decision from all handlers.
|
||||
* Handlers that return void are treated as pass.
|
||||
*/
|
||||
async function runBeforeAgentRun(
|
||||
event: PluginHookBeforeAgentRunEvent,
|
||||
ctx: PluginHookAgentContext,
|
||||
): Promise<GateHookResult<InputGateDecision> | undefined> {
|
||||
let winningPluginId: string | undefined;
|
||||
const decision = await runModifyingHook<"before_agent_run", InputGateDecision | undefined>(
|
||||
"before_agent_run",
|
||||
event,
|
||||
ctx,
|
||||
{
|
||||
mergeResults: (_acc, next, reg) => {
|
||||
if (next === undefined || next === null) {
|
||||
const normalized: InputGateDecision = {
|
||||
outcome: "block",
|
||||
reason: "before_agent_run returned an invalid decision",
|
||||
};
|
||||
winningPluginId = reg.pluginId;
|
||||
return normalized;
|
||||
}
|
||||
const normalized: InputGateDecision = isHookDecision(next)
|
||||
? next
|
||||
: {
|
||||
outcome: "block",
|
||||
reason: "before_agent_run returned an invalid decision",
|
||||
};
|
||||
const merged =
|
||||
!_acc || (normalized.outcome === "block" && _acc.outcome !== "block")
|
||||
? normalized
|
||||
: _acc;
|
||||
if (merged === normalized) {
|
||||
winningPluginId = reg.pluginId;
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
mergeNullResults: true,
|
||||
shouldStop: (result) => result?.outcome === "block",
|
||||
terminalLabel: "gate-decision",
|
||||
},
|
||||
);
|
||||
if (!decision) {
|
||||
return undefined;
|
||||
}
|
||||
return { decision, pluginId: winningPluginId ?? "unknown" };
|
||||
}
|
||||
|
||||
// Tool Hooks
|
||||
// =========================================================================
|
||||
|
||||
@@ -1396,9 +1460,6 @@ export function createHookRunner(
|
||||
// Utility
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if any hooks are registered for a given hook name.
|
||||
*/
|
||||
function hasHooks(hookName: PluginHookName): boolean {
|
||||
return registry.typedHooks.some((h) => h.hookName === hookName);
|
||||
}
|
||||
@@ -1426,6 +1487,8 @@ export function createHookRunner(
|
||||
runBeforeCompaction,
|
||||
runAfterCompaction,
|
||||
runBeforeReset,
|
||||
// Lifecycle gate hooks
|
||||
runBeforeAgentRun,
|
||||
// Message hooks
|
||||
runInboundClaim,
|
||||
runInboundClaimForPlugin,
|
||||
|
||||
@@ -5350,6 +5350,7 @@ module.exports = {
|
||||
"hook-policy": {
|
||||
hooks: {
|
||||
allowPromptInjection: false,
|
||||
allowConversationAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -5465,6 +5466,7 @@ module.exports = {
|
||||
entries: {
|
||||
"hook-timeouts": {
|
||||
hooks: {
|
||||
allowConversationAccess: true,
|
||||
timeoutMs: 250,
|
||||
timeouts: {
|
||||
before_model_resolve: 750,
|
||||
@@ -5490,10 +5492,13 @@ module.exports = {
|
||||
id: "conversation-hooks",
|
||||
filename: "conversation-hooks.cjs",
|
||||
body: `module.exports = { id: "conversation-hooks", register(api) {
|
||||
api.on("before_model_resolve", () => undefined);
|
||||
api.on("before_agent_reply", () => undefined);
|
||||
api.on("llm_input", () => undefined);
|
||||
api.on("llm_output", () => undefined);
|
||||
api.on("before_agent_finalize", () => undefined);
|
||||
api.on("agent_end", () => undefined);
|
||||
api.on("before_agent_run", () => undefined);
|
||||
} };`,
|
||||
});
|
||||
|
||||
@@ -5510,7 +5515,7 @@ module.exports = {
|
||||
"non-bundled plugins must set plugins.entries.conversation-hooks.hooks.allowConversationAccess=true",
|
||||
),
|
||||
);
|
||||
expect(blockedDiagnostics).toHaveLength(4);
|
||||
expect(blockedDiagnostics).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("allows conversation typed hooks for non-bundled plugins when explicitly enabled", () => {
|
||||
@@ -5519,10 +5524,13 @@ module.exports = {
|
||||
id: "conversation-hooks-allowed",
|
||||
filename: "conversation-hooks-allowed.cjs",
|
||||
body: `module.exports = { id: "conversation-hooks-allowed", register(api) {
|
||||
api.on("before_model_resolve", () => undefined);
|
||||
api.on("before_agent_reply", () => undefined);
|
||||
api.on("llm_input", () => undefined);
|
||||
api.on("llm_output", () => undefined);
|
||||
api.on("before_agent_finalize", () => undefined);
|
||||
api.on("agent_end", () => undefined);
|
||||
api.on("before_agent_run", () => undefined);
|
||||
} };`,
|
||||
});
|
||||
|
||||
@@ -5541,10 +5549,13 @@ module.exports = {
|
||||
});
|
||||
|
||||
expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual([
|
||||
"before_model_resolve",
|
||||
"before_agent_reply",
|
||||
"llm_input",
|
||||
"llm_output",
|
||||
"before_agent_finalize",
|
||||
"agent_end",
|
||||
"before_agent_run",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5564,6 +5575,13 @@ module.exports = {
|
||||
plugin,
|
||||
pluginConfig: {
|
||||
allow: ["hook-unknown"],
|
||||
entries: {
|
||||
"hook-unknown": {
|
||||
hooks: {
|
||||
allowConversationAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user