@inneranimalmedia/agentsam-sdk 2.6.9 → 2.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/apps/cad-creator/shared/cad/package.json +1 -1
- package/apps/client-cms-editor/backend/package.json +3 -3
- package/apps/client-cms-editor/frontend/package.json +4 -4
- package/apps/client-cms-editor/package-lock.json +8 -8
- package/apps/client-cms-editor/package.json +1 -1
- package/apps/client-cms-editor/shared/cms/package.json +2 -2
- package/docs/architecture/HOOKS-MODELS-SELF-HOSTING-PLAN.md +199 -0
- package/examples/hyperspace-demo/index.html +16 -0
- package/examples/hyperspace-demo/package.json +21 -0
- package/examples/hyperspace-demo/src/main.tsx +160 -0
- package/examples/hyperspace-demo/tsconfig.json +9 -0
- package/examples/hyperspace-demo/vite.config.ts +24 -0
- package/migrations/runtime/0005_agentsam_hooks.sql +87 -0
- package/package.json +23 -7
- package/packages/agentsam-brand/package.json +1 -1
- package/packages/agentsam-brand/src/core/v2/compile.js +1 -1
- package/packages/agentsam-brand/src/core/v2/schema.js +1 -1
- package/packages/agentsam-contracts/package.json +1 -1
- package/packages/agentsam-contracts/src/errors.ts +19 -10
- package/packages/agentsam-errors/package.json +11 -3
- package/packages/agentsam-errors/protocol/agentsam.error.v2.schema.json +821 -0
- package/packages/agentsam-errors/protocol/error-catalog.json +3512 -0
- package/packages/agentsam-errors/src/envelope.js +63 -11
- package/packages/agentsam-errors/src/error.js +6 -0
- package/packages/agentsam-errors/src/fingerprint.js +9 -1
- package/packages/agentsam-errors/src/normalize.js +51 -6
- package/packages/agentsam-errors/src/recovery.js +9 -35
- package/packages/agentsam-errors/src/transport.js +6 -1
- package/packages/agentsam-errors/src/vocabulary.js +515 -126
- package/packages/agentsam-errors/test/catalog-v2.test.mjs +106 -0
- package/packages/agentsam-errors/types/index.d.ts +157 -0
- package/packages/agentsam-goap/package.json +2 -2
- package/packages/agentsam-hooks/README.md +206 -0
- package/packages/agentsam-hooks/bin/agentsam-hooks.mjs +64 -0
- package/packages/agentsam-hooks/bindings/go/command.go +55 -0
- package/packages/agentsam-hooks/bindings/go/errors_generated.go +402 -0
- package/packages/agentsam-hooks/bindings/go/go.mod +3 -0
- package/packages/agentsam-hooks/bindings/go/hooks.go +285 -0
- package/packages/agentsam-hooks/bindings/go/hooks_test.go +48 -0
- package/packages/agentsam-hooks/bindings/rust/Cargo.toml +11 -0
- package/packages/agentsam-hooks/bindings/rust/src/errors_generated.rs +396 -0
- package/packages/agentsam-hooks/bindings/rust/src/lib.rs +397 -0
- package/packages/agentsam-hooks/package.json +69 -0
- package/packages/agentsam-hooks/schema/manifest.json +54 -0
- package/packages/agentsam-hooks/scripts/clean-protocol.mjs +17 -0
- package/packages/agentsam-hooks/scripts/sync-protocol.mjs +27 -0
- package/packages/agentsam-hooks/src/adapters/agents.js +82 -0
- package/packages/agentsam-hooks/src/adapters/callback.js +4 -0
- package/packages/agentsam-hooks/src/adapters/capability.js +122 -0
- package/packages/agentsam-hooks/src/adapters/command.js +104 -0
- package/packages/agentsam-hooks/src/adapters/composite.js +31 -0
- package/packages/agentsam-hooks/src/adapters/http.js +58 -0
- package/packages/agentsam-hooks/src/adapters/lsp.js +116 -0
- package/packages/agentsam-hooks/src/adapters/mcp.js +76 -0
- package/packages/agentsam-hooks/src/adapters/provider.js +129 -0
- package/packages/agentsam-hooks/src/config.js +143 -0
- package/packages/agentsam-hooks/src/contracts.js +284 -0
- package/packages/agentsam-hooks/src/errors-generated.js +2732 -0
- package/packages/agentsam-hooks/src/index.d.ts +193 -0
- package/packages/agentsam-hooks/src/index.js +14 -0
- package/packages/agentsam-hooks/src/policy.js +34 -0
- package/packages/agentsam-hooks/src/runtime.js +312 -0
- package/packages/agentsam-hooks/src/store.js +264 -0
- package/packages/agentsam-hooks/test/adapters.test.mjs +216 -0
- package/packages/agentsam-hooks/test/runtime.test.mjs +258 -0
- package/packages/agentsam-knowledge/package.json +1 -1
- package/packages/agentsam-nav/dist/Nav.d.ts +43 -0
- package/packages/agentsam-nav/dist/NavCollapsible.d.ts +10 -0
- package/packages/agentsam-nav/dist/NavContent.d.ts +2 -0
- package/packages/agentsam-nav/dist/NavFooter.d.ts +2 -0
- package/packages/agentsam-nav/dist/NavGroup.d.ts +3 -0
- package/packages/agentsam-nav/dist/NavHeader.d.ts +2 -0
- package/packages/agentsam-nav/dist/NavMenu.d.ts +22 -0
- package/packages/agentsam-nav/dist/NavProvider.d.ts +35 -0
- package/packages/agentsam-nav/dist/NavTrigger.d.ts +2 -0
- package/packages/agentsam-nav/dist/Surfaces.d.ts +41 -0
- package/packages/agentsam-nav/dist/browser.d.ts +11 -0
- package/packages/agentsam-nav/dist/browser.global.js +135 -0
- package/packages/agentsam-nav/dist/browser.js +7290 -0
- package/packages/agentsam-nav/dist/contracts.d.ts +76 -0
- package/packages/agentsam-nav/dist/index.d.ts +11 -0
- package/packages/agentsam-nav/dist/index.js +7291 -0
- package/packages/agentsam-nav/dist/legacy.css +83 -0
- package/packages/agentsam-nav/dist/shell.css +113 -0
- package/packages/agentsam-nav/dist/theme.css +113 -0
- package/packages/agentsam-nav/dist/utils.d.ts +1 -0
- package/packages/agentsam-nav/package.json +1 -1
- package/packages/agentsam-queue-control/package.json +1 -1
- package/packages/agentsam-repository/package.json +1 -1
- package/packages/agentsam-repository/src/goap.js.bak-20260922011251 +670 -0
- package/packages/agentsam-vault/package.json +1 -1
- package/packages/agentsam-workbench/package.json +3 -3
- package/packages/connectors/cloudflare/package.json +1 -1
- package/packages/heuristic-theme/package.json +1 -1
- package/packages/identity/package.json +2 -2
- package/packages/providers/completeful/package.json +1 -1
- package/packages/theme-church-site/package.json +2 -2
- package/packages/theme-companions-site/package.json +2 -2
- package/packages/theme-floors-site/package.json +2 -2
- package/packages/theme-fuelnfree-site/package.json +2 -2
- package/packages/theme-handyman-site/package.json +2 -2
- package/packages/theme-insurance-site/package.json +2 -2
- package/packages/theme-scenes/package.json +1 -1
- package/packages/theme-shinshu-site/package.json +2 -2
- package/packages/work-graph/package.json +1 -1
- package/protocol/apps/agentsam.app.v1.schema.json +1 -1
- package/protocol/apps/agentsam.host-install.v1.schema.json +1 -1
- package/protocol/assets/asset-record.v1.schema.json +1 -1
- package/protocol/brand/brandpack.v1.schema.json +1 -1
- package/protocol/capabilities/capability-manifest.schema.json +1 -1
- package/protocol/capabilities/knowledge-search-input.schema.json +1 -0
- package/protocol/credentials/issue.v1.schema.json +1 -1
- package/protocol/database/agentsam.schema-pack.v1.schema.json +1 -1
- package/protocol/database/connection.v1.schema.json +1 -1
- package/protocol/embeddings/embedding-profile.v1.schema.json +1 -1
- package/protocol/errors/README.md +36 -0
- package/protocol/errors/agentsam.error.v2.schema.json +821 -0
- package/protocol/errors/error-catalog.json +3506 -158
- package/protocol/errors/error-envelope.schema.json +195 -52
- package/protocol/errors/recovery.v1.schema.json +1 -1
- package/protocol/features/agentsam.feature.v1.schema.json +1 -1
- package/protocol/hooks/README.md +18 -0
- package/protocol/hooks/agentsam.hook.output.v1.schema.json +27 -0
- package/protocol/hooks/agentsam.hook.receipt.v1.schema.json +61 -0
- package/protocol/hooks/agentsam.hook.v1.schema.json +35 -0
- package/protocol/hooks/agentsam.hooks.config.v1.schema.json +78 -0
- package/protocol/local-engine/benchmark.v1.schema.json +1 -1
- package/protocol/local-engine/engine.v1.schema.json +1 -1
- package/protocol/models/model-inventory-v2.schema.json +1 -1
- package/protocol/models/provider-model-snapshot.v1.schema.json +22 -0
- package/protocol/rpc/v1/errors.proto +1 -0
- package/protocol/runtime/agentsam.runtime.v1.schema.json +1 -1
- package/protocol/runtime/workspace-fs.v1.schema.json +1 -1
- package/protocol/sam/activity.v1.schema.json +1 -1
- package/protocol/sam/answer.v1.schema.json +1 -1
- package/protocol/sam/calibration.v1.schema.json +1 -1
- package/protocol/sam/decision-receipt.v1.schema.json +1 -1
- package/protocol/sam/evaluation.v1.schema.json +1 -1
- package/protocol/sam/operation.schema.json +1 -1
- package/protocol/sam/outcome.v1.schema.json +1 -1
- package/protocol/sam/question.v1.schema.json +1 -1
- package/protocol/sam/result.schema.json +1 -1
- package/protocol/sam/state.v1.schema.json +1 -1
- package/protocol/scoring/scorecard.v1.schema.json +1 -1
- package/protocol/skills/agentsam.interaction.v1.schema.json +1 -1
- package/protocol/skills/agentsam.skill.v1.schema.json +1 -1
- package/protocol/theme-refinery/product-metadata.schema.json +1 -1
- package/protocol/theme-refinery/reconciliation-receipt.schema.json +1 -1
- package/protocol/ui/icon.v1.schema.json +1 -1
- package/python/README.md +20 -0
- package/python/agentsam_sdk/errors/__init__.py +4 -0
- package/python/agentsam_sdk/errors/vocabulary.py +2730 -0
- package/python/agentsam_sdk/hooks/__init__.py +32 -0
- package/python/agentsam_sdk/hooks/adapters.py +132 -0
- package/python/agentsam_sdk/hooks/contracts.py +121 -0
- package/python/agentsam_sdk/hooks/runtime.py +253 -0
- package/python/tests/test_hooks.py +115 -0
- package/src/agent/responses-runner.js +145 -1
- package/src/cli/command-catalog.js +1 -0
- package/src/cli.js +9 -0
- package/src/commands/cheat-sheet.js +1 -0
- package/src/commands/hooks.js +260 -0
- package/src/commands/models.js +1 -1
- package/src/commands/shell.js +114 -9
- package/src/errors/diagnostic.js +9 -9
- package/src/index.js +32 -0
- package/src/knowledge/service/grpc-codec.js +2 -0
- package/src/lib/slash-commands.js +1 -0
- package/src/local/runtime-store.js +46 -0
- package/src/models/catalog.js +0 -49
- package/src/models/inventory-core.js +69 -0
- package/src/plugins/index.js +8 -1
- package/src/plugins/registry.js +56 -0
- package/src/rpc/generated/errors_pb.js +49 -1
- package/src/tools/runtime.js +13 -5
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Portable AgentSam lifecycle hooks."""
|
|
2
|
+
from .adapters import callback_adapter, command_adapter, http_adapter
|
|
3
|
+
from .contracts import (
|
|
4
|
+
ERROR_HANDLING_DECISIONS,
|
|
5
|
+
FAILURE_MODES,
|
|
6
|
+
HOOK_EVENTS,
|
|
7
|
+
HOOK_PROTOCOL_SCHEMA,
|
|
8
|
+
HOOK_RECEIPT_SCHEMA,
|
|
9
|
+
PERMISSION_DECISIONS,
|
|
10
|
+
HookEnvelope,
|
|
11
|
+
HookOutput,
|
|
12
|
+
normalize_event,
|
|
13
|
+
)
|
|
14
|
+
from .runtime import HookDefinition, HookExecutionError, HookRuntime
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ERROR_HANDLING_DECISIONS",
|
|
18
|
+
"FAILURE_MODES",
|
|
19
|
+
"HOOK_EVENTS",
|
|
20
|
+
"HOOK_PROTOCOL_SCHEMA",
|
|
21
|
+
"HOOK_RECEIPT_SCHEMA",
|
|
22
|
+
"PERMISSION_DECISIONS",
|
|
23
|
+
"HookDefinition",
|
|
24
|
+
"HookEnvelope",
|
|
25
|
+
"HookExecutionError",
|
|
26
|
+
"HookOutput",
|
|
27
|
+
"HookRuntime",
|
|
28
|
+
"callback_adapter",
|
|
29
|
+
"command_adapter",
|
|
30
|
+
"http_adapter",
|
|
31
|
+
"normalize_event",
|
|
32
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Callback, subprocess, and HTTP ports for AgentSam Hooks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Mapping
|
|
12
|
+
|
|
13
|
+
from .contracts import HookEnvelope
|
|
14
|
+
|
|
15
|
+
_PORTABLE_ENV_KEYS = (
|
|
16
|
+
"PATH", "Path", "PATHEXT", "SYSTEMROOT", "WINDIR", "HOME", "USERPROFILE",
|
|
17
|
+
"TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def callback_adapter(handler: Callable[[Mapping[str, Any], Mapping[str, Any], HookEnvelope], Any]):
|
|
22
|
+
async def invoke(envelope: HookEnvelope):
|
|
23
|
+
result = handler(envelope.input, envelope.invocation, envelope)
|
|
24
|
+
if hasattr(result, "__await__"):
|
|
25
|
+
return await result
|
|
26
|
+
return result
|
|
27
|
+
return invoke
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def command_adapter(
|
|
31
|
+
command: str,
|
|
32
|
+
args: list[str] | None = None,
|
|
33
|
+
*,
|
|
34
|
+
cwd: str | None = None,
|
|
35
|
+
env: Mapping[str, str] | None = None,
|
|
36
|
+
inherit_environment: bool = False,
|
|
37
|
+
timeout_ms: int = 10_000,
|
|
38
|
+
max_output_bytes: int = 1_048_576,
|
|
39
|
+
):
|
|
40
|
+
if not str(command).strip():
|
|
41
|
+
raise ValueError("hook_command_required")
|
|
42
|
+
configured_args = [str(value) for value in (args or [])]
|
|
43
|
+
environment = dict(os.environ) if inherit_environment else {
|
|
44
|
+
key: os.environ[key] for key in _PORTABLE_ENV_KEYS if key in os.environ
|
|
45
|
+
}
|
|
46
|
+
environment.update({str(key): str(value) for key, value in (env or {}).items()})
|
|
47
|
+
|
|
48
|
+
async def invoke(envelope: HookEnvelope):
|
|
49
|
+
try:
|
|
50
|
+
process = await asyncio.create_subprocess_exec(
|
|
51
|
+
command, *configured_args,
|
|
52
|
+
cwd=cwd or envelope.cwd,
|
|
53
|
+
env=environment,
|
|
54
|
+
stdin=asyncio.subprocess.PIPE,
|
|
55
|
+
stdout=asyncio.subprocess.PIPE,
|
|
56
|
+
stderr=asyncio.subprocess.PIPE,
|
|
57
|
+
)
|
|
58
|
+
except Exception as error:
|
|
59
|
+
failure = RuntimeError(f"hook_command_spawn_failed:{command}:{error}")
|
|
60
|
+
failure.__cause__ = error
|
|
61
|
+
raise failure
|
|
62
|
+
payload = (json.dumps(envelope.to_dict(), separators=(",", ":")) + "\n").encode()
|
|
63
|
+
try:
|
|
64
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(payload), timeout=timeout_ms / 1000)
|
|
65
|
+
except asyncio.TimeoutError:
|
|
66
|
+
process.kill()
|
|
67
|
+
await process.wait()
|
|
68
|
+
raise TimeoutError(f"hook_command_timeout:{command}:{timeout_ms}")
|
|
69
|
+
if len(stdout) + len(stderr) > max_output_bytes:
|
|
70
|
+
raise RuntimeError(f"hook_command_output_limit_exceeded:{command}:{max_output_bytes}")
|
|
71
|
+
if process.returncode != 0:
|
|
72
|
+
detail = stderr.decode(errors="replace").strip()[:512]
|
|
73
|
+
raise RuntimeError(f"hook_command_failed:{command}:exit={process.returncode}:{detail}")
|
|
74
|
+
lines = [line for line in stdout.decode().splitlines() if line.strip()]
|
|
75
|
+
if not lines:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return json.loads(lines[-1])
|
|
79
|
+
except json.JSONDecodeError as error:
|
|
80
|
+
raise RuntimeError(f"hook_command_invalid_json:{command}:{error}") from error
|
|
81
|
+
|
|
82
|
+
return invoke
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def http_adapter(
|
|
86
|
+
url: str,
|
|
87
|
+
*,
|
|
88
|
+
headers: Mapping[str, str] | None = None,
|
|
89
|
+
timeout_ms: int = 10_000,
|
|
90
|
+
max_response_bytes: int = 1_048_576,
|
|
91
|
+
):
|
|
92
|
+
parsed = urllib.parse.urlparse(url)
|
|
93
|
+
if parsed.scheme not in ("http", "https"):
|
|
94
|
+
raise ValueError(f"unsupported_hook_http_protocol:{parsed.scheme}")
|
|
95
|
+
if parsed.username or parsed.password:
|
|
96
|
+
raise ValueError("hook_http_url_must_not_contain_credentials")
|
|
97
|
+
configured_headers = {"Accept": "application/json", "Content-Type": "application/json", **dict(headers or {})}
|
|
98
|
+
|
|
99
|
+
def failure(message: str, cause: BaseException) -> RuntimeError:
|
|
100
|
+
error = RuntimeError(message)
|
|
101
|
+
error.adapter = "http"
|
|
102
|
+
error.protocol = "http"
|
|
103
|
+
error.transport = parsed.scheme
|
|
104
|
+
error.__cause__ = cause
|
|
105
|
+
return error
|
|
106
|
+
|
|
107
|
+
def request(envelope: HookEnvelope):
|
|
108
|
+
payload = json.dumps(envelope.to_dict(), separators=(",", ":")).encode()
|
|
109
|
+
req = urllib.request.Request(url, data=payload, headers=configured_headers, method="POST")
|
|
110
|
+
try:
|
|
111
|
+
with urllib.request.urlopen(req, timeout=timeout_ms / 1000) as response:
|
|
112
|
+
body = response.read(max_response_bytes + 1)
|
|
113
|
+
except urllib.error.HTTPError as error:
|
|
114
|
+
detail = error.read(512).decode(errors="replace")
|
|
115
|
+
raise failure(f"hook_http_failed:{error.code}:{detail}", error)
|
|
116
|
+
except (urllib.error.URLError, TimeoutError) as error:
|
|
117
|
+
detail = str(getattr(error, "reason", error))
|
|
118
|
+
reason = "hook_http_timeout" if "timed out" in detail.lower() or isinstance(getattr(error, "reason", None), TimeoutError) else "hook_http_request_failed"
|
|
119
|
+
raise failure(f"{reason}:{detail}", error)
|
|
120
|
+
if len(body) > max_response_bytes:
|
|
121
|
+
raise failure(f"hook_http_response_limit_exceeded:{max_response_bytes}", RuntimeError("response limit exceeded"))
|
|
122
|
+
if not body.strip():
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
return json.loads(body)
|
|
126
|
+
except json.JSONDecodeError as error:
|
|
127
|
+
raise failure(f"hook_http_invalid_json:{error}", error)
|
|
128
|
+
|
|
129
|
+
async def invoke(envelope: HookEnvelope):
|
|
130
|
+
return await asyncio.to_thread(request, envelope)
|
|
131
|
+
|
|
132
|
+
return invoke
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Language-neutral AgentSam hook protocol models.
|
|
2
|
+
|
|
3
|
+
The wire contract uses snake_case and JSON-compatible values so the same hook
|
|
4
|
+
can run in-process, as a subprocess, or behind HTTP in any SDK language.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from typing import Any, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
HOOK_PROTOCOL_SCHEMA = "agentsam.hook.v1"
|
|
12
|
+
HOOK_RECEIPT_SCHEMA = "agentsam.hook.receipt.v1"
|
|
13
|
+
HOOK_EVENTS = (
|
|
14
|
+
"session_start", "session_end", "user_prompt_submitted", "user_prompt_transformed",
|
|
15
|
+
"pre_model_use", "post_model_use", "pre_tool_use", "post_tool_use",
|
|
16
|
+
"post_tool_use_failure", "error_occurred", "agent_stop", "subagent_start", "subagent_stop",
|
|
17
|
+
)
|
|
18
|
+
PERMISSION_DECISIONS = ("allow", "deny", "ask")
|
|
19
|
+
ERROR_HANDLING_DECISIONS = ("retry", "skip", "abort")
|
|
20
|
+
FAILURE_MODES = ("open", "closed", "error")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_event(value: str) -> str:
|
|
24
|
+
source = str(value or "").strip()
|
|
25
|
+
if source in HOOK_EVENTS:
|
|
26
|
+
return source
|
|
27
|
+
if source.startswith("on") and len(source) > 2:
|
|
28
|
+
source = source[2:3].lower() + source[3:]
|
|
29
|
+
normalized = ""
|
|
30
|
+
for character in source:
|
|
31
|
+
if character.isupper():
|
|
32
|
+
normalized += "_" + character.lower()
|
|
33
|
+
else:
|
|
34
|
+
normalized += character
|
|
35
|
+
if normalized in HOOK_EVENTS:
|
|
36
|
+
return normalized
|
|
37
|
+
raise ValueError(f"unsupported_hook_event:{value or '<missing>'}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class HookEnvelope:
|
|
42
|
+
hook: str
|
|
43
|
+
timestamp: int
|
|
44
|
+
cwd: str
|
|
45
|
+
invocation: Mapping[str, Any] = field(default_factory=dict)
|
|
46
|
+
input: Mapping[str, Any] = field(default_factory=dict)
|
|
47
|
+
schema: str = HOOK_PROTOCOL_SCHEMA
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
normalize_event(self.hook)
|
|
51
|
+
if self.timestamp < 0:
|
|
52
|
+
raise ValueError("hook_timestamp_must_be_non_negative")
|
|
53
|
+
allowed = {"session_id", "run_id", "turn_id", "message_id", "agent_id", "parent_agent_id", "source", "metadata"}
|
|
54
|
+
unknown = set(self.invocation) - allowed
|
|
55
|
+
if unknown:
|
|
56
|
+
raise ValueError(f"unsupported_hook_invocation_field:{sorted(unknown)[0]}")
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, Any]:
|
|
59
|
+
return asdict(self)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, value: Mapping[str, Any]) -> "HookEnvelope":
|
|
63
|
+
if value.get("schema") != HOOK_PROTOCOL_SCHEMA:
|
|
64
|
+
raise ValueError(f"unsupported_hook_schema:{value.get('schema')}")
|
|
65
|
+
return cls(
|
|
66
|
+
hook=normalize_event(str(value.get("hook", ""))),
|
|
67
|
+
timestamp=int(value.get("timestamp", 0)),
|
|
68
|
+
cwd=str(value.get("cwd", "")),
|
|
69
|
+
invocation=dict(value.get("invocation") or {}),
|
|
70
|
+
input=dict(value.get("input") or {}),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class HookOutput:
|
|
76
|
+
permission_decision: Optional[str] = None
|
|
77
|
+
permission_decision_reason: Optional[str] = None
|
|
78
|
+
modified_args: Optional[Mapping[str, Any]] = None
|
|
79
|
+
modified_request: Optional[Mapping[str, Any]] = None
|
|
80
|
+
modified_result: Any = None
|
|
81
|
+
modified_prompt: Optional[str] = None
|
|
82
|
+
modified_transformed_prompt: Optional[str] = None
|
|
83
|
+
modified_config: Optional[Mapping[str, Any]] = None
|
|
84
|
+
additional_context: Optional[str] = None
|
|
85
|
+
suppress_output: Optional[bool] = None
|
|
86
|
+
error_handling: Optional[str] = None
|
|
87
|
+
retry_count: Optional[int] = None
|
|
88
|
+
user_notification: Optional[str] = None
|
|
89
|
+
decision: Optional[str] = None
|
|
90
|
+
reason: Optional[str] = None
|
|
91
|
+
cleanup_actions: Optional[list[str]] = None
|
|
92
|
+
session_summary: Optional[str] = None
|
|
93
|
+
metadata: Optional[Mapping[str, Any]] = None
|
|
94
|
+
_present_fields: frozenset[str] = field(default_factory=frozenset, repr=False, compare=False)
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
if self.permission_decision and self.permission_decision not in PERMISSION_DECISIONS:
|
|
98
|
+
raise ValueError(f"invalid_permission_decision:{self.permission_decision}")
|
|
99
|
+
if self.error_handling and self.error_handling not in ERROR_HANDLING_DECISIONS:
|
|
100
|
+
raise ValueError(f"invalid_error_handling:{self.error_handling}")
|
|
101
|
+
if self.decision and self.decision not in ("allow", "block"):
|
|
102
|
+
raise ValueError(f"invalid_stop_decision:{self.decision}")
|
|
103
|
+
if self.retry_count is not None and not 0 <= self.retry_count <= 10:
|
|
104
|
+
raise ValueError("retry_count_must_be_between_0_and_10")
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> dict[str, Any]:
|
|
107
|
+
values = asdict(self)
|
|
108
|
+
values.pop("_present_fields", None)
|
|
109
|
+
return {
|
|
110
|
+
key: value for key, value in values.items()
|
|
111
|
+
if value is not None or key in self._present_fields
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_value(cls, value: Optional[Mapping[str, Any] | "HookOutput"]) -> "HookOutput":
|
|
116
|
+
if value is None:
|
|
117
|
+
return cls()
|
|
118
|
+
if isinstance(value, cls):
|
|
119
|
+
return value
|
|
120
|
+
fields = dict(value)
|
|
121
|
+
return cls(**fields, _present_fields=frozenset(fields))
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Async reference runtime for AgentSam hooks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import copy
|
|
6
|
+
import inspect
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Awaitable, Callable, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
from ..errors import error_policy, normalize_error_reason
|
|
14
|
+
from .contracts import FAILURE_MODES, HookEnvelope, HookOutput, normalize_event
|
|
15
|
+
|
|
16
|
+
HookHandler = Callable[[HookEnvelope], Optional[HookOutput | Mapping[str, Any]] | Awaitable[Optional[HookOutput | Mapping[str, Any]]]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class HookDefinition:
|
|
21
|
+
id: str
|
|
22
|
+
handler: HookHandler
|
|
23
|
+
priority: float = 100
|
|
24
|
+
timeout_ms: int = 10_000
|
|
25
|
+
failure_mode: Optional[str] = None
|
|
26
|
+
enabled: bool = True
|
|
27
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HookExecutionError(RuntimeError):
|
|
31
|
+
def __init__(self, definition: HookDefinition, cause: BaseException):
|
|
32
|
+
super().__init__(f"hook_execution_failed:{definition.id}:{cause}")
|
|
33
|
+
self.code = "AGENTSAM_HOOK_EXECUTION_FAILED"
|
|
34
|
+
self.reason = normalize_error_reason(getattr(cause, "reason", None), str(cause), getattr(cause, "code", None))
|
|
35
|
+
self.error_code = error_policy(self.reason)["code"]
|
|
36
|
+
self.hook_id = definition.id
|
|
37
|
+
self.__cause__ = cause
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _safe_error(error: BaseException) -> str:
|
|
41
|
+
message = str(error)
|
|
42
|
+
message = re.sub(r"(authorization\s*[:=]\s*bearer\s+)[^\s,;]+", r"\1[REDACTED]", message, flags=re.I)
|
|
43
|
+
message = re.sub(r"((?:api[_-]?key|password|secret|token)\s*[:=]\s*)[^\s,;]+", r"\1[REDACTED]", message, flags=re.I)
|
|
44
|
+
return message[:512]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fingerprint(*values: object) -> str:
|
|
48
|
+
text = "\x1f".join(str(value or "").strip() for value in values).encode()
|
|
49
|
+
value = 0xCBF29CE484222325
|
|
50
|
+
for byte in text:
|
|
51
|
+
value ^= byte
|
|
52
|
+
value = (value * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
|
53
|
+
return f"err_{value:016x}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _canonical_failure(definition: HookDefinition, event: str, error: BaseException) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
57
|
+
reason = normalize_error_reason(getattr(error, "reason", None), str(error), getattr(error, "code", None))
|
|
58
|
+
if reason == "hook_handler_failed" and isinstance(error, (TimeoutError, asyncio.TimeoutError)):
|
|
59
|
+
reason = "hook_handler_timeout"
|
|
60
|
+
if reason.startswith(("mcp_", "lsp_")):
|
|
61
|
+
hook_specific = normalize_error_reason(f"hook_{reason}")
|
|
62
|
+
if hook_specific != "hook_handler_failed":
|
|
63
|
+
reason = hook_specific
|
|
64
|
+
policy = error_policy(reason)
|
|
65
|
+
is_post = event in ("post_tool_use", "post_model_use")
|
|
66
|
+
mode = definition.failure_mode or _default_failure_mode(event)
|
|
67
|
+
behavior = "fail_closed" if mode == "closed" else "fail_open" if mode == "open" else "no_replay" if is_post else "abort"
|
|
68
|
+
side_effect = "confirmed_applied" if is_post else "unknown" if event == "post_tool_use_failure" else "not_started"
|
|
69
|
+
adapter = str(getattr(error, "adapter", "") or definition.metadata.get("adapter_type", "") or "").strip() or None
|
|
70
|
+
if adapter is None:
|
|
71
|
+
adapter = next((name for name in ("http", "mcp", "lsp", "command") if reason.startswith(f"hook_{name}_")), None)
|
|
72
|
+
protocol = str(getattr(error, "protocol", "") or ("agentsam.hook.v1" if adapter == "command" else adapter or "")).strip() or None
|
|
73
|
+
transport = str(getattr(error, "transport", "") or ("process" if adapter == "command" else "")).strip() or None
|
|
74
|
+
feature = f"hooks.{event}"
|
|
75
|
+
message = _safe_error(error)
|
|
76
|
+
retryable = bool(policy["retryable"]) and not is_post and behavior != "fail_closed"
|
|
77
|
+
canonical = {
|
|
78
|
+
"error_code": policy["code"], "reason": reason, "domain": policy["domain"],
|
|
79
|
+
"failure_class": policy["failure_class"], "stage": policy["default_stage"], "feature": feature,
|
|
80
|
+
"failure_behavior": behavior, "retryable": retryable, "side_effect_state": side_effect,
|
|
81
|
+
"adapter": adapter, "protocol": protocol, "transport": transport,
|
|
82
|
+
"fingerprint": _fingerprint(
|
|
83
|
+
policy["code"], reason, policy["domain"], policy["failure_class"], policy["default_stage"],
|
|
84
|
+
feature, behavior, side_effect, adapter, protocol, transport, definition.id,
|
|
85
|
+
),
|
|
86
|
+
"message": message,
|
|
87
|
+
}
|
|
88
|
+
native = {
|
|
89
|
+
"code": str(getattr(error, "code", ""))[:128] or None,
|
|
90
|
+
"exception_type": type(error).__name__[:128],
|
|
91
|
+
"message": message,
|
|
92
|
+
}
|
|
93
|
+
return canonical, native
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _default_failure_mode(event: str) -> str:
|
|
97
|
+
return "closed" if event in ("pre_tool_use", "pre_model_use") else "open"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _merge_output(current: dict[str, Any], update: dict[str, Any]) -> None:
|
|
101
|
+
for key, value in update.items():
|
|
102
|
+
if key == "additional_context":
|
|
103
|
+
continue
|
|
104
|
+
if key == "suppress_output":
|
|
105
|
+
current[key] = bool(current.get(key)) or value is True
|
|
106
|
+
elif key == "retry_count":
|
|
107
|
+
current[key] = max(int(current.get(key, 0)), int(value))
|
|
108
|
+
elif key == "cleanup_actions":
|
|
109
|
+
current[key] = [*current.get(key, []), *value]
|
|
110
|
+
elif key == "modified_config":
|
|
111
|
+
current[key] = {**current.get(key, {}), **copy.deepcopy(value)}
|
|
112
|
+
else:
|
|
113
|
+
current[key] = copy.deepcopy(value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _update_input(event: str, value: dict[str, Any], output: dict[str, Any]) -> None:
|
|
117
|
+
if "modified_args" in output:
|
|
118
|
+
value["tool_args"] = copy.deepcopy(output["modified_args"])
|
|
119
|
+
if "modified_request" in output:
|
|
120
|
+
value["request"] = copy.deepcopy(output["modified_request"])
|
|
121
|
+
if "modified_result" in output:
|
|
122
|
+
value["tool_result" if event == "post_tool_use" else "model_result"] = copy.deepcopy(output["modified_result"])
|
|
123
|
+
if "modified_prompt" in output:
|
|
124
|
+
value["prompt"] = output["modified_prompt"]
|
|
125
|
+
if "modified_transformed_prompt" in output:
|
|
126
|
+
value["transformed_prompt"] = output["modified_transformed_prompt"]
|
|
127
|
+
if "modified_config" in output:
|
|
128
|
+
value["config"] = {**value.get("config", {}), **copy.deepcopy(output["modified_config"])}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _terminal(event: str, output: dict[str, Any]) -> bool:
|
|
132
|
+
return (
|
|
133
|
+
event in ("pre_tool_use", "pre_model_use")
|
|
134
|
+
and output.get("permission_decision") in ("deny", "ask")
|
|
135
|
+
) or (event == "agent_stop" and output.get("decision") == "block")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class HookRuntime:
|
|
139
|
+
def __init__(self, *, clock: Callable[[], float] | None = None, on_receipt: Callable[[Mapping[str, Any]], Any] | None = None):
|
|
140
|
+
self._clock = clock or (lambda: time.time() * 1000)
|
|
141
|
+
self._on_receipt = on_receipt
|
|
142
|
+
self._registry: dict[str, list[HookDefinition]] = {}
|
|
143
|
+
self._sequence = 0
|
|
144
|
+
|
|
145
|
+
def register(self, event: str, definition: HookDefinition | HookHandler, **options: Any) -> "HookRuntime":
|
|
146
|
+
hook = normalize_event(event)
|
|
147
|
+
if not isinstance(definition, HookDefinition):
|
|
148
|
+
definition = HookDefinition(id=options.pop("id", f"{hook}:{self._sequence + 1}"), handler=definition, **options)
|
|
149
|
+
if definition.failure_mode and definition.failure_mode not in FAILURE_MODES:
|
|
150
|
+
raise ValueError(f"invalid_hook_failure_mode:{definition.failure_mode}")
|
|
151
|
+
if definition.timeout_ms < 1 or definition.timeout_ms > 600_000:
|
|
152
|
+
raise ValueError(f"invalid_hook_timeout_ms:{definition.timeout_ms}")
|
|
153
|
+
rows = self._registry.setdefault(hook, [])
|
|
154
|
+
if any(row.id == definition.id for row in rows):
|
|
155
|
+
raise ValueError(f"duplicate_hook_id:{definition.id}")
|
|
156
|
+
rows.append(definition)
|
|
157
|
+
rows.sort(key=lambda row: (row.priority, row.id))
|
|
158
|
+
self._sequence += 1
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
def list(self, event: str | None = None) -> list[dict[str, Any]]:
|
|
162
|
+
rows = self._registry.get(normalize_event(event), []) if event else [row for group in self._registry.values() for row in group]
|
|
163
|
+
return [{
|
|
164
|
+
"id": row.id, "hook": next((hook for hook, group in self._registry.items() if row in group), None),
|
|
165
|
+
"priority": row.priority, "timeout_ms": row.timeout_ms,
|
|
166
|
+
"failure_mode": row.failure_mode, "enabled": row.enabled, "metadata": dict(row.metadata),
|
|
167
|
+
} for row in rows]
|
|
168
|
+
|
|
169
|
+
async def _observe(self, receipt: Mapping[str, Any]) -> None:
|
|
170
|
+
if self._on_receipt is None:
|
|
171
|
+
return
|
|
172
|
+
try:
|
|
173
|
+
result = self._on_receipt(receipt)
|
|
174
|
+
if inspect.isawaitable(result):
|
|
175
|
+
await result
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
async def dispatch(
|
|
180
|
+
self,
|
|
181
|
+
event: str,
|
|
182
|
+
input: Mapping[str, Any] | None = None,
|
|
183
|
+
invocation: Mapping[str, Any] | None = None,
|
|
184
|
+
*,
|
|
185
|
+
cwd: str | None = None,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
hook = normalize_event(event)
|
|
188
|
+
working = copy.deepcopy(dict(input or {}))
|
|
189
|
+
invocation_value = copy.deepcopy(dict(invocation or {}))
|
|
190
|
+
combined: dict[str, Any] = {}
|
|
191
|
+
contexts: list[str] = []
|
|
192
|
+
receipts: list[dict[str, Any]] = []
|
|
193
|
+
errors: list[dict[str, Any]] = []
|
|
194
|
+
|
|
195
|
+
for definition in self._registry.get(hook, []):
|
|
196
|
+
if not definition.enabled:
|
|
197
|
+
continue
|
|
198
|
+
envelope = HookEnvelope(
|
|
199
|
+
hook=hook,
|
|
200
|
+
timestamp=int(self._clock()),
|
|
201
|
+
cwd=str(cwd or working.get("cwd") or os.getcwd()),
|
|
202
|
+
invocation=invocation_value,
|
|
203
|
+
input=copy.deepcopy(working),
|
|
204
|
+
)
|
|
205
|
+
started = int(self._clock())
|
|
206
|
+
try:
|
|
207
|
+
result = definition.handler(envelope)
|
|
208
|
+
if inspect.isawaitable(result):
|
|
209
|
+
result = await asyncio.wait_for(result, timeout=definition.timeout_ms / 1000)
|
|
210
|
+
output = HookOutput.from_value(result).to_dict()
|
|
211
|
+
_update_input(hook, working, output)
|
|
212
|
+
_merge_output(combined, output)
|
|
213
|
+
if output.get("additional_context"):
|
|
214
|
+
contexts.append(str(output["additional_context"]))
|
|
215
|
+
receipt = {
|
|
216
|
+
"schema": "agentsam.hook.receipt.v1", "hook_id": definition.id, "hook": hook,
|
|
217
|
+
"status": "completed", "started_at": started, "completed_at": int(self._clock()),
|
|
218
|
+
"input_keys": sorted(envelope.input.keys()), "output_keys": sorted(output.keys()),
|
|
219
|
+
}
|
|
220
|
+
receipt["duration_ms"] = max(0, receipt["completed_at"] - started)
|
|
221
|
+
receipts.append(receipt)
|
|
222
|
+
await self._observe(receipt)
|
|
223
|
+
if _terminal(hook, output):
|
|
224
|
+
break
|
|
225
|
+
except Exception as error:
|
|
226
|
+
completed = int(self._clock())
|
|
227
|
+
safe_error = _safe_error(error)
|
|
228
|
+
canonical, native_evidence = _canonical_failure(definition, hook, error)
|
|
229
|
+
receipt = {
|
|
230
|
+
"schema": "agentsam.hook.receipt.v1", "hook_id": definition.id, "hook": hook,
|
|
231
|
+
"status": "failed", "started_at": started, "completed_at": completed,
|
|
232
|
+
"duration_ms": max(0, completed - started), "input_keys": sorted(envelope.input.keys()),
|
|
233
|
+
"output_keys": [], "error": canonical, "native_evidence": native_evidence,
|
|
234
|
+
}
|
|
235
|
+
receipts.append(receipt)
|
|
236
|
+
errors.append({"hook_id": definition.id, "hook": hook, **canonical, "native_evidence": native_evidence})
|
|
237
|
+
await self._observe(receipt)
|
|
238
|
+
mode = definition.failure_mode or _default_failure_mode(hook)
|
|
239
|
+
if mode == "error":
|
|
240
|
+
raise HookExecutionError(definition, error) from error
|
|
241
|
+
if mode == "closed":
|
|
242
|
+
combined.update({
|
|
243
|
+
"permission_decision": "deny",
|
|
244
|
+
"permission_decision_reason": f"Hook '{definition.id}' failed closed: {safe_error}",
|
|
245
|
+
})
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
if contexts:
|
|
249
|
+
combined["additional_context"] = "\n\n".join(contexts)
|
|
250
|
+
return {
|
|
251
|
+
"schema": "agentsam.hook.dispatch.v1", "hook": hook,
|
|
252
|
+
"input": working, "output": combined, "receipts": receipts, "errors": errors,
|
|
253
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from agentsam_sdk.errors import normalize_error_reason
|
|
9
|
+
from agentsam_sdk.hooks import (
|
|
10
|
+
HOOK_PROTOCOL_SCHEMA,
|
|
11
|
+
HookDefinition,
|
|
12
|
+
HookOutput,
|
|
13
|
+
HookRuntime,
|
|
14
|
+
command_adapter,
|
|
15
|
+
normalize_event,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HookContractTests(unittest.TestCase):
|
|
20
|
+
def test_protocol_contract_is_canonical(self):
|
|
21
|
+
self.assertEqual(HOOK_PROTOCOL_SCHEMA, "agentsam.hook.v1")
|
|
22
|
+
self.assertEqual(normalize_event("onPreToolUse"), "pre_tool_use")
|
|
23
|
+
self.assertEqual(HookOutput(permission_decision="allow").to_dict(), {"permission_decision": "allow"})
|
|
24
|
+
self.assertEqual(HookOutput.from_value({"modified_result": None}).to_dict(), {"modified_result": None})
|
|
25
|
+
self.assertEqual(HookOutput.from_value({"modified_config": {"auto_compact": False}}).to_dict(), {"modified_config": {"auto_compact": False}})
|
|
26
|
+
with self.assertRaisesRegex(ValueError, "invalid_permission_decision"):
|
|
27
|
+
HookOutput(permission_decision="maybe")
|
|
28
|
+
self.assertEqual(normalize_error_reason("AGENTSAM_HOOK_HTTP_FAILED"), "hook_http_request_failed")
|
|
29
|
+
self.assertEqual(normalize_error_reason("hook_command_invalid_json:node"), "hook_command_output_invalid")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HookRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
|
33
|
+
async def test_composes_modified_arguments_and_context(self):
|
|
34
|
+
runtime = HookRuntime()
|
|
35
|
+
runtime.register("pre_tool_use", HookDefinition(
|
|
36
|
+
id="first", priority=10,
|
|
37
|
+
handler=lambda envelope: {
|
|
38
|
+
"permission_decision": "allow",
|
|
39
|
+
"modified_args": {**envelope.input["tool_args"], "first": True},
|
|
40
|
+
"additional_context": "first context",
|
|
41
|
+
},
|
|
42
|
+
))
|
|
43
|
+
runtime.register("pre_tool_use", HookDefinition(
|
|
44
|
+
id="second", priority=20,
|
|
45
|
+
handler=lambda envelope: {
|
|
46
|
+
"modified_args": {**envelope.input["tool_args"], "second": True},
|
|
47
|
+
"additional_context": "second context",
|
|
48
|
+
},
|
|
49
|
+
))
|
|
50
|
+
result = await runtime.dispatch("pre_tool_use", {
|
|
51
|
+
"tool_name": "read", "tool_args": {"value": 1},
|
|
52
|
+
}, {"session_id": "session-test"})
|
|
53
|
+
self.assertEqual(result["input"]["tool_args"], {"value": 1, "first": True, "second": True})
|
|
54
|
+
self.assertEqual(result["output"]["additional_context"], "first context\n\nsecond context")
|
|
55
|
+
self.assertEqual([row["hook_id"] for row in result["receipts"]], ["first", "second"])
|
|
56
|
+
|
|
57
|
+
async def test_session_config_patches_compose(self):
|
|
58
|
+
runtime = HookRuntime()
|
|
59
|
+
runtime.register("session_start", HookDefinition(
|
|
60
|
+
id="model", priority=10,
|
|
61
|
+
handler=lambda _: {"modified_config": {"model_key": "xai:grok-4"}},
|
|
62
|
+
))
|
|
63
|
+
runtime.register("session_start", HookDefinition(
|
|
64
|
+
id="effort", priority=20,
|
|
65
|
+
handler=lambda _: {"modified_config": {"reasoning_effort": "high"}},
|
|
66
|
+
))
|
|
67
|
+
result = await runtime.dispatch("session_start", {"config": {"auto_compact": True}})
|
|
68
|
+
self.assertEqual(result["input"]["config"], {
|
|
69
|
+
"auto_compact": True,
|
|
70
|
+
"model_key": "xai:grok-4",
|
|
71
|
+
"reasoning_effort": "high",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
async def test_permission_hook_fails_closed(self):
|
|
75
|
+
runtime = HookRuntime()
|
|
76
|
+
def broken(_):
|
|
77
|
+
raise RuntimeError("policy unavailable")
|
|
78
|
+
runtime.register("pre_tool_use", HookDefinition(id="policy", handler=broken))
|
|
79
|
+
result = await runtime.dispatch("pre_tool_use", {"tool_name": "write", "tool_args": {}})
|
|
80
|
+
self.assertEqual(result["output"]["permission_decision"], "deny")
|
|
81
|
+
self.assertIn("failed closed", result["output"]["permission_decision_reason"])
|
|
82
|
+
error = result["receipts"][0]["error"]
|
|
83
|
+
self.assertEqual(error["error_code"], "INTERNAL")
|
|
84
|
+
self.assertEqual(error["reason"], "hook_handler_failed")
|
|
85
|
+
self.assertEqual(error["failure_behavior"], "fail_closed")
|
|
86
|
+
self.assertEqual(error["side_effect_state"], "not_started")
|
|
87
|
+
self.assertFalse(error["retryable"])
|
|
88
|
+
|
|
89
|
+
async def test_observer_receipt_preserves_timeout_and_applied_side_effect(self):
|
|
90
|
+
runtime = HookRuntime()
|
|
91
|
+
def broken(_):
|
|
92
|
+
raise TimeoutError("hook_lsp_timeout:rust")
|
|
93
|
+
runtime.register("post_tool_use", HookDefinition(id="observer", handler=broken))
|
|
94
|
+
result = await runtime.dispatch("post_tool_use", {"tool_name": "write", "tool_result": {"ok": True}})
|
|
95
|
+
error = result["receipts"][0]["error"]
|
|
96
|
+
self.assertEqual(error["error_code"], "DEADLINE_EXCEEDED")
|
|
97
|
+
self.assertEqual(error["reason"], "hook_lsp_timeout")
|
|
98
|
+
self.assertEqual(error["failure_behavior"], "fail_open")
|
|
99
|
+
self.assertEqual(error["side_effect_state"], "confirmed_applied")
|
|
100
|
+
self.assertFalse(error["retryable"])
|
|
101
|
+
|
|
102
|
+
async def test_command_adapter_uses_same_wire_envelope(self):
|
|
103
|
+
code = (
|
|
104
|
+
"import json,sys; e=json.load(sys.stdin); "
|
|
105
|
+
"print(json.dumps({'permission_decision': 'allow' if e['schema']=='agentsam.hook.v1' else 'deny'}))"
|
|
106
|
+
)
|
|
107
|
+
handler = command_adapter(sys.executable, ["-c", code], cwd=os.getcwd(), timeout_ms=2000)
|
|
108
|
+
runtime = HookRuntime()
|
|
109
|
+
runtime.register("pre_tool_use", HookDefinition(id="python", handler=handler))
|
|
110
|
+
result = await runtime.dispatch("pre_tool_use", {"tool_name": "read", "tool_args": {}})
|
|
111
|
+
self.assertEqual(result["output"]["permission_decision"], "allow")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
unittest.main()
|