agentenv-framework 0.9.1254__py3-none-any.whl

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 (312) hide show
  1. agent_env/__init__.py +0 -0
  2. agent_env/a2a_agent/__init__.py +23 -0
  3. agent_env/a2a_agent/a2a_agent.py +600 -0
  4. agent_env/a2a_agent/conversation_store.py +356 -0
  5. agent_env/a2a_agent/object_transfer.py +609 -0
  6. agent_env/a2a_agent/protocol.py +238 -0
  7. agent_env/a2a_agent/store.py +201 -0
  8. agent_env/a2a_agent/validator.py +901 -0
  9. agent_env/artifact/__init__.py +48 -0
  10. agent_env/artifact/artifact.py +74 -0
  11. agent_env/artifact/artifacts/__init__.py +0 -0
  12. agent_env/artifact/artifacts/cli.py +83 -0
  13. agent_env/artifact/artifacts/docker_image.py +457 -0
  14. agent_env/artifact/artifacts/environment.py +89 -0
  15. agent_env/artifact/artifacts/environment_universe.py +156 -0
  16. agent_env/artifact/artifacts/file.py +214 -0
  17. agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
  18. agent_env/artifact/artifacts/skill.py +206 -0
  19. agent_env/artifact/artifacts/vm_image.py +95 -0
  20. agent_env/artifact/ref.py +15 -0
  21. agent_env/artifact/registry.py +251 -0
  22. agent_env/artifact/store.py +295 -0
  23. agent_env/artifact/universe.py +38 -0
  24. agent_env/attribution.py +41 -0
  25. agent_env/bundle/__init__.py +4 -0
  26. agent_env/bundle/_fs.py +128 -0
  27. agent_env/bundle/authoring.py +239 -0
  28. agent_env/bundle/installed.py +175 -0
  29. agent_env/bundle/ledger.py +269 -0
  30. agent_env/bundle/materialize.py +204 -0
  31. agent_env/bundle/parse.py +594 -0
  32. agent_env/bundle/plan.py +414 -0
  33. agent_env/bundle/resolve.py +404 -0
  34. agent_env/bundle/run.py +361 -0
  35. agent_env/cli/__init__.py +69 -0
  36. agent_env/cli/__main__.py +10 -0
  37. agent_env/cli/_installers.py +576 -0
  38. agent_env/cli/_plugin_changes.py +947 -0
  39. agent_env/cli/a2a_agent/__init__.py +22 -0
  40. agent_env/cli/a2a_agent/add_skill.py +97 -0
  41. agent_env/cli/a2a_agent/deploy.py +60 -0
  42. agent_env/cli/a2a_agent/get.py +15 -0
  43. agent_env/cli/a2a_agent/get_instance.py +26 -0
  44. agent_env/cli/a2a_agent/put.py +78 -0
  45. agent_env/cli/a2a_agent/validate.py +56 -0
  46. agent_env/cli/artifact/__init__.py +20 -0
  47. agent_env/cli/artifact/cli.py +83 -0
  48. agent_env/cli/artifact/environment.py +44 -0
  49. agent_env/cli/artifact/environment_universe.py +163 -0
  50. agent_env/cli/artifact/file_artifact_universe.py +281 -0
  51. agent_env/cli/artifact/skill.py +128 -0
  52. agent_env/cli/banner.py +25 -0
  53. agent_env/cli/config.py +263 -0
  54. agent_env/cli/env/__init__.py +31 -0
  55. agent_env/cli/env/deploy.py +112 -0
  56. agent_env/cli/env/gateway.py +57 -0
  57. agent_env/cli/env/get_instance.py +40 -0
  58. agent_env/cli/env/mcp_server.py +410 -0
  59. agent_env/cli/env/multi.py +282 -0
  60. agent_env/cli/env/service_db.py +99 -0
  61. agent_env/cli/env/snapshot.py +35 -0
  62. agent_env/cli/env/state/__init__.py +6 -0
  63. agent_env/cli/env/state/init.py +108 -0
  64. agent_env/cli/env/state/teardown.py +44 -0
  65. agent_env/cli/env/website.py +216 -0
  66. agent_env/cli/env/website_browser.py +67 -0
  67. agent_env/cli/eval/__init__.py +16 -0
  68. agent_env/cli/eval/add_tasks.py +63 -0
  69. agent_env/cli/eval/create.py +49 -0
  70. agent_env/cli/eval/run.py +194 -0
  71. agent_env/cli/identity/__init__.py +5 -0
  72. agent_env/cli/identity/client_identity.py +37 -0
  73. agent_env/cli/plugin.py +438 -0
  74. agent_env/cli/run.py +285 -0
  75. agent_env/cli/task/__init__.py +21 -0
  76. agent_env/cli/task/create.py +109 -0
  77. agent_env/cli/task/get.py +17 -0
  78. agent_env/cli/task/get_instance.py +34 -0
  79. agent_env/cli/task/run.py +615 -0
  80. agent_env/cli/task/validate.py +31 -0
  81. agent_env/cli/up.py +123 -0
  82. agent_env/cli/utils.py +183 -0
  83. agent_env/config/__init__.py +50 -0
  84. agent_env/config/describe.py +1067 -0
  85. agent_env/config/errors.py +13 -0
  86. agent_env/config/loader.py +245 -0
  87. agent_env/config/model.py +154 -0
  88. agent_env/config/paths.py +25 -0
  89. agent_env/config/plugin_tables.py +101 -0
  90. agent_env/config/provenance.py +134 -0
  91. agent_env/config/runtime.py +1064 -0
  92. agent_env/config/snapshot.py +81 -0
  93. agent_env/entity_refs.py +186 -0
  94. agent_env/env/__init__.py +49 -0
  95. agent_env/env/env.py +433 -0
  96. agent_env/env/env_artifact_store.py +87 -0
  97. agent_env/env/envs/__init__.py +7 -0
  98. agent_env/env/envs/_deployment.py +281 -0
  99. agent_env/env/envs/gateway_server.py +32 -0
  100. agent_env/env/envs/mcp_server.py +387 -0
  101. agent_env/env/envs/multi_env.py +780 -0
  102. agent_env/env/envs/service_db/Dockerfile +20 -0
  103. agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
  104. agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
  105. agent_env/env/envs/service_db.py +92 -0
  106. agent_env/env/envs/website.py +294 -0
  107. agent_env/env/envs/website_browser/Dockerfile +40 -0
  108. agent_env/env/envs/website_browser/__init__.py +17 -0
  109. agent_env/env/envs/website_browser/entrypoint.sh +13 -0
  110. agent_env/env/gateway/Dockerfile +14 -0
  111. agent_env/env/gateway/__init__.py +37 -0
  112. agent_env/env/gateway/clock.py +184 -0
  113. agent_env/env/gateway/constants.py +190 -0
  114. agent_env/env/gateway/entrypoint.py +156 -0
  115. agent_env/env/gateway/gateway.py +1095 -0
  116. agent_env/env/gateway/get_time.py +60 -0
  117. agent_env/env/gateway/requirements.txt +4 -0
  118. agent_env/env/gateway/triggers.py +1193 -0
  119. agent_env/env/legacy_protocol.py +146 -0
  120. agent_env/env/registry.py +93 -0
  121. agent_env/env/snapshot_store.py +372 -0
  122. agent_env/env/store.py +411 -0
  123. agent_env/eval/__init__.py +14 -0
  124. agent_env/eval/eval.py +68 -0
  125. agent_env/eval/store.py +116 -0
  126. agent_env/examples/hello/README.md +9 -0
  127. agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
  128. agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
  129. agent_env/examples/hello/tasks/hello.json +10 -0
  130. agent_env/explorer/__init__.py +12 -0
  131. agent_env/explorer/app.py +277 -0
  132. agent_env/explorer/openapi_docs.py +242 -0
  133. agent_env/explorer/plugin.py +95 -0
  134. agent_env/explorer/routers/__init__.py +1 -0
  135. agent_env/explorer/routers/common.py +174 -0
  136. agent_env/explorer/routers/conversations.py +25 -0
  137. agent_env/explorer/routers/objects.py +112 -0
  138. agent_env/explorer/routers/runs.py +475 -0
  139. agent_env/plugins/__init__.py +59 -0
  140. agent_env/plugins/_cli.py +244 -0
  141. agent_env/plugins/_discovery.py +97 -0
  142. agent_env/plugins/_inventory.py +262 -0
  143. agent_env/plugins/_registration.py +268 -0
  144. agent_env/plugins/_report.py +47 -0
  145. agent_env/plugins/_requirements.py +55 -0
  146. agent_env/providers/__init__.py +37 -0
  147. agent_env/providers/env_providers/__init__.py +12 -0
  148. agent_env/providers/env_providers/constants.py +13 -0
  149. agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
  150. agent_env/providers/env_providers/env_provider.py +237 -0
  151. agent_env/providers/env_providers/env_server_provider.py +133 -0
  152. agent_env/providers/env_state/__init__.py +49 -0
  153. agent_env/providers/env_state/env_state_provider.py +554 -0
  154. agent_env/providers/env_state/local_postgres.py +390 -0
  155. agent_env/providers/env_state/store.py +112 -0
  156. agent_env/providers/sandbox_providers/__init__.py +29 -0
  157. agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
  158. agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
  159. agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
  160. agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
  161. agent_env/providers/sandbox_providers/e2b/template.py +158 -0
  162. agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
  163. agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
  164. agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
  165. agent_env/providers/sandbox_providers/sandbox.py +387 -0
  166. agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
  167. agent_env/py.typed +0 -0
  168. agent_env/runner/__init__.py +16 -0
  169. agent_env/runner/local_runner.py +190 -0
  170. agent_env/runner/runner.py +127 -0
  171. agent_env/runner/store.py +94 -0
  172. agent_env/store/__init__.py +126 -0
  173. agent_env/store/_google.py +68 -0
  174. agent_env/store/base.py +33 -0
  175. agent_env/store/document_store/__init__.py +60 -0
  176. agent_env/store/document_store/document_store.py +519 -0
  177. agent_env/store/document_store/dynamodb_document_store.py +306 -0
  178. agent_env/store/document_store/evaluation.py +199 -0
  179. agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
  180. agent_env/store/document_store/mongo_document_store.py +259 -0
  181. agent_env/store/document_store/sqlite_document_store.py +314 -0
  182. agent_env/store/ids.py +123 -0
  183. agent_env/store/image_store/__init__.py +34 -0
  184. agent_env/store/image_store/ecr_image_store.py +131 -0
  185. agent_env/store/image_store/google_credentials.py +113 -0
  186. agent_env/store/image_store/image_store.py +113 -0
  187. agent_env/store/image_store/local_registry_image_store.py +106 -0
  188. agent_env/store/image_store/oci_registry_credentials.py +196 -0
  189. agent_env/store/local_state.py +27 -0
  190. agent_env/store/object_store/__init__.py +24 -0
  191. agent_env/store/object_store/gcs_object_store.py +415 -0
  192. agent_env/store/object_store/local_object_store.py +143 -0
  193. agent_env/store/object_store/object_store.py +198 -0
  194. agent_env/store/object_store/s3_object_store.py +354 -0
  195. agent_env/store/query.py +112 -0
  196. agent_env/store/routing.py +609 -0
  197. agent_env/store/secret_store/__init__.py +19 -0
  198. agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
  199. agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
  200. agent_env/store/secret_store/local_secret_store.py +47 -0
  201. agent_env/store/secret_store/secret_store.py +19 -0
  202. agent_env/task/__init__.py +27 -0
  203. agent_env/task/interrupts.py +109 -0
  204. agent_env/task/registry.py +27 -0
  205. agent_env/task/step_journal.py +99 -0
  206. agent_env/task/store.py +940 -0
  207. agent_env/task/task.py +815 -0
  208. agent_env/task/teardown.py +142 -0
  209. agent_env/task_step/__init__.py +47 -0
  210. agent_env/task_step/context.py +195 -0
  211. agent_env/task_step/context_ops.py +224 -0
  212. agent_env/task_step/registry.py +182 -0
  213. agent_env/task_step/review_store.py +118 -0
  214. agent_env/task_step/snapshot_utils/__init__.py +2 -0
  215. agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
  216. agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
  217. agent_env/task_step/store.py +129 -0
  218. agent_env/task_step/task_step.py +173 -0
  219. agent_env/task_step/task_steps/__init__.py +27 -0
  220. agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
  221. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
  222. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
  223. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
  224. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
  225. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
  226. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
  227. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
  228. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
  229. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
  230. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
  231. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
  232. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
  233. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
  234. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
  235. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
  236. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
  237. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
  238. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
  239. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
  240. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
  241. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
  242. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
  243. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
  244. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
  245. agent_env/task_step/task_steps/add_skills.py +240 -0
  246. agent_env/task_step/task_steps/apply_server_config.py +288 -0
  247. agent_env/task_step/task_steps/collect_artifacts.py +940 -0
  248. agent_env/task_step/task_steps/deploy_agent.py +626 -0
  249. agent_env/task_step/task_steps/deploy_env.py +197 -0
  250. agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
  251. agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
  252. agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
  253. agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
  254. agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
  255. agent_env/task_step/task_steps/install_agent.py +376 -0
  256. agent_env/task_step/task_steps/load_artifact.py +775 -0
  257. agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
  258. agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
  259. agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
  260. agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
  261. agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
  262. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
  263. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
  264. agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
  265. agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
  266. agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
  267. agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
  268. agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
  269. agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
  270. agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
  271. agent_env/task_step/task_steps/peer_agents.py +92 -0
  272. agent_env/task_step/task_steps/prompt_agent.py +900 -0
  273. agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
  274. agent_env/task_step/task_steps/register_env_triggers.py +97 -0
  275. agent_env/task_step/task_steps/reset_env.py +71 -0
  276. agent_env/task_step/task_steps/review.py +152 -0
  277. agent_env/task_step/task_steps/run_code.py +461 -0
  278. agent_env/task_step/task_steps/run_code_runner.py +52 -0
  279. agent_env/task_step/task_steps/run_docker_container.py +380 -0
  280. agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
  281. agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
  282. agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
  283. agent_env/task_step/task_steps/snapshot_env.py +637 -0
  284. agent_env/task_step/task_steps/sync_env_clock.py +132 -0
  285. agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
  286. agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
  287. agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
  288. agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
  289. agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
  290. agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
  291. agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
  292. agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
  293. agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
  294. agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
  295. agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
  296. agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
  297. agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
  298. agent_env/task_step/thread_work.py +61 -0
  299. agent_env/utils/__init__.py +0 -0
  300. agent_env/utils/card_naming.py +108 -0
  301. agent_env/utils/deprecation.py +109 -0
  302. agent_env/utils/docker_build.py +29 -0
  303. agent_env/utils/exec_retry.py +77 -0
  304. agent_env/utils/litellm_attribution.py +59 -0
  305. agent_env/utils/paths.py +20 -0
  306. agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
  307. agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
  308. agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
  309. agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
  310. agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
  311. agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
  312. agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
@@ -0,0 +1,1193 @@
1
+ """Typed Action/State trigger engine (urn:agentenv:triggers/v1) hosted on the gateway:
2
+ post-call detection on watched-role traffic, async fail-open firing, additive fail-loud registration."""
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import copy
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import math
12
+ import random
13
+ import re
14
+ import uuid
15
+ from collections import deque
16
+ from datetime import datetime, timedelta, timezone
17
+ from typing import Any
18
+
19
+ import httpx
20
+ from mcp.types import CallToolResult, TextContent
21
+
22
+ from .clock import ClockError, _advance, _iso, _parse_duration, _parse_rfc3339
23
+ from .constants import TRIGGER_IN_FLIGHT_STATUSES, TRIGGER_STATUSES
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _PREDICATE_KEYS = ("regex", "equals", "exists")
28
+ _ACTION_TYPES = ("nl", "tool", "permission")
29
+ _TERMINAL_A2A_STATES = ("completed", "failed", "canceled", "rejected")
30
+ # Bounds the per-evaluation catch-up burst; the backlog drains on later evaluations.
31
+ _MAX_CATCHUP = 1000
32
+ # Head + tail of the firing log kept in memory; the driver fires for the gateway's whole life.
33
+ _EVENTS_HEAD = 1000
34
+ _EVENTS_TAIL = 9000
35
+ # Schedule keys state() echoes; excludes the unbounded `check`/`where`.
36
+ _WHEN_SUMMARY_KEYS = ("type", "tool", "repeat", "at", "after", "offset", "every", "count", "until", "seed")
37
+ # Placeholder roots reading the provoking call; a bare name is a check-pipeline binding.
38
+ _CTX_PREFIXES = ("args", "result")
39
+ # Where a barrier waits; a further wait point is a new value here, not a new top-level field.
40
+ _BARRIER_AT = ("provoking_call",)
41
+ _BARRIER_KEYS = ("at", "timeout_seconds")
42
+ _DROP = object() # an unresolved `${x?}` used as a whole value: drop the key / element
43
+ # An allowlist, not a size bound: identifier/metadata keys only, since bodies and addresses are PII.
44
+ _ECHO_KEYS = frozenset({
45
+ "action", "createdTime", "fileName", "kind", "mimeType", "modifiedTime", "name", "role",
46
+ "title", "type",
47
+ })
48
+ _ECHO_ID_RE = re.compile(r"(?:^id|Id|_id)$") # id, fileId, documentId, email_id, …
49
+ _ECHO_VALUE_MAX = 200 # chars; an allowlisted scalar longer than this is a payload wearing an id's name
50
+ _ECHO_DIGEST_CHARS = 16
51
+
52
+ # Firing-log event vocabulary (the GET /triggers/state contract); _emit rejects anything not here.
53
+ _EVENT_KINDS = frozenset({
54
+ "added", "removed", # registration lifecycle
55
+ "detected", # a trigger's condition matched
56
+ "anchored", # an event-anchored time-trigger's mark was stamped
57
+ "reanchored", # a clock re-arm dropped a time-trigger's old-axis mark/backlog
58
+ "action_ok", "action_failed", # one fire-action within a trigger
59
+ "verify_ok", "verify_failed", # typed acceptance of an action
60
+ "fired", "failed", "eval_error", # per trigger; `failed` re-arms an action/state trigger, retires a time one
61
+ })
62
+ # Trigger-level failures; the per-action kinds are details of one of these, so this counts once per failure.
63
+ _FAILURE_KINDS = ("failed", "eval_error")
64
+ _VAR_RE = re.compile(r"\$\{([\w.\[\]*]+\??)\}")
65
+ # The strict pattern is both finder and resolver, so a shape it cannot parse would be sent verbatim.
66
+ _VAR_LOOSE_RE = re.compile(r"\$\{[^{}]*\}?")
67
+
68
+
69
+ class TriggerError(ValueError):
70
+ """Registration validation failure (surfaced as HTTP 400)."""
71
+
72
+
73
+ class TemplateError(LookupError):
74
+ """A `${args.*}` / `${result.*}` placeholder had no value in the provoking call (fires as action_failed)."""
75
+
76
+
77
+ def _time_mark_spec(value: Any) -> tuple[str, Any]:
78
+ """('abs', datetime) for an RFC3339 stamp, or ('rel', timedelta) for a t0-relative duration."""
79
+ if isinstance(value, str) and value.startswith("P"):
80
+ return ("rel", _parse_duration(value))
81
+ return ("abs", _parse_rfc3339(value))
82
+
83
+
84
+ def _dur_or_400(value: Any, where: str) -> timedelta:
85
+ try:
86
+ return _parse_duration(value)
87
+ except ClockError as e:
88
+ raise TriggerError(f"{where}: {e}")
89
+
90
+
91
+ def _mark_or_400(value: Any, where: str) -> tuple[str, Any]:
92
+ try:
93
+ return _time_mark_spec(value)
94
+ except ClockError as e:
95
+ raise TriggerError(f"{where}: {e}")
96
+
97
+
98
+ def _make_rng(spec: dict) -> random.Random | None:
99
+ """A per-trigger seeded RNG for a stochastic time-trigger, else None."""
100
+ when = spec["when"]
101
+ if when["type"] == "time" and isinstance(when.get("every"), dict):
102
+ return random.Random(when["seed"])
103
+ return None
104
+
105
+
106
+ def _now() -> str:
107
+ return datetime.now(timezone.utc).isoformat()
108
+
109
+
110
+ def _as_text(value: Any) -> str:
111
+ if isinstance(value, str):
112
+ return value
113
+ return json.dumps(value, default=str)
114
+
115
+
116
+ def _eval_predicate(pred: dict, value: Any) -> bool:
117
+ if "regex" in pred:
118
+ return re.search(pred["regex"], _as_text(value)) is not None
119
+ if "equals" in pred:
120
+ return value == pred["equals"]
121
+ non_empty = value is not None and value != "" and value != []
122
+ return non_empty == pred["exists"]
123
+
124
+
125
+ def _apply_path(data: Any, path: str) -> Any:
126
+ """Dot path with [n] indices and a trailing [*] meaning 'the list itself'."""
127
+ current = data
128
+ for segment in path.split("."):
129
+ m = re.fullmatch(r"(\w+)((?:\[(?:\d+|\*)\])*)", segment)
130
+ if m is None:
131
+ raise TriggerError(f"invalid extract path segment {segment!r}")
132
+ key, indices = m.group(1), m.group(2)
133
+ if not isinstance(current, dict) or key not in current:
134
+ return None
135
+ current = current[key]
136
+ for idx in re.findall(r"\[(\d+|\*)\]", indices):
137
+ if idx == "*":
138
+ continue
139
+ if not isinstance(current, list) or int(idx) >= len(current):
140
+ return None
141
+ current = current[int(idx)]
142
+ return current
143
+
144
+
145
+ def _apply_extract(extract: Any, data: Any, raw_text: str) -> Any:
146
+ if extract is None:
147
+ return raw_text
148
+ if isinstance(extract, str):
149
+ return _apply_path(data, extract)
150
+ items = _apply_path(data, extract["path"])
151
+ if not isinstance(items, list):
152
+ return None
153
+ for item in items:
154
+ if not isinstance(item, dict):
155
+ continue
156
+ match = extract.get("match", {})
157
+ if all(_eval_predicate(pred, item.get(field)) for field, pred in match.items()):
158
+ return item.get(extract["take"]) if extract.get("take") else item
159
+ return None
160
+
161
+
162
+ def _result_data(result: Any) -> Any:
163
+ """A CallToolResult's text parsed as JSON (dict / list / scalar), the raw text when it is not JSON, or None."""
164
+ content = getattr(result, "content", None)
165
+ if not content:
166
+ return None
167
+ text = "\n".join(c.text for c in content if getattr(c, "type", None) == "text")
168
+ if not text:
169
+ return None
170
+ try:
171
+ return json.loads(text)
172
+ except (ValueError, TypeError):
173
+ return text
174
+
175
+
176
+ def _ctx_path(name: str) -> tuple[str | None, str]:
177
+ """The `args`/`result` root a placeholder or `where` key addresses and the path beneath it;
178
+ `(None, "")` for a check-pipeline binding name. Pass a name with any trailing `?` removed —
179
+ the optional marker belongs to placeholders, not to `where` keys."""
180
+ head, _, path = name.partition(".")
181
+ return (head, path) if head in _CTX_PREFIXES else (None, "")
182
+
183
+
184
+ def _ctx_lookup(name: str, bindings: dict, ctx: dict | None) -> tuple[bool, Any]:
185
+ """(found, value) for a placeholder name: check-pipeline bindings win; `args` / `result` roots (with an
186
+ optional dotted path) read the provoking call's arguments / parsed result."""
187
+ name = name.rstrip("?")
188
+ if name in bindings:
189
+ return True, bindings[name]
190
+ root_name, path = _ctx_path(name)
191
+ if root_name is None or ctx is None:
192
+ return False, None
193
+ root = ctx.get(root_name)
194
+ if not path:
195
+ return root is not None, root
196
+ value = _apply_path(root, path) if isinstance(root, dict) else None
197
+ return value is not None, value
198
+
199
+
200
+ def _resolve(name: str, literal: str, bindings: dict, ctx: dict | None) -> tuple[bool, Any]:
201
+ """(found, value) for one placeholder. An unresolved required `args.*` / `result.*` is fail-loud —
202
+ the target tool must never receive the literal `${...}` text."""
203
+ found, value = _ctx_lookup(name, bindings, ctx)
204
+ if not found and not name.endswith("?") and _ctx_path(name.rstrip("?"))[0] is not None:
205
+ raise TemplateError(f"unresolved placeholder {literal}")
206
+ return found, value
207
+
208
+
209
+ def _is_ctx_attempt(token: str) -> bool:
210
+ """Whether placeholder-shaped text is reaching for the provoking call, whitespace aside.
211
+
212
+ For nl instructions, which are never templated: `${result.x}` there can only be an author
213
+ expecting substitution, while `${HOME}` is ordinary prose. All whitespace is removed before the
214
+ root is read, so `${result .x}` is recognised as the attempt it is rather than mistaken for a
215
+ shell variable -- the strict pattern would not resolve it either, so it must not register."""
216
+ compact = re.sub(r"\s+", "", token)
217
+ return any(compact.startswith(f"${{{root}.") for root in _CTX_PREFIXES)
218
+
219
+
220
+ def _template(args: Any, bindings: dict, ctx: dict | None = None) -> Any:
221
+ """Substitute `${...}` placeholders recursively through dicts and lists. A string that is exactly one
222
+ placeholder resolves to the raw value; an embedded one stringifies. Unresolved: a required
223
+ `args.*` / `result.*` raises, a bare name stays literal, an optional whole value is dropped."""
224
+ def sub(value: Any) -> Any:
225
+ # Dropping the key rather than sending null is what lets the target tool apply its own default.
226
+ if isinstance(value, dict):
227
+ return {k: r for k, v in value.items() if (r := sub(v)) is not _DROP}
228
+ if isinstance(value, list):
229
+ return [r for v in value if (r := sub(v)) is not _DROP]
230
+ if not isinstance(value, str):
231
+ return value
232
+ whole = _VAR_RE.fullmatch(value)
233
+ if whole is not None:
234
+ name = whole.group(1)
235
+ found, resolved = _resolve(name, value, bindings, ctx)
236
+ if not found:
237
+ return _DROP if name.endswith("?") else value
238
+ return resolved if _ctx_path(name.rstrip("?"))[0] else str(resolved)
239
+
240
+ def repl(m: re.Match) -> str:
241
+ found, resolved = _resolve(m.group(1), m.group(0), bindings, ctx)
242
+ if not found:
243
+ return "" if m.group(1).endswith("?") else m.group(0)
244
+ return resolved if isinstance(resolved, str) else json.dumps(resolved, default=str)
245
+ return _VAR_RE.sub(repl, value)
246
+
247
+ out = sub(args)
248
+ return args if out is _DROP else out # a whole-value drop has nowhere to go at the top level
249
+
250
+
251
+ def _where_value(ctx: dict, field: str) -> Any:
252
+ """The value a `where` key names: `result.<path>` / `args.<path>` via _apply_path, a bare key is a
253
+ top-level argument (the v1 behaviour)."""
254
+ root_name, path = _ctx_path(field)
255
+ if root_name is not None and path:
256
+ root = ctx.get(root_name)
257
+ return _apply_path(root, path) if isinstance(root, dict) else None
258
+ args = ctx.get("args")
259
+ return args.get(field) if isinstance(args, dict) else None
260
+
261
+
262
+ def _echoable(key: str) -> bool:
263
+ return key in _ECHO_KEYS or _ECHO_ID_RE.search(key) is not None
264
+
265
+
266
+ def _shape(value: Any) -> dict:
267
+ """What a redacted value was, without any of what it said."""
268
+ shape: dict[str, Any] = {"type": type(value).__name__}
269
+ if isinstance(value, (str, bytes, list, dict)):
270
+ shape["len"] = len(value)
271
+ return {"redacted": shape}
272
+
273
+
274
+ def _echo_args(value: Any, key: str | None = None) -> Any:
275
+ """Trajectory-safe projection of a mirror's resolved args.
276
+
277
+ Allowlisted identifier/metadata scalars pass through so a grader can see *which* object the
278
+ mirror wrote; every other value becomes a `{"redacted": {...}}` shape descriptor. Structure is
279
+ preserved either way, so the arg *names* a mirror sent stay reviewable — it is only the values
280
+ that are withheld. Keys are matched at every depth, so a nested `{"file": {"content": ...}}`
281
+ is redacted just like a top-level one.
282
+ """
283
+ if isinstance(value, dict):
284
+ return {k: _echo_args(v, k) for k, v in value.items()}
285
+ if isinstance(value, list):
286
+ return [_echo_args(v, key) for v in value]
287
+ if key is not None and _echoable(key) and isinstance(value, (str, int, float, bool, type(None))):
288
+ # A bool is an int subclass; both are unconditionally safe. Only a str can hide a payload.
289
+ if isinstance(value, str) and len(value) > _ECHO_VALUE_MAX:
290
+ return _shape(value)
291
+ return value
292
+ return _shape(value)
293
+
294
+
295
+ def _strings(value: Any) -> list[str]:
296
+ """Every string anywhere in an args structure, so the two placeholder scans share one walk."""
297
+ if isinstance(value, dict):
298
+ return [t for v in value.values() for t in _strings(v)]
299
+ if isinstance(value, list):
300
+ return [t for v in value for t in _strings(v)]
301
+ return [value] if isinstance(value, str) else []
302
+
303
+
304
+ def _placeholders(value: Any) -> list[str]:
305
+ return [n for text in _strings(value) for n in _VAR_RE.findall(text)]
306
+
307
+
308
+ def _inert_placeholders(value: Any) -> list[str]:
309
+ """Placeholder-shaped text the templater will not act on, so the tool would receive it verbatim."""
310
+ return [m for text in _strings(value) for m in _VAR_LOOSE_RE.findall(text)
311
+ if _VAR_RE.fullmatch(m) is None]
312
+
313
+
314
+ def _validate_path(path: str, where: str) -> None:
315
+ for segment in path.split("."):
316
+ if re.fullmatch(r"\w+((?:\[(?:\d+|\*)\])*)", segment) is None:
317
+ raise TriggerError(f"{where}: invalid path segment {segment!r} in {path!r}")
318
+
319
+
320
+ def _validate_placeholders(value: Any, where: str, allow_ctx: bool) -> None:
321
+ """Fail-loud at registration: `${args.*}` / `${result.*}` only on action triggers (they alone carry a
322
+ provoking call), any other placeholder must be a bare check-binding name. A shape the templater
323
+ cannot parse is rejected first, or it would reach the tool as literal text."""
324
+ inert = _inert_placeholders(value)
325
+ if inert:
326
+ raise TriggerError(f"{where}: {inert[0]!r} is not a placeholder the engine can resolve; "
327
+ f"expected ${{args.<path>}}, ${{result.<path>}} or ${{<check binding>}}")
328
+ for name in _placeholders(value):
329
+ name = name.rstrip("?")
330
+ root_name, path = _ctx_path(name)
331
+ if root_name is None:
332
+ if re.fullmatch(r"\w+", name) is None:
333
+ raise TriggerError(f"{where}: placeholder ${{{name}}} must be a check binding name or an args./result. path")
334
+ elif not allow_ctx:
335
+ raise TriggerError(f"{where}: ${{{name}}} needs a provoking tool call; only action triggers carry args/result")
336
+ elif path:
337
+ _validate_path(path, where)
338
+ elif "." in name:
339
+ raise TriggerError(f"{where}: placeholder ${{{name}}} has an empty path")
340
+
341
+
342
+ def _check_tools(check: dict) -> list[str]:
343
+ return [step["tool"] for step in check["steps"]]
344
+
345
+
346
+ def _validate_predicate(pred: Any, where: str) -> None:
347
+ if not isinstance(pred, dict) or sum(k in pred for k in _PREDICATE_KEYS) != 1:
348
+ raise TriggerError(f"{where}: predicate must have exactly one of {_PREDICATE_KEYS}")
349
+ if "regex" in pred:
350
+ try:
351
+ re.compile(pred["regex"])
352
+ except re.error as e:
353
+ raise TriggerError(f"{where}: invalid regex: {e}")
354
+ if "exists" in pred and not isinstance(pred["exists"], bool):
355
+ raise TriggerError(f"{where}: exists must be a boolean")
356
+
357
+
358
+ def _validate_check(check: Any, where: str) -> dict:
359
+ if not isinstance(check, dict):
360
+ raise TriggerError(f"{where}: check must be an object")
361
+ steps = check.get("steps", [check] if "tool" in check else None)
362
+ if not isinstance(steps, list) or not steps:
363
+ raise TriggerError(f"{where}: check needs 'steps' or a single-step tool/predicate form")
364
+ for i, step in enumerate(steps):
365
+ w = f"{where}.steps[{i}]"
366
+ if not isinstance(step.get("tool"), str) or not step["tool"]:
367
+ raise TriggerError(f"{w}: tool must be a non-empty string")
368
+ if not isinstance(step.get("args", {}), dict):
369
+ raise TriggerError(f"{w}: args must be an object")
370
+ extract = step.get("extract")
371
+ if extract is not None and not isinstance(extract, str):
372
+ if not isinstance(extract, dict) or "path" not in extract:
373
+ raise TriggerError(f"{w}: extract must be a dot-path string or {{path, match, take}}")
374
+ for field, pred in extract.get("match", {}).items():
375
+ _validate_predicate(pred, f"{w}.extract.match.{field}")
376
+ if "predicate" in step:
377
+ _validate_predicate(step["predicate"], f"{w}.predicate")
378
+ if "predicate" not in steps[-1]:
379
+ raise TriggerError(f"{where}: the last check step must carry a predicate")
380
+ return {"steps": steps}
381
+
382
+
383
+ class TriggerEngine:
384
+ def __init__(self, gateway):
385
+ self._gw = gateway
386
+ self._executor: dict | None = None
387
+ self._executor_role: str | None = None
388
+ self._watch_roles: set[str] = set()
389
+ self._config_set = False
390
+ self._triggers: dict[str, dict] = {}
391
+ self._events: list[dict] = [] # head of the firing log
392
+ self._events_tail: deque[dict] = deque(maxlen=_EVENTS_TAIL) # most-recent window
393
+ self._seq = 0
394
+ self._tasks: set[asyncio.Task] = set()
395
+ self._readonly_tools: set[str] | None = None
396
+ self._has_time_triggers = False
397
+ self._driver_task: asyncio.Task | None = None
398
+ self._driver_interval = 1.0 # real seconds between autonomous time-trigger polls
399
+ self._time_trigger_gate = asyncio.Event() # parks the driver while no time-trigger is armed
400
+
401
+ def register(self, body: Any) -> dict:
402
+ """Additively add each trigger by id (idempotent upsert; conflicting id or bad shape -> 400); watch_roles/executor are set once."""
403
+ if not isinstance(body, dict):
404
+ raise TriggerError("body must be a JSON object")
405
+ watch_roles = self._resolve_watch_roles(body)
406
+ executor = self._resolve_executor(body, watch_roles)
407
+ triggers = body.get("triggers")
408
+ if not isinstance(triggers, list):
409
+ raise TriggerError("triggers must be a list")
410
+ known = self._known_tools()
411
+ batch_order: list[str] = []
412
+ to_add: list[tuple[str, dict]] = []
413
+ for raw in triggers:
414
+ if not isinstance(raw, dict):
415
+ raise TriggerError("every trigger needs a non-empty string id")
416
+ spec = copy.deepcopy(raw) # validation normalizes in place; never mutate the caller's dict
417
+ tid = spec.get("id")
418
+ if tid is None:
419
+ tid = f"trg_{uuid.uuid4().hex[:12]}"
420
+ spec["id"] = tid
421
+ if not isinstance(tid, str) or not tid:
422
+ raise TriggerError("every trigger needs a non-empty string id")
423
+ if tid in batch_order:
424
+ raise TriggerError(f"trigger '{tid}': duplicate id")
425
+ batch_order.append(tid)
426
+ spec = self._normalize_trigger(spec, executor)
427
+ if known is not None:
428
+ self._check_tools_known(spec, known)
429
+ existing = self._triggers.get(tid)
430
+ if existing is not None:
431
+ if existing["spec"] != spec:
432
+ raise TriggerError(f"trigger '{tid}': already registered with a different spec — remove it first or use a new id")
433
+ continue # identical re-add: no-op (a fired trigger stays fired)
434
+ to_add.append((tid, spec))
435
+ self._readonly_tools = None
436
+ self._watch_roles = watch_roles
437
+ self._executor = executor
438
+ self._executor_role = executor["role"] if executor else None
439
+ self._config_set = True
440
+ for tid, spec in to_add:
441
+ self._triggers[tid] = {"spec": spec, "status": "armed", "detected_at": None,
442
+ "fired_at": None, "evaluating": False,
443
+ "next_mark": None, "resolved": False, "fire_count": 0,
444
+ "failure_count": 0, "last_failure_at": None,
445
+ "rng": _make_rng(spec), "mark_gen": None,
446
+ "pending": deque(), "task": None}
447
+ self._emit("added", tid)
448
+ self._recompute_time_flag()
449
+ return {"ok": True, "added": batch_order, "all": list(self._triggers)}
450
+
451
+ def _known_tools(self) -> set[str] | None:
452
+ """Every tool name the gateway can route to, or None before discovery (then unknown tools surface at fire time)."""
453
+ server_tools = getattr(self._gw, "_server_tools", None)
454
+ if not server_tools or not getattr(self._gw, "_tools_discovered", True):
455
+ return None
456
+ known = {tool.name for tools in server_tools.values() for tool in tools}
457
+ mcp = getattr(self._gw, "_mcp", None)
458
+ if mcp is not None:
459
+ known.update(mcp._tool_manager._tools)
460
+ return known
461
+
462
+ @staticmethod
463
+ def _check_tools_known(spec: dict, known: set[str]) -> None:
464
+ tid = spec["id"]
465
+ when = spec["when"]
466
+ named: list[tuple[str, str]] = []
467
+ if when["type"] == "action":
468
+ named.append((when["tool"], "when.tool"))
469
+ elif when["type"] == "state":
470
+ named.extend((t, "when.check") for t in _check_tools(when["check"]))
471
+ for i, action in enumerate(spec["actions"]):
472
+ if action["type"] == "tool":
473
+ named.append((action["tool"], f"actions[{i}].tool"))
474
+ if "verify" in action:
475
+ named.extend((t, f"actions[{i}].verify") for t in _check_tools(action["verify"]))
476
+ for tool, where in named:
477
+ if tool not in known:
478
+ raise TriggerError(f"trigger '{tid}': {where} names unknown tool {tool!r}")
479
+
480
+ def remove(self, body: Any) -> dict:
481
+ """Disarm + delete triggers by id (idempotent: unknown ids are ignored). Removed ids may be re-added."""
482
+ if not isinstance(body, dict):
483
+ raise TriggerError("body must be a JSON object")
484
+ ids = body.get("ids")
485
+ if not isinstance(ids, list) or not all(isinstance(i, str) and i for i in ids):
486
+ raise TriggerError("ids must be a list of non-empty strings")
487
+ removed = []
488
+ for tid in ids:
489
+ if self._triggers.pop(tid, None) is not None:
490
+ removed.append(tid)
491
+ self._emit("removed", tid)
492
+ self._recompute_time_flag()
493
+ return {"ok": True, "removed": removed}
494
+
495
+ def clear(self) -> dict:
496
+ """Full reset — triggers, config, and the event log (setup/testing only; never mid-task)."""
497
+ for task in list(self._tasks):
498
+ task.cancel()
499
+ self._tasks.clear()
500
+ self._triggers = {}
501
+ self._events = []
502
+ self._events_tail.clear()
503
+ self._seq = 0
504
+ self._watch_roles = set()
505
+ self._executor = None
506
+ self._executor_role = None
507
+ self._config_set = False
508
+ self._readonly_tools = None
509
+ self._recompute_time_flag()
510
+ return {"ok": True}
511
+
512
+ def _recompute_time_flag(self) -> None:
513
+ # "firing" counts: a recurrence mid-fire returns to "armed" and still needs the driver.
514
+ self._has_time_triggers = any(
515
+ t["spec"]["when"]["type"] == "time" and t["status"] in ("armed", "firing")
516
+ for t in self._triggers.values())
517
+ if self._has_time_triggers:
518
+ self._time_trigger_gate.set()
519
+ else:
520
+ self._time_trigger_gate.clear()
521
+
522
+ def start_driver(self) -> None:
523
+ """Start the background poller (idempotent; requires a running loop)."""
524
+ if self._driver_task is None or self._driver_task.done():
525
+ self._driver_task = asyncio.get_running_loop().create_task(self._drive_time())
526
+
527
+ async def _drive_time(self) -> None:
528
+ """Poll armed time-triggers every _driver_interval real seconds; parks while none are armed."""
529
+ while True:
530
+ try:
531
+ await self._time_trigger_gate.wait() # park until a time-trigger is armed
532
+ await asyncio.sleep(self._driver_interval)
533
+ if self._has_time_triggers and self._gw._clock.armed:
534
+ self._eval_time_triggers()
535
+ except asyncio.CancelledError:
536
+ raise
537
+ except Exception:
538
+ logger.exception("clock time-driver tick failed")
539
+
540
+ async def stop_driver(self) -> None:
541
+ """Cancel the background poller on gateway teardown (idempotent)."""
542
+ task, self._driver_task = self._driver_task, None
543
+ if task is not None:
544
+ task.cancel()
545
+ with contextlib.suppress(asyncio.CancelledError):
546
+ await task
547
+
548
+ def _resolve_watch_roles(self, body: dict) -> set[str]:
549
+ raw = body.get("watch_roles")
550
+ if raw is None:
551
+ return self._watch_roles if self._config_set else {"default"}
552
+ if not isinstance(raw, list) or not all(isinstance(r, str) and r for r in raw):
553
+ raise TriggerError("watch_roles must be a list of non-empty strings")
554
+ new = set(raw)
555
+ if self._config_set and new != self._watch_roles:
556
+ raise TriggerError(f"watch_roles {sorted(new)} conflicts with the already-registered {sorted(self._watch_roles)} — clear to change it")
557
+ return new
558
+
559
+ def _resolve_executor(self, body: dict, watch_roles: set[str]) -> dict | None:
560
+ raw = body.get("executor")
561
+ if raw is None:
562
+ return self._executor
563
+ if not isinstance(raw, dict) or not isinstance(raw.get("a2a_url"), str) or not raw["a2a_url"]:
564
+ raise TriggerError("executor.a2a_url must be a non-empty string")
565
+ role = raw.get("role")
566
+ if not isinstance(role, str) or not role:
567
+ raise TriggerError("executor.role must be a non-empty string (the deployed executor's AgentEnv-Role, kept out of watch_roles so its own tool calls never fire triggers)")
568
+ if role in watch_roles:
569
+ raise TriggerError(f"executor.role {role!r} must not be in watch_roles {sorted(watch_roles)} — the executor's own tool calls would fire triggers (cascade)")
570
+ normalized = {"a2a_url": raw["a2a_url"].rstrip("/"),
571
+ "timeout_seconds": float(raw.get("timeout_seconds", 120)),
572
+ "role": role}
573
+ if self._executor is not None and self._executor != normalized:
574
+ raise TriggerError("executor conflicts with the already-registered executor — clear to change it")
575
+ return normalized
576
+
577
+ @staticmethod
578
+ def _normalize_barrier(spec: dict, tid: str, has_ctx: bool) -> None:
579
+ """Validate `barrier` in place. Absent stays absent and the timeout is resolved at wait time,
580
+ so an idempotent re-add still matches even after the gateway default moves."""
581
+ barrier = spec.get("barrier")
582
+ if barrier is None:
583
+ spec.pop("barrier", None)
584
+ return
585
+ w = f"trigger '{tid}': barrier"
586
+ if not isinstance(barrier, dict):
587
+ raise TriggerError(f"{w} must be an object, e.g. {{\"at\": \"provoking_call\"}}")
588
+ unknown = sorted(set(barrier) - set(_BARRIER_KEYS))
589
+ if unknown:
590
+ raise TriggerError(f"{w} has unknown key(s) {unknown}; expected {list(_BARRIER_KEYS)}")
591
+ at = barrier.get("at")
592
+ if at not in _BARRIER_AT:
593
+ raise TriggerError(f"{w}.at must be one of {list(_BARRIER_AT)} (got {at!r})")
594
+ if at == "provoking_call" and not has_ctx:
595
+ raise TriggerError(f"{w}.at 'provoking_call' is only valid on action triggers — they alone have a "
596
+ "provoking call to hold")
597
+ timeout = barrier.get("timeout_seconds")
598
+ if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (int, float))
599
+ or not math.isfinite(timeout) or timeout <= 0):
600
+ raise TriggerError(f"{w}.timeout_seconds must be a positive, finite number (got {timeout!r})")
601
+
602
+ def _normalize_trigger(self, spec: dict, executor: dict | None) -> dict:
603
+ """Validate + normalize a trigger spec in place (fail-loud); returns it, so pass a copy."""
604
+ tid = spec["id"]
605
+ when = spec.get("when")
606
+ if not isinstance(when, dict) or when.get("type") not in ("action", "state", "time"):
607
+ raise TriggerError(f"trigger '{tid}': when.type must be 'action', 'state', or 'time'")
608
+ if when["type"] == "action":
609
+ self._normalize_action_when(when, tid)
610
+ elif when["type"] == "state":
611
+ if "repeat" in when:
612
+ raise TriggerError(f"trigger '{tid}': when.repeat is only valid on action triggers")
613
+ when["check"] = _validate_check(when.get("check"), f"trigger '{tid}': when.check")
614
+ for j, step in enumerate(when["check"]["steps"]):
615
+ _validate_placeholders(step.get("args", {}), f"trigger '{tid}': when.check.steps[{j}].args", allow_ctx=False)
616
+ else:
617
+ if "repeat" in when:
618
+ raise TriggerError(f"trigger '{tid}': when.repeat is only valid on action triggers (time triggers recur via 'every')")
619
+ self._normalize_time_when(when, tid)
620
+ has_ctx = when["type"] == "action"
621
+ self._normalize_barrier(spec, tid, has_ctx)
622
+ actions = spec.get("actions")
623
+ if not isinstance(actions, list):
624
+ raise TriggerError(f"trigger '{tid}': actions must be a list")
625
+ for i, action in enumerate(actions):
626
+ self._normalize_action(action, f"trigger '{tid}': actions[{i}]", has_ctx, executor)
627
+ return spec
628
+
629
+ @staticmethod
630
+ def _normalize_action_when(when: dict, tid: str) -> None:
631
+ """Validate a when.type=='action' spec: the provoking tool, its `where` predicates, `repeat`."""
632
+ if not isinstance(when.get("tool"), str) or not when["tool"]:
633
+ raise TriggerError(f"trigger '{tid}': when.tool must be a non-empty string")
634
+ where = when.get("where", {})
635
+ if not isinstance(where, dict):
636
+ raise TriggerError(f"trigger '{tid}': when.where must be an object")
637
+ for field, pred in where.items():
638
+ _validate_predicate(pred, f"trigger '{tid}': where.{field}")
639
+ _, path = _ctx_path(field)
640
+ if path:
641
+ _validate_path(path, f"trigger '{tid}': where.{field}")
642
+ if "repeat" in when and not isinstance(when["repeat"], bool):
643
+ raise TriggerError(f"trigger '{tid}': when.repeat must be a boolean")
644
+
645
+ @staticmethod
646
+ def _normalize_action(action: Any, w: str, has_ctx: bool, executor: dict | None) -> None:
647
+ """Validate one fire-action in place, plus its optional `verify` check."""
648
+ atype = action.get("type") if isinstance(action, dict) else None
649
+ if atype not in _ACTION_TYPES:
650
+ raise TriggerError(f"{w}: type must be one of {_ACTION_TYPES}")
651
+ if atype == "nl":
652
+ if not isinstance(action.get("instruction"), str) or not action["instruction"]:
653
+ raise TriggerError(f"{w}: instruction must be a non-empty string")
654
+ # nl is never templated; only context-rooted shapes reject, since prose carries `${HOME}`.
655
+ for token in _VAR_LOOSE_RE.findall(action["instruction"]):
656
+ if _is_ctx_attempt(token):
657
+ raise TriggerError(f"{w}: instruction contains {token!r}, but nl instructions are "
658
+ f"not templated; read the provoking call from a tool action's args")
659
+ if executor is None:
660
+ raise TriggerError(f"{w}: nl actions require an executor")
661
+ elif atype == "tool":
662
+ if not isinstance(action.get("tool"), str) or not action["tool"]:
663
+ raise TriggerError(f"{w}: tool must be a non-empty string")
664
+ if not isinstance(action.get("args", {}), dict):
665
+ raise TriggerError(f"{w}: args must be an object")
666
+ _validate_placeholders(action.get("args", {}), f"{w}.args", allow_ctx=has_ctx)
667
+ else:
668
+ if action.get("action") not in ("enable", "disable"):
669
+ raise TriggerError(f"{w}: action must be 'enable' or 'disable'")
670
+ if not isinstance(action.get("role"), str) or not action["role"]:
671
+ raise TriggerError(f"{w}: role must be a non-empty string")
672
+ tools = action.get("tools")
673
+ if tools != "*" and (not isinstance(tools, list) or not all(isinstance(t, str) for t in tools)):
674
+ raise TriggerError(f"{w}: tools must be a list of strings or '*'")
675
+ if "verify" in action:
676
+ action["verify"] = _validate_check(action["verify"], f"{w}.verify")
677
+ for j, step in enumerate(action["verify"]["steps"]):
678
+ _validate_placeholders(step.get("args", {}), f"{w}.verify.steps[{j}].args", allow_ctx=has_ctx)
679
+
680
+ def _normalize_time_when(self, when: dict, tid: str) -> None:
681
+ """Validate a when.type=='time' spec fail-loud, leaving it JSON-clean."""
682
+ w = f"trigger '{tid}'"
683
+ has_at, has_after, has_every = "at" in when, "after" in when, "every" in when
684
+ if not (has_at or has_after or has_every):
685
+ raise TriggerError(f"{w}: when.type=='time' needs at least one of 'at', 'after', or 'every'")
686
+ if has_at and has_after:
687
+ raise TriggerError(f"{w}: when may have at most one of 'at' / 'after'")
688
+ if has_after:
689
+ if not isinstance(when["after"], str) or not when["after"]:
690
+ raise TriggerError(f"{w}: when.after must be a non-empty trigger id")
691
+ if not isinstance(when.get("offset"), str) or not when["offset"]:
692
+ raise TriggerError(f"{w}: when.after requires a non-empty 'offset' duration")
693
+ _dur_or_400(when["offset"], f"{w}: offset")
694
+ elif "offset" in when:
695
+ raise TriggerError(f"{w}: 'offset' is only valid with 'after'")
696
+ if has_at:
697
+ _mark_or_400(when["at"], f"{w}: at")
698
+ is_stochastic = has_every and isinstance(when["every"], dict)
699
+ if has_every:
700
+ every = when["every"]
701
+ if isinstance(every, str):
702
+ if _dur_or_400(every, f"{w}: every") <= timedelta(0):
703
+ raise TriggerError(f"{w}: every must be a positive duration")
704
+ elif isinstance(every, dict):
705
+ if every.get("dist") != "exp":
706
+ raise TriggerError(f"{w}: every.dist must be 'exp'")
707
+ if not isinstance(every.get("mean"), str) or _dur_or_400(every["mean"], f"{w}: every.mean") <= timedelta(0):
708
+ raise TriggerError(f"{w}: every.mean must be a positive ISO-8601 duration")
709
+ if not isinstance(when.get("seed"), int) or isinstance(when.get("seed"), bool):
710
+ raise TriggerError(f"{w}: a stochastic 'every' requires an integer 'seed'")
711
+ else:
712
+ raise TriggerError(f"{w}: every must be a duration string or {{dist, mean}}")
713
+ if "seed" in when and not is_stochastic:
714
+ raise TriggerError(f"{w}: 'seed' is only valid with a stochastic 'every'")
715
+ for term in ("until", "count"):
716
+ if term in when and not has_every:
717
+ raise TriggerError(f"{w}: '{term}' requires a recurring 'every'")
718
+ if "count" in when and (not isinstance(when["count"], int) or isinstance(when["count"], bool) or when["count"] <= 0):
719
+ raise TriggerError(f"{w}: count must be a positive integer")
720
+ if "until" in when:
721
+ _mark_or_400(when["until"], f"{w}: until")
722
+
723
+ def state(self) -> dict:
724
+ events = self._events + list(self._events_tail)
725
+ for t in self._triggers.values():
726
+ assert t["status"] in TRIGGER_STATUSES, f"unknown trigger status {t['status']!r}"
727
+ return {
728
+ "config": {"watch_roles": sorted(self._watch_roles),
729
+ "executor_configured": self._executor is not None},
730
+ "triggers": [
731
+ {"id": tid, "type": t["spec"]["when"]["type"],
732
+ "when": {k: t["spec"]["when"][k] for k in _WHEN_SUMMARY_KEYS if k in t["spec"]["when"]},
733
+ "status": t["status"], "detected_at": t["detected_at"],
734
+ "fired_at": t["fired_at"], "fire_count": t["fire_count"],
735
+ "failure_count": t["failure_count"], "last_failure_at": t["last_failure_at"],
736
+ "barrier": t["spec"].get("barrier"),
737
+ "pending": len(t["pending"]),
738
+ "next_mark": _iso(t["next_mark"]) if t["next_mark"] else None}
739
+ for tid, t in self._triggers.items()
740
+ ],
741
+ "events": events,
742
+ "events_dropped": max(0, self._seq - len(events)),
743
+ }
744
+
745
+ def on_tool_call(self, role: str, name: str, arguments: dict,
746
+ result: Any) -> tuple[list[asyncio.Task], float | None]:
747
+ """Synchronous post-call detection hook (invoked from the gateway's role-filter wrapper).
748
+
749
+ Returns the fire tasks of triggers whose barrier waits at `provoking_call`, and how long to
750
+ hold the call for: the widest of their timeouts, counting an omitted one as the gateway
751
+ default so a neighbour's tighter ask cannot cut it short.
752
+ """
753
+ pending: list[asyncio.Task] = []
754
+ timeout_s: float | None = None
755
+ if role == self._executor_role: # executor's own traffic is never watched (prevents cascades)
756
+ return pending, timeout_s
757
+ if role not in self._watch_roles or not self._triggers:
758
+ return pending, timeout_s
759
+ # Before the isError guard: an errored call is still a clock tick.
760
+ if self._has_time_triggers:
761
+ self._eval_time_triggers()
762
+ # Handled refusals arrive flagged too: BaseService.flag_handled_errors sets isError at the wire.
763
+ if getattr(result, "isError", False):
764
+ return pending, timeout_s
765
+ data = _result_data(result)
766
+ ctx = {"args": arguments, "result": data}
767
+ provoking = {"tool": name, "role": role}
768
+ for tid, trig in self._triggers.items():
769
+ when = trig["spec"]["when"]
770
+ if when["type"] != "action" or when["tool"] != name:
771
+ continue
772
+ if trig["status"] != "armed" and not (when.get("repeat") and trig["status"] in TRIGGER_IN_FLIGHT_STATUSES):
773
+ continue
774
+ if not all(_eval_predicate(pred, _where_value(ctx, field))
775
+ for field, pred in when.get("where", {}).items()):
776
+ continue
777
+ task = self._mark_detected(tid, trig, provoking, ctx)
778
+ barrier = trig["spec"].get("barrier") or {}
779
+ if task is not None and barrier.get("at") == "provoking_call":
780
+ pending.append(task)
781
+ asked = barrier.get("timeout_seconds") or self._gw.TRIGGER_BARRIER_TIMEOUT_S
782
+ timeout_s = asked if timeout_s is None else max(timeout_s, asked)
783
+ if not self._is_readonly(name):
784
+ for tid, trig in self._triggers.items():
785
+ if trig["status"] == "armed" and trig["spec"]["when"]["type"] == "state" and not trig["evaluating"]:
786
+ trig["evaluating"] = True
787
+ self._schedule(self._eval_state(tid, trig, provoking))
788
+ return pending, timeout_s
789
+
790
+ def _mark_detected(self, tid: str, trig: dict, provoking: dict, ctx: dict | None = None) -> asyncio.Task | None:
791
+ ctx = ctx if ctx is not None else {}
792
+ if trig["status"] in TRIGGER_IN_FLIGHT_STATUSES:
793
+ # Mid-fire: queue it; the running fire drains the queue before it settles.
794
+ trig["pending"].append(ctx)
795
+ trig["status"] = "queued"
796
+ self._emit("detected", tid, provoking=provoking, queued=len(trig["pending"]))
797
+ return trig["task"]
798
+ trig["status"] = "firing"
799
+ trig["detected_at"] = _now()
800
+ self._emit("detected", tid, provoking=provoking)
801
+ trig["task"] = self._schedule(self._fire(tid, trig, ctx))
802
+ trig["task"].set_name(tid)
803
+ return trig["task"]
804
+
805
+ def _eval_time_triggers(self) -> None:
806
+ """Poll armed time-triggers against the virtual clock and schedule the due ones."""
807
+ clock = self._gw._clock
808
+ now = clock.now()
809
+ if now is None: # clock unarmed -> nothing can be due yet
810
+ return
811
+ gen = clock.generation
812
+ for tid, trig in list(self._triggers.items()):
813
+ when = trig["spec"]["when"]
814
+ if when["type"] != "time" or trig["status"] != "armed":
815
+ continue
816
+ # Re-anchor to the new t0 across a clock re-arm, per-trigger so a mid-fire one isn't skipped.
817
+ # An event-anchored mark is an absolute instant its fire-once anchor can't re-derive -> kept.
818
+ if "after" not in when and trig["resolved"] and trig["mark_gen"] != gen:
819
+ # The old mark (and any backlog behind it) is on a timeline the new clock never reached.
820
+ self._emit("reanchored", tid, generation=gen,
821
+ dropped_mark=_iso(trig["next_mark"]) if trig["next_mark"] else None)
822
+ trig["resolved"] = False
823
+ trig["next_mark"] = None
824
+ trig["rng"] = _make_rng(trig["spec"])
825
+ if not trig["resolved"]:
826
+ self._resolve_first_mark(trig, now)
827
+ if trig["resolved"]:
828
+ trig["mark_gen"] = gen
829
+ mark = trig["next_mark"]
830
+ if mark is None or now < mark:
831
+ continue
832
+ marks, capped, terminated = self._advance_marks(trig, now)
833
+ if marks:
834
+ # The cause is time passing, not whichever path observed it.
835
+ self._mark_time_detected(tid, trig, marks, capped, terminated, {"source": "clock"})
836
+ elif terminated: # retired without firing (e.g. `until` already passed)
837
+ trig["status"] = "fired"
838
+ trig["next_mark"] = None
839
+ self._recompute_time_flag()
840
+
841
+ def _resolve_first_mark(self, trig: dict, now: datetime) -> None:
842
+ """Stamp the first mark for a non-anchored time-trigger; event-anchored ones wait for _resolve_dependents."""
843
+ when = trig["spec"]["when"]
844
+ if "after" in when:
845
+ return
846
+ t0 = self._gw._clock.t0()
847
+ if "at" in when:
848
+ kind, val = _time_mark_spec(when["at"])
849
+ start = val if kind == "abs" else _advance(t0, val.total_seconds())
850
+ else: # recurring with no explicit start: first fire is one interval after t0
851
+ start = _advance(t0, self._next_interval_seconds(trig))
852
+ trig["next_mark"] = start
853
+ trig["resolved"] = True
854
+
855
+ def _resolve_dependents(self, anchor_tid: str) -> None:
856
+ """Stamp the concrete mark (virtual_now + offset) of every time-trigger anchored on `anchor_tid`."""
857
+ # Safe despite _fire_time recomputing the flag first: an unresolved `after` dependent is itself
858
+ # an armed time trigger, so the flag stays set for exactly as long as one can be stranded.
859
+ if not self._has_time_triggers:
860
+ return
861
+ now = self._gw._clock.now()
862
+ if now is None:
863
+ return
864
+ for tid, trig in self._triggers.items():
865
+ when = trig["spec"]["when"]
866
+ if when["type"] != "time" or when.get("after") != anchor_tid or trig["resolved"]:
867
+ continue
868
+ trig["next_mark"] = _advance(now, _parse_duration(when["offset"]).total_seconds())
869
+ trig["resolved"] = True
870
+ self._emit("anchored", tid, anchor=anchor_tid, mark=_iso(trig["next_mark"]))
871
+
872
+ def _until_dt(self, trig: dict) -> datetime | None:
873
+ when = trig["spec"]["when"]
874
+ if "until" not in when:
875
+ return None
876
+ kind, val = _time_mark_spec(when["until"])
877
+ return val if kind == "abs" else _advance(self._gw._clock.t0(), val.total_seconds())
878
+
879
+ def _next_interval_seconds(self, trig: dict) -> float:
880
+ """Virtual seconds to the next mark; drawn on the sync path so the sequence depends only on the seed."""
881
+ every = trig["spec"]["when"]["every"]
882
+ if isinstance(every, dict):
883
+ mean_s = _parse_duration(every["mean"]).total_seconds()
884
+ return trig["rng"].expovariate(1.0 / mean_s)
885
+ return _parse_duration(every).total_seconds()
886
+
887
+ def _advance_marks(self, trig: dict, now: datetime) -> tuple[list[datetime], bool, bool]:
888
+ """SYNC: the marks due at `now` (<= _MAX_CATCHUP), advancing next_mark past them.
889
+
890
+ Returns (due_marks, capped, terminated). A capped backlog drains on later evaluations of the
891
+ same clock generation; a re-arm drops it. Marks are returned so each arrival can record the
892
+ virtual instant it was due at."""
893
+ when = trig["spec"]["when"]
894
+ recurring = "every" in when
895
+ count = when.get("count")
896
+ until = self._until_dt(trig)
897
+ marks: list[datetime] = []
898
+ capped = False
899
+ terminated = False
900
+ while trig["next_mark"] is not None and now >= trig["next_mark"]:
901
+ if until is not None and trig["next_mark"] > until:
902
+ trig["next_mark"] = None
903
+ terminated = True
904
+ break
905
+ marks.append(trig["next_mark"])
906
+ if not recurring:
907
+ trig["next_mark"] = None
908
+ terminated = True
909
+ break
910
+ if count is not None and trig["fire_count"] + len(marks) >= count:
911
+ trig["next_mark"] = None
912
+ terminated = True
913
+ break
914
+ prev_mark = trig["next_mark"]
915
+ trig["next_mark"] = _advance(prev_mark, self._next_interval_seconds(trig))
916
+ if trig["next_mark"] <= prev_mark: # saturated at the datetime ceiling: can't advance -> retire
917
+ trig["next_mark"] = None # (else now==next_mark==max would re-burst _MAX_CATCHUP every call)
918
+ terminated = True
919
+ break
920
+ if len(marks) >= _MAX_CATCHUP:
921
+ capped = True
922
+ break
923
+ return marks, capped, terminated
924
+
925
+ def _mark_time_detected(self, tid: str, trig: dict, marks: list[datetime], capped: bool,
926
+ terminated: bool, provoking: dict) -> None:
927
+ trig["status"] = "firing"
928
+ trig["detected_at"] = _now()
929
+ # first/last only: a burst can carry _MAX_CATCHUP marks, each with its own `fired` event.
930
+ self._emit("detected", tid, provoking=provoking, due=len(marks), capped=capped,
931
+ first_mark=_iso(marks[0]), last_mark=_iso(marks[-1]))
932
+ self._schedule(self._fire_time(tid, trig, marks, capped, terminated))
933
+
934
+ async def _fire_time(self, tid: str, trig: dict, marks: list[datetime], capped: bool, terminated: bool) -> None:
935
+ """Run the actions once per due mark, then re-arm (recurring) or retire (one-shot / terminated)."""
936
+ try:
937
+ await self._gw._log_event({"event_type": "trigger_fired", "source": "trigger_engine", "trigger_id": tid})
938
+ for mark in marks:
939
+ for index, action in enumerate(trig["spec"]["actions"]):
940
+ if not await self._run_action(tid, index, action, {}):
941
+ trig["status"] = "failed"
942
+ trig["next_mark"] = None
943
+ self._emit("failed", tid, action_index=index)
944
+ return
945
+ trig["fire_count"] += 1
946
+ trig["fired_at"] = _now()
947
+ self._emit("fired", tid, fire_count=trig["fire_count"], mark=_iso(mark), capped=capped)
948
+ trig["status"] = "fired" if terminated else "armed"
949
+ except Exception as e:
950
+ logger.exception(f"time trigger '{tid}' firing failed")
951
+ trig["status"] = "failed"
952
+ trig["next_mark"] = None
953
+ self._emit("failed", tid, detail=str(e)[:300])
954
+ return
955
+ finally:
956
+ self._recompute_time_flag() # a terminated trigger lets the driver park again
957
+ self._resolve_dependents(tid) # success-only, outside the try (see _fire)
958
+
959
+ def invalidate_readonly_cache(self) -> None:
960
+ """Drop the memoized readOnlyHint set; a stale memo classes a read-only tool as writable."""
961
+ self._readonly_tools = None
962
+
963
+ def _is_readonly(self, name: str) -> bool:
964
+ if self._readonly_tools is None:
965
+ readonly: set[str] = set()
966
+ for tools in self._gw._server_tools.values():
967
+ for tool in tools:
968
+ annotations = getattr(tool, "annotations", None)
969
+ if annotations is not None and getattr(annotations, "readOnlyHint", None) is True:
970
+ readonly.add(tool.name)
971
+ if not self._gw._server_tools:
972
+ return False
973
+ self._readonly_tools = readonly
974
+ return name in self._readonly_tools
975
+
976
+ def _schedule(self, coro) -> asyncio.Task:
977
+ task = asyncio.get_running_loop().create_task(coro)
978
+ self._tasks.add(task)
979
+ task.add_done_callback(self._tasks.discard)
980
+ return task
981
+
982
+ def _emit(self, kind: str, trigger_id: str | None, **payload) -> dict:
983
+ assert kind in _EVENT_KINDS, f"unknown trigger event kind {kind!r}"
984
+ self._seq += 1
985
+ # `ts` is real; a catch-up burst collapses into a few ms of it, hence `virtual_time` too
986
+ # (omitted when unarmed, so a clock-less deployment sees unchanged events).
987
+ virtual = self._gw._clock.now()
988
+ event = {"seq": self._seq, "ts": _now(),
989
+ **({"virtual_time": _iso(virtual)} if virtual is not None else {}),
990
+ "kind": kind, **({"trigger_id": trigger_id} if trigger_id else {}),
991
+ **payload}
992
+ (self._events if len(self._events) < _EVENTS_HEAD else self._events_tail).append(event)
993
+ if kind in _FAILURE_KINDS and trigger_id:
994
+ # A failure no longer parks the status and the event log is capped, so this is the lasting record.
995
+ trig = self._triggers.get(trigger_id)
996
+ if trig is not None:
997
+ trig["failure_count"] += 1
998
+ trig["last_failure_at"] = event["ts"]
999
+ logger.info(f"trigger event: {event}")
1000
+ return event
1001
+
1002
+ async def _eval_state(self, tid: str, trig: dict, provoking: dict) -> None:
1003
+ try:
1004
+ matched = await self._run_check(trig["spec"]["when"]["check"])
1005
+ except Exception as e:
1006
+ self._emit("eval_error", tid, detail=str(e)[:300])
1007
+ matched = False
1008
+ finally:
1009
+ trig["evaluating"] = False
1010
+ if matched and trig["status"] == "armed":
1011
+ self._mark_detected(tid, trig, provoking)
1012
+
1013
+ async def _run_check(self, check: dict, ctx: dict | None = None) -> bool:
1014
+ bindings: dict[str, Any] = {}
1015
+ for step in check["steps"]:
1016
+ result = await self._internal_call(step["tool"], _template(step.get("args", {}), bindings, ctx))
1017
+ if getattr(result, "isError", False):
1018
+ return False
1019
+ raw_text = "\n".join(c.text for c in result.content if getattr(c, "type", None) == "text")
1020
+ try:
1021
+ data = json.loads(raw_text)
1022
+ except (ValueError, TypeError):
1023
+ data = None
1024
+ value = _apply_extract(step.get("extract"), data, raw_text)
1025
+ bind = step.get("bind")
1026
+ if bind is not None:
1027
+ if value in (None, "", []):
1028
+ return False
1029
+ bindings[bind] = value
1030
+ if "predicate" in step and not _eval_predicate(step["predicate"], value):
1031
+ return False
1032
+ return True
1033
+
1034
+ async def _internal_call(self, tool_name: str, arguments: dict) -> CallToolResult:
1035
+ gw = self._gw
1036
+ await gw._ensure_tools_discovered()
1037
+ server_url = gw._tool_server_urls.get(tool_name)
1038
+ if server_url is None:
1039
+ tool = gw._mcp._tool_manager._tools.get(tool_name)
1040
+ if tool is None:
1041
+ raise RuntimeError(f"unknown tool {tool_name!r}")
1042
+ result = await tool.run(arguments)
1043
+ if isinstance(result, CallToolResult):
1044
+ return result
1045
+ text = result if isinstance(result, str) else json.dumps(result, default=str)
1046
+ return CallToolResult(content=[TextContent(type="text", text=text)], isError=False)
1047
+ session = await gw._get_step_session(server_url)
1048
+ return await asyncio.wait_for(session.call_tool(tool_name, arguments), timeout=gw.TOOL_CALL_TIMEOUT_S)
1049
+
1050
+ async def _fire(self, tid: str, trig: dict, ctx: dict) -> None:
1051
+ """Run the actions for one provoking call, then drain any calls queued while firing (repeat
1052
+ triggers). A failure always re-arms: a mirror states a standing invariant, so one transient
1053
+ error must not silence it for the rest of the run. Ends armed or fired (one-shot success)."""
1054
+ spec = trig["spec"]
1055
+ repeat = bool(spec["when"].get("repeat"))
1056
+ try:
1057
+ while True:
1058
+ ok = True
1059
+ try:
1060
+ await self._gw._log_event({"event_type": "trigger_fired", "source": "trigger_engine", "trigger_id": tid,
1061
+ "provoking_tool": (ctx.get("args") is not None and spec["when"].get("tool")) or None})
1062
+ for index, action in enumerate(spec["actions"]):
1063
+ if not await self._run_action(tid, index, action, ctx):
1064
+ ok = False
1065
+ self._emit("failed", tid, action_index=index)
1066
+ break
1067
+ if ok:
1068
+ trig["fire_count"] += 1
1069
+ trig["fired_at"] = _now()
1070
+ self._emit("fired", tid, fire_count=trig["fire_count"])
1071
+ except Exception as e:
1072
+ logger.exception(f"trigger '{tid}' firing failed")
1073
+ ok = False
1074
+ self._emit("failed", tid, detail=str(e)[:300])
1075
+ if ok:
1076
+ self._resolve_dependents(tid) # success-only, outside the try so it can't flip a fired trigger to failed
1077
+ if trig["pending"]:
1078
+ ctx = trig["pending"].popleft()
1079
+ trig["status"] = "firing"
1080
+ trig["detected_at"] = _now()
1081
+ continue
1082
+ trig["status"] = "armed" if (repeat or not ok) else "fired"
1083
+ return
1084
+ finally:
1085
+ # An abnormal exit must not wedge the trigger in firing/queued, nor drop its queue silently.
1086
+ if trig["status"] in TRIGGER_IN_FLIGHT_STATUSES:
1087
+ self._emit("failed", tid, detail="firing did not settle", dropped=len(trig["pending"]))
1088
+ trig["status"] = "armed"
1089
+ trig["pending"].clear()
1090
+ trig["task"] = None
1091
+
1092
+ async def _run_action(self, tid: str, index: int, action: dict, ctx: dict) -> bool:
1093
+ changelog_before = self._gw._query_changelog_id()
1094
+ try:
1095
+ if action["type"] == "permission":
1096
+ tools = action["tools"] if isinstance(action["tools"], list) else [action["tools"]]
1097
+ async with self._gw._role_rules_lock:
1098
+ for tool in tools:
1099
+ self._gw._apply_rule(action["role"], tool, action["action"] == "disable")
1100
+ detail = {"role": action["role"], "action": action["action"], "tools": tools}
1101
+ elif action["type"] == "tool":
1102
+ try:
1103
+ args = _template(action.get("args", {}), {}, ctx)
1104
+ except TemplateError as e:
1105
+ self._emit("action_failed", tid, action_index=index, detail=str(e)[:300], tool=action["tool"])
1106
+ return False
1107
+ result = await self._internal_call(action["tool"], args)
1108
+ text = _result_text(result)
1109
+ failed = bool(getattr(result, "isError", False))
1110
+ # The write itself, not just the trigger_fired marker: a Harbor grader has no timeline.
1111
+ await self._gw._log_event({
1112
+ "event_type": "internal_tool_call", "source": "trigger_engine", "trigger_id": tid,
1113
+ "action_index": index, "tool": action["tool"], "arguments": _echo_args(args),
1114
+ "ok": not failed, "result_len": len(text),
1115
+ "result_sha256": hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:_ECHO_DIGEST_CHARS],
1116
+ "changelog_id_before": changelog_before, "changelog_id_after": self._gw._query_changelog_id()})
1117
+ if failed:
1118
+ # The refusal text is the only account of why a mirror broke; counters cannot replace it.
1119
+ self._emit("action_failed", tid, action_index=index, detail=text[:300],
1120
+ tool=action["tool"], args=_echo_args(args))
1121
+ return False
1122
+ detail = {"tool": action["tool"], "args": _echo_args(args)}
1123
+ else:
1124
+ if not await self._run_nl(tid, index, action, ctx):
1125
+ return False
1126
+ detail = {"nl": action["instruction"][:120]}
1127
+ if "verify" in action and action["type"] != "nl":
1128
+ if not await self._run_verify(tid, index, action["verify"], ctx):
1129
+ return False
1130
+ self._emit("action_ok", tid, action_index=index, detail=detail,
1131
+ changelog_id_before=changelog_before, changelog_id_after=self._gw._query_changelog_id())
1132
+ return True
1133
+ except Exception as e:
1134
+ self._emit("action_failed", tid, action_index=index, detail=str(e)[:300])
1135
+ return False
1136
+
1137
+ async def _run_verify(self, tid: str, index: int, verify: dict, ctx: dict | None = None) -> bool:
1138
+ if await self._run_check(verify, ctx):
1139
+ self._emit("verify_ok", tid, action_index=index)
1140
+ return True
1141
+ self._emit("verify_failed", tid, action_index=index)
1142
+ return False
1143
+
1144
+ async def _run_nl(self, tid: str, index: int, action: dict, ctx: dict | None = None) -> bool:
1145
+ instruction = action["instruction"]
1146
+ for attempt in (1, 2):
1147
+ reply = await self._call_executor(instruction, context_id=f"trigger-{tid}")
1148
+ if reply is None:
1149
+ self._emit("action_failed", tid, action_index=index, detail=f"executor attempt {attempt} did not complete")
1150
+ return False
1151
+ if "verify" not in action:
1152
+ return True
1153
+ if await self._run_verify(tid, index, action["verify"], ctx):
1154
+ return True
1155
+ if attempt == 1:
1156
+ instruction = (f"{action['instruction']}\n\nYour previous attempt did not pass verification — "
1157
+ f"the expected change is not observable in the environment. Do it again exactly as described.")
1158
+ self._emit("action_failed", tid, action_index=index, detail="verification failed after retry")
1159
+ return False
1160
+
1161
+ async def _call_executor(self, instruction: str, context_id: str) -> str | None:
1162
+ executor = self._executor
1163
+ if executor is None:
1164
+ return None
1165
+ url = f"{executor['a2a_url']}/a2a"
1166
+ deadline = asyncio.get_running_loop().time() + executor["timeout_seconds"]
1167
+ async with httpx.AsyncClient(timeout=30) as client:
1168
+ send = await client.post(url, json={
1169
+ "jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": "message/send",
1170
+ "params": {"message": {"role": "user", "messageId": uuid.uuid4().hex,
1171
+ "contextId": context_id,
1172
+ "parts": [{"kind": "text", "text": instruction}]}},
1173
+ })
1174
+ send.raise_for_status()
1175
+ task_id = (send.json().get("result") or {}).get("id")
1176
+ if not task_id:
1177
+ return None
1178
+ while asyncio.get_running_loop().time() < deadline:
1179
+ await asyncio.sleep(2)
1180
+ poll = await client.post(url, json={"jsonrpc": "2.0", "id": str(uuid.uuid4()),
1181
+ "method": "tasks/get", "params": {"id": task_id}})
1182
+ poll.raise_for_status()
1183
+ status = ((poll.json().get("result") or {}).get("status") or {})
1184
+ if status.get("state") in _TERMINAL_A2A_STATES:
1185
+ if status.get("state") != "completed":
1186
+ return None
1187
+ parts = (status.get("message") or {}).get("parts") or []
1188
+ return "\n".join(p.get("text", "") for p in parts if p.get("kind") == "text")
1189
+ return None
1190
+
1191
+
1192
+ def _result_text(result: CallToolResult) -> str:
1193
+ return "\n".join(c.text for c in result.content if getattr(c, "type", None) == "text")