Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
d8a43d3
feat(hooks): adding outbound messagesent hook
vincentkoc Feb 5, 2026
495a8cd
feat(hooks): outbound messagesent hooks
vincentkoc Feb 5, 2026
9bae626
chore(hooks): wiring session keys to heatbeat hooks
vincentkoc Feb 5, 2026
7ce8b01
chore(hooks): wiring sessionkey to outbound session hooks
vincentkoc Feb 5, 2026
b22aca7
chore(hooks): wiring sessionkey to messaging hooks
vincentkoc Feb 5, 2026
8e5caac
chore(hooks): includes and type changes
vincentkoc Feb 5, 2026
1a1e6b0
feat(hooks): adding runAfterToolCallHook
vincentkoc Feb 5, 2026
f427ed2
feat(hooks): adding runAfterToolCallHook
vincentkoc Feb 5, 2026
eafd8c0
docs(hooks): updating documentation on hooks
vincentkoc Feb 5, 2026
0ddfb55
feat(hooks): add compaction events
vincentkoc Feb 5, 2026
c98ac13
feat(hooks): add missing agent internal hooks
vincentkoc Feb 5, 2026
f8bf9f8
feat(hooks): add context pruning hooks
vincentkoc Feb 5, 2026
bbffe4b
feat(hooks): add internal tool events
vincentkoc Feb 5, 2026
915d87c
feat(hooks): add shutdown events
vincentkoc Feb 5, 2026
0e8924f
docs(hooks): update hooks docs
vincentkoc Feb 5, 2026
df3af67
Update pi-tools.before-tool-call.test.ts
vincentkoc Feb 5, 2026
d2f7ee3
chore: lint
vincentkoc Feb 5, 2026
df41c92
test(hooks): lint and extend hooks
vincentkoc Feb 5, 2026
7b34881
Update pi-tools.before-tool-call.ts
vincentkoc Feb 5, 2026
9c6e99a
test(hooks): extend hooks
vincentkoc Feb 5, 2026
33661c6
Merge branch 'vk/otel-plugin-hooks' of https://github.com/vincentkoc/…
vincentkoc Feb 5, 2026
fe56190
chore: lint
vincentkoc Feb 5, 2026
2029fef
Merge branch 'main' into vk/otel-plugin-hooks
vincentkoc Feb 5, 2026
1b01ec4
chore(cicd): improve formatter to show issues
vincentkoc Feb 5, 2026
3cc4480
chore: fix and patch various greptile and cicd issues
vincentkoc Feb 5, 2026
e2866b0
chore(cicd): formatter
vincentkoc Feb 5, 2026
b421db2
chore: testing diff on formatter to find root cause
vincentkoc Feb 5, 2026
9773f67
Merge branch 'main' into vk/otel-plugin-hooks
vincentkoc Feb 5, 2026
ca02f69
fix: formateer to match cidcd behaviour
vincentkoc Feb 5, 2026
c4e90b9
Merge branch 'vk/otel-plugin-hooks' of https://github.com/vincentkoc/…
vincentkoc Feb 5, 2026
78dc13a
Merge branch 'main' into vk/otel-plugin-hooks
vincentkoc Feb 5, 2026
6cc249a
fix(hooks): greptile fixes
vincentkoc Feb 5, 2026
8593a77
chore: reverse lint with updated formatter
vincentkoc Feb 5, 2026
d9f5d63
fix: formatter isseues
vincentkoc Feb 5, 2026
8c569ef
fix: lint package to meet test req
vincentkoc Feb 5, 2026
7c05fe3
chore: tsgo fix
vincentkoc Feb 5, 2026
1bf50ee
chore: lint
vincentkoc Feb 5, 2026
d85b34a
Revert "chore: lint"
vincentkoc Feb 5, 2026
22f1f9d
fix: formatter yaml
vincentkoc Feb 5, 2026
6f2cf78
chore: reverse temporary fix
vincentkoc Feb 5, 2026
ab2e0c9
Merge branch 'main' into vk/otel-plugin-hooks
vincentkoc Feb 5, 2026
5788e5a
chore: greptile
vincentkoc Feb 5, 2026
bfc2b6f
chore: tsgo error
vincentkoc Feb 5, 2026
8f91e1b
chore: greptile improvements
vincentkoc Feb 5, 2026
3cd840c
chore: tsgo fix on await type
vincentkoc Feb 5, 2026
867f28d
chore: lint and tsgo types for greptile
vincentkoc Feb 5, 2026
98b8475
chore: fix tests to async
vincentkoc Feb 5, 2026
5ed1933
Update deliver.ts
vincentkoc Feb 5, 2026
0587a0f
chore: greptile fix
vincentkoc Feb 5, 2026
0975922
chore: greptile seqByRun fix
vincentkoc Feb 5, 2026
5d39d01
Update send.test.ts
vincentkoc Feb 5, 2026
0ca91b1
fix: sessionkey normalization across all hooks
vincentkoc Feb 5, 2026
e7c0802
fix: further sessionkey normalization
vincentkoc Feb 5, 2026
a198819
chore: fix issues in tests due to context seq change
vincentkoc Feb 5, 2026
920a4a0
chore: fix duplicate event emitter
vincentkoc Feb 5, 2026
159c215
Update deliver.ts
vincentkoc Feb 5, 2026
f40f14a
chore: greptile patch
vincentkoc Feb 5, 2026
002be12
chore: greptile client toolid
vincentkoc Feb 5, 2026
d07c7bd
chore: greptile patch
vincentkoc Feb 5, 2026
7b86300
chore: greptile issues on surrogate toolids
vincentkoc Feb 6, 2026
ac0abba
Update pi-tools.before-tool-call.test.ts
vincentkoc Feb 6, 2026
3ea5e4f
Merge branch 'main' into vk/otel-plugin-hooks
vincentkoc Feb 6, 2026
cce73ca
chore: greptile reqs
vincentkoc Feb 6, 2026
368838f
fix: failing security audit test on windows
vincentkoc Feb 6, 2026
1e34848
fix: upstream tests failures blocking merge
vincentkoc Feb 6, 2026
a13e93d
Merge branch 'main' into vk/otel-plugin-hooks
vincentkoc Feb 6, 2026
9192c40
fix: use UUID when no toolcallId
vincentkoc Feb 6, 2026
899854b
chore: reesolve test issues
vincentkoc Feb 6, 2026
d1c83d9
chore: sessionid fallbacks greptile
vincentkoc Feb 6, 2026
0ecf223
chore: greptile
vincentkoc Feb 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .oxfmtrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
"experimentalSortPackageJson": {
"sortScripts": true,
},
"tabWidth": 2,
"useTabs": false,
"ignorePatterns": [
"apps/",
"assets/",
"docker-compose.yml",
"dist/",
"docs/_layouts/",
"node_modules/",
Expand Down
62 changes: 57 additions & 5 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,31 +230,83 @@ Triggered when agent commands are issued:
- **`command:reset`**: When `/reset` command is issued
- **`command:stop`**: When `/stop` command is issued

### Session Events

Triggered during session lifecycle when session persistence is enabled and a valid `sessionKey` exists:

- **`session:start`**: When a new persisted session begins (user-initiated or auto-recovery; non-persisted flows do not fire this hook)
- **`session:end`**: When a persisted session is terminated (auto-recovery resets only; requires persisted session entry)
- **`session:reset`**: Emitted after `session:end` during resets to provide transition context (auto-recovery resets only; requires persisted session entry)
- **`session:compact:before`**: Right before compaction summarizes history
- **`session:compact:after`**: After compaction completes with summary metadata
- **`session:prune`**: When context pruning trims tool output from the in-memory context

Context includes:

- `sessionId`: The session ID (present in current implementation; for `session:reset`, equals `newSessionId`)
- `oldSessionId` and `newSessionId`: For `session:reset` only

Lifecycle patterns:

- **User-initiated reset** (`/new` or `/reset`): `command:new` or `command:reset` fires immediately; `session:start` fires during the agent turn if a session key exists. `session:end` and `session:reset` do not fire for user-initiated resets.
- **Auto-recovery reset** (compaction failure, role ordering conflict): `session:end` then `session:reset` fire during the reset if a persisted session entry exists; `session:start` fires when the agent turn runs.

### Agent Events

- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
- **`agent:reply`**: After each agent turn completes with user input or assistant output
- **`agent:flush`**: When memory flush starts (before the flush operation runs)
- **`agent:thinking:start`**, **`agent:thinking:end`**: Model call start and end
- **`agent:response:start`**, **`agent:response:end`**: Response generation start and end
- **`agent:tool:start`**, **`agent:tool:end`**: Tool execution start and end

Context for `agent:reply` includes:

- `sessionId`: Current session ID
- `input`: User input message (may be empty string when only output is present)
- `output`: Assistant response (may be empty string when only input is present)
- `turnId`: Unique turn identifier
- `senderId`: Sender ID

Context for `agent:flush` includes:

- `sessionId`: Current session ID
- `phase`: `start`
- `contextTokensUsed`: Optional token count when flush was triggered
- `reason`: Reason for flush (example: `context_limit`)

### Gateway Events

Triggered when the gateway starts:

- **`gateway:startup`**: After channels start and hooks are loaded
- **`gateway:shutdown`**: When the gateway begins shutting down
- **`gateway:pre-restart`**: Before a gateway restart is initiated

### Tool Result Hooks (Plugin API)

These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before OpenClaw persists them.

- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop).

### Future Events
### Plugin Hook Events

These hooks are registered via the plugin API and run inside core workflows:

- **`before_agent_start`** and **`agent_end`**
- **`before_tool_call`** and **`after_tool_call`**
- **`message_received`**, **`message_sending`**, **`message_sent`**
- **`before_compaction`**, **`after_compaction`**
- **`session_start`**, **`session_end`**
- **`gateway_start`**, **`gateway_stop`**

`message_sent` fires for canceled sends with `success: false` and `error: "canceled by message_sending hook"`.

### Planned Events

Planned event types:

- **`session:start`**: When a new session begins
- **`session:end`**: When a session ends
- **`agent:error`**: When an agent encounters an error
- **`message:sent`**: When a message is sent
- **`message:received`**: When a message is received

## Creating Custom Hooks

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"docs:list": "node scripts/docs-list.js",
"format": "oxfmt --check",
"format:all": "pnpm format && pnpm format:swift",
"format:diff": "oxfmt --write && git --no-pager diff",
"format:fix": "oxfmt --write",
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/OpenClawKit/Sources",
"gateway:dev": "OPENCLAW_SKIP_CHANNELS=1 CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
Expand Down
4 changes: 2 additions & 2 deletions src/agents/agent-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { resolveStateDir } from "../config/paths.js";
import {
DEFAULT_AGENT_ID,
normalizeAgentId,
normalizeSessionKey,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
Expand Down Expand Up @@ -77,8 +78,7 @@ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: O
sessionAgentId: string;
} {
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
const sessionKey = params.sessionKey?.trim();
const normalizedSessionKey = sessionKey ? sessionKey.toLowerCase() : undefined;
const normalizedSessionKey = normalizeSessionKey(params.sessionKey);
const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null;
const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
return { defaultAgentId, sessionAgentId };
Expand Down
271 changes: 271 additions & 0 deletions src/agents/pi-embedded-runner/compact.hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { describe, expect, it, vi } from "vitest";

const { hookRunner, triggerInternalHook } = vi.hoisted(() => ({
hookRunner: {
hasHooks: vi.fn(),
runBeforeCompaction: vi.fn(),
runAfterCompaction: vi.fn(),
},
triggerInternalHook: vi.fn(),
}));

vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookRunner,
}));

vi.mock("../../hooks/internal-hooks.js", async () => {
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
"../../hooks/internal-hooks.js",
);
return {
...actual,
triggerInternalHook,
};
});

vi.mock("@mariozechner/pi-coding-agent", () => {
return {
createAgentSession: vi.fn(async () => {
const session = {
sessionId: "session-1",
messages: [
{ role: "user", content: "hello", timestamp: 1 },
{ role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 },
{
role: "toolResult",
toolCallId: "t1",
toolName: "exec",
content: [{ type: "text", text: "output" }],
isError: false,
timestamp: 3,
},
],
agent: { replaceMessages: vi.fn(), streamFn: vi.fn() },
compact: vi.fn(async () => {
// simulate compaction trimming to a single message
session.messages.splice(1);
return {
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 120,
details: { ok: true },
};
}),
dispose: vi.fn(),
};
return { session };
}),
SessionManager: {
open: vi.fn(() => ({})),
},
SettingsManager: {
create: vi.fn(() => ({})),
},
estimateTokens: vi.fn(() => 10),
};
});

vi.mock("../session-tool-result-guard-wrapper.js", () => ({
guardSessionManager: vi.fn(() => ({
flushPendingToolResults: vi.fn(),
})),
}));

vi.mock("../pi-settings.js", () => ({
ensurePiCompactionReserveTokens: vi.fn(),
resolveCompactionReserveTokensFloor: vi.fn(() => 0),
}));

vi.mock("../models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));

vi.mock("../model-auth.js", () => ({
getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })),
resolveModelAuthMode: vi.fn(() => "env"),
}));

vi.mock("../sandbox.js", () => ({
resolveSandboxContext: vi.fn(async () => null),
}));

vi.mock("../session-file-repair.js", () => ({
repairSessionFileIfNeeded: vi.fn(async () => {}),
}));

vi.mock("../session-write-lock.js", () => ({
acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })),
}));

vi.mock("../bootstrap-files.js", () => ({
makeBootstrapWarn: vi.fn(() => () => {}),
resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })),
}));

vi.mock("../docs-path.js", () => ({
resolveOpenClawDocsPath: vi.fn(async () => undefined),
}));

vi.mock("../channel-tools.js", () => ({
listChannelSupportedActions: vi.fn(() => undefined),
resolveChannelMessageToolHints: vi.fn(() => undefined),
}));

vi.mock("../pi-tools.js", () => ({
createOpenClawCodingTools: vi.fn(() => []),
}));

vi.mock("./google.js", () => ({
logToolSchemasForGoogle: vi.fn(),
sanitizeSessionHistory: vi.fn(async (params: { messages: unknown[] }) => params.messages),
sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools),
}));

vi.mock("./tool-split.js", () => ({
splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })),
}));

vi.mock("../transcript-policy.js", () => ({
resolveTranscriptPolicy: vi.fn(() => ({
allowSyntheticToolResults: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
})),
}));

vi.mock("./extensions.js", () => ({
buildEmbeddedExtensionPaths: vi.fn(),
}));

vi.mock("./history.js", () => ({
getDmHistoryLimitFromSessionKey: vi.fn(() => undefined),
limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs),
}));

vi.mock("../skills.js", () => ({
applySkillEnvOverrides: vi.fn(() => () => {}),
applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}),
loadWorkspaceSkillEntries: vi.fn(() => []),
resolveSkillsPromptForRun: vi.fn(() => undefined),
}));

vi.mock("../agent-paths.js", () => ({
resolveOpenClawAgentDir: vi.fn(() => "/tmp"),
}));

vi.mock("../agent-scope.js", () => ({
resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })),
}));

vi.mock("../date-time.js", () => ({
formatUserTime: vi.fn(() => ""),
resolveUserTimeFormat: vi.fn(() => ""),
resolveUserTimezone: vi.fn(() => ""),
}));

vi.mock("../defaults.js", () => ({
DEFAULT_MODEL: "fake-model",
DEFAULT_PROVIDER: "openai",
}));

vi.mock("../utils.js", () => ({
resolveUserPath: vi.fn((p: string) => p),
}));

vi.mock("../../infra/machine-name.js", () => ({
getMachineDisplayName: vi.fn(async () => "machine"),
}));

vi.mock("../../config/channel-capabilities.js", () => ({
resolveChannelCapabilities: vi.fn(() => undefined),
}));

vi.mock("../../utils/message-channel.js", () => ({
normalizeMessageChannel: vi.fn(() => undefined),
}));

vi.mock("../pi-embedded-helpers.js", () => ({
ensureSessionHeader: vi.fn(async () => {}),
validateAnthropicTurns: vi.fn((m: unknown[]) => m),
validateGeminiTurns: vi.fn((m: unknown[]) => m),
}));

vi.mock("./sandbox-info.js", () => ({
buildEmbeddedSandboxInfo: vi.fn(() => undefined),
}));

vi.mock("./model.js", () => ({
buildModelAliasLines: vi.fn(() => []),
resolveModel: vi.fn(() => ({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
})),
}));

vi.mock("./session-manager-cache.js", () => ({
prewarmSessionFile: vi.fn(async () => {}),
trackSessionManagerAccess: vi.fn(),
}));

vi.mock("./system-prompt.js", () => ({
applySystemPromptOverrideToSession: vi.fn(),
buildEmbeddedSystemPrompt: vi.fn(() => ""),
createSystemPromptOverride: vi.fn(() => () => ""),
}));

vi.mock("./utils.js", () => ({
describeUnknownError: vi.fn((err: unknown) => String(err)),
mapThinkingLevel: vi.fn(() => "off"),
resolveExecToolDefaults: vi.fn(() => undefined),
}));

import { compactEmbeddedPiSessionDirect } from "./compact.js";

const sessionHook = (action: string) =>
triggerInternalHook.mock.calls.find(
(call) => call[0]?.type === "session" && call[0]?.action === action,
)?.[0];

describe("compactEmbeddedPiSessionDirect hooks", () => {
it("emits internal + plugin compaction hooks with counts", async () => {
hookRunner.hasHooks.mockReturnValue(true);

const result = await compactEmbeddedPiSessionDirect({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
customInstructions: "focus on decisions",
});

expect(result.ok).toBe(true);
expect(sessionHook("compact:before")).toMatchObject({
type: "session",
action: "compact:before",
});
expect(sessionHook("compact:after")?.context).toMatchObject({
messageCount: 1,
compactedCount: 2,
});

expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith(
expect.objectContaining({
messageCount: 3,
tokenCount: 30,
messageCountOriginal: 3,
tokenCountOriginal: 30,
}),
expect.objectContaining({ sessionKey: "agent:main:session-1" }),
);
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
{
messageCount: 1,
tokenCount: 10,
compactedCount: 2,
},
expect.objectContaining({ sessionKey: "agent:main:session-1" }),
);
});
});
Loading
Loading