hyperagents 1.2.3rc5.post3__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 (361) hide show
  1. agents/__init__.py +31 -0
  2. agents/background_monitor.py +453 -0
  3. agents/learning.py +460 -0
  4. agents/planner.py +421 -0
  5. agents/transcriber.py +341 -0
  6. civilization/__init__.py +51 -0
  7. civilization/api/__init__.py +7 -0
  8. civilization/api/civilization_api.py +361 -0
  9. civilization/graph/__init__.py +19 -0
  10. civilization/graph/knowledge_linker.py +348 -0
  11. civilization/graph/org_graph.py +348 -0
  12. civilization/graph/process_graph.py +386 -0
  13. civilization/ingestion/__init__.py +20 -0
  14. civilization/ingestion/chunker.py +273 -0
  15. civilization/ingestion/document_ingestor.py +358 -0
  16. civilization/ingestion/embedder.py +262 -0
  17. civilization/ingestion/extractor.py +224 -0
  18. civilization/interview/__init__.py +19 -0
  19. civilization/interview/gap_detector.py +380 -0
  20. civilization/interview/interviewer.py +323 -0
  21. civilization/interview/questioner.py +363 -0
  22. civilization/retrieval/__init__.py +17 -0
  23. civilization/retrieval/civilization_rag.py +316 -0
  24. civilization/retrieval/context_injector.py +342 -0
  25. civilization/retrieval/relevance_ranker.py +287 -0
  26. civilization/schema.py +299 -0
  27. civilization/store.py +177 -0
  28. civilization/sync/__init__.py +16 -0
  29. civilization/sync/confluence_sync.py +317 -0
  30. civilization/sync/gdrive_sync.py +262 -0
  31. civilization/sync/notion_sync.py +237 -0
  32. civilization/versioning/__init__.py +19 -0
  33. civilization/versioning/diff_engine.py +351 -0
  34. civilization/versioning/staleness_detector.py +303 -0
  35. civilization/versioning/version_manager.py +293 -0
  36. cli/__init__.py +1 -0
  37. cli/doctor.py +334 -0
  38. cli/hyperclaw.py +1357 -0
  39. cli/onboarding.py +469 -0
  40. core/__init__.py +1 -0
  41. core/connectors/__init__.py +119 -0
  42. core/connectors/infrastructure.py +772 -0
  43. core/connectors/messaging.py +586 -0
  44. core/connectors/productivity.py +764 -0
  45. core/connectors/smarthome.py +528 -0
  46. core/connectors/social.py +730 -0
  47. core/hyperrouter/__init__.py +23 -0
  48. core/hyperrouter/bandit.py +218 -0
  49. core/hyperrouter/fast_loop.py +68 -0
  50. core/hyperrouter/slow_loop.py +143 -0
  51. core/hyperstate/__init__.py +24 -0
  52. core/hyperstate/certifier.py +154 -0
  53. core/hyperstate/schema.py +160 -0
  54. core/hyperstate/state_manager.py +103 -0
  55. core/hyperstate/store.py +163 -0
  56. core/providers/__init__.py +121 -0
  57. core/providers/embeddings.py +389 -0
  58. core/providers/image.py +534 -0
  59. core/providers/llm.py +1935 -0
  60. core/providers/search.py +608 -0
  61. core/providers/voice.py +474 -0
  62. hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
  63. hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
  64. hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
  65. hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
  66. hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
  67. hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
  68. hyperclaw/__init__.py +67 -0
  69. hyperclaw/__main__.py +220 -0
  70. hyperclaw/agent.py +775 -0
  71. hyperclaw/agent_coordinator.py +577 -0
  72. hyperclaw/anthropic_params.py +225 -0
  73. hyperclaw/api_utils.py +85 -0
  74. hyperclaw/approval_telegram.py +209 -0
  75. hyperclaw/approval_terminal.py +80 -0
  76. hyperclaw/attachments.py +177 -0
  77. hyperclaw/branding.py +55 -0
  78. hyperclaw/brief/__init__.py +11 -0
  79. hyperclaw/brief/cli.py +204 -0
  80. hyperclaw/brief/collect.py +146 -0
  81. hyperclaw/brief/compose.py +333 -0
  82. hyperclaw/brief/diff.py +110 -0
  83. hyperclaw/brief/errors.py +99 -0
  84. hyperclaw/brief/google.py +269 -0
  85. hyperclaw/brief/google_auth.py +294 -0
  86. hyperclaw/brief/links.py +124 -0
  87. hyperclaw/brief/service.py +424 -0
  88. hyperclaw/brief/store.py +205 -0
  89. hyperclaw/brief/tasks.py +200 -0
  90. hyperclaw/chat_scope.py +115 -0
  91. hyperclaw/cli_tui.py +497 -0
  92. hyperclaw/client_guard.py +717 -0
  93. hyperclaw/connection_check.py +113 -0
  94. hyperclaw/cost_guard.py +1728 -0
  95. hyperclaw/default_config/agents.yaml +534 -0
  96. hyperclaw/default_config/hyperclaw.yaml +27 -0
  97. hyperclaw/default_config/models.yaml +161 -0
  98. hyperclaw/deployment.py +39 -0
  99. hyperclaw/digest/__init__.py +9 -0
  100. hyperclaw/digest/cli.py +117 -0
  101. hyperclaw/digest/errors.py +100 -0
  102. hyperclaw/digest/gitlab.py +187 -0
  103. hyperclaw/digest/http.py +113 -0
  104. hyperclaw/digest/redact.py +32 -0
  105. hyperclaw/digest/service.py +271 -0
  106. hyperclaw/digest/slack.py +223 -0
  107. hyperclaw/digest/state.py +124 -0
  108. hyperclaw/digest/summarize.py +198 -0
  109. hyperclaw/digest/tokens.py +143 -0
  110. hyperclaw/distribution.py +46 -0
  111. hyperclaw/documents.py +351 -0
  112. hyperclaw/feeds.py +339 -0
  113. hyperclaw/google_workspace/__init__.py +5 -0
  114. hyperclaw/google_workspace/api.py +151 -0
  115. hyperclaw/google_workspace/auth.py +397 -0
  116. hyperclaw/google_workspace/calendar.py +157 -0
  117. hyperclaw/google_workspace/cli.py +72 -0
  118. hyperclaw/google_workspace/drive.py +236 -0
  119. hyperclaw/google_workspace/errors.py +58 -0
  120. hyperclaw/google_workspace/gmail.py +295 -0
  121. hyperclaw/google_workspace/meet.py +122 -0
  122. hyperclaw/google_workspace/tools.py +319 -0
  123. hyperclaw/hyperspeed.py +57 -0
  124. hyperclaw/integrations_layer.py +1427 -0
  125. hyperclaw/live_state.py +188 -0
  126. hyperclaw/managed_config.py +245 -0
  127. hyperclaw/media_hub.py +179 -0
  128. hyperclaw/memory_manager.py +584 -0
  129. hyperclaw/model_catalog.py +551 -0
  130. hyperclaw/model_policy.py +211 -0
  131. hyperclaw/model_router.py +595 -0
  132. hyperclaw/model_selector.py +72 -0
  133. hyperclaw/naming.py +284 -0
  134. hyperclaw/notices.py +65 -0
  135. hyperclaw/oai_stream.py +106 -0
  136. hyperclaw/orchestrator.py +591 -0
  137. hyperclaw/outbox.py +82 -0
  138. hyperclaw/persona.py +367 -0
  139. hyperclaw/prometheus.py +219 -0
  140. hyperclaw/prompt_assembly.py +157 -0
  141. hyperclaw/provider_setup.py +146 -0
  142. hyperclaw/providers.py +340 -0
  143. hyperclaw/qualification.py +341 -0
  144. hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
  145. hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
  146. hyperclaw/reasoning_display.py +35 -0
  147. hyperclaw/scheduler.py +454 -0
  148. hyperclaw/server.py +847 -0
  149. hyperclaw/setup.py +627 -0
  150. hyperclaw/skills.py +717 -0
  151. hyperclaw/solomon.py +442 -0
  152. hyperclaw/swarm.py +533 -0
  153. hyperclaw/taint.py +832 -0
  154. hyperclaw/team/__init__.py +28 -0
  155. hyperclaw/team/dispatcher.py +431 -0
  156. hyperclaw/team/genesis.py +106 -0
  157. hyperclaw/team/lead.py +110 -0
  158. hyperclaw/team/model.py +135 -0
  159. hyperclaw/team/morning.py +238 -0
  160. hyperclaw/team/repos.py +66 -0
  161. hyperclaw/team/roster.py +264 -0
  162. hyperclaw/team/specialist.py +235 -0
  163. hyperclaw/team/status.py +163 -0
  164. hyperclaw/team/watchers.py +112 -0
  165. hyperclaw/teamsetup/__init__.py +17 -0
  166. hyperclaw/teamsetup/checks.py +208 -0
  167. hyperclaw/teamsetup/item.py +391 -0
  168. hyperclaw/teamsetup/oauth_loopback.py +84 -0
  169. hyperclaw/teamsetup/onboard.py +550 -0
  170. hyperclaw/teamsetup/profile.py +82 -0
  171. hyperclaw/teamsetup/secrets.py +313 -0
  172. hyperclaw/teamsetup/selfcheck.py +295 -0
  173. hyperclaw/teamsetup/service.py +495 -0
  174. hyperclaw/teamsetup/slack_oauth.py +442 -0
  175. hyperclaw/teamsetup/tty.py +62 -0
  176. hyperclaw/telegram_bot.py +1106 -0
  177. hyperclaw/telegram_intake.py +126 -0
  178. hyperclaw/telegram_pairing.py +360 -0
  179. hyperclaw/terminal_provider.py +323 -0
  180. hyperclaw/tools/__init__.py +1 -0
  181. hyperclaw/tools/approval.py +949 -0
  182. hyperclaw/tools/args.py +212 -0
  183. hyperclaw/tools/catalog.py +244 -0
  184. hyperclaw/tools/classify.py +266 -0
  185. hyperclaw/tools/effects.py +724 -0
  186. hyperclaw/tools/egress.py +210 -0
  187. hyperclaw/tools/envelope.py +188 -0
  188. hyperclaw/tools/guard.py +367 -0
  189. hyperclaw/tools/ledger.py +339 -0
  190. hyperclaw/tools/receipts.py +70 -0
  191. hyperclaw/tools/registry.py +318 -0
  192. hyperclaw/tools/service_guard.py +151 -0
  193. hyperclaw/tools/surfaces.py +54 -0
  194. hyperclaw/tools/workspace.py +87 -0
  195. hyperclaw/trading/__init__.py +77 -0
  196. hyperclaw/truth_guard.py +791 -0
  197. hyperclaw/tts.py +124 -0
  198. hyperclaw/tui.py +4255 -0
  199. hyperclaw/tui_bridge.py +938 -0
  200. hyperclaw/unattended.py +60 -0
  201. hyperclaw/updates/__init__.py +54 -0
  202. hyperclaw/updates/check.py +131 -0
  203. hyperclaw/updates/index.py +178 -0
  204. hyperclaw/updates/install.py +110 -0
  205. hyperclaw/updates/managed.py +161 -0
  206. hyperclaw/updates/manifest.py +138 -0
  207. hyperclaw/updates/notices.py +86 -0
  208. hyperclaw/updates/paths.py +53 -0
  209. hyperclaw/updates/release.py +146 -0
  210. hyperclaw/updates/upgrade.py +253 -0
  211. hyperclaw/updates/versioning.py +104 -0
  212. hyperclaw/userconfig.py +119 -0
  213. hyperclaw/xai.py +83 -0
  214. integrations/__init__.py +31 -0
  215. integrations/automation/__init__.py +1 -0
  216. integrations/automation/make.py +87 -0
  217. integrations/automation/zapier.py +87 -0
  218. integrations/base.py +476 -0
  219. integrations/communication/__init__.py +1 -0
  220. integrations/communication/twilio.py +279 -0
  221. integrations/communication/zoom.py +290 -0
  222. integrations/data/__init__.py +1 -0
  223. integrations/data/google_analytics.py +135 -0
  224. integrations/data/postgres.py +182 -0
  225. integrations/data/supabase_connector.py +202 -0
  226. integrations/data/weather_feeds.py +450 -0
  227. integrations/developer/__init__.py +9 -0
  228. integrations/developer/github.py +406 -0
  229. integrations/developer/gitlab.py +298 -0
  230. integrations/enterprise/__init__.py +13 -0
  231. integrations/enterprise/confluence.py +266 -0
  232. integrations/enterprise/hubspot.py +293 -0
  233. integrations/enterprise/jira.py +314 -0
  234. integrations/enterprise/salesforce.py +286 -0
  235. integrations/finance/__init__.py +1 -0
  236. integrations/finance/polymarket_client.py +247 -0
  237. integrations/finance/quickbooks.py +281 -0
  238. integrations/finance/stripe.py +261 -0
  239. integrations/gateway.py +238 -0
  240. integrations/messaging/__init__.py +32 -0
  241. integrations/messaging/discord.py +159 -0
  242. integrations/messaging/email.py +401 -0
  243. integrations/messaging/imessage.py +443 -0
  244. integrations/messaging/signal.py +175 -0
  245. integrations/messaging/slack.py +217 -0
  246. integrations/messaging/sms.py +175 -0
  247. integrations/messaging/teams.py +208 -0
  248. integrations/messaging/telegram.py +260 -0
  249. integrations/messaging/whatsapp.py +203 -0
  250. integrations/productivity/__init__.py +17 -0
  251. integrations/productivity/airtable.py +217 -0
  252. integrations/productivity/asana.py +248 -0
  253. integrations/productivity/google/__init__.py +19 -0
  254. integrations/productivity/google/base.py +89 -0
  255. integrations/productivity/google/calendar.py +272 -0
  256. integrations/productivity/google/docs.py +202 -0
  257. integrations/productivity/google/drive.py +268 -0
  258. integrations/productivity/google/gmail.py +279 -0
  259. integrations/productivity/google/meet.py +218 -0
  260. integrations/productivity/google/sheets.py +222 -0
  261. integrations/productivity/google/tasks.py +199 -0
  262. integrations/productivity/linear.py +279 -0
  263. integrations/productivity/microsoft/__init__.py +13 -0
  264. integrations/productivity/microsoft/base.py +93 -0
  265. integrations/productivity/microsoft/calendar.py +207 -0
  266. integrations/productivity/microsoft/onedrive.py +222 -0
  267. integrations/productivity/microsoft/outlook.py +241 -0
  268. integrations/productivity/microsoft/sharepoint.py +211 -0
  269. integrations/productivity/notion.py +243 -0
  270. integrations/productivity/todoist.py +250 -0
  271. integrations/productivity/trello.py +244 -0
  272. integrations/storage/__init__.py +1 -0
  273. integrations/storage/box.py +279 -0
  274. integrations/storage/dropbox.py +215 -0
  275. integrations/telegram_inbound.py +179 -0
  276. integrations/telegram_poller.py +151 -0
  277. memory/__init__.py +1 -0
  278. memory/causal_graph.py +125 -0
  279. memory/impact_tracker.py +88 -0
  280. memory/vector_memory.py +436 -0
  281. models/__init__.py +12 -0
  282. models/chatjimmy_client.py +210 -0
  283. models/claude_client.py +235 -0
  284. models/claude_code_subagent.py +192 -0
  285. models/openai_client.py +120 -0
  286. models/router.py +162 -0
  287. recursive/discovery_loop.py +118 -0
  288. recursive/optimization_loop.py +135 -0
  289. recursive/validation_loop.py +38 -0
  290. security/__init__.py +1 -0
  291. security/audit_logger.py +106 -0
  292. security/filesystem_guard.py +100 -0
  293. security/hypershield.py +99 -0
  294. security/network_guard.py +97 -0
  295. security/policy_engine.py +198 -0
  296. swarm/__init__.py +1 -0
  297. swarm/agents/__init__.py +0 -0
  298. swarm/agents/base.py +230 -0
  299. swarm/agents/business/__init__.py +0 -0
  300. swarm/agents/business/counsel.py +31 -0
  301. swarm/agents/business/herald.py +32 -0
  302. swarm/agents/business/ledger.py +26 -0
  303. swarm/agents/business/nexus.py +30 -0
  304. swarm/agents/business/ops.py +30 -0
  305. swarm/agents/business/pipeline.py +28 -0
  306. swarm/agents/business/revenue.py +30 -0
  307. swarm/agents/business/sovereign.py +31 -0
  308. swarm/agents/business/strategos.py +27 -0
  309. swarm/agents/business/talent.py +27 -0
  310. swarm/agents/business/venture.py +32 -0
  311. swarm/agents/comms/__init__.py +1 -0
  312. swarm/agents/comms/cipher.py +32 -0
  313. swarm/agents/comms/echo_comms.py +31 -0
  314. swarm/agents/comms/envoy.py +31 -0
  315. swarm/agents/comms/herald_scheduler.py +33 -0
  316. swarm/agents/comms/pulse.py +29 -0
  317. swarm/agents/creative/__init__.py +0 -0
  318. swarm/agents/creative/author.py +27 -0
  319. swarm/agents/creative/lens.py +25 -0
  320. swarm/agents/executive/__init__.py +1 -0
  321. swarm/agents/intelligence/__init__.py +1 -0
  322. swarm/agents/intelligence/arbiter.py +30 -0
  323. swarm/agents/intelligence/sentinel.py +30 -0
  324. swarm/agents/media/__init__.py +0 -0
  325. swarm/agents/personal/__init__.py +0 -0
  326. swarm/agents/personal/atlas.py +27 -0
  327. swarm/agents/personal/hearth.py +26 -0
  328. swarm/agents/personal/midas.py +27 -0
  329. swarm/agents/personal/navigator.py +27 -0
  330. swarm/agents/personal/nourish.py +31 -0
  331. swarm/agents/personal/vitals.py +33 -0
  332. swarm/agents/recursive/__init__.py +0 -0
  333. swarm/agents/recursive/alchemist.py +73 -0
  334. swarm/agents/recursive/calibrator.py +45 -0
  335. swarm/agents/recursive/scout.py +97 -0
  336. swarm/agents/scientific/__init__.py +0 -0
  337. swarm/agents/scientific/cosmos.py +36 -0
  338. swarm/agents/scientific/gaia.py +26 -0
  339. swarm/agents/scientific/medicus.py +33 -0
  340. swarm/agents/scientific/oracle_agent.py +39 -0
  341. swarm/agents/scientific/scribe.py +39 -0
  342. swarm/agents/talent/__init__.py +0 -0
  343. swarm/agents/talent/deal.py +28 -0
  344. swarm/agents/talent/roster.py +29 -0
  345. swarm/agents/talent/scout.py +29 -0
  346. swarm/agents/talent/stage.py +27 -0
  347. swarm/agents/tech/__init__.py +0 -0
  348. swarm/agents/tech/aegis.py +28 -0
  349. swarm/agents/tech/bridge.py +28 -0
  350. swarm/agents/tech/forge.py +28 -0
  351. swarm/agents/trading/__init__.py +1 -0
  352. swarm/agents/trading/global_prediction_engine.py +611 -0
  353. swarm/agents/trading/polymarket_trader.py +165 -0
  354. swarm/agents/trading/prediction_strategist.py +218 -0
  355. swarm/autogen_bridge.py +68 -0
  356. swarm/bid_protocol.py +196 -0
  357. swarm/dispatcher.py +527 -0
  358. swarm/nexus.py +181 -0
  359. swarm/registry.py +112 -0
  360. telegram_listener.py +403 -0
  361. ui/tui/app.py +214 -0
@@ -0,0 +1,367 @@
1
+ """
2
+ The per-turn runtime guard (tool standard wave 2: S3 idempotency, S5 retry
3
+ budgets). One TurnGuard is created per user message by every loop that runs
4
+ tools (the terminal's Anthropic and OpenAI-compatible loops and the chat
5
+ bridge) and passed to ToolRegistry.dispatch(..., guard=). The registry calls:
6
+
7
+ precheck(spec) before argument validation: turn cap, per-tool budget
8
+ on_invalid(spec) after invalid_args: one corrected retry is free
9
+ check_budgets(spec, args) before the approval gate (wave 3): per-resource and
10
+ per-target budgets, so a refused call is never queued
11
+ before(spec, args) per-resource budget, per-target retry budget,
12
+ effect-ledger check + intent row
13
+ after(spec, args, env, ctx) outcome row, counters, not_found resources
14
+ observe(spec, args, env) an owner-approved call's outcome, counted in this turn
15
+
16
+ The model never sees a key and cannot opt a call out. What persists across
17
+ turns and restarts is the ledger (hyperclaw/tools/ledger.py); the budgets and
18
+ the not-found set are per turn, as the standard says.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ import time
25
+ import uuid
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ from . import effects as fx
29
+ from .envelope import (REASON_CONFIRM, REASON_RESOURCE_MISSING, REASON_RETRY_BUDGET,
30
+ REASON_TURN_CAP, REASON_UNKNOWN_TOOL, Status, ToolResult, make,
31
+ new_receipt_id)
32
+ from .ledger import CONFLICT_EFFECTS, EffectLedger, get_ledger
33
+
34
+ log = logging.getLogger("hyperclaw.tools.guard")
35
+
36
+ # Statuses that are not a failed attempt (they don't spend retry budget).
37
+ _NOT_FAILURES = {Status.OK.value, Status.DUPLICATE.value, Status.NEEDS_APPROVAL.value,
38
+ Status.BUDGET_EXHAUSTED.value, Status.INVALID_ARGS.value}
39
+
40
+ CHAT_TOOL_CALL_CAP = 8 # chat surfaces (bridge/Telegram); the terminal keeps 25 rounds
41
+
42
+ TOOL_CAP_NOTE = (
43
+ "[HyperClaw system note: the tool-call limit for one message ({limit}) was reached, so "
44
+ "this turn was stopped. These calls did NOT run: {names}.]"
45
+ )
46
+
47
+ _VERB = {"send": "send", "spend": "spend", "delete": "delete", "write": "write",
48
+ "system": "action"}
49
+
50
+
51
+ def chat_tool_call_cap() -> int:
52
+ try:
53
+ return int(os.environ.get("HYPERCLAW_CHAT_TOOL_CALL_CAP", CHAT_TOOL_CALL_CAP))
54
+ except ValueError:
55
+ return CHAT_TOOL_CALL_CAP
56
+
57
+
58
+ def _when(ts) -> str:
59
+ try:
60
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(float(ts)))
61
+ except (TypeError, ValueError):
62
+ return "earlier"
63
+
64
+
65
+ def _display_target(target: str, tool: str) -> str:
66
+ if target.startswith("file:"):
67
+ return target[5:]
68
+ if target.startswith(tool + ":"):
69
+ return target[len(tool) + 1:]
70
+ return target
71
+
72
+
73
+ class TurnGuard:
74
+ def __init__(self, *, conversation: str, surface: str,
75
+ ledger: Optional[EffectLedger] = None, call_cap: Optional[int] = None,
76
+ turn_id: Optional[str] = None, base_dir: Optional[str] = None,
77
+ provenance: Optional[List[str]] = None, requester: str = "owner",
78
+ approved: bool = False):
79
+ self.conversation = conversation
80
+ # Wave 3 (approval layer). provenance: the user's own words and the
81
+ # content they gave this conversation (a read-only fetch may only
82
+ # target a URL that appears verbatim here). requester: "owner" or
83
+ # "agent:<id>" for sub-agents. approved: set only for the run of an
84
+ # owner-approved payload (tools.approval.send).
85
+ self.provenance: List[str] = [p for p in (provenance or []) if isinstance(p, str)]
86
+ self.requester = requester
87
+ self.approved = approved
88
+ self.untrusted_seen = False # a read of email/web/document content ran this turn
89
+ self.memory_saved_unprompted: List[str] = [] # own-words memories saved this turn
90
+ self.queued: List[str] = [] # approval ids queued this turn
91
+ self.surface = surface
92
+ self._ledger = ledger
93
+ self.call_cap = call_cap
94
+ self.turn_id = turn_id or uuid.uuid4().hex[:12]
95
+ self.base_dir = base_dir
96
+ self.calls = 0
97
+ self.failures: Dict[Tuple[str, str], int] = {}
98
+ self.invalid: Dict[str, int] = {}
99
+ self.missing: List[fx.Resource] = []
100
+ self.skipped: List[str] = []
101
+ self.cap_hit = False
102
+ # Every tool that ran this turn, with the status it really returned:
103
+ # the evidence the cost guard's stop message is built from.
104
+ self.results: List[Dict[str, Any]] = []
105
+
106
+ @property
107
+ def ledger(self) -> EffectLedger:
108
+ if self._ledger is None:
109
+ self._ledger = get_ledger()
110
+ return self._ledger
111
+
112
+ # ── before validation ──
113
+ def precheck(self, spec) -> Optional[ToolResult]:
114
+ if self.call_cap is not None and self.calls >= self.call_cap:
115
+ self.cap_hit = True
116
+ self.skipped.append(spec.name)
117
+ return make(Status.BUDGET_EXHAUSTED,
118
+ f"{spec.name} was NOT run: the limit of {self.call_cap} tool calls for "
119
+ f"one message was reached.", reason=REASON_TURN_CAP)
120
+ self.calls += 1
121
+ if self.failures.get((spec.name, "*"), 0) > spec.retry_budget:
122
+ return self._retry_refusal(spec, "*")
123
+ return None
124
+
125
+ def on_invalid(self, spec) -> None:
126
+ n = self.invalid.get(spec.name, 0) + 1
127
+ self.invalid[spec.name] = n
128
+ if n >= 2: # one corrected retry is free; the next invalid_args counts
129
+ self.failures[(spec.name, "*")] = self.failures.get((spec.name, "*"), 0) + 1
130
+
131
+ def _retry_refusal(self, spec, tkey: str) -> ToolResult:
132
+ where = "" if tkey == "*" else f" on {tkey.split(':', 1)[-1]}"
133
+ return make(Status.BUDGET_EXHAUSTED,
134
+ f"{spec.name} was NOT run: it already failed{where} this turn and its retry "
135
+ f"budget ({spec.retry_budget}) is used up.", reason=REASON_RETRY_BUDGET)
136
+
137
+ # ── after validation, before the approval gate ──
138
+ def check_budgets(self, spec, args: dict) -> Optional[ToolResult]:
139
+ """The S5 refusals (still looking for something not found this turn;
140
+ retry budget used up), checked BEFORE a call is queued for approval,
141
+ so a call this turn would refuse is never put in front of the owner.
142
+ Writes nothing but the refusal row. before() repeats the same checks."""
143
+ res = fx.resources(spec.name, args, self.base_dir)
144
+ check = [r for r in res if r.kind != "path"] if spec.name in fx.CREATE_TOOLS else res
145
+ eff = None
146
+ if check:
147
+ for m in self.missing:
148
+ if any(fx.touches(r, m) for r in check):
149
+ eff = fx.call_effect(spec, args)
150
+ env = make(Status.BUDGET_EXHAUSTED,
151
+ f"{spec.name} was NOT run: it is still looking for {m.value}, "
152
+ f"which was not found earlier in this turn.",
153
+ reason=REASON_RESOURCE_MISSING)
154
+ self._log_refusal(spec, eff, args, env)
155
+ return env
156
+ eff = fx.call_effect(spec, args)
157
+ tkey = "*"
158
+ if eff is not None and eff.target != spec.name:
159
+ tkey = eff.target
160
+ elif res and res[0].kind != "text":
161
+ tkey = str(res[0])
162
+ if self.failures.get((spec.name, tkey), 0) > spec.retry_budget:
163
+ env = self._retry_refusal(spec, tkey)
164
+ self._log_refusal(spec, eff, args, env)
165
+ return env
166
+ return None
167
+
168
+ def observe(self, spec, args: dict, env: ToolResult) -> None:
169
+ """An owner-approved call ran (tools.approval.send, under its own
170
+ guard, which wrote the ledger rows). Count its outcome in THIS turn's
171
+ budgets too, so a failed approved write is not retried this turn and
172
+ a not-found resource stays not found."""
173
+ tkey = "*"
174
+ try:
175
+ eff = fx.call_effect(spec, args)
176
+ res = fx.resources(spec.name, args, self.base_dir)
177
+ if eff is not None and eff.target != spec.name:
178
+ tkey = eff.target
179
+ elif res and res[0].kind != "text":
180
+ tkey = str(res[0])
181
+ except Exception:
182
+ res = []
183
+ self._count(spec, env, {"resources": res, "tkey": tkey})
184
+
185
+ # ── after validation, before running ──
186
+ def before(self, spec, args: dict) -> Tuple[Optional[ToolResult], Dict[str, Any]]:
187
+ res = fx.resources(spec.name, args, self.base_dir)
188
+ eff = fx.call_effect(spec, args)
189
+ ctx: Dict[str, Any] = {"resources": res, "effect": eff, "intent_id": None,
190
+ "writes": [], "tkey": "*"}
191
+ if eff is not None and eff.target != spec.name:
192
+ ctx["tkey"] = eff.target
193
+ elif res and res[0].kind != "text":
194
+ ctx["tkey"] = str(res[0])
195
+
196
+ # S5 per resource: still going after something that was not found.
197
+ # A tool that creates a file may still create it at that path (its
198
+ # inputs, e.g. a download URL, are still checked).
199
+ check = [r for r in res if r.kind != "path"] if spec.name in fx.CREATE_TOOLS else res
200
+ if check:
201
+ for m in self.missing:
202
+ if any(fx.touches(r, m) for r in check):
203
+ env = make(Status.BUDGET_EXHAUSTED,
204
+ f"{spec.name} was NOT run: it is still looking for {m.value}, "
205
+ f"which was not found earlier in this turn.",
206
+ reason=REASON_RESOURCE_MISSING)
207
+ self._log_refusal(spec, eff, args, env)
208
+ return env, ctx
209
+
210
+ # S5 per tool (and target).
211
+ if self.failures.get((spec.name, ctx["tkey"]), 0) > spec.retry_budget:
212
+ env = self._retry_refusal(spec, ctx["tkey"])
213
+ self._log_refusal(spec, eff, args, env)
214
+ return env, ctx
215
+
216
+ # S3 effect ledger.
217
+ if spec.name == "bash":
218
+ return self._before_bash(spec, args, ctx)
219
+ if eff is None:
220
+ return None, ctx
221
+ try:
222
+ d = self.ledger.begin(
223
+ conversation=self.conversation, turn=self.turn_id, surface=self.surface,
224
+ tool=spec.name, effect=eff.effect, target=eff.target,
225
+ payload_hash=eff.payload_hash, key=eff.key, args=fx.summarize_args(args),
226
+ current_file_hash=fx.file_hash(eff.file_path),
227
+ expected_post_hash=eff.expected_post_hash, is_file=bool(eff.file_path),
228
+ allow_conflict=self.approved)
229
+ except Exception as e:
230
+ log.warning("effect ledger unavailable: %s", e, exc_info=True)
231
+ if eff.effect in CONFLICT_EFFECTS:
232
+ return make(Status.FAILED, f"{spec.name} was NOT run: the activity ledger is "
233
+ f"unavailable ({type(e).__name__}), so the runtime "
234
+ f"cannot guarantee it runs at most once."), ctx
235
+ return None, ctx
236
+ if d.action == "proceed":
237
+ ctx["intent_id"] = d.intent_id
238
+ return None, ctx
239
+ return self._held(spec, eff, d), ctx
240
+
241
+ def _held(self, spec, eff: fx.Effect, d) -> ToolResult:
242
+ prior = d.prior or {}
243
+ when = _when(prior.get("ts"))
244
+ receipt = prior.get("receipt_id")
245
+ target = _display_target(eff.target, spec.name)
246
+ original = {"tool": prior.get("tool"), "at": when, "receipt_id": receipt,
247
+ "target": prior.get("target"), "status": prior.get("status")}
248
+ verb = _VERB.get(eff.effect, "action")
249
+ if d.action == "duplicate":
250
+ return make(Status.DUPLICATE,
251
+ f"{spec.name} was NOT run: the same {verb} to {target} was already done "
252
+ f"at {when} by {prior.get('tool')}"
253
+ + (f" (receipt {receipt})." if receipt else "."),
254
+ data={"original": original}, receipt_id=receipt)
255
+ if d.reason == "uncertain":
256
+ msg = (f"{spec.name} was NOT run: an identical {verb} to {target} at {when} has an "
257
+ f"uncertain outcome; it may already have been sent. Ask the user to check "
258
+ f"before trying again.")
259
+ else:
260
+ msg = (f"{spec.name} was NOT run: a different {verb} to {target} was already done at "
261
+ f"{when}" + (f" (receipt {receipt})" if receipt else "") +
262
+ f". Ask the user whether to {verb} this as well.")
263
+ return make(Status.NEEDS_APPROVAL, msg, data={"original": original},
264
+ reason=REASON_CONFIRM)
265
+
266
+ def _before_bash(self, spec, args, ctx) -> Tuple[Optional[ToolResult], Dict[str, Any]]:
267
+ writes = fx.bash_file_writes(args.get("command", ""), self.base_dir)
268
+ ctx["writes"] = writes
269
+ ctx["pre"] = {w.path: fx.file_hash(w.path) for w in writes}
270
+ try:
271
+ for path, eff in fx.planned_file_effects(writes):
272
+ if eff is None:
273
+ continue
274
+ d = self.ledger.check(conversation=self.conversation, effect="write",
275
+ target=eff.target, key=eff.key,
276
+ current_file_hash=fx.file_hash(path),
277
+ expected_post_hash=eff.expected_post_hash, is_file=True)
278
+ if d.action == "duplicate":
279
+ env = self._held(spec, eff, d)
280
+ self._log_refusal(spec, eff, args, env)
281
+ return env, ctx
282
+ d = self.ledger.begin(conversation=self.conversation, turn=self.turn_id,
283
+ surface=self.surface, tool="bash", effect=spec.effect,
284
+ target="bash", payload_hash=fx.sha256(args.get("command", "")),
285
+ key=None, args=fx.summarize_args(args))
286
+ ctx["intent_id"] = d.intent_id
287
+ except Exception as e:
288
+ log.warning("effect ledger unavailable for bash: %s", e, exc_info=True)
289
+ return None, ctx
290
+
291
+ # ── after running ──
292
+ def after(self, spec, args: dict, env: ToolResult, ctx: Dict[str, Any]) -> ToolResult:
293
+ self._count(spec, env, ctx)
294
+ try:
295
+ self._record_outcome(spec, env, ctx)
296
+ except Exception as e:
297
+ log.warning("effect ledger: could not record outcome: %s", e, exc_info=True)
298
+ return env
299
+
300
+ def _count(self, spec, env: ToolResult, ctx: Dict[str, Any]) -> None:
301
+ status = env.status
302
+ eff = ctx.get("effect") if ctx else None
303
+ self.results.append({"tool": spec.name, "status": status,
304
+ "target": _display_target(eff.target, spec.name)[:80]
305
+ if eff is not None and eff.target else "",
306
+ "receipt_id": env.receipt_id})
307
+ if spec.effect == "read" and status == Status.OK.value:
308
+ from .approval import PURE_COMPUTE
309
+ if spec.name not in PURE_COMPUTE:
310
+ self.untrusted_seen = True
311
+ # Connector content (email, calendar, documents, web): recorded in
312
+ # this conversation's taint ledger (hyperclaw.taint).
313
+ try:
314
+ from hyperclaw import taint
315
+ taint.mark_tool_result(spec.name, env, self.conversation, self.turn_id)
316
+ except Exception:
317
+ log.warning("taint ledger: could not record %s", spec.name, exc_info=True)
318
+ if status not in _NOT_FAILURES:
319
+ k = (spec.name, ctx.get("tkey", "*"))
320
+ self.failures[k] = self.failures.get(k, 0) + 1
321
+ if status == Status.NOT_FOUND.value and spec.name != "bash" \
322
+ and env.instruction != make(Status.NOT_FOUND, reason=REASON_UNKNOWN_TOOL).instruction:
323
+ for r in ctx.get("resources", []):
324
+ if r.kind == "path" and os.path.exists(r.value):
325
+ continue # e.g. edit_file "text not found": the file exists
326
+ if r not in self.missing:
327
+ self.missing.append(r)
328
+
329
+ def _record_outcome(self, spec, env: ToolResult, ctx) -> None:
330
+ eff: Optional[fx.Effect] = ctx.get("effect")
331
+ if ctx.get("intent_id"):
332
+ post = fx.file_hash(eff.file_path) if (eff and eff.file_path) else None
333
+ self.ledger.finish(ctx["intent_id"], status=env.status, receipt_id=env.receipt_id,
334
+ post_hash=post, message=env.message)
335
+ if spec.name == "bash" and ctx.get("writes"):
336
+ for path in dict.fromkeys(w.path for w in ctx["writes"]):
337
+ fe = fx.recorded_file_effect(path, ctx["writes"])
338
+ post = fx.file_hash(path)
339
+ if fe is None or post is None or post == ctx.get("pre", {}).get(path):
340
+ continue # nothing was written there (or nothing changed)
341
+ self.ledger.record(conversation=self.conversation, turn=self.turn_id,
342
+ surface=self.surface, tool="bash", effect="write",
343
+ target=fe.target, payload_hash=fe.payload_hash, key=fe.key,
344
+ status=Status.OK.value, receipt_id=new_receipt_id(),
345
+ ref=ctx.get("intent_id"), post_hash=post,
346
+ args=fx.summarize_args({"via": "bash", "path": path}),
347
+ message=f"bash wrote {path}")
348
+
349
+ def _log_refusal(self, spec, eff: Optional[fx.Effect], args, env: ToolResult) -> None:
350
+ """Refusals of effectful calls go in the activity log too."""
351
+ if eff is None and spec.name != "bash":
352
+ return
353
+ try:
354
+ self.ledger.record(conversation=self.conversation, turn=self.turn_id,
355
+ surface=self.surface, tool=spec.name,
356
+ effect=eff.effect if eff else spec.effect,
357
+ target=eff.target if eff else spec.name,
358
+ payload_hash=eff.payload_hash if eff else None,
359
+ key=None, status=env.status, receipt_id=env.receipt_id,
360
+ args=fx.summarize_args(args), message=env.message)
361
+ except Exception as e:
362
+ log.warning("effect ledger: could not record refusal: %s", e)
363
+
364
+ # ── turn end ──
365
+ def stop_note(self) -> str:
366
+ return TOOL_CAP_NOTE.format(limit=self.call_cap,
367
+ names=", ".join(self.skipped) or "none")