diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e500e09c2..b1af62e535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Only write entries that are worth mentioning to users. ## Unreleased - Kosong: Stop sending an empty `anthropic-beta` header when no beta features are declared — adaptive thinking removes the interleaved-thinking beta, which previously left an empty header value that some backends reject +- Hooks: Fire `PostToolUse` and `PostToolUseFailure` through `fire_and_forget_trigger` so the hook task keeps a strong reference and failures are logged, instead of a bare `asyncio.create_task` whose handle is dropped ## 1.49.0 (2026-07-16) diff --git a/src/kimi_cli/soul/toolset.py b/src/kimi_cli/soul/toolset.py index 5d66344aaa..5504093fef 100644 --- a/src/kimi_cli/soul/toolset.py +++ b/src/kimi_cli/soul/toolset.py @@ -506,22 +506,17 @@ async def _call(): call_id=tool_call.id, ) # --- PostToolUseFailure (fire-and-forget) --- - _hook_task = asyncio.create_task( - self._hook_engine.trigger( - "PostToolUseFailure", - matcher_value=tool_name, - input_data=events.post_tool_use_failure( - session_id=_get_session_id(), - cwd=str(Path.cwd()), - tool_name=tool_name, - tool_input=tool_input_dict, - error=str(e), - tool_call_id=tool_call.id, - ), - ) - ) - _hook_task.add_done_callback( - lambda t: t.exception() if not t.cancelled() else None + self._hook_engine.fire_and_forget_trigger( + "PostToolUseFailure", + matcher_value=tool_name, + input_data=events.post_tool_use_failure( + session_id=_get_session_id(), + cwd=str(Path.cwd()), + tool_name=tool_name, + tool_input=tool_input_dict, + error=str(e), + tool_call_id=tool_call.id, + ), ) from kimi_cli.telemetry import track @@ -574,21 +569,18 @@ async def _call(): ) # --- PostToolUse (fire-and-forget) --- - _hook_task = asyncio.create_task( - self._hook_engine.trigger( - "PostToolUse", - matcher_value=tool_name, - input_data=events.post_tool_use( - session_id=_get_session_id(), - cwd=str(Path.cwd()), - tool_name=tool_name, - tool_input=tool_input_dict, - tool_output=str(ret)[:2000], - tool_call_id=tool_call.id, - ), - ) + self._hook_engine.fire_and_forget_trigger( + "PostToolUse", + matcher_value=tool_name, + input_data=events.post_tool_use( + session_id=_get_session_id(), + cwd=str(Path.cwd()), + tool_name=tool_name, + tool_input=tool_input_dict, + tool_output=str(ret)[:2000], + tool_call_id=tool_call.id, + ), ) - _hook_task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) return ToolResult(tool_call_id=tool_call.id, return_value=ret) diff --git a/tests/hooks/test_toolset_fire_and_forget.py b/tests/hooks/test_toolset_fire_and_forget.py new file mode 100644 index 0000000000..2193e8fd95 --- /dev/null +++ b/tests/hooks/test_toolset_fire_and_forget.py @@ -0,0 +1,79 @@ +"""PostToolUse hooks must go through the engine's tracked fire-and-forget path. + +`HookEngine.fire_and_forget_trigger` keeps a strong reference to the hook task +(asyncio only holds tasks in a WeakSet) and logs failures. Firing hooks with a +bare `asyncio.create_task` whose handle is discarded loses both. +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue +from pydantic import BaseModel + +from kimi_cli.hooks.engine import HookEngine +from kimi_cli.soul.toolset import KimiToolset +from kimi_cli.wire.types import ToolCall + + +class _Params(BaseModel): + value: str = "" + + +class _OkTool(CallableTool2[_Params]): + name: str = "ToolA" + description: str = "Tool A" + params: type[_Params] = _Params + + async def __call__(self, params: _Params) -> ToolReturnValue: + return ToolOk(output="a") + + +class _FailingTool(CallableTool2[_Params]): + name: str = "ToolB" + description: str = "Tool B" + params: type[_Params] = _Params + + async def __call__(self, params: _Params) -> ToolReturnValue: + raise RuntimeError("boom") + + +class _RecordingHookEngine(HookEngine): + """Records which events were fired through the tracked helper.""" + + def __init__(self) -> None: + super().__init__() + self.fire_and_forget_events: list[str] = [] + + def fire_and_forget_trigger(self, event, *, matcher_value="", input_data): # type: ignore[no-untyped-def] + self.fire_and_forget_events.append(event) + return super().fire_and_forget_trigger( + event, matcher_value=matcher_value, input_data=input_data + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("tool", "event"), + [(_OkTool(), "PostToolUse"), (_FailingTool(), "PostToolUseFailure")], +) +async def test_post_tool_use_hooks_use_tracked_fire_and_forget( + tool: CallableTool2[_Params], event: str +) -> None: + engine = _RecordingHookEngine() + toolset = KimiToolset() + toolset.add(tool) + toolset.set_hook_engine(engine) + + tool_call = ToolCall( + id="call-1", + function=ToolCall.FunctionBody(name=tool.name, arguments=json.dumps({"value": "x"})), + ) + result = toolset.handle(tool_call) + if isinstance(result, asyncio.Task): + await result + + assert engine.fire_and_forget_events == [event]