mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
3141 lines
106 KiB
Python
3141 lines
106 KiB
Python
"""Hook system for tool execution lifecycle and agent loop control.
|
|
|
|
The core registry is callback-based. Skill Protocol frontmatter hooks
|
|
(``command``, ``prompt``, ``http``, and ``agent``) are adapted into scoped
|
|
callbacks in ``skill_engine/protocol.py`` so they share this execution engine.
|
|
|
|
Hooks emit plain message dictionaries and runtime events rather than UI-specific
|
|
attachment objects. Permission decisions are resolved in
|
|
``tool_runtime.pipeline.execution`` via ``has_permissions_to_use_tool``; hook decisions
|
|
can still provide allow/deny signals before that layer runs.
|
|
|
|
Supported events include tool lifecycle hooks, permission hooks, compact hooks,
|
|
session lifecycle hooks, prompt-submit hooks, notification hooks, and teammate
|
|
task hooks. Some enum values are intentionally reserved for optional future
|
|
features such as file watchers, setup hooks, worktree hooks, and enterprise MCP
|
|
elicitation flows.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import os
|
|
import shlex
|
|
import uuid
|
|
from collections.abc import AsyncGenerator, Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal
|
|
|
|
from openspace.services.tooling.context import active_skill_scope_payload
|
|
|
|
if TYPE_CHECKING:
|
|
from openspace.services.tooling.context import ToolUseContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hook events — OpenSpace/entrypoints/sdk/coreSchemas.ts:355 HOOK_EVENTS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class HookEvent(str, Enum):
|
|
"""All hook lifecycle events.
|
|
|
|
Implementation: ``HOOK_EVENTS`` in ``entrypoints/sdk/coreSchemas.ts:355``.
|
|
OS retains events relevant to agent loop / tool execution / multi-agent.
|
|
"""
|
|
|
|
PRE_TOOL_USE = "PreToolUse"
|
|
POST_TOOL_USE = "PostToolUse"
|
|
POST_TOOL_USE_FAILURE = "PostToolUseFailure"
|
|
PERMISSION_REQUEST = "PermissionRequest"
|
|
PERMISSION_DENIED = "PermissionDenied"
|
|
STOP = "Stop"
|
|
SUBAGENT_STOP = "SubagentStop"
|
|
STOP_FAILURE = "StopFailure"
|
|
PRE_COMPACT = "PreCompact"
|
|
POST_COMPACT = "PostCompact"
|
|
SESSION_START = "SessionStart"
|
|
SESSION_END = "SessionEnd"
|
|
NOTIFICATION = "Notification"
|
|
USER_PROMPT_SUBMIT = "UserPromptSubmit"
|
|
TEAMMATE_IDLE = "TeammateIdle"
|
|
TASK_COMPLETED = "TaskCompleted"
|
|
|
|
|
|
def is_hook_event(value: str) -> bool:
|
|
"""Implementation: ``isHookEvent`` in ``types/hooks.ts:22``."""
|
|
try:
|
|
HookEvent(value)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Result types — OpenSpace/utils/hooks.ts:330-376
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(slots=True)
|
|
class HookBlockingError:
|
|
"""Implementation: ``HookBlockingError`` in ``utils/hooks.ts:330``.
|
|
|
|
Represents a hook that blocks tool execution or agent continuation.
|
|
"""
|
|
|
|
blocking_error: str
|
|
command: str = ""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class HookResult:
|
|
"""Single hook callback result.
|
|
|
|
Implementation: ``HookResult`` in ``utils/hooks.ts:338-357``.
|
|
Returned by each registered hook callback.
|
|
|
|
OpenSpace field mapping:
|
|
message → message (dict | None)
|
|
systemMessage → system_message
|
|
blockingError → blocking_error
|
|
outcome → outcome
|
|
preventContinuation → prevent_continuation
|
|
stopReason → stop_reason
|
|
permissionBehavior → permission_behavior
|
|
hookPermissionDecisionReason → hook_permission_decision_reason
|
|
additionalContext → additional_context
|
|
initialUserMessage → initial_user_message
|
|
updatedInput → updated_input
|
|
updatedPermissions → updated_permissions
|
|
updatedMCPToolOutput → updated_tool_output (renamed: OS has RemoteTool, not just MCP)
|
|
watchPaths → watch_paths
|
|
elicitationResponse → elicitation_response
|
|
elicitationResultResponse → elicitation_result_response
|
|
retry → retry
|
|
"""
|
|
|
|
message: dict[str, Any] | None = None
|
|
system_message: str | None = None
|
|
blocking_error: HookBlockingError | None = None
|
|
outcome: Literal["success", "blocking", "non_blocking_error", "cancelled"] = "success"
|
|
prevent_continuation: bool = False
|
|
stop_reason: str | None = None
|
|
permission_behavior: Literal["ask", "deny", "allow", "passthrough"] | None = None
|
|
hook_permission_decision_reason: str | None = None
|
|
additional_context: str | None = None
|
|
initial_user_message: str | None = None
|
|
updated_input: dict[str, Any] | None = None
|
|
updated_permissions: list[Any] | None = None
|
|
updated_tool_output: Any | None = None
|
|
watch_paths: list[str] | None = None
|
|
elicitation_response: dict[str, Any] | None = None
|
|
elicitation_result_response: dict[str, Any] | None = None
|
|
retry: bool = False
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AggregatedHookResult:
|
|
"""Aggregated result from multiple hooks for a single event.
|
|
|
|
Implementation: ``AggregatedHookResult`` in ``utils/hooks.ts:359-376``.
|
|
Built by the hook execution engine, consumed by toolHooks / stopHooks.
|
|
|
|
OpenSpace field mapping:
|
|
message → message
|
|
blockingError → blocking_error (singular in OpenSpace too)
|
|
preventContinuation → prevent_continuation
|
|
stopReason → stop_reason
|
|
hookPermissionDecisionReason → hook_permission_decision_reason
|
|
hookSource → hook_source
|
|
permissionBehavior → permission_behavior
|
|
additionalContexts → additional_contexts (plural in OpenSpace)
|
|
initialUserMessage → initial_user_message
|
|
updatedInput → updated_input
|
|
updatedPermissions → updated_permissions
|
|
updatedMCPToolOutput → updated_tool_output
|
|
watchPaths → watch_paths
|
|
elicitationResponse → elicitation_response
|
|
elicitationResultResponse → elicitation_result_response
|
|
retry → retry
|
|
"""
|
|
|
|
message: dict[str, Any] | None = None
|
|
blocking_error: HookBlockingError | None = None
|
|
prevent_continuation: bool = False
|
|
stop_reason: str | None = None
|
|
hook_permission_decision_reason: str | None = None
|
|
hook_source: str | None = None
|
|
permission_behavior: Literal["allow", "deny", "ask", "passthrough"] | None = None
|
|
additional_contexts: list[str] | None = None
|
|
initial_user_message: str | None = None
|
|
updated_input: dict[str, Any] | None = None
|
|
updated_permissions: list[Any] | None = None
|
|
updated_tool_output: Any | None = None
|
|
watch_paths: list[str] | None = None
|
|
elicitation_response: dict[str, Any] | None = None
|
|
elicitation_result_response: dict[str, Any] | None = None
|
|
retry: bool = False
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CompactHookResult:
|
|
"""Return type of ``run_pre_compact_hooks`` / ``run_post_compact_hooks``.
|
|
|
|
Implementation: return type of ``executePreCompactHooks`` / ``executePostCompactHooks``.
|
|
"""
|
|
|
|
new_custom_instructions: str | None = None
|
|
user_display_message: str | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pre-tool hook yield types — OpenSpace/services/tools/toolHooks.ts:413-430
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(slots=True)
|
|
class PreToolHookYield:
|
|
"""Typed yield from ``run_pre_tool_use_hooks``.
|
|
|
|
Implementation: the discriminated union yielded by ``runPreToolUseHooks`` in
|
|
``services/tools/toolHooks.ts:413-430``.
|
|
|
|
OpenSpace yield types (discriminated by ``type`` field):
|
|
'message' → {message: MessageUpdateLazy}
|
|
'hookPermissionResult' → {hookPermissionResult: PermissionResult}
|
|
'hookUpdatedInput' → {updatedInput: Record<string, unknown>}
|
|
'preventContinuation' → {shouldPreventContinuation: boolean}
|
|
'stopReason' → {stopReason: string}
|
|
'additionalContext' → {message: MessageUpdateLazy}
|
|
'stop' → (no payload)
|
|
"""
|
|
|
|
type: Literal[
|
|
"message",
|
|
"hook_permission_result",
|
|
"hook_updated_input",
|
|
"prevent_continuation",
|
|
"stop_reason",
|
|
"additional_context",
|
|
"stop",
|
|
]
|
|
message: dict[str, Any] | None = None
|
|
hook_permission_result: dict[str, Any] | None = None
|
|
updated_input: dict[str, Any] | None = None
|
|
stop_reason: str | None = None
|
|
|
|
|
|
@dataclass(slots=True, frozen=True)
|
|
class PostToolHookRuntimeState:
|
|
"""Per-tool-call runtime state for PostToolUse hooks.
|
|
|
|
This is an explicit producer/consumer contract between the tool execution
|
|
pipeline and post-tool hooks. It is intentionally per-call data rather than
|
|
mutable shared context state so concurrent tool execution cannot race.
|
|
"""
|
|
|
|
tool_call: Any
|
|
backend: str
|
|
tool: Any | None = None
|
|
execution_time_ms: float = 0.0
|
|
is_last_tool_call_in_iteration: bool = False
|
|
guidance: str | None = None
|
|
task_complete_token: str | None = None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class LifecycleHookResult:
|
|
"""Result for non-tool lifecycle hooks such as SessionStart/UserPromptSubmit."""
|
|
|
|
messages: list[dict[str, Any]] = field(default_factory=list)
|
|
blocking_errors: list[dict[str, Any]] = field(default_factory=list)
|
|
prevent_continuation: bool = False
|
|
stop_reason: str | None = None
|
|
additional_contexts: list[str] = field(default_factory=list)
|
|
initial_user_message: str | None = None
|
|
watch_paths: list[str] = field(default_factory=list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hook registration — OS-only (OpenSpace uses settings + external scripts)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
HookCallback = Callable[..., Awaitable[HookResult]]
|
|
"""Signature for hook callbacks.
|
|
|
|
Pre-tool: callback(tool_name, tool_input, context) -> HookResult
|
|
Post-tool: callback(tool_name, tool_input, tool_result, context) -> HookResult
|
|
Failure: callback(tool_name, tool_input, error, context) -> HookResult
|
|
Stop: callback(messages, last_response, context) -> HookResult
|
|
Compact: callback(compact_data, context) -> HookResult
|
|
"""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class HookRegistration:
|
|
"""A single registered hook.
|
|
|
|
Implementation: Within ``getMatchingHooks`` (utils/hooks.ts:1555), OpenSpace matches
|
|
hooks by event + tool_name (via ``matcher`` field in ``HookMatcher``).
|
|
OS uses the same pattern with ``tool_name`` as optional filter.
|
|
"""
|
|
|
|
event: HookEvent
|
|
callback: HookCallback
|
|
tool_name: str | None = None
|
|
"""None = global hook matching all tools. Specified = match only that tool.
|
|
Implementation: ``matcher`` field in ``HookMatcherSchema`` (schemas/hooks.ts:161).
|
|
"""
|
|
priority: int = 100
|
|
"""Lower number = higher priority. OpenSpace runs hooks in parallel then merges;
|
|
OS runs in priority order (sequential for pre-hooks to allow short-circuit,
|
|
all for post-hooks). This is consistent with OpenSpace's merge semantics:
|
|
deny > ask > allow for permission behaviors.
|
|
"""
|
|
name: str = ""
|
|
"""Human-readable name for logging/debugging.
|
|
Implementation: hookName constructed as ``{hookEvent}:{matcher}`` in
|
|
``executeHooks`` (utils/hooks.ts:1987).
|
|
"""
|
|
once: bool = False
|
|
"""If True, remove after first execution.
|
|
Implementation: ``once`` field in ``BashCommandHookSchema`` (schemas/hooks.ts:57).
|
|
"""
|
|
|
|
|
|
@dataclass(slots=True, frozen=True)
|
|
class ConfiguredHookSpec:
|
|
"""Hook loaded from settings/session/plugin-style configuration."""
|
|
|
|
event: HookEvent
|
|
matcher: str
|
|
hook: Mapping[str, Any]
|
|
source: str
|
|
root: str | None = None
|
|
priority: int = 100
|
|
session_scoped: bool = False
|
|
session_hook_id: str | None = None
|
|
on_hook_success: Callable[..., Any] | None = None
|
|
|
|
|
|
_SESSION_HOOK_ID_KEY = "_openspace_session_hook_id"
|
|
_SESSION_HOOK_SUCCESS_KEY = "_openspace_on_hook_success"
|
|
|
|
|
|
class HookRegistry:
|
|
"""Code-level hook registration and execution.
|
|
|
|
Hook specs from settings, project hook files, plugins, sessions, and skills
|
|
are resolved into callback registrations before execution.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._hooks: list[HookRegistration] = []
|
|
self._once_removal_queue: list[HookRegistration] = []
|
|
|
|
# ── Registration API ─────────────────────────────────────────────
|
|
|
|
def register(
|
|
self,
|
|
event: HookEvent,
|
|
callback: HookCallback,
|
|
*,
|
|
tool_name: str | None = None,
|
|
priority: int = 100,
|
|
name: str = "",
|
|
once: bool = False,
|
|
) -> HookRegistration:
|
|
"""Register a hook callback for a specific event.
|
|
|
|
Implementation: ``registerFrontmatterHooks`` / ``getRegisteredHooks``
|
|
in ``utils/hooks.ts`` + ``bootstrap/state.ts``.
|
|
|
|
Returns the registration object (can be passed to ``unregister``).
|
|
"""
|
|
reg = HookRegistration(
|
|
event=event,
|
|
callback=callback,
|
|
tool_name=tool_name,
|
|
priority=priority,
|
|
name=name,
|
|
once=once,
|
|
)
|
|
self._hooks.append(reg)
|
|
self._hooks.sort(key=lambda h: h.priority)
|
|
return reg
|
|
|
|
def register_pre_tool(
|
|
self,
|
|
callback: HookCallback,
|
|
*,
|
|
tool_name: str | None = None,
|
|
priority: int = 100,
|
|
name: str = "",
|
|
once: bool = False,
|
|
) -> HookRegistration:
|
|
"""Convenience: register a PreToolUse hook."""
|
|
return self.register(
|
|
HookEvent.PRE_TOOL_USE, callback,
|
|
tool_name=tool_name, priority=priority, name=name, once=once,
|
|
)
|
|
|
|
def register_post_tool(
|
|
self,
|
|
callback: HookCallback,
|
|
*,
|
|
tool_name: str | None = None,
|
|
priority: int = 100,
|
|
name: str = "",
|
|
once: bool = False,
|
|
) -> HookRegistration:
|
|
"""Convenience: register a PostToolUse hook."""
|
|
return self.register(
|
|
HookEvent.POST_TOOL_USE, callback,
|
|
tool_name=tool_name, priority=priority, name=name, once=once,
|
|
)
|
|
|
|
def register_stop_hook(
|
|
self,
|
|
callback: HookCallback,
|
|
*,
|
|
priority: int = 100,
|
|
name: str = "",
|
|
once: bool = False,
|
|
) -> HookRegistration:
|
|
"""Convenience: register a Stop hook."""
|
|
return self.register(
|
|
HookEvent.STOP, callback,
|
|
priority=priority, name=name, once=once,
|
|
)
|
|
|
|
def unregister(self, registration: HookRegistration) -> bool:
|
|
"""Remove a previously registered hook. Returns True if found."""
|
|
try:
|
|
self._hooks.remove(registration)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
def clear(self, event: HookEvent | None = None) -> int:
|
|
"""Remove all hooks, optionally filtered by event. Returns count removed.
|
|
|
|
Implementation: ``clearSessionHooks`` in ``utils/hooks/sessionHooks.ts``.
|
|
"""
|
|
if event is None:
|
|
count = len(self._hooks)
|
|
self._hooks.clear()
|
|
return count
|
|
before = len(self._hooks)
|
|
self._hooks = [h for h in self._hooks if h.event != event]
|
|
return before - len(self._hooks)
|
|
|
|
def has_hook_for_event(
|
|
self,
|
|
event: HookEvent,
|
|
tool_name: str | None = None,
|
|
) -> bool:
|
|
"""Check if any hook is registered for the given event.
|
|
|
|
Implementation: ``hasHookForEvent`` in ``utils/hooks.ts`` (~L1490).
|
|
Used for fast-path short-circuit before constructing hook input.
|
|
"""
|
|
return any(
|
|
h.event == event
|
|
and (h.tool_name is None or h.tool_name == tool_name)
|
|
for h in self._hooks
|
|
)
|
|
|
|
# ── Matching ─────────────────────────────────────────────────────
|
|
|
|
def _match_hooks(
|
|
self,
|
|
event: HookEvent,
|
|
tool_name: str | None = None,
|
|
) -> list[HookRegistration]:
|
|
"""Return matching hooks sorted by priority.
|
|
|
|
Implementation: ``getMatchingHooks`` in ``utils/hooks.ts:1555``.
|
|
OpenSpace matching logic: for tool events, ``matchQuery = toolName``
|
|
matched against hook's ``matcher`` field (exact or regex).
|
|
OS simplified: exact ``tool_name`` match or global (None).
|
|
"""
|
|
matches = [
|
|
h for h in self._hooks
|
|
if h.event == event
|
|
and (h.tool_name is None or h.tool_name == tool_name)
|
|
]
|
|
return matches # already sorted by priority from register()
|
|
|
|
def _drain_once_hooks(self) -> None:
|
|
"""Remove hooks marked ``once`` that were queued for removal."""
|
|
for reg in self._once_removal_queue:
|
|
try:
|
|
self._hooks.remove(reg)
|
|
except ValueError:
|
|
pass
|
|
self._once_removal_queue.clear()
|
|
|
|
# ── Core execution engine ────────────────────────────────────────
|
|
|
|
async def execute_hooks(
|
|
self,
|
|
event: HookEvent,
|
|
tool_name: str | None = None,
|
|
*,
|
|
hook_kwargs: dict[str, Any] | None = None,
|
|
context: ToolUseContext | None = None,
|
|
abort_event: asyncio.Event | None = None,
|
|
) -> AsyncGenerator[AggregatedHookResult, None]:
|
|
"""Core hook execution engine.
|
|
|
|
Execution pattern:
|
|
1. Find matching hooks.
|
|
2. Stop early if the abort event is set.
|
|
3. Emit start/progress events through the runtime context.
|
|
4. Run matching callbacks by priority.
|
|
5. Aggregate each callback result into ``AggregatedHookResult``.
|
|
|
|
OS difference from Implementation: OpenSpace runs hooks in parallel then merges. OS runs
|
|
hooks sequentially by priority for PreToolUse (need short-circuit on
|
|
block/deny) and parallel for PostToolUse (no short-circuit needed).
|
|
This matches OpenSpace's semantic: for PreToolUse, OpenSpace's merge applies
|
|
deny > ask > allow, which sequential evaluation with short-circuit
|
|
achieves naturally.
|
|
"""
|
|
kwargs = hook_kwargs or {}
|
|
matching = self._match_hooks(event, tool_name)
|
|
if not matching:
|
|
return
|
|
|
|
if abort_event is not None and abort_event.is_set():
|
|
return
|
|
|
|
hook_name = f"{event.value}:{tool_name}" if tool_name else event.value
|
|
|
|
if context is not None:
|
|
await context.emit_event("hook_start", {
|
|
"hook_event": event.value,
|
|
"hook_name": hook_name,
|
|
"hook_count": len(matching),
|
|
})
|
|
|
|
is_pre_tool = event == HookEvent.PRE_TOOL_USE
|
|
|
|
try:
|
|
for reg in matching:
|
|
if abort_event is not None and abort_event.is_set():
|
|
yield AggregatedHookResult(
|
|
message=_make_hook_cancelled_message(hook_name, event),
|
|
)
|
|
return
|
|
|
|
try:
|
|
single = await reg.callback(**kwargs)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Hook '%s' (%s) failed for %s: %s",
|
|
reg.name, event.value, tool_name, exc,
|
|
)
|
|
if is_pre_tool:
|
|
hook_label = reg.name or hook_name
|
|
yield AggregatedHookResult(
|
|
message=_make_hook_error_message(
|
|
hook_label, event, str(exc),
|
|
),
|
|
blocking_error=HookBlockingError(
|
|
blocking_error=(
|
|
f"Hook {hook_label} failed during pre-tool validation: {exc}"
|
|
),
|
|
),
|
|
)
|
|
return
|
|
|
|
yield AggregatedHookResult(
|
|
message=_make_hook_error_message(
|
|
reg.name or hook_name, event, str(exc),
|
|
),
|
|
)
|
|
continue
|
|
|
|
if reg.once:
|
|
self._once_removal_queue.append(reg)
|
|
|
|
agg = _aggregate_single(single, reg.name or hook_name, event)
|
|
yield agg
|
|
|
|
if event == HookEvent.POST_TOOL_USE and agg.updated_tool_output is not None:
|
|
kwargs = dict(kwargs)
|
|
kwargs["tool_result"] = agg.updated_tool_output
|
|
|
|
if is_pre_tool and (agg.blocking_error or agg.permission_behavior == "deny"):
|
|
break
|
|
finally:
|
|
self._drain_once_hooks()
|
|
|
|
if context is not None:
|
|
await context.emit_event("hook_complete", {
|
|
"hook_event": event.value,
|
|
"hook_name": hook_name,
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool hook execution functions — OpenSpace/services/tools/toolHooks.ts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def run_pre_tool_use_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
tool_name: str,
|
|
tool_input: dict[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext | None = None,
|
|
) -> AsyncGenerator[PreToolHookYield, None]:
|
|
"""Execute PreToolUse hooks and yield typed results.
|
|
|
|
Implementation: ``runPreToolUseHooks`` in ``services/tools/toolHooks.ts:413``.
|
|
|
|
OpenSpace yield types → OS PreToolHookYield types:
|
|
'message' → type="message"
|
|
'hookPermissionResult' → type="hook_permission_result"
|
|
'hookUpdatedInput' → type="hook_updated_input"
|
|
'preventContinuation' → type="prevent_continuation"
|
|
'stopReason' → type="stop_reason"
|
|
'additionalContext' → type="additional_context"
|
|
'stop' → type="stop"
|
|
|
|
OpenSpace branching logic per yielded AggregatedHookResult:
|
|
1. result.message → yield {type: 'message', message}
|
|
2. result.blockingError → yield {type: 'hookPermissionResult',
|
|
hookPermissionResult: {behavior: 'deny', message: denialMessage, ...}}
|
|
3. result.preventContinuation → yield {type: 'preventContinuation'}
|
|
+ optional {type: 'stopReason'}
|
|
4. result.permissionBehavior defined:
|
|
- 'allow' → yield {type: 'hookPermissionResult', ..., behavior: 'allow',
|
|
updatedInput: result.updatedInput}
|
|
- 'ask' → yield {type: 'hookPermissionResult', ..., behavior: 'ask',
|
|
message: reason}
|
|
- 'deny' → yield {type: 'hookPermissionResult', ..., behavior: 'deny',
|
|
message: reason}
|
|
5. result.updatedInput without permissionBehavior → yield passthrough
|
|
{type: 'hookUpdatedInput', updatedInput}
|
|
6. result.additionalContexts → yield {type: 'additionalContext', message}
|
|
7. aborted → yield cancelled message + {type: 'stop'}
|
|
"""
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.PRE_TOOL_USE, tool_name
|
|
):
|
|
return
|
|
|
|
abort_event = context.abort_event if context else None
|
|
|
|
async for result in hook_registry.execute_hooks(
|
|
HookEvent.PRE_TOOL_USE,
|
|
tool_name,
|
|
hook_kwargs={
|
|
"tool_name": tool_name,
|
|
"tool_input": tool_input,
|
|
"tool_use_id": tool_use_id,
|
|
"context": context,
|
|
},
|
|
context=context,
|
|
abort_event=abort_event,
|
|
):
|
|
if result.message:
|
|
yield PreToolHookYield(type="message", message=result.message)
|
|
|
|
if result.blocking_error:
|
|
denial_message = get_pre_tool_hook_blocking_message(
|
|
f"PreToolUse:{tool_name}", result.blocking_error,
|
|
)
|
|
yield PreToolHookYield(
|
|
type="hook_permission_result",
|
|
hook_permission_result={
|
|
"behavior": "deny",
|
|
"message": denial_message,
|
|
"decision_reason": {
|
|
"type": "hook",
|
|
"hook_name": f"PreToolUse:{tool_name}",
|
|
"reason": denial_message,
|
|
},
|
|
},
|
|
)
|
|
|
|
if result.prevent_continuation:
|
|
yield PreToolHookYield(type="prevent_continuation")
|
|
if result.stop_reason:
|
|
yield PreToolHookYield(
|
|
type="stop_reason", stop_reason=result.stop_reason,
|
|
)
|
|
|
|
if result.permission_behavior is not None:
|
|
hook_name = f"PreToolUse:{tool_name}"
|
|
reason = result.hook_permission_decision_reason
|
|
decision_reason = {
|
|
"type": "hook",
|
|
"hook_name": hook_name,
|
|
"hook_source": result.hook_source,
|
|
"reason": reason,
|
|
}
|
|
if result.permission_behavior == "allow":
|
|
yield PreToolHookYield(
|
|
type="hook_permission_result",
|
|
hook_permission_result={
|
|
"behavior": "allow",
|
|
"updated_input": result.updated_input,
|
|
"decision_reason": decision_reason,
|
|
},
|
|
)
|
|
elif result.permission_behavior == "ask":
|
|
yield PreToolHookYield(
|
|
type="hook_permission_result",
|
|
hook_permission_result={
|
|
"behavior": "ask",
|
|
"updated_input": result.updated_input,
|
|
"message": reason or f"Hook {hook_name} asks for permission",
|
|
"decision_reason": decision_reason,
|
|
},
|
|
)
|
|
elif result.permission_behavior == "deny":
|
|
yield PreToolHookYield(
|
|
type="hook_permission_result",
|
|
hook_permission_result={
|
|
"behavior": "deny",
|
|
"message": reason or f"Hook {hook_name} denied this tool",
|
|
"decision_reason": decision_reason,
|
|
},
|
|
)
|
|
|
|
if result.updated_input and result.permission_behavior is None:
|
|
yield PreToolHookYield(
|
|
type="hook_updated_input",
|
|
updated_input=result.updated_input,
|
|
)
|
|
|
|
if result.additional_contexts:
|
|
yield PreToolHookYield(
|
|
type="additional_context",
|
|
message={
|
|
"role": "system",
|
|
"content": "\n".join(result.additional_contexts),
|
|
"_meta": {
|
|
"type": "hook_additional_context",
|
|
"hook_name": f"PreToolUse:{tool_name}",
|
|
"hook_event": "PreToolUse",
|
|
},
|
|
},
|
|
)
|
|
|
|
if abort_event is not None and abort_event.is_set():
|
|
yield PreToolHookYield(
|
|
type="message",
|
|
message=_make_hook_cancelled_message(
|
|
f"PreToolUse:{tool_name}", HookEvent.PRE_TOOL_USE,
|
|
),
|
|
)
|
|
yield PreToolHookYield(type="stop")
|
|
|
|
|
|
async def run_post_tool_use_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
tool_name: str,
|
|
tool_input: dict[str, Any],
|
|
tool_result: Any,
|
|
tool_use_id: str,
|
|
context: ToolUseContext | None = None,
|
|
*,
|
|
post_tool_hook_state: PostToolHookRuntimeState | None = None,
|
|
) -> AsyncGenerator[AggregatedHookResult, None]:
|
|
"""Execute PostToolUse hooks.
|
|
|
|
Implementation: ``runPostToolUseHooks`` in ``services/tools/toolHooks.ts:44``.
|
|
|
|
OpenSpace branching per AggregatedHookResult:
|
|
1. hook_cancelled attachment → yield cancelled message
|
|
2. result.message (skip hook_blocking_error duplicates) → yield
|
|
3. result.blockingError → yield hook_blocking_error attachment
|
|
4. result.preventContinuation → yield hook_stopped_continuation + return
|
|
5. result.additionalContexts → yield hook_additional_context attachment
|
|
6. result.updatedMCPToolOutput (MCP only) → yield {updatedMCPToolOutput}
|
|
|
|
``post_tool_hook_state`` is the explicit per-call runtime contract used by
|
|
production post-tool hooks. Callers should pass it directly rather than
|
|
mutating ``ToolUseContext``.
|
|
"""
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.POST_TOOL_USE, tool_name
|
|
):
|
|
return
|
|
|
|
abort_event = context.abort_event if context else None
|
|
|
|
async for result in hook_registry.execute_hooks(
|
|
HookEvent.POST_TOOL_USE,
|
|
tool_name,
|
|
hook_kwargs={
|
|
"tool_name": tool_name,
|
|
"tool_input": tool_input,
|
|
"tool_result": tool_result,
|
|
"tool_use_id": tool_use_id,
|
|
"context": context,
|
|
"post_tool_hook_state": post_tool_hook_state,
|
|
},
|
|
context=context,
|
|
abort_event=abort_event,
|
|
):
|
|
if result.message:
|
|
yield result
|
|
|
|
if result.blocking_error:
|
|
yield AggregatedHookResult(
|
|
message={
|
|
"role": "system",
|
|
"content": f"Hook blocking error: {result.blocking_error.blocking_error}",
|
|
"_meta": {
|
|
"type": "hook_blocking_error",
|
|
"hook_name": f"PostToolUse:{tool_name}",
|
|
"tool_use_id": tool_use_id,
|
|
"hook_event": "PostToolUse",
|
|
"blocking_error": result.blocking_error.blocking_error,
|
|
},
|
|
},
|
|
)
|
|
|
|
if result.prevent_continuation:
|
|
yield AggregatedHookResult(
|
|
prevent_continuation=True,
|
|
stop_reason=result.stop_reason or "Execution stopped by PostToolUse hook",
|
|
message={
|
|
"role": "system",
|
|
"content": result.stop_reason or "Execution stopped by PostToolUse hook",
|
|
"_meta": {
|
|
"type": "hook_stopped_continuation",
|
|
"hook_name": f"PostToolUse:{tool_name}",
|
|
"hook_event": "PostToolUse",
|
|
},
|
|
},
|
|
)
|
|
return
|
|
|
|
if result.additional_contexts:
|
|
yield AggregatedHookResult(
|
|
additional_contexts=result.additional_contexts,
|
|
message={
|
|
"role": "system",
|
|
"content": "\n".join(result.additional_contexts),
|
|
"_meta": {
|
|
"type": "hook_additional_context",
|
|
"hook_name": f"PostToolUse:{tool_name}",
|
|
"hook_event": "PostToolUse",
|
|
},
|
|
},
|
|
)
|
|
|
|
if result.updated_tool_output is not None:
|
|
yield AggregatedHookResult(updated_tool_output=result.updated_tool_output)
|
|
|
|
|
|
async def run_post_tool_use_failure_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
tool_name: str,
|
|
tool_input: dict[str, Any],
|
|
error: str,
|
|
tool_use_id: str,
|
|
is_interrupt: bool = False,
|
|
context: ToolUseContext | None = None,
|
|
) -> AsyncGenerator[AggregatedHookResult, None]:
|
|
"""Execute PostToolUseFailure hooks.
|
|
|
|
Implementation: ``runPostToolUseFailureHooks`` in
|
|
``services/tools/toolHooks.ts:185``.
|
|
|
|
Same branching as ``run_post_tool_use_hooks`` minus
|
|
``preventContinuation`` and ``updatedMCPToolOutput``.
|
|
"""
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.POST_TOOL_USE_FAILURE, tool_name
|
|
):
|
|
return
|
|
|
|
abort_event = context.abort_event if context else None
|
|
|
|
async for result in hook_registry.execute_hooks(
|
|
HookEvent.POST_TOOL_USE_FAILURE,
|
|
tool_name,
|
|
hook_kwargs={
|
|
"tool_name": tool_name,
|
|
"tool_input": tool_input,
|
|
"error": error,
|
|
"tool_use_id": tool_use_id,
|
|
"is_interrupt": is_interrupt,
|
|
"context": context,
|
|
},
|
|
context=context,
|
|
abort_event=abort_event,
|
|
):
|
|
if result.message:
|
|
yield result
|
|
|
|
if result.blocking_error:
|
|
yield AggregatedHookResult(
|
|
message={
|
|
"role": "system",
|
|
"content": f"Hook blocking error: {result.blocking_error.blocking_error}",
|
|
"_meta": {
|
|
"type": "hook_blocking_error",
|
|
"hook_name": f"PostToolUseFailure:{tool_name}",
|
|
"tool_use_id": tool_use_id,
|
|
"hook_event": "PostToolUseFailure",
|
|
"blocking_error": result.blocking_error.blocking_error,
|
|
},
|
|
},
|
|
)
|
|
|
|
if result.additional_contexts:
|
|
yield AggregatedHookResult(
|
|
additional_contexts=result.additional_contexts,
|
|
message={
|
|
"role": "system",
|
|
"content": "\n".join(result.additional_contexts),
|
|
"_meta": {
|
|
"type": "hook_additional_context",
|
|
"hook_name": f"PostToolUseFailure:{tool_name}",
|
|
"hook_event": "PostToolUseFailure",
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Permission hook resolution — OpenSpace/services/tools/toolHooks.ts:300-412
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def resolve_hook_permission_decision(
|
|
hook_permission_result: dict[str, Any] | None,
|
|
tool_name: str,
|
|
tool_input: dict[str, Any],
|
|
context: ToolUseContext,
|
|
check_permission_fn: Callable[..., Awaitable[dict[str, Any]]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Resolve a PreToolUse hook's permission result into a final decision.
|
|
|
|
Implementation: ``resolveHookPermissionDecision`` in
|
|
``services/tools/toolHooks.ts:300-412``.
|
|
|
|
OpenSpace branching logic:
|
|
hookResult.behavior == 'allow':
|
|
→ requiresInteraction && !interactionSatisfied → canUseTool
|
|
→ requireCanUseTool → canUseTool
|
|
→ checkRuleBasedPermissions:
|
|
null → approve (hook allow takes effect)
|
|
deny → deny (rule overrides hook)
|
|
ask → canUseTool (rule requires prompt)
|
|
hookResult.behavior == 'deny':
|
|
→ deny
|
|
hookResult.behavior == 'ask' or undefined:
|
|
→ canUseTool(forceDecision if 'ask')
|
|
|
|
Permission hooks delegate to ``check_permission_fn`` when available so
|
|
normal settings/rule checks still run after hook decisions.
|
|
"""
|
|
if hook_permission_result is None:
|
|
if check_permission_fn is not None:
|
|
return await check_permission_fn(tool_name, tool_input, context)
|
|
return {"behavior": "allow", "input": tool_input}
|
|
|
|
behavior = hook_permission_result.get("behavior")
|
|
updated_input = hook_permission_result.get("updated_input", tool_input)
|
|
|
|
if behavior == "allow":
|
|
if check_permission_fn is not None:
|
|
pe = context.permission_engine
|
|
if pe is not None and hasattr(pe, "check_rule_based"):
|
|
rule_check = await pe.check_rule_based(tool_name, updated_input, context)
|
|
if rule_check is not None:
|
|
if rule_check.get("behavior") == "deny":
|
|
return {"behavior": "deny", "input": updated_input, **rule_check}
|
|
if rule_check.get("behavior") == "ask":
|
|
return await check_permission_fn(
|
|
tool_name, updated_input, context,
|
|
)
|
|
return {"behavior": "allow", "input": updated_input}
|
|
|
|
if behavior == "deny":
|
|
return {
|
|
"behavior": "deny",
|
|
"input": tool_input,
|
|
"message": hook_permission_result.get("message", "Denied by hook"),
|
|
}
|
|
|
|
# 'ask' or no behavior → normal permission flow
|
|
if check_permission_fn is not None:
|
|
force_decision = hook_permission_result if behavior == "ask" else None
|
|
return await check_permission_fn(
|
|
tool_name,
|
|
updated_input if behavior == "ask" else tool_input,
|
|
context,
|
|
force_decision=force_decision,
|
|
)
|
|
return {"behavior": "allow", "input": tool_input}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Compact hooks — OpenSpace/utils/hooks.ts executePreCompactHooks / executePostCompactHooks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def run_pre_compact_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
compact_data: dict[str, Any],
|
|
context: ToolUseContext | None = None,
|
|
) -> CompactHookResult:
|
|
"""Execute PreCompact hooks.
|
|
|
|
Implementation: ``executePreCompactHooks`` in ``utils/hooks.ts`` (~L4100).
|
|
OpenSpace returns: ``{newCustomInstructions?, userDisplayMessage?}``
|
|
"""
|
|
result = CompactHookResult()
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.PRE_COMPACT
|
|
):
|
|
return result
|
|
|
|
async for agg in hook_registry.execute_hooks(
|
|
HookEvent.PRE_COMPACT,
|
|
hook_kwargs={"compact_data": compact_data, "context": context},
|
|
context=context,
|
|
):
|
|
if agg.additional_contexts:
|
|
result.new_custom_instructions = "\n".join(agg.additional_contexts)
|
|
if agg.message and isinstance(agg.message.get("content"), str):
|
|
if result.user_display_message:
|
|
result.user_display_message += "\n" + agg.message["content"]
|
|
else:
|
|
result.user_display_message = agg.message["content"]
|
|
|
|
return result
|
|
|
|
|
|
async def run_post_compact_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
compact_data: dict[str, Any],
|
|
context: ToolUseContext | None = None,
|
|
) -> CompactHookResult:
|
|
"""Execute PostCompact hooks.
|
|
|
|
Implementation: ``executePostCompactHooks`` in ``utils/hooks.ts`` (~L4150).
|
|
OpenSpace returns: ``{userDisplayMessage?}``
|
|
"""
|
|
result = CompactHookResult()
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.POST_COMPACT
|
|
):
|
|
return result
|
|
|
|
async for agg in hook_registry.execute_hooks(
|
|
HookEvent.POST_COMPACT,
|
|
hook_kwargs={"compact_data": compact_data, "context": context},
|
|
context=context,
|
|
):
|
|
if agg.message and isinstance(agg.message.get("content"), str):
|
|
if result.user_display_message:
|
|
result.user_display_message += "\n" + agg.message["content"]
|
|
else:
|
|
result.user_display_message = agg.message["content"]
|
|
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notification / misc hooks — OpenSpace/utils/hooks.ts executeNotificationHooks etc.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def run_notification_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
message: str,
|
|
*,
|
|
title: str | None = None,
|
|
notification_type: str = "info",
|
|
context: ToolUseContext | None = None,
|
|
) -> None:
|
|
"""Execute Notification hooks (fire-and-forget).
|
|
|
|
Implementation: ``executeNotificationHooks`` in ``utils/hooks.ts:3570``.
|
|
"""
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.NOTIFICATION
|
|
):
|
|
return
|
|
|
|
async for _ in hook_registry.execute_hooks(
|
|
HookEvent.NOTIFICATION,
|
|
hook_kwargs={
|
|
"message": message,
|
|
"title": title,
|
|
"notification_type": notification_type,
|
|
"context": context,
|
|
},
|
|
context=context,
|
|
):
|
|
pass # fire-and-forget: consume but don't act on results
|
|
|
|
|
|
async def run_session_start_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
*,
|
|
source: Literal["startup", "resume", "clear", "compact"] = "startup",
|
|
context: ToolUseContext | None = None,
|
|
session_id: str | None = None,
|
|
agent_type: str | None = None,
|
|
model: str | None = None,
|
|
) -> LifecycleHookResult:
|
|
"""Execute SessionStart hooks before the first model call.
|
|
|
|
Implementation: ``processSessionStartHooks`` / ``executeSessionStartHooks``.
|
|
Blocking errors are reported as messages but do not abort the session, which
|
|
matches OpenSpace's SessionStart behavior.
|
|
"""
|
|
result = LifecycleHookResult()
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.SESSION_START, source
|
|
):
|
|
return result
|
|
|
|
async for agg in hook_registry.execute_hooks(
|
|
HookEvent.SESSION_START,
|
|
source,
|
|
hook_kwargs={
|
|
"source": source,
|
|
"session_id": session_id,
|
|
"agent_type": agent_type,
|
|
"model": model,
|
|
"context": context,
|
|
},
|
|
context=context,
|
|
):
|
|
_collect_lifecycle_hook_result(
|
|
result,
|
|
agg,
|
|
hook_name="SessionStart",
|
|
blocking_is_terminal=False,
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
async def run_user_prompt_submit_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
prompt: str,
|
|
*,
|
|
context: ToolUseContext | None = None,
|
|
permission_mode: str | None = None,
|
|
) -> LifecycleHookResult:
|
|
"""Execute UserPromptSubmit hooks after slash parsing and before LLM input."""
|
|
result = LifecycleHookResult()
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.USER_PROMPT_SUBMIT
|
|
):
|
|
return result
|
|
|
|
async for agg in hook_registry.execute_hooks(
|
|
HookEvent.USER_PROMPT_SUBMIT,
|
|
hook_kwargs={
|
|
"prompt": prompt,
|
|
"permission_mode": permission_mode,
|
|
"context": context,
|
|
},
|
|
context=context,
|
|
):
|
|
_collect_lifecycle_hook_result(
|
|
result,
|
|
agg,
|
|
hook_name="UserPromptSubmit",
|
|
blocking_is_terminal=True,
|
|
)
|
|
if result.blocking_errors or result.prevent_continuation:
|
|
break
|
|
|
|
return result
|
|
|
|
|
|
def _collect_lifecycle_hook_result(
|
|
result: LifecycleHookResult,
|
|
agg: AggregatedHookResult,
|
|
*,
|
|
hook_name: str,
|
|
blocking_is_terminal: bool,
|
|
) -> None:
|
|
if agg.message:
|
|
result.messages.append(agg.message)
|
|
if agg.additional_contexts:
|
|
result.additional_contexts.extend(str(item) for item in agg.additional_contexts)
|
|
if agg.initial_user_message:
|
|
result.initial_user_message = agg.initial_user_message
|
|
if agg.watch_paths:
|
|
result.watch_paths.extend(str(path) for path in agg.watch_paths)
|
|
|
|
if agg.blocking_error:
|
|
result.blocking_errors.append(
|
|
{
|
|
"role": "system",
|
|
"content": _format_lifecycle_blocking_error(hook_name, agg.blocking_error),
|
|
"_meta": {
|
|
"type": "hook_blocking_error",
|
|
"hook_event": hook_name,
|
|
"command": agg.blocking_error.command,
|
|
},
|
|
}
|
|
)
|
|
if blocking_is_terminal:
|
|
result.prevent_continuation = True
|
|
result.stop_reason = agg.stop_reason or agg.blocking_error.blocking_error
|
|
if agg.prevent_continuation:
|
|
result.prevent_continuation = True
|
|
result.stop_reason = agg.stop_reason or result.stop_reason
|
|
|
|
|
|
def _format_lifecycle_blocking_error(
|
|
hook_name: str,
|
|
blocking_error: HookBlockingError,
|
|
) -> str:
|
|
command = f' from command: "{blocking_error.command}"' if blocking_error.command else ""
|
|
return f"{hook_name} hook blocking error{command}: {blocking_error.blocking_error}"
|
|
|
|
|
|
async def run_session_end_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
reason: str,
|
|
context: ToolUseContext | None = None,
|
|
) -> None:
|
|
"""Execute SessionEnd hooks for active session-scoped callbacks."""
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.SESSION_END
|
|
):
|
|
return
|
|
|
|
async for _ in hook_registry.execute_hooks(
|
|
HookEvent.SESSION_END,
|
|
hook_kwargs={
|
|
"reason": reason,
|
|
"context": context,
|
|
},
|
|
context=context,
|
|
):
|
|
pass
|
|
|
|
|
|
async def run_stop_failure_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
error: Any,
|
|
*,
|
|
error_details: str | None = None,
|
|
last_assistant_message: str | None = None,
|
|
context: ToolUseContext | None = None,
|
|
) -> None:
|
|
"""Execute StopFailure hooks after agent-loop failure."""
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.STOP_FAILURE
|
|
):
|
|
return
|
|
|
|
async for _ in hook_registry.execute_hooks(
|
|
HookEvent.STOP_FAILURE,
|
|
hook_kwargs={
|
|
"error": error,
|
|
"error_details": error_details,
|
|
"last_assistant_message": last_assistant_message,
|
|
"context": context,
|
|
},
|
|
context=context,
|
|
):
|
|
pass
|
|
|
|
|
|
async def run_permission_denied_hooks(
|
|
hook_registry: HookRegistry | None,
|
|
tool_name: str,
|
|
tool_input: dict[str, Any],
|
|
reason: str,
|
|
tool_use_id: str,
|
|
context: ToolUseContext | None = None,
|
|
) -> bool:
|
|
"""Execute PermissionDenied hooks and report whether the model may retry.
|
|
|
|
Implementation: ``executePermissionDeniedHooks`` in ``utils/hooks.ts:3529``.
|
|
"""
|
|
if hook_registry is None or not hook_registry.has_hook_for_event(
|
|
HookEvent.PERMISSION_DENIED, tool_name,
|
|
):
|
|
return False
|
|
|
|
hook_says_retry = False
|
|
async for result in hook_registry.execute_hooks(
|
|
HookEvent.PERMISSION_DENIED,
|
|
tool_name,
|
|
hook_kwargs={
|
|
"tool_name": tool_name,
|
|
"tool_input": tool_input,
|
|
"reason": reason,
|
|
"tool_use_id": tool_use_id,
|
|
"context": context,
|
|
},
|
|
context=context,
|
|
):
|
|
if result.retry:
|
|
hook_says_retry = True
|
|
|
|
return hook_says_retry
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Message helpers — OpenSpace/utils/hooks.ts getPreToolHookBlockingMessage etc.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_pre_tool_hook_blocking_message(
|
|
hook_name: str,
|
|
blocking_error: HookBlockingError,
|
|
) -> str:
|
|
"""Format a blocking error message for a PreToolUse hook.
|
|
|
|
Implementation: ``getPreToolHookBlockingMessage`` in ``utils/hooks.ts``.
|
|
OpenSpace format: ``"Hook {hookName} blocked: {blockingError}"``
|
|
"""
|
|
return (
|
|
f"Hook {hook_name} blocked this action: "
|
|
f"{blocking_error.blocking_error}"
|
|
)
|
|
|
|
|
|
def get_stop_hook_message(blocking_error: HookBlockingError) -> str:
|
|
"""Format a blocking error message for a Stop hook.
|
|
|
|
Implementation: ``getStopHookMessage`` in ``utils/hooks.ts``.
|
|
"""
|
|
return f"Stop hook feedback:\n{blocking_error.blocking_error}"
|
|
|
|
|
|
def get_teammate_idle_hook_message(blocking_error: HookBlockingError) -> str:
|
|
"""Implementation: ``getTeammateIdleHookMessage`` in ``utils/hooks.ts``."""
|
|
return f"TeammateIdle hook feedback:\n{blocking_error.blocking_error}"
|
|
|
|
|
|
def get_task_completed_hook_message(blocking_error: HookBlockingError) -> str:
|
|
"""Implementation: ``getTaskCompletedHookMessage`` in ``utils/hooks.ts``."""
|
|
return f"TaskCompleted hook feedback:\n{blocking_error.blocking_error}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _aggregate_single(
|
|
single: HookResult,
|
|
hook_name: str,
|
|
event: HookEvent,
|
|
) -> AggregatedHookResult:
|
|
"""Convert a single HookResult to AggregatedHookResult.
|
|
|
|
Implementation: The aggregation logic inside ``executeHooks`` generator loop
|
|
(``utils/hooks.ts`` ~L2143-2200) that merges individual hook results.
|
|
|
|
OpenSpace aggregation rules:
|
|
- permissionBehavior merges as deny > ask > allow > passthrough
|
|
- blockingError takes the first one
|
|
- additionalContexts accumulate
|
|
- updatedInput takes the last one
|
|
"""
|
|
agg = AggregatedHookResult(
|
|
blocking_error=single.blocking_error,
|
|
prevent_continuation=single.prevent_continuation,
|
|
stop_reason=single.stop_reason,
|
|
hook_permission_decision_reason=single.hook_permission_decision_reason,
|
|
permission_behavior=single.permission_behavior,
|
|
initial_user_message=single.initial_user_message,
|
|
updated_input=single.updated_input,
|
|
updated_permissions=single.updated_permissions,
|
|
updated_tool_output=single.updated_tool_output,
|
|
watch_paths=single.watch_paths,
|
|
elicitation_response=single.elicitation_response,
|
|
elicitation_result_response=single.elicitation_result_response,
|
|
retry=single.retry,
|
|
)
|
|
|
|
if single.additional_context:
|
|
agg.additional_contexts = [single.additional_context]
|
|
|
|
if single.message:
|
|
agg.message = single.message
|
|
elif single.system_message:
|
|
agg.message = {
|
|
"role": "system",
|
|
"content": single.system_message,
|
|
"_meta": {
|
|
"type": "hook_system_message",
|
|
"hook_name": hook_name,
|
|
"hook_event": event.value,
|
|
},
|
|
}
|
|
elif single.blocking_error:
|
|
agg.message = {
|
|
"role": "system",
|
|
"content": f"Hook blocked: {single.blocking_error.blocking_error}",
|
|
"_meta": {
|
|
"type": "hook_blocking_error",
|
|
"hook_name": hook_name,
|
|
"hook_event": event.value,
|
|
"blocking_error": single.blocking_error.blocking_error,
|
|
},
|
|
}
|
|
|
|
return agg
|
|
|
|
|
|
def _make_hook_cancelled_message(
|
|
hook_name: str,
|
|
event: HookEvent,
|
|
) -> dict[str, Any]:
|
|
"""Create a cancellation message for UI.
|
|
|
|
Implementation: ``createAttachmentMessage({type: 'hook_cancelled', ...})``
|
|
in ``services/tools/toolHooks.ts``.
|
|
"""
|
|
return {
|
|
"role": "system",
|
|
"content": f"Hook {hook_name} was cancelled",
|
|
"_meta": {
|
|
"type": "hook_cancelled",
|
|
"hook_name": hook_name,
|
|
"hook_event": event.value,
|
|
},
|
|
}
|
|
|
|
|
|
def _make_hook_error_message(
|
|
hook_name: str,
|
|
event: HookEvent,
|
|
error: str,
|
|
) -> dict[str, Any]:
|
|
"""Create an error message for a failed hook.
|
|
|
|
Implementation: ``createAttachmentMessage({type: 'hook_error_during_execution', ...})``
|
|
in ``services/tools/toolHooks.ts``.
|
|
"""
|
|
return {
|
|
"role": "system",
|
|
"content": f"Hook {hook_name} error: {error}",
|
|
"_meta": {
|
|
"type": "hook_error_during_execution",
|
|
"hook_name": hook_name,
|
|
"hook_event": event.value,
|
|
"error": error,
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Built-in hooks — registered during initialization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create_max_tokens_recovery_hook() -> HookCallback:
|
|
"""Create a Stop hook that handles max_tokens truncation.
|
|
|
|
Implementation: Part of ``handleStopHooks`` in ``query/stopHooks.ts``,
|
|
specifically the logic that detects ``stop_reason === 'length'`` and
|
|
injects a recovery message.
|
|
|
|
In OpenSpace this is inline in the query loop (``query.ts``).
|
|
OS moves it to a hook for cleaner separation.
|
|
"""
|
|
|
|
async def _max_tokens_recovery(
|
|
messages: list[dict[str, Any]],
|
|
last_response: Any,
|
|
context: ToolUseContext,
|
|
**_: Any,
|
|
) -> HookResult:
|
|
stop_reason = getattr(last_response, "stop_reason", None)
|
|
if stop_reason == "length":
|
|
return HookResult(
|
|
prevent_continuation=False,
|
|
system_message=(
|
|
"Your response was truncated due to output length limits. "
|
|
"Please continue where you left off."
|
|
),
|
|
outcome="success",
|
|
)
|
|
return HookResult()
|
|
|
|
return _max_tokens_recovery
|
|
|
|
|
|
def create_permission_check_hook() -> HookCallback:
|
|
"""Create a legacy PreToolUse adapter for external permission engines.
|
|
|
|
The canonical permission path is the tool runtime's
|
|
``_resolve_permissions()`` call into ``has_permissions_to_use_tool``.
|
|
This adapter is not registered by default; callers that still inject a
|
|
custom ``context.permission_engine`` may opt in explicitly.
|
|
"""
|
|
|
|
async def _permission_check(
|
|
tool_name: str = "",
|
|
tool_input: dict[str, Any] | None = None,
|
|
tool_use_id: str = "",
|
|
context: ToolUseContext | None = None,
|
|
**_: Any,
|
|
) -> HookResult:
|
|
if context is None or context.permission_engine is None:
|
|
return HookResult()
|
|
|
|
pe = context.permission_engine
|
|
if hasattr(pe, "check_permissions"):
|
|
try:
|
|
decision = await pe.check_permissions(tool_name, tool_input or {}, context)
|
|
behavior = decision.get("behavior", "allow") if isinstance(decision, dict) else "allow"
|
|
if behavior == "deny":
|
|
return HookResult(
|
|
permission_behavior="deny",
|
|
hook_permission_decision_reason=decision.get("message", "Denied by permission engine"),
|
|
outcome="blocking",
|
|
)
|
|
if behavior == "ask":
|
|
return HookResult(
|
|
permission_behavior="ask",
|
|
hook_permission_decision_reason=decision.get("message"),
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Permission check hook error: %s", exc)
|
|
|
|
return HookResult()
|
|
|
|
return _permission_check
|
|
|
|
|
|
async def _await_if_needed(value: Any) -> Any:
|
|
if inspect.isawaitable(value):
|
|
return await value
|
|
return value
|
|
|
|
|
|
def _tool_result_success(tool_result: Any) -> bool:
|
|
if hasattr(tool_result, "is_success"):
|
|
return bool(getattr(tool_result, "is_success"))
|
|
status = getattr(tool_result, "status", None)
|
|
if status is None:
|
|
return True
|
|
return str(status).lower().endswith("success")
|
|
|
|
|
|
def _tool_result_record_payload(tool_result: Any) -> Any:
|
|
if not _tool_result_success(tool_result):
|
|
error = getattr(tool_result, "error", None)
|
|
if error:
|
|
return error
|
|
return getattr(tool_result, "content", tool_result)
|
|
|
|
|
|
def _tool_result_metadata(tool_result: Any) -> dict[str, Any] | None:
|
|
metadata = getattr(tool_result, "metadata", None)
|
|
return metadata if isinstance(metadata, dict) else None
|
|
|
|
|
|
def _callable_accepts_var_kwargs(callback: Callable[..., Any]) -> bool:
|
|
try:
|
|
signature = inspect.signature(callback)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return any(
|
|
param.kind == inspect.Parameter.VAR_KEYWORD
|
|
for param in signature.parameters.values()
|
|
)
|
|
|
|
|
|
def create_recording_hook() -> HookCallback:
|
|
"""Create a PostToolUse hook that records tool calls to recording_manager.
|
|
|
|
DEC-020 §四 hook (2): Recording hook (PostToolUse, priority=200).
|
|
Replaces the recording logic previously hardcoded in BaseTool._auto_record_execution.
|
|
|
|
Implementation: OpenSpace records tool calls in ``toolExecution.ts`` ``runToolUse``
|
|
after tool execution completes. OS extracts recording to a hook.
|
|
"""
|
|
|
|
async def _recording(
|
|
tool_name: str = "",
|
|
tool_input: dict[str, Any] | None = None,
|
|
tool_result: Any = None,
|
|
tool_use_id: str = "",
|
|
context: ToolUseContext | None = None,
|
|
post_tool_hook_state: PostToolHookRuntimeState | None = None,
|
|
**_: Any,
|
|
) -> HookResult:
|
|
if context is None or context.recording_manager is None:
|
|
return HookResult()
|
|
|
|
rm = context.recording_manager
|
|
runtime_state = _get_post_tool_hook_state(post_tool_hook_state)
|
|
backend = runtime_state.backend if runtime_state is not None else "unknown"
|
|
server_name = None
|
|
tool = runtime_state.tool if runtime_state is not None else None
|
|
runtime_info = getattr(tool, "_runtime_info", None)
|
|
if runtime_info is not None:
|
|
server_name = getattr(runtime_info, "server_name", None)
|
|
|
|
try:
|
|
if hasattr(rm, "record_tool_execution"):
|
|
await _await_if_needed(rm.record_tool_execution(
|
|
tool_name=tool_name,
|
|
backend=backend,
|
|
parameters=tool_input or {},
|
|
result=_tool_result_record_payload(tool_result),
|
|
server_name=server_name,
|
|
is_success=_tool_result_success(tool_result),
|
|
metadata={
|
|
**(_tool_result_metadata(tool_result) or {}),
|
|
"tool_use_id": tool_use_id,
|
|
},
|
|
))
|
|
except Exception as exc:
|
|
logger.warning("Recording hook error (non-fatal): %s", exc)
|
|
|
|
return HookResult()
|
|
|
|
return _recording
|
|
|
|
|
|
def create_quality_hook() -> HookCallback:
|
|
"""Create a PostToolUse hook that updates ToolQualityManager statistics.
|
|
|
|
DEC-020 §四 hook (3): Quality hook (PostToolUse, priority=210).
|
|
Feeds tool execution outcomes into quality tracking for evolution triggers.
|
|
"""
|
|
|
|
async def _quality_tracking(
|
|
tool_name: str = "",
|
|
tool_input: dict[str, Any] | None = None,
|
|
tool_result: Any = None,
|
|
tool_use_id: str = "",
|
|
context: ToolUseContext | None = None,
|
|
post_tool_hook_state: PostToolHookRuntimeState | None = None,
|
|
**_: Any,
|
|
) -> HookResult:
|
|
if context is None or context.quality_manager is None:
|
|
return HookResult()
|
|
|
|
qm = context.quality_manager
|
|
recorded_ids = getattr(context, "quality_recorded_tool_use_ids", None)
|
|
if isinstance(recorded_ids, set) and tool_use_id in recorded_ids:
|
|
return HookResult()
|
|
runtime_state = _get_post_tool_hook_state(post_tool_hook_state)
|
|
tool = runtime_state.tool if runtime_state is not None else None
|
|
execution_time_ms = (
|
|
runtime_state.execution_time_ms if runtime_state is not None else 0.0
|
|
)
|
|
quality_record = None
|
|
try:
|
|
if hasattr(qm, "record_execution"):
|
|
if tool is not None and _is_tool_result_like(tool_result):
|
|
await _await_if_needed(
|
|
qm.record_execution(tool, tool_result, execution_time_ms)
|
|
)
|
|
get_record = getattr(qm, "get_record", None)
|
|
if callable(get_record):
|
|
quality_record = get_record(tool)
|
|
if isinstance(recorded_ids, set):
|
|
recorded_ids.add(tool_use_id)
|
|
elif _callable_accepts_var_kwargs(qm.record_execution):
|
|
await _await_if_needed(qm.record_execution(
|
|
tool_name=tool_name,
|
|
success=_tool_result_success(tool_result),
|
|
result=tool_result,
|
|
tool_use_id=tool_use_id,
|
|
))
|
|
if isinstance(recorded_ids, set):
|
|
recorded_ids.add(tool_use_id)
|
|
else:
|
|
logger.debug(
|
|
"Quality hook skipped for %s: missing tool/runtime ToolResult",
|
|
tool_name,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Quality hook error (non-fatal): %s", exc)
|
|
if quality_record is not None:
|
|
await _emit_tool_quality_evidence(
|
|
context,
|
|
qm,
|
|
quality_record,
|
|
tool_use_id=tool_use_id,
|
|
execution_time_ms=execution_time_ms,
|
|
)
|
|
|
|
return HookResult()
|
|
|
|
return _quality_tracking
|
|
|
|
|
|
async def _emit_tool_quality_evidence(
|
|
context: ToolUseContext,
|
|
quality_manager: Any,
|
|
record: Any,
|
|
*,
|
|
tool_use_id: str,
|
|
execution_time_ms: float,
|
|
source: str = "quality_tracking_hook",
|
|
) -> None:
|
|
payload = _tool_quality_evidence_payload(
|
|
record,
|
|
quality_manager=quality_manager,
|
|
tool_use_id=tool_use_id,
|
|
execution_time_ms=execution_time_ms,
|
|
source=source,
|
|
)
|
|
if not payload:
|
|
return
|
|
payload.update(
|
|
{
|
|
"session_id": getattr(context, "session_id", None),
|
|
"task_id": getattr(context, "task_id", None),
|
|
"agent_id": getattr(context, "agent_id", None),
|
|
"parent_task_id": getattr(context, "parent_task_id", None),
|
|
"current_iteration": getattr(context, "current_iteration", None),
|
|
}
|
|
)
|
|
payload.update(active_skill_scope_payload(context))
|
|
await context.emit_event("tool_quality_recorded", payload)
|
|
|
|
|
|
def _tool_quality_evidence_payload(
|
|
record: Any,
|
|
*,
|
|
quality_manager: Any,
|
|
tool_use_id: str,
|
|
execution_time_ms: float,
|
|
source: str = "quality_tracking_hook",
|
|
) -> dict[str, Any] | None:
|
|
tool_key = getattr(record, "tool_key", None)
|
|
if not tool_key:
|
|
return None
|
|
payload = {
|
|
"tool_key": tool_key,
|
|
"backend": getattr(record, "backend", None),
|
|
"server": getattr(record, "server", None),
|
|
"tool_name": getattr(record, "tool_name", None),
|
|
"total_calls": getattr(record, "total_calls", None),
|
|
"success_count": getattr(record, "success_count", None),
|
|
"recent_success_rate": getattr(record, "recent_success_rate", None),
|
|
"last_updated": _isoformat_or_none(getattr(record, "last_updated", None)),
|
|
"tool_use_id": tool_use_id,
|
|
"execution_time_ms": execution_time_ms,
|
|
"source": source,
|
|
}
|
|
history: list[dict[str, Any]] = []
|
|
store = getattr(quality_manager, "_store", None)
|
|
if store is not None and hasattr(store, "load_recent_history"):
|
|
try:
|
|
history = list(store.load_recent_history(str(tool_key), limit=5))
|
|
if history:
|
|
_annotate_current_quality_history_item(
|
|
history,
|
|
index=0,
|
|
tool_use_id=tool_use_id,
|
|
source=source,
|
|
)
|
|
except Exception:
|
|
history = []
|
|
if not history:
|
|
for item in list(getattr(record, "recent_executions", []) or [])[-5:]:
|
|
history.append(
|
|
{
|
|
"timestamp": _isoformat_or_none(getattr(item, "timestamp", None)),
|
|
"success": getattr(item, "success", None),
|
|
"execution_time_ms": getattr(item, "execution_time_ms", None),
|
|
"error_message": getattr(item, "error_message", None),
|
|
}
|
|
)
|
|
if history:
|
|
_annotate_current_quality_history_item(
|
|
history,
|
|
index=len(history) - 1,
|
|
tool_use_id=tool_use_id,
|
|
source=source,
|
|
)
|
|
payload["history"] = history
|
|
return payload
|
|
|
|
|
|
def _annotate_current_quality_history_item(
|
|
history: list[dict[str, Any]],
|
|
*,
|
|
index: int,
|
|
tool_use_id: str,
|
|
source: str,
|
|
) -> None:
|
|
if not history or not tool_use_id:
|
|
return
|
|
try:
|
|
item = history[index]
|
|
except IndexError:
|
|
return
|
|
if not isinstance(item, dict):
|
|
return
|
|
item.setdefault("tool_use_id", tool_use_id)
|
|
item.setdefault("source", source)
|
|
|
|
|
|
def _isoformat_or_none(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
if hasattr(value, "isoformat"):
|
|
return value.isoformat()
|
|
return str(value)
|
|
|
|
|
|
def _get_post_tool_hook_state(
|
|
post_tool_hook_state: PostToolHookRuntimeState | None,
|
|
) -> PostToolHookRuntimeState | None:
|
|
"""Return optional producer-side runtime state for post-tool hooks.
|
|
|
|
The runtime state is passed explicitly per hook invocation. Post-tool hooks
|
|
should not read per-call state from ``ToolUseContext``.
|
|
"""
|
|
return post_tool_hook_state
|
|
|
|
|
|
def _normalize_visual_tool_call(
|
|
runtime_state: PostToolHookRuntimeState,
|
|
) -> Any:
|
|
"""Return a GUI VisualAnalysisHook-compatible tool call object."""
|
|
runtime_tool_call = runtime_state.tool_call
|
|
if hasattr(runtime_tool_call, "function"):
|
|
return runtime_tool_call
|
|
if isinstance(runtime_tool_call, Mapping):
|
|
function = runtime_tool_call.get("function")
|
|
if isinstance(function, Mapping):
|
|
return SimpleNamespace(
|
|
id=runtime_tool_call.get("id", ""),
|
|
function=SimpleNamespace(
|
|
name=function.get("name", ""),
|
|
arguments=function.get("arguments", {}),
|
|
),
|
|
)
|
|
raise TypeError("post_tool_hook_state.tool_call must expose function arguments")
|
|
|
|
|
|
def _is_tool_result_like(value: Any) -> bool:
|
|
"""Return whether ``value`` looks like a mutable tool result object."""
|
|
return all(
|
|
hasattr(value, attr)
|
|
for attr in ("status", "content", "metadata", "error", "execution_time")
|
|
)
|
|
|
|
|
|
def create_visual_analysis_hook(visual_hook: Any | None = None) -> HookCallback:
|
|
"""Create a PostToolUse hook for GUI visual analysis.
|
|
|
|
DEC-020 §四 hook (4) / DEC-022 §二: GUI Visual Analysis hook
|
|
(PostToolUse, GUI tools only, priority=50).
|
|
|
|
Replaces the old agent-owned visual-analysis callback path.
|
|
Runtime dependencies come from ``ToolUseContext`` and GUI backend config.
|
|
|
|
Implementation: OpenSpace does not have a separate visual analysis hook — it's
|
|
an OS-specific feature (DEC-022).
|
|
"""
|
|
if visual_hook is None:
|
|
from openspace.grounding.backends.gui.hooks import VisualAnalysisHook
|
|
|
|
visual_hook = VisualAnalysisHook()
|
|
|
|
async def _visual_analysis(
|
|
tool_name: str = "",
|
|
tool_input: dict[str, Any] | None = None,
|
|
tool_result: Any = None,
|
|
tool_use_id: str = "",
|
|
context: ToolUseContext | None = None,
|
|
post_tool_hook_state: PostToolHookRuntimeState | None = None,
|
|
**_: Any,
|
|
) -> HookResult:
|
|
runtime_state = _get_post_tool_hook_state(post_tool_hook_state)
|
|
if runtime_state is None or runtime_state.backend != "gui":
|
|
return HookResult()
|
|
|
|
if not _is_tool_result_like(tool_result):
|
|
return HookResult()
|
|
|
|
if not hasattr(visual_hook, "analyze_tool_result"):
|
|
return HookResult()
|
|
|
|
analyzed_result = await visual_hook.analyze_tool_result(
|
|
result=tool_result,
|
|
tool_name=tool_name,
|
|
tool_call=_normalize_visual_tool_call(runtime_state),
|
|
backend=runtime_state.backend,
|
|
task_description=(
|
|
getattr(context, "task_description", "")
|
|
),
|
|
context=context,
|
|
)
|
|
if analyzed_result is None:
|
|
return HookResult()
|
|
return HookResult(updated_tool_output=analyzed_result)
|
|
|
|
return _visual_analysis
|
|
|
|
|
|
def add_session_hook(
|
|
context: ToolUseContext,
|
|
event: HookEvent | str,
|
|
matcher: str,
|
|
hook: Mapping[str, Any],
|
|
*,
|
|
hook_id: str | None = None,
|
|
once: bool | None = None,
|
|
on_hook_success: Callable[..., Any] | None = None,
|
|
) -> str:
|
|
"""Add a OpenSpace JSON hook spec scoped to one ``ToolUseContext``.
|
|
|
|
Returns the generated hook id so callers can remove one hook without
|
|
clearing the entire event.
|
|
"""
|
|
|
|
hook_event = event if isinstance(event, HookEvent) else HookEvent(str(event))
|
|
session_hook_id = hook_id or f"session-hook-{uuid.uuid4().hex}"
|
|
hook_payload = dict(hook)
|
|
hook_payload[_SESSION_HOOK_ID_KEY] = session_hook_id
|
|
if once is not None:
|
|
hook_payload["once"] = bool(once)
|
|
if on_hook_success is not None:
|
|
hook_payload[_SESSION_HOOK_SUCCESS_KEY] = on_hook_success
|
|
_append_session_hook_payload(context, hook_event, matcher, hook_payload)
|
|
return session_hook_id
|
|
|
|
|
|
def add_session_function_hook(
|
|
context: ToolUseContext,
|
|
event: HookEvent | str,
|
|
matcher: str,
|
|
callback: Callable[..., Any],
|
|
error_message: str,
|
|
*,
|
|
hook_id: str | None = None,
|
|
timeout: float | None = None,
|
|
once: bool = False,
|
|
on_hook_success: Callable[..., Any] | None = None,
|
|
) -> str:
|
|
"""Add an in-memory function hook scoped to one ``ToolUseContext``.
|
|
|
|
Function hooks receive the current message list and should return ``True``
|
|
to pass or ``False`` to block with ``error_message``.
|
|
"""
|
|
|
|
if not callable(callback):
|
|
raise TypeError("callback must be callable")
|
|
hook_event = event if isinstance(event, HookEvent) else HookEvent(str(event))
|
|
session_hook_id = hook_id or f"function-hook-{uuid.uuid4().hex}"
|
|
hook: dict[str, Any] = {
|
|
"type": "function",
|
|
"id": session_hook_id,
|
|
"callback": callback,
|
|
"errorMessage": error_message,
|
|
_SESSION_HOOK_ID_KEY: session_hook_id,
|
|
}
|
|
if timeout is not None:
|
|
hook["timeout"] = timeout
|
|
if once:
|
|
hook["once"] = True
|
|
if on_hook_success is not None:
|
|
hook[_SESSION_HOOK_SUCCESS_KEY] = on_hook_success
|
|
_append_session_hook_payload(context, hook_event, matcher, hook)
|
|
return session_hook_id
|
|
|
|
|
|
def remove_session_hook(
|
|
context: ToolUseContext,
|
|
event: HookEvent | str,
|
|
hook_or_id: Mapping[str, Any] | str,
|
|
*,
|
|
matcher: str | None = None,
|
|
) -> int:
|
|
"""Remove one session hook by generated id or OpenSpace hook identity."""
|
|
|
|
hook_event = event if isinstance(event, HookEvent) else HookEvent(str(event))
|
|
return _remove_session_hook_from_hooks_map(
|
|
context.session_hooks,
|
|
hook_event,
|
|
hook_or_id,
|
|
matcher=matcher,
|
|
)
|
|
|
|
|
|
def _remove_session_hook_from_hooks_map(
|
|
session_hooks: Any,
|
|
event: HookEvent,
|
|
hook_or_id: Mapping[str, Any] | str,
|
|
*,
|
|
matcher: str | None = None,
|
|
) -> int:
|
|
if not isinstance(session_hooks, dict):
|
|
return 0
|
|
event_hooks = session_hooks.get(event.value)
|
|
if not isinstance(event_hooks, list):
|
|
return 0
|
|
|
|
removed = 0
|
|
updated_matchers: list[dict[str, Any]] = []
|
|
for matcher_config in event_hooks:
|
|
if not isinstance(matcher_config, Mapping):
|
|
continue
|
|
matcher_value = str(matcher_config.get("matcher") or "")
|
|
hooks = matcher_config.get("hooks")
|
|
if not isinstance(hooks, Sequence) or isinstance(
|
|
hooks,
|
|
(str, bytes, bytearray),
|
|
):
|
|
continue
|
|
retained: list[Any] = []
|
|
for hook in hooks:
|
|
should_remove = False
|
|
if matcher is None or matcher == matcher_value:
|
|
should_remove = _session_hook_matches_remove_target(hook, hook_or_id)
|
|
if should_remove:
|
|
removed += 1
|
|
else:
|
|
retained.append(hook)
|
|
if retained:
|
|
updated = dict(matcher_config)
|
|
updated["hooks"] = retained
|
|
updated_matchers.append(updated)
|
|
|
|
if updated_matchers:
|
|
session_hooks[event.value] = updated_matchers
|
|
else:
|
|
session_hooks.pop(event.value, None)
|
|
return removed
|
|
|
|
|
|
def _remove_channel_context_session_hook(
|
|
context: ToolUseContext,
|
|
event: HookEvent,
|
|
hook_or_id: Mapping[str, Any] | str,
|
|
*,
|
|
matcher: str | None = None,
|
|
) -> int:
|
|
channel_context = getattr(context, "channel_context", None)
|
|
if not isinstance(channel_context, dict):
|
|
return 0
|
|
raw_session_hooks = channel_context.get("session_hooks")
|
|
if isinstance(raw_session_hooks, dict):
|
|
return _remove_session_hook_from_hooks_map(
|
|
raw_session_hooks,
|
|
event,
|
|
hook_or_id,
|
|
matcher=matcher,
|
|
)
|
|
if isinstance(raw_session_hooks, Mapping):
|
|
copied = dict(raw_session_hooks)
|
|
removed = _remove_session_hook_from_hooks_map(
|
|
copied,
|
|
event,
|
|
hook_or_id,
|
|
matcher=matcher,
|
|
)
|
|
if removed:
|
|
channel_context["session_hooks"] = copied
|
|
return removed
|
|
return 0
|
|
|
|
|
|
def remove_session_function_hook(
|
|
context: ToolUseContext,
|
|
event: HookEvent | str,
|
|
hook_id: str,
|
|
) -> int:
|
|
"""Remove one session function hook by id."""
|
|
|
|
return remove_session_hook(context, event, hook_id)
|
|
|
|
|
|
def clear_session_hooks(
|
|
context: ToolUseContext,
|
|
event: HookEvent | str | None = None,
|
|
) -> int:
|
|
"""Clear OpenSpace session hooks from a runtime context."""
|
|
|
|
if event is None:
|
|
count = sum(
|
|
_count_session_hook_payloads(items)
|
|
for items in context.session_hooks.values()
|
|
)
|
|
context.session_hooks.clear()
|
|
return count
|
|
hook_event = event if isinstance(event, HookEvent) else HookEvent(str(event))
|
|
return _count_session_hook_payloads(
|
|
context.session_hooks.pop(hook_event.value, [])
|
|
)
|
|
|
|
|
|
def _append_session_hook_payload(
|
|
context: ToolUseContext,
|
|
event: HookEvent,
|
|
matcher: str,
|
|
hook: Mapping[str, Any],
|
|
) -> None:
|
|
event_hooks = context.session_hooks.setdefault(event.value, [])
|
|
matcher_value = str(matcher or "")
|
|
for matcher_config in event_hooks:
|
|
if not isinstance(matcher_config, dict):
|
|
continue
|
|
if str(matcher_config.get("matcher") or "") != matcher_value:
|
|
continue
|
|
hooks = matcher_config.setdefault("hooks", [])
|
|
if isinstance(hooks, list):
|
|
hooks.append(dict(hook))
|
|
return
|
|
event_hooks.append({"matcher": matcher_value, "hooks": [dict(hook)]})
|
|
|
|
|
|
def _session_hook_matches_remove_target(
|
|
hook: Any,
|
|
hook_or_id: Mapping[str, Any] | str,
|
|
) -> bool:
|
|
if not isinstance(hook, Mapping):
|
|
return False
|
|
if isinstance(hook_or_id, str):
|
|
return str(hook.get(_SESSION_HOOK_ID_KEY) or hook.get("id") or "") == hook_or_id
|
|
return _session_hook_identity_equal(hook, hook_or_id)
|
|
|
|
|
|
def _session_hook_identity_equal(
|
|
left: Mapping[str, Any],
|
|
right: Mapping[str, Any],
|
|
) -> bool:
|
|
left_type = str(left.get("type") or _infer_hook_type(left))
|
|
right_type = str(right.get("type") or _infer_hook_type(right))
|
|
if left_type != right_type:
|
|
return False
|
|
if str(left.get("if") or "") != str(right.get("if") or ""):
|
|
return False
|
|
if left_type == "command":
|
|
return (
|
|
str(left.get("command") or "") == str(right.get("command") or "")
|
|
and str(left.get("shell") or "bash") == str(right.get("shell") or "bash")
|
|
)
|
|
if left_type in {"prompt", "agent"}:
|
|
return str(left.get("prompt") or "") == str(right.get("prompt") or "")
|
|
if left_type == "http":
|
|
return str(left.get("url") or "") == str(right.get("url") or "")
|
|
if left_type == "function":
|
|
return bool(left.get("id")) and left.get("id") == right.get("id")
|
|
return False
|
|
|
|
|
|
def _count_session_hook_payloads(matchers: Any) -> int:
|
|
if not isinstance(matchers, Sequence) or isinstance(
|
|
matchers,
|
|
(str, bytes, bytearray),
|
|
):
|
|
return 0
|
|
count = 0
|
|
for matcher_config in matchers:
|
|
if not isinstance(matcher_config, Mapping):
|
|
continue
|
|
hooks = matcher_config.get("hooks")
|
|
if isinstance(hooks, Sequence) and not isinstance(
|
|
hooks,
|
|
(str, bytes, bytearray),
|
|
):
|
|
count += len(hooks)
|
|
elif _is_hook_payload(matcher_config):
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def _register_configured_hook_dispatchers(registry: HookRegistry) -> None:
|
|
if getattr(registry, "_openspace_configured_hook_dispatchers", False):
|
|
return
|
|
setattr(registry, "_openspace_configured_hook_dispatchers", True)
|
|
for event in HookEvent:
|
|
registry.register(
|
|
event,
|
|
_configured_hook_callback(event),
|
|
priority=80,
|
|
name=f"configured_hooks:{event.value}",
|
|
)
|
|
|
|
|
|
def _configured_hook_callback(event: HookEvent) -> HookCallback:
|
|
async def _callback(
|
|
tool_name: str = "",
|
|
tool_input: dict[str, Any] | None = None,
|
|
tool_use_id: str = "",
|
|
context: ToolUseContext | None = None,
|
|
**kwargs: Any,
|
|
) -> HookResult:
|
|
if context is None:
|
|
return HookResult()
|
|
return await _run_configured_hooks(
|
|
event=event,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input or {},
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
|
|
return _callback
|
|
|
|
|
|
async def _run_configured_hooks(
|
|
*,
|
|
event: HookEvent,
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext,
|
|
kwargs: Mapping[str, Any],
|
|
) -> HookResult:
|
|
if tool_input.get("_skill_hook_command") is True:
|
|
return HookResult()
|
|
|
|
specs = _load_configured_hook_specs(event, context)
|
|
if not specs:
|
|
return HookResult()
|
|
|
|
merged = HookResult()
|
|
ran = False
|
|
for spec in specs:
|
|
if not _configured_hook_matches(
|
|
spec,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
kwargs=kwargs,
|
|
):
|
|
continue
|
|
ran = True
|
|
result = await _execute_configured_hook_spec(
|
|
spec,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
if result.outcome == "success":
|
|
await _handle_session_hook_success(spec, result, context)
|
|
merged = _merge_configured_hook_result(merged, result)
|
|
if merged.blocking_error or merged.permission_behavior == "deny":
|
|
return merged
|
|
return merged if ran else HookResult()
|
|
|
|
|
|
def _load_configured_hook_specs(
|
|
event: HookEvent,
|
|
context: ToolUseContext,
|
|
) -> list[ConfiguredHookSpec]:
|
|
cwd = Path(getattr(context, "cwd", "") or os.getcwd()).expanduser()
|
|
specs: list[ConfiguredHookSpec] = []
|
|
specs.extend(_settings_hook_specs(event, cwd))
|
|
specs.extend(_hooks_directory_specs(event, cwd))
|
|
specs.extend(_session_hook_specs(event, context))
|
|
specs.extend(_plugin_hook_specs(event, cwd))
|
|
return sorted(specs, key=lambda spec: spec.priority)
|
|
|
|
|
|
def _settings_hook_specs(event: HookEvent, cwd: Path) -> list[ConfiguredHookSpec]:
|
|
specs: list[ConfiguredHookSpec] = []
|
|
try:
|
|
from openspace.services.runtime_support.settings import (
|
|
get_project_root,
|
|
get_settings_with_errors,
|
|
)
|
|
|
|
snapshot = get_settings_with_errors(cwd)
|
|
specs.extend(
|
|
_normalise_hooks_config(
|
|
snapshot.raw.get("hooks"),
|
|
event=event,
|
|
source="settings",
|
|
root=snapshot.project_root,
|
|
priority=100,
|
|
session_scoped=False,
|
|
)
|
|
)
|
|
project_root = get_project_root(cwd)
|
|
except Exception:
|
|
logger.debug("Failed to load OpenSpace settings hooks", exc_info=True)
|
|
project_root = cwd
|
|
|
|
return specs
|
|
|
|
|
|
def _hooks_directory_specs(event: HookEvent, cwd: Path) -> list[ConfiguredHookSpec]:
|
|
try:
|
|
from openspace.services.runtime_support.settings import get_project_root
|
|
|
|
project_root = get_project_root(cwd)
|
|
except Exception:
|
|
project_root = cwd
|
|
|
|
openspace_home = Path(
|
|
os.environ.get("OPENSPACE_CONFIG_HOME") or Path.home() / ".openspace"
|
|
)
|
|
dirs = (
|
|
(project_root / ".openspace" / "hooks", "openspace:projectHooks", 110),
|
|
(openspace_home / "hooks", "openspace:userHooks", 112),
|
|
)
|
|
specs: list[ConfiguredHookSpec] = []
|
|
for directory, source, priority in dirs:
|
|
for path in _iter_hook_json_files(directory):
|
|
data = _read_json_mapping(path)
|
|
if data is None:
|
|
continue
|
|
config = (
|
|
data.get("hooks")
|
|
if isinstance(data.get("hooks"), Mapping)
|
|
else data
|
|
)
|
|
specs.extend(
|
|
_normalise_hooks_config(
|
|
config,
|
|
event=event,
|
|
source=f"{source}:{path.name}",
|
|
root=str(path.parent),
|
|
priority=priority,
|
|
session_scoped=False,
|
|
)
|
|
)
|
|
return specs
|
|
|
|
|
|
def _session_hook_specs(
|
|
event: HookEvent,
|
|
context: ToolUseContext,
|
|
) -> list[ConfiguredHookSpec]:
|
|
specs: list[ConfiguredHookSpec] = []
|
|
session_hooks = getattr(context, "session_hooks", None)
|
|
if isinstance(session_hooks, Mapping):
|
|
specs.extend(
|
|
_normalise_hooks_config(
|
|
{event.value: session_hooks.get(event.value, [])},
|
|
event=event,
|
|
source="session",
|
|
root=getattr(context, "cwd", None),
|
|
priority=130,
|
|
session_scoped=True,
|
|
)
|
|
)
|
|
channel_hooks = getattr(context, "channel_context", {}).get("session_hooks")
|
|
if isinstance(channel_hooks, Mapping):
|
|
specs.extend(
|
|
_normalise_hooks_config(
|
|
{event.value: channel_hooks.get(event.value, [])},
|
|
event=event,
|
|
source="session:channel_context",
|
|
root=getattr(context, "cwd", None),
|
|
priority=131,
|
|
session_scoped=True,
|
|
)
|
|
)
|
|
return specs
|
|
|
|
|
|
def _plugin_hook_specs(event: HookEvent, cwd: Path) -> list[ConfiguredHookSpec]:
|
|
specs: list[ConfiguredHookSpec] = []
|
|
for config, plugin_root, plugin_name, label in _iter_plugin_hook_configs(cwd):
|
|
specs.extend(
|
|
_normalise_hooks_config(
|
|
config,
|
|
event=event,
|
|
source=f"plugin:{plugin_name}:{label}",
|
|
root=str(plugin_root),
|
|
priority=120,
|
|
session_scoped=False,
|
|
)
|
|
)
|
|
return specs
|
|
|
|
|
|
def _normalise_hooks_config(
|
|
config: Any,
|
|
*,
|
|
event: HookEvent,
|
|
source: str,
|
|
root: str | None,
|
|
priority: int,
|
|
session_scoped: bool,
|
|
) -> list[ConfiguredHookSpec]:
|
|
if not isinstance(config, Mapping):
|
|
return []
|
|
raw_matchers = config.get(event.value)
|
|
if raw_matchers is None:
|
|
return []
|
|
if isinstance(raw_matchers, Mapping):
|
|
raw_matchers = [raw_matchers]
|
|
if not isinstance(raw_matchers, Sequence) or isinstance(
|
|
raw_matchers,
|
|
(str, bytes, bytearray),
|
|
):
|
|
return []
|
|
|
|
specs: list[ConfiguredHookSpec] = []
|
|
for matcher_config in raw_matchers:
|
|
if not isinstance(matcher_config, Mapping):
|
|
continue
|
|
matcher = str(matcher_config.get("matcher") or "").strip()
|
|
raw_hooks = matcher_config.get("hooks")
|
|
if raw_hooks is None and _is_hook_payload(matcher_config):
|
|
raw_hooks = [matcher_config]
|
|
if isinstance(raw_hooks, Mapping):
|
|
raw_hooks = [raw_hooks]
|
|
if not isinstance(raw_hooks, Sequence) or isinstance(
|
|
raw_hooks,
|
|
(str, bytes, bytearray),
|
|
):
|
|
continue
|
|
for raw_hook in raw_hooks:
|
|
if not isinstance(raw_hook, Mapping):
|
|
continue
|
|
hook = dict(raw_hook)
|
|
hook.pop("matcher", None)
|
|
hook.pop("hooks", None)
|
|
if not _is_hook_payload(hook):
|
|
continue
|
|
hook.setdefault("type", _infer_hook_type(hook))
|
|
specs.append(
|
|
ConfiguredHookSpec(
|
|
event=event,
|
|
matcher=matcher,
|
|
hook=hook,
|
|
source=source,
|
|
root=root,
|
|
priority=priority,
|
|
session_scoped=session_scoped,
|
|
session_hook_id=(
|
|
str(hook.get(_SESSION_HOOK_ID_KEY) or hook.get("id") or "")
|
|
or None
|
|
),
|
|
on_hook_success=(
|
|
hook.get(_SESSION_HOOK_SUCCESS_KEY)
|
|
if callable(hook.get(_SESSION_HOOK_SUCCESS_KEY))
|
|
else None
|
|
),
|
|
)
|
|
)
|
|
return specs
|
|
|
|
|
|
def _is_hook_payload(value: Mapping[str, Any]) -> bool:
|
|
return any(key in value for key in ("command", "prompt", "url", "type"))
|
|
|
|
|
|
def _infer_hook_type(hook: Mapping[str, Any]) -> str:
|
|
if hook.get("url"):
|
|
return "http"
|
|
if hook.get("prompt"):
|
|
return "prompt"
|
|
return "command"
|
|
|
|
|
|
def _configured_hook_matches(
|
|
spec: ConfiguredHookSpec,
|
|
*,
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
kwargs: Mapping[str, Any],
|
|
) -> bool:
|
|
try:
|
|
from openspace.skill_engine.protocol import (
|
|
_skill_hook_condition_matches,
|
|
_skill_hook_matcher_matches,
|
|
)
|
|
|
|
if not _skill_hook_matcher_matches(
|
|
spec.matcher,
|
|
event=spec.event.value,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
kwargs=kwargs,
|
|
):
|
|
return False
|
|
condition = spec.hook.get("if")
|
|
return not condition or _skill_hook_condition_matches(
|
|
condition,
|
|
tool_name,
|
|
tool_input,
|
|
)
|
|
except Exception:
|
|
logger.debug(
|
|
"Configured hook matcher failed for %s",
|
|
spec.source,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
|
|
|
|
async def _execute_configured_hook_spec(
|
|
spec: ConfiguredHookSpec,
|
|
*,
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext,
|
|
kwargs: Mapping[str, Any],
|
|
) -> HookResult:
|
|
hook_type = str(spec.hook.get("type") or "command")
|
|
if hook_type == "command":
|
|
return await _execute_configured_command_hook(
|
|
spec,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
if hook_type == "function":
|
|
return await _execute_configured_function_hook(
|
|
spec,
|
|
context=context,
|
|
)
|
|
|
|
meta = SimpleNamespace(
|
|
name=spec.source,
|
|
path=Path(spec.root or getattr(context, "cwd", ".") or ".") / "HOOKS",
|
|
shell=spec.hook.get("shell"),
|
|
model=None,
|
|
allowed_tools=[],
|
|
loaded_from="configured_hook",
|
|
)
|
|
try:
|
|
if hook_type == "prompt":
|
|
from openspace.skill_engine.protocol import _execute_skill_prompt_hook
|
|
|
|
return await _execute_skill_prompt_hook(
|
|
meta=meta,
|
|
event=spec.event.value,
|
|
hook=spec.hook,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
if hook_type == "http":
|
|
from openspace.skill_engine.protocol import _execute_skill_http_hook
|
|
|
|
return await _execute_skill_http_hook(
|
|
meta=meta,
|
|
event=spec.event.value,
|
|
hook=spec.hook,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
if hook_type == "agent":
|
|
from openspace.skill_engine.protocol import _execute_skill_agent_hook
|
|
|
|
return await _execute_skill_agent_hook(
|
|
meta=meta,
|
|
event=spec.event.value,
|
|
hook=spec.hook,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
except Exception as exc:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
f"Configured hook {spec.source} failed: {exc}",
|
|
command=str(
|
|
spec.hook.get("command")
|
|
or spec.hook.get("prompt")
|
|
or spec.hook.get("url")
|
|
or ""
|
|
),
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
f"Configured hook {spec.source} uses unsupported hook type {hook_type!r}",
|
|
command=str(spec.hook),
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
|
|
|
|
async def _execute_configured_command_hook(
|
|
spec: ConfiguredHookSpec,
|
|
*,
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext,
|
|
kwargs: Mapping[str, Any],
|
|
) -> HookResult:
|
|
from openspace.grounding.core.types import ToolStatus
|
|
from openspace.services.conversation.content_blocks import extract_text_from_content
|
|
from openspace.skill_engine.protocol import (
|
|
_execute_skill_hook_command_via_runtime,
|
|
_find_skill_prompt_shell_tool,
|
|
_hook_result_from_ok_json,
|
|
_looks_like_hook_json,
|
|
_skill_hook_payload,
|
|
)
|
|
|
|
command = str(spec.hook.get("command") or "").strip()
|
|
if not command:
|
|
return HookResult()
|
|
|
|
shell_tool = _find_skill_prompt_shell_tool(
|
|
context,
|
|
str(spec.hook.get("shell") or "bash").strip().lower() or None,
|
|
)
|
|
if shell_tool is None:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
"Configured command hook requires bash/powershell tool",
|
|
command=command,
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
|
|
payload = _skill_hook_payload(
|
|
event=spec.event.value,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
payload_json = json.dumps(payload, ensure_ascii=False)
|
|
if "$ARGUMENTS" in command:
|
|
rendered = command.replace("$ARGUMENTS", shlex.quote(payload_json))
|
|
else:
|
|
rendered = f"printf %s {shlex.quote(payload_json)} | {command}"
|
|
if spec.root:
|
|
rendered = (
|
|
f"export OPENSPACE_PLUGIN_ROOT={shlex.quote(spec.root)}; "
|
|
f"{rendered}"
|
|
)
|
|
|
|
result = await _execute_skill_hook_command_via_runtime(
|
|
shell_tool=shell_tool,
|
|
command=rendered,
|
|
context=context,
|
|
timeout=int(spec.hook.get("timeout") or 30),
|
|
description=str(
|
|
spec.hook.get("statusMessage")
|
|
or f"Configured hook: {spec.source}"
|
|
),
|
|
)
|
|
output = extract_text_from_content(result.content).strip()
|
|
metadata = getattr(result, "metadata", None) or {}
|
|
try:
|
|
exit_code = int(metadata.get("exit_code"))
|
|
except (TypeError, ValueError):
|
|
exit_code = None
|
|
|
|
label = f"Configured command hook `{spec.source}` ({spec.event.value})"
|
|
if result.status == ToolStatus.SUCCESS:
|
|
if _looks_like_hook_json(output):
|
|
return _hook_result_from_ok_json(
|
|
output,
|
|
hook_label=label,
|
|
command=command,
|
|
event=spec.event.value,
|
|
)
|
|
return HookResult()
|
|
|
|
message = output or result.error or f"Hook command exited with code {exit_code}"
|
|
if exit_code == 2:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(message, command=command),
|
|
permission_behavior="deny" if spec.event == HookEvent.PRE_TOOL_USE else None,
|
|
hook_permission_decision_reason=message,
|
|
prevent_continuation=spec.event not in {
|
|
HookEvent.PRE_TOOL_USE,
|
|
HookEvent.PERMISSION_REQUEST,
|
|
},
|
|
stop_reason=message,
|
|
outcome="blocking",
|
|
)
|
|
|
|
await context.emit_event(
|
|
"hook_non_blocking_error",
|
|
{
|
|
"hook_event": spec.event.value,
|
|
"hook_source": spec.source,
|
|
"exit_code": exit_code,
|
|
"message": message,
|
|
},
|
|
)
|
|
return HookResult(outcome="non_blocking_error")
|
|
|
|
|
|
async def _execute_configured_function_hook(
|
|
spec: ConfiguredHookSpec,
|
|
*,
|
|
context: ToolUseContext,
|
|
) -> HookResult:
|
|
callback = spec.hook.get("callback")
|
|
if not callable(callback):
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
f"Configured function hook {spec.source} requires a callable callback",
|
|
command="function",
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
|
|
abort_event = getattr(context, "abort_event", None)
|
|
if abort_event is not None and abort_event.is_set():
|
|
return HookResult(outcome="cancelled")
|
|
|
|
messages = list(getattr(context, "messages", []) or [])
|
|
timeout_seconds = _configured_function_hook_timeout(spec.hook)
|
|
hook_name = f"{spec.source}:{spec.event.value}:function"
|
|
try:
|
|
call = _call_function_hook_callback(callback, messages, abort_event)
|
|
passed = (
|
|
await asyncio.wait_for(call, timeout=timeout_seconds)
|
|
if timeout_seconds > 0
|
|
else await call
|
|
)
|
|
except TimeoutError:
|
|
return HookResult(outcome="cancelled")
|
|
except Exception as exc:
|
|
logger.debug("Function hook %s failed", hook_name, exc_info=True)
|
|
return HookResult(
|
|
message=_make_hook_error_message(hook_name, spec.event, str(exc)),
|
|
outcome="non_blocking_error",
|
|
)
|
|
|
|
if passed:
|
|
return HookResult(outcome="success")
|
|
|
|
error_message = str(
|
|
spec.hook.get("errorMessage")
|
|
or spec.hook.get("error_message")
|
|
or "Function hook blocked execution"
|
|
)
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(error_message, command="function"),
|
|
permission_behavior="deny" if spec.event == HookEvent.PRE_TOOL_USE else None,
|
|
hook_permission_decision_reason=error_message,
|
|
prevent_continuation=spec.event not in {
|
|
HookEvent.PRE_TOOL_USE,
|
|
HookEvent.PERMISSION_REQUEST,
|
|
},
|
|
stop_reason=error_message,
|
|
outcome="blocking",
|
|
)
|
|
|
|
|
|
async def _call_function_hook_callback(
|
|
callback: Callable[..., Any],
|
|
messages: list[dict[str, Any]],
|
|
abort_event: asyncio.Event | None,
|
|
) -> bool:
|
|
args = _positional_args_for_callback(callback, messages, abort_event)
|
|
result = callback(*args)
|
|
if inspect.isawaitable(result):
|
|
result = await result
|
|
return bool(result)
|
|
|
|
|
|
def _configured_function_hook_timeout(hook: Mapping[str, Any]) -> float:
|
|
raw = hook.get("timeout")
|
|
if raw is None:
|
|
return 5.0
|
|
try:
|
|
timeout = float(raw)
|
|
except (TypeError, ValueError):
|
|
return 5.0
|
|
return max(0.0, timeout)
|
|
|
|
|
|
async def _handle_session_hook_success(
|
|
spec: ConfiguredHookSpec,
|
|
result: HookResult,
|
|
context: ToolUseContext,
|
|
) -> None:
|
|
if not spec.session_scoped:
|
|
return
|
|
|
|
hook_payload = _public_session_hook_payload(spec.hook)
|
|
aggregated = _aggregate_single(
|
|
result,
|
|
f"{spec.source}:{spec.event.value}",
|
|
spec.event,
|
|
)
|
|
|
|
if spec.on_hook_success is not None:
|
|
try:
|
|
args = _positional_args_for_callback(
|
|
spec.on_hook_success,
|
|
hook_payload,
|
|
aggregated,
|
|
)
|
|
maybe = spec.on_hook_success(*args)
|
|
if inspect.isawaitable(maybe):
|
|
await maybe
|
|
except Exception:
|
|
logger.debug("Session hook success callback failed", exc_info=True)
|
|
|
|
if spec.hook.get("once") is True:
|
|
target: Mapping[str, Any] | str = (
|
|
spec.session_hook_id if spec.session_hook_id else hook_payload
|
|
)
|
|
if spec.source == "session:channel_context":
|
|
_remove_channel_context_session_hook(
|
|
context,
|
|
spec.event,
|
|
target,
|
|
matcher=spec.matcher,
|
|
)
|
|
else:
|
|
remove_session_hook(
|
|
context,
|
|
spec.event,
|
|
target,
|
|
matcher=spec.matcher,
|
|
)
|
|
|
|
|
|
def _public_session_hook_payload(hook: Mapping[str, Any]) -> dict[str, Any]:
|
|
payload = dict(hook)
|
|
payload.pop(_SESSION_HOOK_ID_KEY, None)
|
|
payload.pop(_SESSION_HOOK_SUCCESS_KEY, None)
|
|
return payload
|
|
|
|
|
|
def _positional_args_for_callback(
|
|
callback: Callable[..., Any],
|
|
*args: Any,
|
|
) -> tuple[Any, ...]:
|
|
try:
|
|
signature = inspect.signature(callback)
|
|
except (TypeError, ValueError):
|
|
return tuple(args)
|
|
|
|
positional_count = 0
|
|
for parameter in signature.parameters.values():
|
|
if parameter.kind == inspect.Parameter.VAR_POSITIONAL:
|
|
return tuple(args)
|
|
if parameter.kind in {
|
|
inspect.Parameter.POSITIONAL_ONLY,
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
}:
|
|
positional_count += 1
|
|
return tuple(args[:positional_count])
|
|
|
|
|
|
def _merge_configured_hook_result(
|
|
current: HookResult,
|
|
incoming: HookResult,
|
|
) -> HookResult:
|
|
if incoming.blocking_error or incoming.permission_behavior == "deny":
|
|
return incoming
|
|
|
|
permission_behavior = _stronger_permission_behavior(
|
|
current.permission_behavior,
|
|
incoming.permission_behavior,
|
|
)
|
|
updated_input = _merged_configured_updated_input(current, incoming)
|
|
additional_context = "\n".join(
|
|
part
|
|
for part in (current.additional_context, incoming.additional_context)
|
|
if part
|
|
) or None
|
|
system_message = "\n".join(
|
|
part
|
|
for part in (current.system_message, incoming.system_message)
|
|
if part
|
|
) or None
|
|
return HookResult(
|
|
message=incoming.message or current.message,
|
|
system_message=system_message,
|
|
prevent_continuation=(
|
|
current.prevent_continuation or incoming.prevent_continuation
|
|
),
|
|
stop_reason=incoming.stop_reason or current.stop_reason,
|
|
permission_behavior=permission_behavior,
|
|
hook_permission_decision_reason=(
|
|
incoming.hook_permission_decision_reason
|
|
or current.hook_permission_decision_reason
|
|
),
|
|
additional_context=additional_context,
|
|
initial_user_message=(
|
|
incoming.initial_user_message or current.initial_user_message
|
|
),
|
|
updated_input=updated_input,
|
|
updated_permissions=incoming.updated_permissions or current.updated_permissions,
|
|
updated_tool_output=(
|
|
incoming.updated_tool_output
|
|
if incoming.updated_tool_output is not None
|
|
else current.updated_tool_output
|
|
),
|
|
watch_paths=incoming.watch_paths or current.watch_paths,
|
|
elicitation_response=incoming.elicitation_response or current.elicitation_response,
|
|
elicitation_result_response=(
|
|
incoming.elicitation_result_response
|
|
or current.elicitation_result_response
|
|
),
|
|
retry=current.retry or incoming.retry,
|
|
)
|
|
|
|
|
|
def _stronger_permission_behavior(
|
|
left: Literal["ask", "deny", "allow", "passthrough"] | None,
|
|
right: Literal["ask", "deny", "allow", "passthrough"] | None,
|
|
) -> Literal["ask", "deny", "allow", "passthrough"] | None:
|
|
rank = {None: 0, "passthrough": 1, "allow": 2, "ask": 3, "deny": 4}
|
|
return right if rank.get(right, 0) > rank.get(left, 0) else left
|
|
|
|
|
|
def _configured_permission_rank(
|
|
behavior: Literal["ask", "deny", "allow", "passthrough"] | None,
|
|
) -> int:
|
|
return {None: 0, "passthrough": 1, "allow": 2, "ask": 3, "deny": 4}.get(
|
|
behavior,
|
|
0,
|
|
)
|
|
|
|
|
|
def _merged_configured_updated_input(
|
|
current: HookResult,
|
|
incoming: HookResult,
|
|
) -> dict[str, Any] | None:
|
|
current_rank = _configured_permission_rank(current.permission_behavior)
|
|
incoming_rank = _configured_permission_rank(incoming.permission_behavior)
|
|
|
|
if incoming_rank > current_rank:
|
|
return incoming.updated_input
|
|
if current_rank > incoming_rank:
|
|
return current.updated_input
|
|
return incoming.updated_input or current.updated_input
|
|
|
|
|
|
def _read_json_mapping(path: Path) -> dict[str, Any] | None:
|
|
try:
|
|
if not path.is_file():
|
|
return None
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
logger.debug("Failed to read hook config %s", path, exc_info=True)
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _iter_hook_json_files(directory: Path) -> list[Path]:
|
|
if not directory.is_dir():
|
|
return []
|
|
paths: list[Path] = []
|
|
primary = directory / "hooks.json"
|
|
if primary.is_file():
|
|
paths.append(primary)
|
|
for path in sorted(directory.glob("*.json")):
|
|
if path != primary:
|
|
paths.append(path)
|
|
return paths
|
|
|
|
|
|
def _iter_plugin_hook_configs(
|
|
cwd: Path,
|
|
) -> list[tuple[Mapping[str, Any], Path, str, str]]:
|
|
roots = _plugin_search_roots(cwd)
|
|
seen_roots: set[Path] = set()
|
|
found: list[tuple[Mapping[str, Any], Path, str, str]] = []
|
|
for root in roots:
|
|
if not root.exists():
|
|
continue
|
|
candidates = [root] if _looks_like_plugin_root(root) else []
|
|
try:
|
|
for manifest_path in root.rglob("plugin.json"):
|
|
if len(candidates) > 200:
|
|
break
|
|
candidates.append(manifest_path.parent)
|
|
for hooks_path in root.rglob("hooks.json"):
|
|
if len(candidates) > 200:
|
|
break
|
|
plugin_root = hooks_path.parent.parent
|
|
if _looks_like_plugin_root(plugin_root):
|
|
candidates.append(plugin_root)
|
|
except OSError:
|
|
continue
|
|
for plugin_root in candidates:
|
|
try:
|
|
resolved_root = plugin_root.resolve()
|
|
except OSError:
|
|
resolved_root = plugin_root.absolute()
|
|
if resolved_root in seen_roots:
|
|
continue
|
|
seen_roots.add(resolved_root)
|
|
found.extend(_plugin_hook_configs_for_root(plugin_root))
|
|
return found
|
|
|
|
|
|
def _plugin_hook_configs_for_root(
|
|
plugin_root: Path,
|
|
) -> list[tuple[Mapping[str, Any], Path, str, str]]:
|
|
manifest = _read_json_mapping(plugin_root / "plugin.json") or {}
|
|
plugin_name = _plugin_name(plugin_root, manifest)
|
|
loaded_files: set[Path] = set()
|
|
configs: list[tuple[Mapping[str, Any], Path, str, str]] = []
|
|
|
|
standard = plugin_root / "hooks" / "hooks.json"
|
|
standard_config = _read_plugin_hooks_file(standard)
|
|
if standard_config is not None:
|
|
configs.append((standard_config, plugin_root, plugin_name, "hooks.json"))
|
|
try:
|
|
loaded_files.add(standard.resolve())
|
|
except OSError:
|
|
loaded_files.add(standard.absolute())
|
|
|
|
hooks_spec = manifest.get("hooks")
|
|
if hooks_spec is None:
|
|
return configs
|
|
hook_items = hooks_spec if isinstance(hooks_spec, list) else [hooks_spec]
|
|
for index, hook_spec in enumerate(hook_items):
|
|
if isinstance(hook_spec, str):
|
|
hook_file = plugin_root / hook_spec
|
|
try:
|
|
key = hook_file.resolve()
|
|
except OSError:
|
|
key = hook_file.absolute()
|
|
if key in loaded_files:
|
|
continue
|
|
config = _read_plugin_hooks_file(hook_file)
|
|
if config is None:
|
|
continue
|
|
loaded_files.add(key)
|
|
configs.append((config, plugin_root, plugin_name, hook_spec))
|
|
elif isinstance(hook_spec, Mapping):
|
|
configs.append(
|
|
(
|
|
dict(hook_spec),
|
|
plugin_root,
|
|
plugin_name,
|
|
f"manifest:{index}",
|
|
)
|
|
)
|
|
return configs
|
|
|
|
|
|
def _read_plugin_hooks_file(path: Path) -> Mapping[str, Any] | None:
|
|
data = _read_json_mapping(path)
|
|
if data is None:
|
|
return None
|
|
hooks = data.get("hooks")
|
|
if isinstance(hooks, Mapping):
|
|
return dict(hooks)
|
|
return data
|
|
|
|
|
|
def _plugin_search_roots(cwd: Path) -> list[Path]:
|
|
try:
|
|
from openspace.services.runtime_support.settings import get_project_root
|
|
|
|
project_root = get_project_root(cwd)
|
|
except Exception:
|
|
project_root = cwd
|
|
roots: list[Path] = []
|
|
raw = os.environ.get("OPENSPACE_PLUGIN_PATHS")
|
|
if raw:
|
|
roots.extend(
|
|
Path(item).expanduser()
|
|
for item in raw.split(os.pathsep)
|
|
if item
|
|
)
|
|
roots.extend(
|
|
[
|
|
project_root / ".agents" / "plugins",
|
|
project_root / ".openspace" / "plugins",
|
|
Path.home() / ".openspace" / "plugins",
|
|
]
|
|
)
|
|
deduped: list[Path] = []
|
|
seen: set[Path] = set()
|
|
for root in roots:
|
|
try:
|
|
key = root.expanduser().resolve()
|
|
except OSError:
|
|
key = root.expanduser().absolute()
|
|
if key not in seen:
|
|
seen.add(key)
|
|
deduped.append(key)
|
|
return deduped
|
|
|
|
|
|
def _looks_like_plugin_root(path: Path) -> bool:
|
|
return (path / "plugin.json").is_file() or (
|
|
path / "hooks" / "hooks.json"
|
|
).is_file()
|
|
|
|
|
|
def _plugin_name(
|
|
plugin_root: Path,
|
|
manifest: Mapping[str, Any] | None = None,
|
|
) -> str:
|
|
if manifest is None:
|
|
manifest = _read_json_mapping(plugin_root / "plugin.json")
|
|
if manifest is not None and manifest.get("name"):
|
|
return str(manifest["name"])
|
|
return plugin_root.name
|
|
|
|
|
|
def setup_default_hooks(
|
|
registry: HookRegistry,
|
|
*,
|
|
include_configured_hooks: bool = True,
|
|
) -> None:
|
|
"""Register built-in hooks per DEC-020 §四/§五 and EXECUTION_CHECKLIST 3.1.
|
|
|
|
Called during ``GroundingAgent.__init__``.
|
|
All hooks get their runtime dependencies from ``ToolUseContext`` fields,
|
|
so registration can happen before the context is constructed.
|
|
|
|
Built-in hooks registered:
|
|
(1) visual_analysis — PostToolUse, priority=50 → DEC-022, GUI tools only
|
|
(2) recording — PostToolUse, priority=200 → records to recording_manager
|
|
(3) quality_tracking — PostToolUse, priority=210 → feeds ToolQualityManager
|
|
(4) max_tokens_recovery — Stop, priority=10 → detects model truncation
|
|
(5) configured_hooks — all events, priority=80 → settings/.openspace/session/plugin
|
|
"""
|
|
if include_configured_hooks:
|
|
_register_configured_hook_dispatchers(registry)
|
|
|
|
# (1) Visual analysis — PostToolUse, GUI tools only, runs early
|
|
registry.register_post_tool(
|
|
create_visual_analysis_hook(),
|
|
priority=50,
|
|
name="visual_analysis",
|
|
)
|
|
|
|
# (2) Recording — PostToolUse, low priority (after business logic hooks)
|
|
registry.register_post_tool(
|
|
create_recording_hook(),
|
|
priority=200,
|
|
name="recording",
|
|
)
|
|
|
|
# (3) Quality tracking — PostToolUse, lowest priority
|
|
registry.register_post_tool(
|
|
create_quality_hook(),
|
|
priority=210,
|
|
name="quality_tracking",
|
|
)
|
|
|
|
# (4) Max tokens recovery — Stop hook
|
|
registry.register_stop_hook(
|
|
create_max_tokens_recovery_hook(),
|
|
priority=10,
|
|
name="max_tokens_recovery",
|
|
)
|