Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f88935299 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user