OpenSpace/openspace/services/tooling/hooks.py
2026-07-17 11:43:42 +08:00

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",
)