Compare commits

...

1 Commits

Author SHA1 Message Date
tjb-tech 4f88935299 feat(hooks): support context injection 2026-05-09 05:33:57 +00:00
8 changed files with 291 additions and 13 deletions
+36
View File
@@ -777,6 +777,42 @@ Create `.openharness/plugins/my-plugin/.claude-plugin/plugin.json`:
Add commands in `commands/*.md`, hooks in `hooks/hooks.json`, agents in `agents/*.md`.
### Command Hook Context
Command hooks can receive their JSON payload on stdin and return additional model context:
```json
{
"hooks": {
"session_start": [
{
"type": "command",
"command": "my-memory-hook",
"stdin_payload": true
}
]
}
}
```
A command hook may print either OpenHarness-style context:
```json
{"additional_context": "Relevant memory for this session."}
```
or Claude-style context:
```json
{
"hookSpecificOutput": {
"additionalContext": "Relevant memory for this session."
}
}
```
`session_start` context is appended to the runtime system prompt. `user_prompt_submit` context is injected for the current turn only and is not persisted into conversation history.
---
## 🌍 Showcase
+20 -1
View File
@@ -810,6 +810,10 @@ async def run_query(
{
"event": HookEvent.STOP.value,
"stop_reason": "tool_uses_empty",
"cwd": str(context.cwd),
"session_id": str((context.tool_metadata or {}).get("session_id", "")),
"last_assistant_message": final_message.text,
"model": context.model,
},
)
return
@@ -881,7 +885,14 @@ async def _execute_tool_call(
if context.hook_executor is not None:
pre_hooks = await context.hook_executor.execute(
HookEvent.PRE_TOOL_USE,
{"tool_name": tool_name, "tool_input": tool_input, "event": HookEvent.PRE_TOOL_USE.value},
{
"tool_name": tool_name,
"tool_input": tool_input,
"event": HookEvent.PRE_TOOL_USE.value,
"cwd": str(context.cwd),
"session_id": str((context.tool_metadata or {}).get("session_id", "")),
"model": context.model,
},
)
if pre_hooks.blocked:
return ToolResultBlock(
@@ -998,8 +1009,16 @@ async def _execute_tool_call(
"tool_name": tool_name,
"tool_input": tool_input,
"tool_output": tool_result.content,
"tool_response": {
"output": tool_result.content,
"is_error": tool_result.is_error,
"metadata": result.metadata,
},
"tool_is_error": tool_result.is_error,
"event": HookEvent.POST_TOOL_USE.value,
"cwd": str(context.cwd),
"session_id": str((context.tool_metadata or {}).get("session_id", "")),
"model": context.model,
},
)
return tool_result
+16 -2
View File
@@ -154,14 +154,23 @@ class QueryEngine:
if user_message.text.strip() and not self._tool_metadata.pop("_suppress_next_user_goal", False):
remember_user_goal(self._tool_metadata, user_message.text)
self._messages.append(user_message)
prompt_context_message: ConversationMessage | None = None
if self._hook_executor is not None:
await self._hook_executor.execute(
prompt_hooks = await self._hook_executor.execute(
HookEvent.USER_PROMPT_SUBMIT,
{
"event": HookEvent.USER_PROMPT_SUBMIT.value,
"prompt": user_message.text,
"cwd": str(self._cwd),
"session_id": str(self._tool_metadata.get("session_id", "")),
"model": self._model,
"permission_mode": str(self._tool_metadata.get("permission_mode", "")),
},
)
if prompt_hooks.additional_context:
prompt_context_message = ConversationMessage.from_user_text(
f"# Hook Additional Context\n\n{prompt_hooks.additional_context}"
)
context = QueryContext(
api_client=self._api_client,
tool_registry=self._tool_registry,
@@ -179,12 +188,17 @@ class QueryEngine:
tool_metadata=self._tool_metadata,
)
query_messages = list(self._messages)
if prompt_context_message is not None:
query_messages.append(prompt_context_message)
coordinator_context = self._build_coordinator_context_message()
if coordinator_context is not None:
query_messages.append(coordinator_context)
async for event, usage in run_query(context, query_messages):
if isinstance(event, AssistantTurnComplete):
self._messages = list(query_messages)
self._messages = [
message for message in query_messages
if message is not prompt_context_message and message is not coordinator_context
]
if usage is not None:
self._cost_tracker.add(usage)
yield event
+18 -3
View File
@@ -85,15 +85,17 @@ class HookExecutor:
) -> HookResult:
command = _inject_arguments(hook.command, payload, shell_escape=True)
try:
payload_json = json.dumps(payload)
process = await create_shell_subprocess(
command,
cwd=self._context.cwd,
stdin=asyncio.subprocess.PIPE if hook.stdin_payload else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={
**os.environ,
"OPENHARNESS_HOOK_EVENT": event.value,
"OPENHARNESS_HOOK_PAYLOAD": json.dumps(payload),
"OPENHARNESS_HOOK_PAYLOAD": payload_json,
},
)
except SandboxUnavailableError as exc:
@@ -104,9 +106,10 @@ class HookExecutor:
reason=str(exc),
)
stdin_data = payload_json.encode("utf-8") if hook.stdin_payload else None
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
process.communicate(stdin_data),
timeout=hook.timeout_seconds,
)
except asyncio.TimeoutError:
@@ -126,13 +129,17 @@ class HookExecutor:
) if part
)
success = process.returncode == 0
metadata: dict[str, Any] = {"returncode": process.returncode}
parsed_stdout = _parse_command_output_json(stdout.decode("utf-8", errors="replace").strip())
if parsed_stdout is not None:
metadata.update(parsed_stdout)
return HookResult(
hook_type=hook.type,
success=success,
output=output,
blocked=hook.block_on_failure and not success,
reason=output or f"command hook failed with exit code {process.returncode}",
metadata={"returncode": process.returncode},
metadata=metadata,
)
async def _run_http_hook(
@@ -229,6 +236,14 @@ def _inject_arguments(
return template.replace("$ARGUMENTS", serialized)
def _parse_command_output_json(text: str) -> dict[str, Any] | None:
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
def _parse_hook_json(text: str) -> dict[str, Any]:
try:
parsed = json.loads(text)
+1
View File
@@ -15,6 +15,7 @@ class CommandHookDefinition(BaseModel):
timeout_seconds: int = Field(default=30, ge=1, le=600)
matcher: str | None = None
block_on_failure: bool = False
stdin_payload: bool = False
class PromptHookDefinition(BaseModel):
+49
View File
@@ -36,3 +36,52 @@ class AggregatedHookResult:
if result.blocked:
return result.reason or result.output
return ""
@property
def additional_context(self) -> str:
"""Return context fragments emitted by hooks.
Hooks may expose context either as explicit metadata
(``additional_context`` or Claude-style ``additionalContext``) or as a
JSON object in stdout with the same fields. Plain command stdout is not
treated as context so existing notification hooks do not accidentally
affect model input.
"""
fragments: list[str] = []
for result in self.results:
value = _extract_additional_context(result)
if value:
fragments.append(value)
return "\n\n".join(fragments)
def _extract_additional_context(result: HookResult) -> str:
for key in ("additional_context", "additionalContext"):
value = result.metadata.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
hook_output = result.metadata.get("hookSpecificOutput")
if isinstance(hook_output, dict):
value = hook_output.get("additionalContext") or hook_output.get("additional_context")
if isinstance(value, str) and value.strip():
return value.strip()
import json
try:
parsed = json.loads(result.output)
except Exception:
return ""
if not isinstance(parsed, dict):
return ""
for key in ("additional_context", "additionalContext"):
value = parsed.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
hook_output = parsed.get("hookSpecificOutput")
if isinstance(hook_output, dict):
value = hook_output.get("additionalContext") or hook_output.get("additional_context")
if isinstance(value, str) and value.strip():
return value.strip()
return ""
+19 -7
View File
@@ -291,6 +291,9 @@ async def build_runtime(
),
)
engine_max_turns = settings.max_turns if (enforce_max_turns or max_turns is not None) else None
from uuid import uuid4
session_id = uuid4().hex[:12]
system_prompt_text = build_runtime_system_prompt(
settings,
cwd=cwd,
@@ -299,9 +302,21 @@ async def build_runtime(
extra_plugin_roots=normalized_plugin_roots,
include_project_memory=include_project_memory,
)
from uuid import uuid4
session_id = uuid4().hex[:12]
session_start_hooks = await hook_executor.execute(
HookEvent.SESSION_START,
{
"cwd": cwd,
"event": HookEvent.SESSION_START.value,
"session_id": session_id,
"model": settings.model,
"permission_mode": settings.permission.mode.value,
},
)
if session_start_hooks.additional_context:
system_prompt_text = (
f"{system_prompt_text}\n\n# Hook Additional Context\n\n"
f"{session_start_hooks.additional_context}"
)
restored_metadata = {
"permission_mode": settings.permission.mode.value,
@@ -394,10 +409,7 @@ async def build_runtime(
async def start_runtime(bundle: RuntimeBundle) -> None:
"""Run session start hooks."""
await bundle.hook_executor.execute(
HookEvent.SESSION_START,
{"cwd": bundle.cwd, "event": HookEvent.SESSION_START.value},
)
return None
async def close_runtime(bundle: RuntimeBundle) -> None:
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from openharness.api.client import ApiMessageCompleteEvent, SupportsStreamingMessages
from openharness.api.usage import UsageSnapshot
from openharness.config.settings import Settings, save_settings
from openharness.engine.messages import ConversationMessage, TextBlock
from openharness.engine.query_engine import QueryEngine
from openharness.hooks import HookEvent, HookExecutionContext, HookExecutor, load_hook_registry
from openharness.hooks.schemas import CommandHookDefinition
from openharness.permissions.checker import PermissionChecker
from openharness.tools.base import ToolRegistry
from openharness.ui.runtime import build_runtime
class CapturingApiClient(SupportsStreamingMessages):
def __init__(self) -> None:
self.requests = []
async def stream_message(self, request):
self.requests.append(request)
yield ApiMessageCompleteEvent(
message=ConversationMessage(role="assistant", content=[TextBlock(text="done")]),
usage=UsageSnapshot(),
)
def _python_json_command(code: str) -> str:
return f"{sys.executable} -c {json.dumps(code)}"
@pytest.mark.asyncio
async def test_command_hook_can_receive_payload_on_stdin(tmp_path: Path) -> None:
seen = tmp_path / "seen.json"
code = (
"import json, pathlib, sys; "
f"pathlib.Path({str(seen)!r}).write_text(sys.stdin.read(), encoding='utf-8'); "
"print(json.dumps({'additional_context': 'stdin ok'}))"
)
registry = load_hook_registry(
Settings(
hooks={
HookEvent.USER_PROMPT_SUBMIT.value: [
CommandHookDefinition(command=_python_json_command(code), stdin_payload=True)
]
}
)
)
client = CapturingApiClient()
executor = HookExecutor(
registry,
HookExecutionContext(cwd=tmp_path, api_client=client, default_model="test-model"),
)
result = await executor.execute(HookEvent.USER_PROMPT_SUBMIT, {"prompt": "hello"})
assert json.loads(seen.read_text(encoding="utf-8"))["prompt"] == "hello"
assert result.additional_context == "stdin ok"
@pytest.mark.asyncio
async def test_user_prompt_hook_context_is_injected_for_one_turn(tmp_path: Path) -> None:
code = "import json; print(json.dumps({'additional_context': 'CLAUDE_MEM_CONTEXT'}))"
registry = load_hook_registry(
Settings(
hooks={
HookEvent.USER_PROMPT_SUBMIT.value: [
CommandHookDefinition(command=_python_json_command(code))
]
}
)
)
client = CapturingApiClient()
engine = QueryEngine(
api_client=client,
tool_registry=ToolRegistry(),
permission_checker=PermissionChecker(Settings().permission),
cwd=tmp_path,
model="test-model",
system_prompt="system",
hook_executor=HookExecutor(
registry,
HookExecutionContext(cwd=tmp_path, api_client=client, default_model="test-model"),
),
tool_metadata={"session_id": "s1"},
)
events = [event async for event in engine.submit_message("hello")]
assert events
assert any("CLAUDE_MEM_CONTEXT" in message.text for message in client.requests[0].messages)
assert all("CLAUDE_MEM_CONTEXT" not in message.text for message in engine.messages)
@pytest.mark.asyncio
async def test_session_start_hook_context_is_added_to_runtime_system_prompt(tmp_path: Path) -> None:
code = "import json; print(json.dumps({'additional_context': 'SESSION_MEMORY_CONTEXT'}))"
settings = Settings(
provider="anthropic",
model="test-model",
hooks={
HookEvent.SESSION_START.value: [
CommandHookDefinition(command=_python_json_command(code))
]
},
)
config_dir = tmp_path / "config"
config_dir.mkdir()
save_settings(settings, config_dir / "settings.json")
import os
old_config_dir = os.environ.get("OPENHARNESS_CONFIG_DIR")
os.environ["OPENHARNESS_CONFIG_DIR"] = str(config_dir)
try:
bundle = await build_runtime(
cwd=str(tmp_path),
api_client=CapturingApiClient(),
include_project_memory=False,
)
finally:
if old_config_dir is None:
os.environ.pop("OPENHARNESS_CONFIG_DIR", None)
else:
os.environ["OPENHARNESS_CONFIG_DIR"] = old_config_dir
assert "SESSION_MEMORY_CONTEXT" in bundle.engine.system_prompt