@inneranimalmedia/agentsam-sdk 2.6.8 → 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.
Files changed (203) hide show
  1. package/README.md +18 -0
  2. package/apps/README.md +25 -0
  3. package/apps/cad-creator/shared/cad/package.json +1 -1
  4. package/apps/client-cms-editor/backend/package.json +5 -7
  5. package/apps/client-cms-editor/frontend/package.json +5 -7
  6. package/apps/client-cms-editor/frontend/tsconfig.json +27 -8
  7. package/apps/client-cms-editor/package-lock.json +8 -8
  8. package/apps/client-cms-editor/package.json +1 -1
  9. package/apps/client-cms-editor/shared/cms/package.json +3 -5
  10. package/docs/LOCAL_ENGINE_COMPUTE_AUDIT_PLAN.md +299 -0
  11. package/docs/architecture/HOOKS-MODELS-SELF-HOSTING-PLAN.md +199 -0
  12. package/examples/hyperspace-demo/index.html +16 -0
  13. package/examples/hyperspace-demo/package.json +21 -0
  14. package/examples/hyperspace-demo/src/main.tsx +160 -0
  15. package/examples/hyperspace-demo/tsconfig.json +9 -0
  16. package/examples/hyperspace-demo/vite.config.ts +24 -0
  17. package/migrations/runtime/0005_agentsam_hooks.sql +87 -0
  18. package/package.json +37 -10
  19. package/packages/agentsam-brand/package.json +1 -1
  20. package/packages/agentsam-brand/src/core/v2/compile.js +1 -1
  21. package/packages/agentsam-brand/src/core/v2/schema.js +1 -1
  22. package/packages/agentsam-contracts/package.json +71 -20
  23. package/packages/agentsam-contracts/scripts/build.mjs +18 -0
  24. package/packages/agentsam-contracts/src/errors.ts +19 -10
  25. package/packages/agentsam-contracts/test/contracts.test.mjs +4 -1
  26. package/packages/agentsam-contracts/test/repository.test.mjs +4 -1
  27. package/packages/agentsam-contracts/tsconfig.build.json +10 -0
  28. package/packages/agentsam-errors/package.json +13 -4
  29. package/packages/agentsam-errors/protocol/agentsam.error.v2.schema.json +821 -0
  30. package/packages/agentsam-errors/protocol/error-catalog.json +3512 -0
  31. package/packages/agentsam-errors/src/envelope.js +63 -11
  32. package/packages/agentsam-errors/src/error.js +6 -0
  33. package/packages/agentsam-errors/src/fingerprint.js +9 -1
  34. package/packages/agentsam-errors/src/normalize.js +51 -6
  35. package/packages/agentsam-errors/src/recovery.js +9 -35
  36. package/packages/agentsam-errors/src/transport.js +6 -1
  37. package/packages/agentsam-errors/src/vocabulary.js +515 -126
  38. package/packages/agentsam-errors/test/catalog-v2.test.mjs +106 -0
  39. package/packages/agentsam-errors/types/index.d.ts +157 -0
  40. package/packages/agentsam-goap/package.json +2 -2
  41. package/packages/agentsam-hooks/README.md +206 -0
  42. package/packages/agentsam-hooks/bin/agentsam-hooks.mjs +64 -0
  43. package/packages/agentsam-hooks/bindings/go/command.go +55 -0
  44. package/packages/agentsam-hooks/bindings/go/errors_generated.go +402 -0
  45. package/packages/agentsam-hooks/bindings/go/go.mod +3 -0
  46. package/packages/agentsam-hooks/bindings/go/hooks.go +285 -0
  47. package/packages/agentsam-hooks/bindings/go/hooks_test.go +48 -0
  48. package/packages/agentsam-hooks/bindings/rust/Cargo.toml +11 -0
  49. package/packages/agentsam-hooks/bindings/rust/src/errors_generated.rs +396 -0
  50. package/packages/agentsam-hooks/bindings/rust/src/lib.rs +397 -0
  51. package/packages/agentsam-hooks/package.json +69 -0
  52. package/packages/agentsam-hooks/schema/manifest.json +54 -0
  53. package/packages/agentsam-hooks/scripts/clean-protocol.mjs +17 -0
  54. package/packages/agentsam-hooks/scripts/sync-protocol.mjs +27 -0
  55. package/packages/agentsam-hooks/src/adapters/agents.js +82 -0
  56. package/packages/agentsam-hooks/src/adapters/callback.js +4 -0
  57. package/packages/agentsam-hooks/src/adapters/capability.js +122 -0
  58. package/packages/agentsam-hooks/src/adapters/command.js +104 -0
  59. package/packages/agentsam-hooks/src/adapters/composite.js +31 -0
  60. package/packages/agentsam-hooks/src/adapters/http.js +58 -0
  61. package/packages/agentsam-hooks/src/adapters/lsp.js +116 -0
  62. package/packages/agentsam-hooks/src/adapters/mcp.js +76 -0
  63. package/packages/agentsam-hooks/src/adapters/provider.js +129 -0
  64. package/packages/agentsam-hooks/src/config.js +143 -0
  65. package/packages/agentsam-hooks/src/contracts.js +284 -0
  66. package/packages/agentsam-hooks/src/errors-generated.js +2732 -0
  67. package/packages/agentsam-hooks/src/index.d.ts +193 -0
  68. package/packages/agentsam-hooks/src/index.js +14 -0
  69. package/packages/agentsam-hooks/src/policy.js +34 -0
  70. package/packages/agentsam-hooks/src/runtime.js +312 -0
  71. package/packages/agentsam-hooks/src/store.js +264 -0
  72. package/packages/agentsam-hooks/test/adapters.test.mjs +216 -0
  73. package/packages/agentsam-hooks/test/runtime.test.mjs +258 -0
  74. package/packages/agentsam-knowledge/package.json +3 -2
  75. package/packages/agentsam-nav/package.json +1 -1
  76. package/packages/agentsam-queue-control/package.json +1 -1
  77. package/packages/agentsam-rapid-rust/src/main.rs +1 -0
  78. package/packages/agentsam-repository/package.json +3 -2
  79. package/packages/agentsam-vault/package.json +8 -5
  80. package/packages/agentsam-workbench/package.json +45 -18
  81. package/packages/connectors/cloudflare/package.json +1 -1
  82. package/packages/heuristic-theme/package.json +1 -1
  83. package/packages/identity/package.json +3 -2
  84. package/packages/identity/tests/lane1-packed-consumer.test.mjs +9 -1
  85. package/packages/providers/completeful/package.json +1 -1
  86. package/packages/theme-church-site/README.md +2 -2
  87. package/packages/theme-church-site/package.json +2 -3
  88. package/packages/theme-church-site/src/index.js +1 -1
  89. package/packages/theme-church-site/test/theme.test.mjs +1 -1
  90. package/packages/theme-companions-site/README.md +2 -2
  91. package/packages/theme-companions-site/package.json +2 -3
  92. package/packages/theme-companions-site/src/index.js +1 -1
  93. package/packages/theme-companions-site/test/theme.test.mjs +1 -1
  94. package/packages/theme-floors-site/README.md +2 -2
  95. package/packages/theme-floors-site/package.json +2 -3
  96. package/packages/theme-floors-site/src/index.js +1 -1
  97. package/packages/theme-floors-site/test/theme.test.mjs +1 -1
  98. package/packages/theme-fuelnfree-site/README.md +2 -2
  99. package/packages/theme-fuelnfree-site/package.json +2 -3
  100. package/packages/theme-fuelnfree-site/src/index.js +1 -1
  101. package/packages/theme-fuelnfree-site/test/theme.test.mjs +1 -1
  102. package/packages/theme-handyman-site/README.md +2 -2
  103. package/packages/theme-handyman-site/package.json +2 -3
  104. package/packages/theme-handyman-site/src/index.js +1 -1
  105. package/packages/theme-handyman-site/test/theme.test.mjs +1 -1
  106. package/packages/theme-insurance-site/README.md +2 -2
  107. package/packages/theme-insurance-site/package.json +2 -3
  108. package/packages/theme-insurance-site/src/index.js +1 -1
  109. package/packages/theme-insurance-site/test/theme.test.mjs +1 -1
  110. package/packages/theme-scenes/package.json +1 -1
  111. package/packages/theme-shinshu-site/README.md +2 -2
  112. package/packages/theme-shinshu-site/package.json +2 -3
  113. package/packages/theme-shinshu-site/src/index.js +1 -1
  114. package/packages/theme-shinshu-site/test/theme.test.mjs +1 -1
  115. package/packages/work-graph/package.json +3 -2
  116. package/protocol/apps/agentsam.app.v1.schema.json +1 -1
  117. package/protocol/apps/agentsam.host-install.v1.schema.json +1 -1
  118. package/protocol/assets/asset-record.v1.schema.json +1 -1
  119. package/protocol/brand/brandpack.v1.schema.json +1 -1
  120. package/protocol/capabilities/capability-manifest.schema.json +1 -1
  121. package/protocol/capabilities/knowledge-search-input.schema.json +1 -0
  122. package/protocol/credentials/issue.v1.schema.json +1 -1
  123. package/protocol/database/agentsam.schema-pack.v1.schema.json +1 -1
  124. package/protocol/database/connection.v1.schema.json +1 -1
  125. package/protocol/embeddings/embedding-profile.v1.schema.json +1 -1
  126. package/protocol/errors/README.md +36 -0
  127. package/protocol/errors/agentsam.error.v2.schema.json +821 -0
  128. package/protocol/errors/error-catalog.json +3506 -158
  129. package/protocol/errors/error-envelope.schema.json +195 -52
  130. package/protocol/errors/recovery.v1.schema.json +1 -1
  131. package/protocol/features/agentsam.feature.v1.schema.json +1 -1
  132. package/protocol/hooks/README.md +18 -0
  133. package/protocol/hooks/agentsam.hook.output.v1.schema.json +27 -0
  134. package/protocol/hooks/agentsam.hook.receipt.v1.schema.json +61 -0
  135. package/protocol/hooks/agentsam.hook.v1.schema.json +35 -0
  136. package/protocol/hooks/agentsam.hooks.config.v1.schema.json +78 -0
  137. package/protocol/local-engine/benchmark.v1.schema.json +14 -0
  138. package/protocol/local-engine/engine.v1.schema.json +17 -0
  139. package/protocol/models/model-inventory-v2.schema.json +1 -1
  140. package/protocol/models/provider-model-snapshot.v1.schema.json +22 -0
  141. package/protocol/rpc/v1/errors.proto +1 -0
  142. package/protocol/runtime/agentsam.runtime.v1.schema.json +1 -1
  143. package/protocol/runtime/workspace-fs.v1.schema.json +1 -1
  144. package/protocol/sam/activity.v1.schema.json +1 -1
  145. package/protocol/sam/answer.v1.schema.json +1 -1
  146. package/protocol/sam/calibration.v1.schema.json +1 -1
  147. package/protocol/sam/decision-receipt.v1.schema.json +1 -1
  148. package/protocol/sam/evaluation.v1.schema.json +1 -1
  149. package/protocol/sam/operation.schema.json +1 -1
  150. package/protocol/sam/outcome.v1.schema.json +1 -1
  151. package/protocol/sam/question.v1.schema.json +1 -1
  152. package/protocol/sam/result.schema.json +1 -1
  153. package/protocol/sam/state.v1.schema.json +1 -1
  154. package/protocol/scoring/scorecard.v1.schema.json +1 -1
  155. package/protocol/skills/agentsam.interaction.v1.schema.json +1 -1
  156. package/protocol/skills/agentsam.skill.v1.schema.json +1 -1
  157. package/protocol/theme-refinery/product-metadata.schema.json +1 -1
  158. package/protocol/theme-refinery/reconciliation-receipt.schema.json +1 -1
  159. package/protocol/ui/icon.v1.schema.json +1 -1
  160. package/python/README.md +20 -0
  161. package/python/agentsam_sdk/errors/__init__.py +4 -0
  162. package/python/agentsam_sdk/errors/vocabulary.py +2730 -0
  163. package/python/agentsam_sdk/hooks/__init__.py +32 -0
  164. package/python/agentsam_sdk/hooks/adapters.py +132 -0
  165. package/python/agentsam_sdk/hooks/contracts.py +121 -0
  166. package/python/agentsam_sdk/hooks/runtime.py +253 -0
  167. package/python/tests/test_hooks.py +115 -0
  168. package/src/agent/responses-runner.js +145 -1
  169. package/src/cli/command-catalog.js +2 -0
  170. package/src/cli.js +19 -0
  171. package/src/commands/app.js +10 -0
  172. package/src/commands/cheat-sheet.js +1 -0
  173. package/src/commands/engine.js +10 -0
  174. package/src/commands/hooks.js +260 -0
  175. package/src/commands/machine.js +71 -38
  176. package/src/commands/models.js +1 -1
  177. package/src/commands/package.js +10 -3
  178. package/src/commands/runtime.js +25 -10
  179. package/src/commands/setup.js +30 -6
  180. package/src/commands/shell.js +114 -9
  181. package/src/commands/terminal.js +20 -8
  182. package/src/errors/contract.js +4 -4
  183. package/src/errors/diagnostic.js +10 -10
  184. package/src/errors/index.js +1 -1
  185. package/src/index.js +52 -0
  186. package/src/indexing/ingest/materials.js +237 -30
  187. package/src/knowledge/service/grpc-codec.js +2 -0
  188. package/src/lib/setup/runtime.js +4 -2
  189. package/src/lib/slash-commands.js +1 -0
  190. package/src/local/runtime-store.js +46 -0
  191. package/src/local-engine/assets.js +37 -0
  192. package/src/local-engine/audit.js +133 -0
  193. package/src/local-engine/benchmark.js +26 -0
  194. package/src/local-engine/contracts.js +101 -0
  195. package/src/local-engine/engines.js +142 -0
  196. package/src/local-engine/index.js +18 -0
  197. package/src/local-engine/runtime.js +39 -0
  198. package/src/models/catalog.js +0 -49
  199. package/src/models/inventory-core.js +69 -0
  200. package/src/plugins/index.js +8 -1
  201. package/src/plugins/registry.js +56 -0
  202. package/src/rpc/generated/errors_pb.js +49 -1
  203. 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()