minutehand 0.0.1__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 (308) hide show
  1. minutehand/__init__.py +6 -0
  2. minutehand/adapters/__init__.py +0 -0
  3. minutehand/adapters/agent/__init__.py +1 -0
  4. minutehand/adapters/agent/command.py +54 -0
  5. minutehand/adapters/agent/inboxes.py +324 -0
  6. minutehand/adapters/agent/openapi.py +224 -0
  7. minutehand/adapters/agent/polled.py +37 -0
  8. minutehand/adapters/agent/reach.py +55 -0
  9. minutehand/adapters/agent/replies.py +138 -0
  10. minutehand/adapters/agent/reported.py +85 -0
  11. minutehand/adapters/answering.py +251 -0
  12. minutehand/adapters/control/__init__.py +0 -0
  13. minutehand/adapters/control/app.py +646 -0
  14. minutehand/adapters/control/wire.py +599 -0
  15. minutehand/adapters/database/__init__.py +0 -0
  16. minutehand/adapters/database/postgres/__init__.py +0 -0
  17. minutehand/adapters/database/postgres/client.py +258 -0
  18. minutehand/adapters/database/postgres/conversation.py +405 -0
  19. minutehand/adapters/database/postgres/relay.py +447 -0
  20. minutehand/adapters/database/postgres/wire.py +168 -0
  21. minutehand/adapters/emulator/__init__.py +0 -0
  22. minutehand/adapters/emulator/answers.py +58 -0
  23. minutehand/adapters/emulator/fleet.py +77 -0
  24. minutehand/adapters/emulator/process.py +254 -0
  25. minutehand/adapters/emulator/relay.py +208 -0
  26. minutehand/adapters/mcp/__init__.py +0 -0
  27. minutehand/adapters/mcp/results.py +193 -0
  28. minutehand/adapters/mcp/server.py +387 -0
  29. minutehand/adapters/model/__init__.py +0 -0
  30. minutehand/adapters/model/openai_compatible.py +222 -0
  31. minutehand/adapters/providers/__init__.py +0 -0
  32. minutehand/adapters/providers/asana/CLAIMS.md +65 -0
  33. minutehand/adapters/providers/asana/__init__.py +5 -0
  34. minutehand/adapters/providers/asana/app.py +1284 -0
  35. minutehand/adapters/providers/asana/manifest.py +27 -0
  36. minutehand/adapters/providers/asana/provider.py +164 -0
  37. minutehand/adapters/providers/asana/seed.py +596 -0
  38. minutehand/adapters/providers/asana/state.py +451 -0
  39. minutehand/adapters/providers/asana/wire.py +1299 -0
  40. minutehand/adapters/providers/aws/__init__.py +1 -0
  41. minutehand/adapters/providers/aws/manifest.py +28 -0
  42. minutehand/adapters/providers/aws/provider.py +418 -0
  43. minutehand/adapters/providers/aws/schedule.py +134 -0
  44. minutehand/adapters/providers/aws/wire.py +386 -0
  45. minutehand/adapters/providers/github/CLAIMS.md +97 -0
  46. minutehand/adapters/providers/github/README.md +57 -0
  47. minutehand/adapters/providers/github/__init__.py +5 -0
  48. minutehand/adapters/providers/github/app.py +793 -0
  49. minutehand/adapters/providers/github/content.py +161 -0
  50. minutehand/adapters/providers/github/graphql.py +826 -0
  51. minutehand/adapters/providers/github/manifest.py +16 -0
  52. minutehand/adapters/providers/github/provider.py +51 -0
  53. minutehand/adapters/providers/github/search.py +169 -0
  54. minutehand/adapters/providers/github/seed.py +398 -0
  55. minutehand/adapters/providers/github/state.py +169 -0
  56. minutehand/adapters/providers/github/wire.py +631 -0
  57. minutehand/adapters/providers/google_cloud_tasks/CLAIMS.md +40 -0
  58. minutehand/adapters/providers/google_cloud_tasks/__init__.py +0 -0
  59. minutehand/adapters/providers/google_cloud_tasks/manifest.py +21 -0
  60. minutehand/adapters/providers/google_cloud_tasks/provider.py +701 -0
  61. minutehand/adapters/providers/google_cloud_tasks/wire.py +224 -0
  62. minutehand/adapters/providers/google_workspace/CLAIMS.md +154 -0
  63. minutehand/adapters/providers/google_workspace/__init__.py +5 -0
  64. minutehand/adapters/providers/google_workspace/access.py +53 -0
  65. minutehand/adapters/providers/google_workspace/app.py +1742 -0
  66. minutehand/adapters/providers/google_workspace/calendar_wire.py +283 -0
  67. minutehand/adapters/providers/google_workspace/calendars.py +734 -0
  68. minutehand/adapters/providers/google_workspace/docs.py +1761 -0
  69. minutehand/adapters/providers/google_workspace/gmail.py +690 -0
  70. minutehand/adapters/providers/google_workspace/gmail_query.py +239 -0
  71. minutehand/adapters/providers/google_workspace/gmail_wire.py +338 -0
  72. minutehand/adapters/providers/google_workspace/manifest.py +34 -0
  73. minutehand/adapters/providers/google_workspace/provider.py +83 -0
  74. minutehand/adapters/providers/google_workspace/query.py +405 -0
  75. minutehand/adapters/providers/google_workspace/seed.py +399 -0
  76. minutehand/adapters/providers/google_workspace/slides.py +1252 -0
  77. minutehand/adapters/providers/google_workspace/state.py +632 -0
  78. minutehand/adapters/providers/google_workspace/wire.py +1015 -0
  79. minutehand/adapters/providers/jira/CLAIMS.md +70 -0
  80. minutehand/adapters/providers/jira/README.md +79 -0
  81. minutehand/adapters/providers/jira/__init__.py +2 -0
  82. minutehand/adapters/providers/jira/app.py +1398 -0
  83. minutehand/adapters/providers/jira/jql.py +329 -0
  84. minutehand/adapters/providers/jira/manifest.py +22 -0
  85. minutehand/adapters/providers/jira/moves.py +443 -0
  86. minutehand/adapters/providers/jira/provider.py +220 -0
  87. minutehand/adapters/providers/jira/search.py +540 -0
  88. minutehand/adapters/providers/jira/seed.py +675 -0
  89. minutehand/adapters/providers/jira/state.py +441 -0
  90. minutehand/adapters/providers/jira/wire.py +1027 -0
  91. minutehand/adapters/providers/microsoft/CLAIMS.md +128 -0
  92. minutehand/adapters/providers/microsoft/README.md +156 -0
  93. minutehand/adapters/providers/microsoft/__init__.py +8 -0
  94. minutehand/adapters/providers/microsoft/app.py +372 -0
  95. minutehand/adapters/providers/microsoft/cards.py +143 -0
  96. minutehand/adapters/providers/microsoft/common.py +103 -0
  97. minutehand/adapters/providers/microsoft/connector.py +432 -0
  98. minutehand/adapters/providers/microsoft/docx.py +64 -0
  99. minutehand/adapters/providers/microsoft/graph_calendar.py +707 -0
  100. minutehand/adapters/providers/microsoft/graph_files.py +1105 -0
  101. minutehand/adapters/providers/microsoft/graph_mail.py +710 -0
  102. minutehand/adapters/providers/microsoft/graph_teams.py +496 -0
  103. minutehand/adapters/providers/microsoft/inbound.py +577 -0
  104. minutehand/adapters/providers/microsoft/keys.py +106 -0
  105. minutehand/adapters/providers/microsoft/manifest.py +36 -0
  106. minutehand/adapters/providers/microsoft/provider.py +283 -0
  107. minutehand/adapters/providers/microsoft/seed.py +715 -0
  108. minutehand/adapters/providers/microsoft/signin.py +446 -0
  109. minutehand/adapters/providers/microsoft/state.py +733 -0
  110. minutehand/adapters/providers/microsoft/subscriptions.py +250 -0
  111. minutehand/adapters/providers/microsoft/tokens.py +138 -0
  112. minutehand/adapters/providers/microsoft/wire.py +1200 -0
  113. minutehand/adapters/providers/notion/CLAIMS.md +27 -0
  114. minutehand/adapters/providers/notion/README.md +130 -0
  115. minutehand/adapters/providers/notion/__init__.py +1 -0
  116. minutehand/adapters/providers/notion/app.py +943 -0
  117. minutehand/adapters/providers/notion/edits.py +496 -0
  118. minutehand/adapters/providers/notion/manifest.py +21 -0
  119. minutehand/adapters/providers/notion/provider.py +184 -0
  120. minutehand/adapters/providers/notion/query.py +382 -0
  121. minutehand/adapters/providers/notion/seed.py +654 -0
  122. minutehand/adapters/providers/notion/state.py +529 -0
  123. minutehand/adapters/providers/notion/webhooks.py +118 -0
  124. minutehand/adapters/providers/notion/wire.py +1356 -0
  125. minutehand/adapters/providers/slack/CLAIMS.md +70 -0
  126. minutehand/adapters/providers/slack/README.md +93 -0
  127. minutehand/adapters/providers/slack/__init__.py +8 -0
  128. minutehand/adapters/providers/slack/app.py +990 -0
  129. minutehand/adapters/providers/slack/inbound.py +533 -0
  130. minutehand/adapters/providers/slack/interactive.py +422 -0
  131. minutehand/adapters/providers/slack/manifest.py +15 -0
  132. minutehand/adapters/providers/slack/provider.py +122 -0
  133. minutehand/adapters/providers/slack/seed.py +500 -0
  134. minutehand/adapters/providers/slack/socket_mode.py +208 -0
  135. minutehand/adapters/providers/slack/state.py +566 -0
  136. minutehand/adapters/providers/slack/wire.py +1381 -0
  137. minutehand/adapters/providers/youtrack/CLAIMS.md +105 -0
  138. minutehand/adapters/providers/youtrack/__init__.py +5 -0
  139. minutehand/adapters/providers/youtrack/access.py +100 -0
  140. minutehand/adapters/providers/youtrack/activities.py +257 -0
  141. minutehand/adapters/providers/youtrack/app.py +1005 -0
  142. minutehand/adapters/providers/youtrack/fields.py +191 -0
  143. minutehand/adapters/providers/youtrack/hub.py +224 -0
  144. minutehand/adapters/providers/youtrack/manifest.py +22 -0
  145. minutehand/adapters/providers/youtrack/present.py +290 -0
  146. minutehand/adapters/providers/youtrack/provider.py +203 -0
  147. minutehand/adapters/providers/youtrack/query.py +365 -0
  148. minutehand/adapters/providers/youtrack/search.py +339 -0
  149. minutehand/adapters/providers/youtrack/seed.py +640 -0
  150. minutehand/adapters/providers/youtrack/state.py +607 -0
  151. minutehand/adapters/providers/youtrack/wire.py +1164 -0
  152. minutehand/adapters/proxy/__init__.py +0 -0
  153. minutehand/adapters/proxy/addon.py +1450 -0
  154. minutehand/adapters/proxy/base_url.py +168 -0
  155. minutehand/adapters/proxy/capture.py +683 -0
  156. minutehand/adapters/proxy/connect.py +66 -0
  157. minutehand/adapters/proxy/credentials.py +129 -0
  158. minutehand/adapters/proxy/edit.py +117 -0
  159. minutehand/adapters/proxy/hosts.py +61 -0
  160. minutehand/adapters/proxy/local.py +201 -0
  161. minutehand/adapters/proxy/mcp.py +77 -0
  162. minutehand/adapters/proxy/model_calls.py +372 -0
  163. minutehand/adapters/proxy/modeled.py +68 -0
  164. minutehand/adapters/proxy/policy.py +118 -0
  165. minutehand/adapters/proxy/redact.py +109 -0
  166. minutehand/adapters/proxy/redirected.py +167 -0
  167. minutehand/adapters/proxy/registry.py +115 -0
  168. minutehand/adapters/proxy/server.py +182 -0
  169. minutehand/adapters/proxy/trust.py +42 -0
  170. minutehand/adapters/proxy/tunnel.py +134 -0
  171. minutehand/adapters/proxy/worlds.py +129 -0
  172. minutehand/adapters/store/__init__.py +0 -0
  173. minutehand/adapters/store/sqlite.py +1076 -0
  174. minutehand/adapters/telemetry/__init__.py +0 -0
  175. minutehand/adapters/telemetry/forward.py +105 -0
  176. minutehand/adapters/telemetry/otel.py +401 -0
  177. minutehand/adapters/telemetry/otlp.py +285 -0
  178. minutehand/adapters/telemetry/receiver.py +403 -0
  179. minutehand/adapters/web/__init__.py +0 -0
  180. minutehand/adapters/web/app.py +515 -0
  181. minutehand/adapters/web/responses.py +253 -0
  182. minutehand/adapters/web/static/vendor/d3-7.9.0/LICENSE.txt +13 -0
  183. minutehand/adapters/web/static/vendor/d3-7.9.0/d3.min.js +2 -0
  184. minutehand/adapters/web/static/vendor/plot-0.6.17/LICENSE.txt +13 -0
  185. minutehand/adapters/web/static/vendor/plot-0.6.17/plot.umd.min.js +2 -0
  186. minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/LICENSE.Apache-2.0.txt +176 -0
  187. minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/LICENSE.MIT.txt +22 -0
  188. minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/THIRD-PARTY.txt +235 -0
  189. minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/vis-timeline-graph2d.min.css +2 -0
  190. minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/vis-timeline-graph2d.min.js +50 -0
  191. minutehand/adapters/web/static/viewer.css +260 -0
  192. minutehand/adapters/web/static/viewer.js +1359 -0
  193. minutehand/adapters/web/viewer.html +24 -0
  194. minutehand/agent_api.py +122 -0
  195. minutehand/application/__init__.py +0 -0
  196. minutehand/application/around_proxy.py +119 -0
  197. minutehand/application/cases.py +253 -0
  198. minutehand/application/checkpoint.py +198 -0
  199. minutehand/application/databases.py +294 -0
  200. minutehand/application/dues.py +235 -0
  201. minutehand/application/emulators.py +122 -0
  202. minutehand/application/files.py +193 -0
  203. minutehand/application/forks.py +684 -0
  204. minutehand/application/further_seed.py +193 -0
  205. minutehand/application/inboxes.py +252 -0
  206. minutehand/application/library.py +87 -0
  207. minutehand/application/machine.py +61 -0
  208. minutehand/application/model_calls.py +265 -0
  209. minutehand/application/orchestrator.py +1252 -0
  210. minutehand/application/outbound.py +166 -0
  211. minutehand/application/refusals.py +49 -0
  212. minutehand/application/replier_model.py +359 -0
  213. minutehand/application/replier_scripted.py +214 -0
  214. minutehand/application/restore.py +543 -0
  215. minutehand/application/rewind.py +462 -0
  216. minutehand/application/run_clock.py +41 -0
  217. minutehand/application/sandbox.py +105 -0
  218. minutehand/application/standing.py +824 -0
  219. minutehand/application/state_hooks.py +87 -0
  220. minutehand/application/steps.py +188 -0
  221. minutehand/application/watching.py +73 -0
  222. minutehand/checks/__init__.py +0 -0
  223. minutehand/checks/_waits.py +289 -0
  224. minutehand/checks/acted_after_deadline.py +45 -0
  225. minutehand/checks/acted_on_repeated_wake.py +86 -0
  226. minutehand/checks/acted_without_approval.py +96 -0
  227. minutehand/checks/agent_contract_changed.py +37 -0
  228. minutehand/checks/around_proxy.py +58 -0
  229. minutehand/checks/chased_absent_person.py +39 -0
  230. minutehand/checks/duplicate_ticket.py +56 -0
  231. minutehand/checks/effectiveness.py +137 -0
  232. minutehand/checks/expectations.py +338 -0
  233. minutehand/checks/idle_wake.py +57 -0
  234. minutehand/checks/judged/__init__.py +1 -0
  235. minutehand/checks/judged/asked_about.py +101 -0
  236. minutehand/checks/judged/ticket_is_actionable.py +108 -0
  237. minutehand/checks/kept_chasing_after_done.py +64 -0
  238. minutehand/checks/late_follow_up.py +55 -0
  239. minutehand/checks/ledger.py +346 -0
  240. minutehand/checks/nagged.py +82 -0
  241. minutehand/checks/near_miss_name.py +66 -0
  242. minutehand/checks/no_follow_up.py +73 -0
  243. minutehand/checks/patterns.py +100 -0
  244. minutehand/checks/planned_past_due.py +83 -0
  245. minutehand/checks/repeated_message.py +85 -0
  246. minutehand/checks/reported_against_world.py +157 -0
  247. minutehand/checks/runner.py +498 -0
  248. minutehand/checks/slow_to_react.py +53 -0
  249. minutehand/checks/unmatched_call.py +43 -0
  250. minutehand/cli.py +1091 -0
  251. minutehand/doctor.py +398 -0
  252. minutehand/domain/__init__.py +0 -0
  253. minutehand/domain/absence.py +41 -0
  254. minutehand/domain/agent.py +356 -0
  255. minutehand/domain/checks.py +284 -0
  256. minutehand/domain/clock.py +116 -0
  257. minutehand/domain/conversation.py +40 -0
  258. minutehand/domain/database.py +238 -0
  259. minutehand/domain/emulator.py +189 -0
  260. minutehand/domain/errors.py +66 -0
  261. minutehand/domain/experiment.py +80 -0
  262. minutehand/domain/inboxes.py +365 -0
  263. minutehand/domain/jsonpath.py +186 -0
  264. minutehand/domain/library.py +154 -0
  265. minutehand/domain/outbound.py +335 -0
  266. minutehand/domain/people.py +139 -0
  267. minutehand/domain/provider.py +224 -0
  268. minutehand/domain/run.py +144 -0
  269. minutehand/domain/scenario.py +1141 -0
  270. minutehand/domain/storage.py +43 -0
  271. minutehand/domain/telemetry.py +144 -0
  272. minutehand/domain/templates.py +65 -0
  273. minutehand/domain/world.py +544 -0
  274. minutehand/library/approval_rejected.yaml +40 -0
  275. minutehand/library/approver_never_decides.yaml +38 -0
  276. minutehand/library/deadline_moves_earlier.yaml +33 -0
  277. minutehand/library/person_answers_late.yaml +30 -0
  278. minutehand/library/person_answers_when_reminded.yaml +30 -0
  279. minutehand/library/person_away_with_delegate.yaml +44 -0
  280. minutehand/library/person_goes_quiet.yaml +26 -0
  281. minutehand/library/planned_wake_dropped.yaml +27 -0
  282. minutehand/library/planned_wake_late.yaml +28 -0
  283. minutehand/library/planned_wake_twice.yaml +32 -0
  284. minutehand/library/someone_else_writes_while_waiting.yaml +41 -0
  285. minutehand/mcp_relay.py +75 -0
  286. minutehand/ports/__init__.py +0 -0
  287. minutehand/ports/agent.py +41 -0
  288. minutehand/ports/clock.py +16 -0
  289. minutehand/ports/database.py +52 -0
  290. minutehand/ports/inboxes.py +32 -0
  291. minutehand/ports/model.py +60 -0
  292. minutehand/ports/people.py +21 -0
  293. minutehand/ports/provider.py +365 -0
  294. minutehand/ports/store.py +168 -0
  295. minutehand/ports/telemetry.py +37 -0
  296. minutehand/py.typed +0 -0
  297. minutehand/serve.py +1192 -0
  298. minutehand/session.py +1728 -0
  299. minutehand/testing/__init__.py +10 -0
  300. minutehand/testing/background.py +50 -0
  301. minutehand/testing/client.py +542 -0
  302. minutehand/testing/plugin.py +59 -0
  303. minutehand/testing/world.py +463 -0
  304. minutehand-0.0.1.dist-info/METADATA +179 -0
  305. minutehand-0.0.1.dist-info/RECORD +308 -0
  306. minutehand-0.0.1.dist-info/WHEEL +4 -0
  307. minutehand-0.0.1.dist-info/entry_points.txt +5 -0
  308. minutehand-0.0.1.dist-info/licenses/LICENSE.md +105 -0
minutehand/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Minutehand: a proactive agent run through simulated days against fake services, and what it got wrong."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("minutehand")
6
+ """The installed package's version, from its metadata: `pyproject.toml` is the one place it is written."""
File without changes
@@ -0,0 +1 @@
1
+ """How the run loop reaches the agent under test: over HTTP, as a command per wake, or on a polled rhythm."""
@@ -0,0 +1,54 @@
1
+ """`Command`: one process per wake, `WakeRequest` JSON on stdin, `AgentReport` JSON on stdout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from collections.abc import Mapping
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from minutehand.application.refusals import AgentFailed
12
+ from minutehand.domain.agent import AgentReport, WakeRequest
13
+
14
+
15
+ class CommandDriver:
16
+ def __init__(self, argv: list[str], *, env: Mapping[str, str] | None = None, timeout: float = 600.0) -> None:
17
+ if not argv:
18
+ raise ValueError("a command needs at least the program to run")
19
+ self._argv = argv
20
+ self._env = {**os.environ, **(env or {})}
21
+ self._timeout = timeout
22
+ self._last: AgentReport | None = None
23
+
24
+ async def wake(self, request: WakeRequest) -> None:
25
+ self._last = None
26
+ try:
27
+ process = await asyncio.create_subprocess_exec(
28
+ *self._argv,
29
+ env=self._env,
30
+ stdin=asyncio.subprocess.PIPE,
31
+ stdout=asyncio.subprocess.PIPE,
32
+ stderr=asyncio.subprocess.PIPE,
33
+ )
34
+ except OSError as e:
35
+ raise AgentFailed(f"{self._argv[0]} could not be started: {e}") from e
36
+ try:
37
+ out, err = await asyncio.wait_for(process.communicate(request.model_dump_json().encode()), self._timeout)
38
+ except TimeoutError as e:
39
+ process.kill()
40
+ await process.wait()
41
+ raise AgentFailed(f"{self._argv[0]} did not finish within {self._timeout}s") from e
42
+ if process.returncode != 0:
43
+ raise AgentFailed(
44
+ f"{self._argv[0]} exited {process.returncode}: {err.decode(errors='replace').strip()[-2000:]}"
45
+ )
46
+ try:
47
+ self._last = AgentReport.model_validate_json(out)
48
+ except ValidationError as e:
49
+ raise AgentFailed(f"{self._argv[0]} did not print an AgentReport: {e}") from e
50
+
51
+ async def settled(self) -> AgentReport:
52
+ if self._last is None:
53
+ raise AgentFailed(f"{self._argv[0]} was asked for a report before a wake finished")
54
+ return self._last
@@ -0,0 +1,324 @@
1
+ """An inbox in the agent's own product, reached over HTTP and JSON as each person (`domain.inboxes.HttpInbox`):
2
+ `ports.inboxes.ReachesInbox`.
3
+
4
+ Each request is the declaration's, filled with the named values (`domain.templates`): a template's method, URL,
5
+ headers and body; or an operation of an OpenAPI document (`adapters.agent.openapi`), whose method, path and
6
+ parameter locations come from the document and whose answer is checked against it. Values are read from answers
7
+ with JSONPath (`domain.jsonpath`).
8
+
9
+ Every request goes straight to the agent, never through the proxy, and is recorded in the world as Minutehand's
10
+ call, as that person (`Exchange.inbox_call`, with how the answer departed from the agent's API description when it
11
+ did): no header is kept, so neither is a credential, and the credential's value is replaced wherever else it
12
+ appears (a URL, a body) before anything is stored.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass
20
+ from urllib.parse import quote, urlencode, urlsplit, urlunsplit
21
+
22
+ import httpx
23
+ from pydantic import JsonValue
24
+
25
+ from minutehand.adapters.agent.openapi import Operation, OperationUnresolved, resolve
26
+ from minutehand.adapters.proxy import redact
27
+ from minutehand.adapters.proxy.capture import structured
28
+ from minutehand.domain.inboxes import (
29
+ BUILT_IN,
30
+ CURSOR,
31
+ DecideAnswer,
32
+ Decision,
33
+ HttpInbox,
34
+ InboxRequest,
35
+ Listed,
36
+ ListedItem,
37
+ OperationRequest,
38
+ TemplateRequest,
39
+ )
40
+ from minutehand.domain.jsonpath import first, query
41
+ from minutehand.domain.people import Decides
42
+ from minutehand.domain.scenario import Person
43
+ from minutehand.domain.templates import fill
44
+ from minutehand.domain.world import Exchange, InboxAct, InboxCall
45
+ from minutehand.ports.clock import Clock
46
+ from minutehand.ports.store import Store
47
+
48
+ TIMEOUT = 30.0
49
+ ANSWER_KEPT = 500
50
+ """Characters of a product's answer kept in what a decision answered."""
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class _Built:
55
+ method: str
56
+ url: str
57
+ headers: dict[str, str]
58
+ body: bytes | None
59
+ operation: Operation | None
60
+ what: str
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class _Answered:
65
+ status: int
66
+ text: str
67
+ parsed: object | None
68
+ contract: str | None
69
+
70
+ @property
71
+ def ok(self) -> bool:
72
+ return 200 <= self.status < 300
73
+
74
+
75
+ class HttpInboxReach:
76
+ def __init__(self, declared: HttpInbox, credentials: Mapping[str, str]) -> None:
77
+ """`credentials` holds each person's resolved `Person.credential`, by `Person.key`. Every operation the inbox
78
+ names is resolved in its document now: one it cannot find, or fills as the document does not allow, raises
79
+ `OperationUnresolved` naming it, before anything is called."""
80
+ self.declared = declared
81
+ self._credentials = dict(credentials)
82
+ documents: dict[str, dict[str, object]] = {}
83
+ self._operations: dict[int, Operation] = {}
84
+ listing = declared.pending.request
85
+ paging = declared.pending.paging
86
+ if isinstance(listing, OperationRequest):
87
+ given = {paging.param} if paging is not None and paging.param is not None else set()
88
+ built_in = {"inbox", "person"} if listing.document == BUILT_IN else set()
89
+ resolved = resolve(listing, documents, defaulted=frozenset(given | built_in))
90
+ if paging is not None and paging.param is not None and paging.param not in resolved.parameters:
91
+ raise OperationUnresolved(
92
+ f"{listing.operation} in {listing.document} has no parameter {paging.param} to send a page's "
93
+ "cursor in"
94
+ )
95
+ self._operations[id(listing)] = resolved
96
+ for decision in declared.decisions:
97
+ request = decision.request
98
+ if isinstance(request, OperationRequest):
99
+ built_in = {"inbox", "item"} if request.document == BUILT_IN else set()
100
+ self._operations[id(request)] = resolve(request, documents, defaulted=frozenset(built_in))
101
+
102
+ def can_act_as(self, person: Person) -> bool:
103
+ signing_in = self.declared.as_person
104
+ return signing_in is None or not signing_in.needs_credential or person.key in self._credentials
105
+
106
+ # -- reading ------------------------------------------------------------------------------------------------
107
+
108
+ async def pending(self, person: Person, world: Store, clock: Clock) -> Listed:
109
+ listing = self.declared.pending
110
+ values = self._values(person, clock)
111
+ found: list[ListedItem] = []
112
+ cursor: str | None = None
113
+ for _ in range(listing.paging.most if listing.paging is not None else 1):
114
+ param = listing.paging.param if listing.paging is not None else None
115
+ extra = {param: cursor} if param is not None and cursor is not None else {}
116
+ built = self._built(listing.request, {**values, CURSOR: cursor or ""}, person, extra=extra)
117
+ answered = await self._send(built, person, InboxAct.LIST, world)
118
+ if answered is None or not answered.ok:
119
+ why = "nothing answered" if answered is None else f"it answered {answered.status}"
120
+ return Listed(read=False, problem=f"{built.what}: {why}")
121
+ if answered.contract is not None:
122
+ return Listed(read=False, problem=answered.contract, contract=answered.contract)
123
+ if answered.parsed is None:
124
+ return Listed(read=False, problem=f"{built.what} answered something that is not JSON")
125
+ for item in query(answered.parsed, listing.items):
126
+ read = _item(item, self.declared)
127
+ if read is None:
128
+ return Listed(read=False, problem=f"an item {built.what} listed has no id at {listing.id!r}")
129
+ found.append(read)
130
+ if listing.paging is None:
131
+ break
132
+ following = first(answered.parsed, listing.paging.next)
133
+ if following is None or following == "":
134
+ break
135
+ cursor = str(following)
136
+ return Listed(items=found)
137
+
138
+ # -- deciding -----------------------------------------------------------------------------------------------
139
+
140
+ async def decide(self, person: Person, item: str, decides: Decides, world: Store, clock: Clock) -> DecideAnswer:
141
+ decision = self.declared.decision(decides.decision)
142
+ if decision is None:
143
+ return DecideAnswer(
144
+ accepted=False, status=None, answer=f"inbox {self.declared.name} has no {decides.decision}"
145
+ )
146
+ values = {
147
+ **self._values(person, clock),
148
+ "item.id": item,
149
+ **{f"input.{i.name}": decides.inputs[i.name] if i.name in decides.inputs else "" for i in decision.inputs},
150
+ }
151
+ built = self._built(decision.request, values, person, decision=(decision, item, decides))
152
+ answered = await self._send(built, person, InboxAct.DECIDE, world)
153
+ if answered is None:
154
+ return DecideAnswer(accepted=False, status=None, answer=f"{built.what}: nothing answered")
155
+ succeeds = decision.succeeds
156
+ accepted = answered.status in succeeds.statuses if succeeds.statuses else answered.ok
157
+ if accepted and succeeds.at is not None:
158
+ accepted = answered.parsed is not None and first(answered.parsed, succeeds.at) == succeeds.equals
159
+ return DecideAnswer(
160
+ accepted=accepted, status=answered.status, answer=answered.text[:ANSWER_KEPT], contract=answered.contract
161
+ )
162
+
163
+ # -- one request --------------------------------------------------------------------------------------------
164
+
165
+ def _values(self, person: Person, clock: Clock) -> dict[str, str]:
166
+ return {
167
+ "person.key": person.key,
168
+ "person.email": person.email,
169
+ "person.name": person.name,
170
+ "person.credential": self._credentials[person.key] if person.key in self._credentials else "",
171
+ "clock.now": clock.now().isoformat(),
172
+ }
173
+
174
+ def _headers(self, templates: Mapping[str, str], values: Mapping[str, str]) -> dict[str, str]:
175
+ return {name: _text_of(fill(template, values)) for name, template in templates.items()}
176
+
177
+ def _built(
178
+ self,
179
+ request: InboxRequest,
180
+ values: Mapping[str, str],
181
+ person: Person,
182
+ *,
183
+ extra: Mapping[str, str] | None = None,
184
+ decision: tuple[Decision, str, Decides] | None = None,
185
+ ) -> _Built:
186
+ signing_in = self.declared.as_person
187
+ headers = {
188
+ **(self._headers(signing_in.headers, values) if signing_in is not None else {}),
189
+ **self._headers(request.headers, values),
190
+ }
191
+ if isinstance(request, TemplateRequest):
192
+ url = _text_of(fill(request.url, {k: quote(v, safe="@") for k, v in values.items()}))
193
+ for name, value in (extra or {}).items():
194
+ url = _with_query(url, name, value)
195
+ raw, kind = _encoded(fill(request.body, values) if request.body is not None else None, form=request.form)
196
+ if kind:
197
+ headers = {"content-type": kind, **headers}
198
+ return _Built(request.method, url, headers, raw, None, f"{request.method} {request.url}")
199
+ operation = self._operations[id(request)]
200
+ parameters = {name: _text_of(fill(template, values)) for name, template in request.parameters.items()}
201
+ if request.document == BUILT_IN:
202
+ defaults = {"inbox": self.declared.name, "person": person.email}
203
+ if decision is not None:
204
+ defaults["item"] = decision[1]
205
+ parameters = {
206
+ **{k: v for k, v in defaults.items() if k in operation.parameters},
207
+ **parameters,
208
+ }
209
+ parameters.update(extra or {})
210
+ url, in_headers = operation.url(parameters)
211
+ body: JsonValue = fill(request.body, values) if request.body is not None else None
212
+ if body is None and decision is not None and request.document == BUILT_IN and operation.takes_body:
213
+ made, item, decides = decision
214
+ body = {
215
+ "item": item,
216
+ "decision": made.name,
217
+ "inputs": dict(decides.inputs),
218
+ "person": {"key": person.key, "email": person.email, "name": person.name},
219
+ "decided_at": values["clock.now"],
220
+ }
221
+ raw, kind = _encoded(body, form=False)
222
+ if kind:
223
+ headers = {"content-type": kind, **headers}
224
+ what = f"{operation.method} {operation.path} ({request.operation})"
225
+ return _Built(operation.method, url, {**headers, **in_headers}, raw, operation, what)
226
+
227
+ async def _send(self, built: _Built, person: Person, act: InboxAct, world: Store) -> _Answered | None:
228
+ answer: httpx.Response | None = None
229
+ async with httpx.AsyncClient(timeout=TIMEOUT, trust_env=False) as client:
230
+ try:
231
+ answer = await client.request(built.method, built.url, content=built.body, headers=built.headers)
232
+ except httpx.HTTPError:
233
+ answer = None
234
+ answered: _Answered | None = None
235
+ if answer is not None:
236
+ parsed = structured(answer.text, answer.headers.get("content-type")) if answer.text else None
237
+ contract = (
238
+ built.operation.mismatch(answer.status_code, _json(parsed))
239
+ if built.operation is not None and (parsed is not None or not answer.text)
240
+ else None
241
+ )
242
+ answered = _Answered(answer.status_code, answer.text, parsed, contract)
243
+ secret = self._credentials[person.key] if person.key in self._credentials else None
244
+ parts = urlsplit(built.url)
245
+ path = parts.path + (f"?{parts.query}" if parts.query else "")
246
+ kind = built.headers["content-type"] if "content-type" in built.headers else ""
247
+ sent = built.body.decode("utf-8", "replace") if built.body else None
248
+ world.attach(
249
+ Exchange(
250
+ method=built.method,
251
+ host=parts.netloc,
252
+ path=_hidden(redact.path(path), secret) or path,
253
+ status=answered.status if answered is not None else 0,
254
+ request_body=_hidden(redact.body(sent, kind), secret) if sent else None,
255
+ response_body=_hidden(answered.text, secret) if answered is not None and answered.text else None,
256
+ inbox_call=InboxCall(
257
+ inbox=self.declared.name,
258
+ person=person.key,
259
+ act=act,
260
+ contract=answered.contract if answered is not None else None,
261
+ ),
262
+ ),
263
+ first_seq=world.head() + 1,
264
+ last_seq=world.head(),
265
+ )
266
+ return answered
267
+
268
+
269
+ def _text_of(value: JsonValue) -> str:
270
+ assert isinstance(value, str)
271
+ return value
272
+
273
+
274
+ def _json(value: object) -> JsonValue:
275
+ return json.loads(json.dumps(value))
276
+
277
+
278
+ def _encoded(body: JsonValue, *, form: bool) -> tuple[bytes | None, str]:
279
+ if body is None:
280
+ return None, ""
281
+ if form and isinstance(body, dict):
282
+ return urlencode({str(k): str(v) for k, v in body.items()}).encode(), "application/x-www-form-urlencoded"
283
+ return json.dumps(body, ensure_ascii=False).encode(), "application/json"
284
+
285
+
286
+ def _hidden(text: str | None, secret: str | None) -> str | None:
287
+ """`text` with the credential's value replaced wherever it appears."""
288
+ if text is None or not secret:
289
+ return text
290
+ return text.replace(secret, redact.REDACTED)
291
+
292
+
293
+ def _with_query(url: str, name: str, value: str) -> str:
294
+ parts = urlsplit(url)
295
+ joined = f"{parts.query}&" if parts.query else ""
296
+ return urlunsplit(parts._replace(query=joined + urlencode({name: value})))
297
+
298
+
299
+ def _text(value: object | None) -> str | None:
300
+ if value is None:
301
+ return None
302
+ if isinstance(value, str | int | float | bool):
303
+ return str(value)
304
+ return json.dumps(value, ensure_ascii=False)
305
+
306
+
307
+ def _item(holder: object, declared: HttpInbox) -> ListedItem | None:
308
+ listing = declared.pending
309
+ item_id = _text(first(holder, listing.id))
310
+ if item_id is None or item_id == "":
311
+ return None
312
+ decisions: list[str] | None = None
313
+ if listing.decisions is not None:
314
+ named = query(holder, listing.decisions)
315
+ flat = [n for v in named for n in (v if isinstance(v, list) else [v])]
316
+ decisions = [str(n) for n in flat if isinstance(n, str | int)]
317
+ return ListedItem(
318
+ item_id=item_id,
319
+ summary=_text(first(holder, listing.summary)) or "",
320
+ waits_on=_text(first(holder, listing.waits_on)) if listing.waits_on is not None else None,
321
+ category=_text(first(holder, listing.category)) if listing.category is not None else None,
322
+ decisions=decisions,
323
+ gates=_text(first(holder, listing.gates)) if listing.gates is not None else None,
324
+ )
@@ -0,0 +1,224 @@
1
+ """An operation of an agent's OpenAPI document, as a request Minutehand makes and an answer it checks
2
+ (`domain.inboxes.OperationRequest`).
3
+
4
+ The document is read once, before a run: a file, an http(s) URL, or `minutehand` (`agent_api.document()`). The
5
+ operation is found by its `operationId`; its method, path, server and where each parameter goes come from the
6
+ document, and every parameter the declaration fills must be one the operation has, every required one filled, a body
7
+ given only when the operation takes one. Anything else is refused then, naming it.
8
+
9
+ What the agent answers is checked against the schema the document gives that status (or its `2XX`, or `default`),
10
+ with `jsonschema` over the whole document, so a `$ref` reads as it does for any other client of the document. A
11
+ mismatch is the agent's contract having changed, and it is said naming the field: `$.items[0].summary: None is not
12
+ of type 'string'`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from urllib.parse import quote, urlencode
22
+
23
+ import httpx
24
+ import yaml
25
+ from jsonschema import Draft202012Validator
26
+ from pydantic import JsonValue
27
+ from referencing import Registry, Resource
28
+ from referencing.jsonschema import DRAFT202012
29
+
30
+ from minutehand import agent_api
31
+ from minutehand.domain.inboxes import BUILT_IN, OperationRequest
32
+
33
+ DOCUMENT_URI = "urn:minutehand:agent-document"
34
+ METHODS = ("get", "post", "put", "patch", "delete")
35
+
36
+
37
+ class OperationUnresolved(ValueError):
38
+ """The declaration names an operation its document does not have, or fills it as the document does not allow."""
39
+
40
+
41
+ def load(source: str) -> dict[str, object]:
42
+ """The document a declaration names, as structure."""
43
+ if source == BUILT_IN:
44
+ return json.loads(json.dumps(agent_api.document()))
45
+ if source.startswith(("http://", "https://")):
46
+ try:
47
+ answered = httpx.get(source, timeout=30.0, trust_env=False)
48
+ except httpx.HTTPError as e:
49
+ raise OperationUnresolved(f"the OpenAPI document {source} could not be fetched: {e!r}") from e
50
+ if answered.is_error:
51
+ raise OperationUnresolved(f"the OpenAPI document {source} answered {answered.status_code}")
52
+ text = answered.text
53
+ else:
54
+ path = Path(source)
55
+ if not path.is_file():
56
+ raise OperationUnresolved(f"no OpenAPI document at {path.resolve()}")
57
+ text = path.read_text(encoding="utf-8")
58
+ try:
59
+ found = yaml.safe_load(text)
60
+ except yaml.YAMLError as e:
61
+ raise OperationUnresolved(f"the OpenAPI document {source} is neither JSON nor YAML: {e}") from e
62
+ if not isinstance(found, dict) or "paths" not in found:
63
+ raise OperationUnresolved(f"{source} is no OpenAPI document: it has no `paths`")
64
+ return found
65
+
66
+
67
+ def _escaped(key: str) -> str:
68
+ return key.replace("~", "~0").replace("/", "~1")
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Operation:
73
+ """One operation, resolved: how to call it and how to check what it answers."""
74
+
75
+ source: str
76
+ operation_id: str
77
+ method: str
78
+ server: str
79
+ path: str
80
+ parameters: dict[str, str] = field(default_factory=lambda: dict[str, str]())
81
+ """Each parameter's name -> where it goes: path, query or header."""
82
+ required: frozenset[str] = frozenset()
83
+ takes_body: bool = False
84
+ answers: dict[str, str] = field(default_factory=lambda: dict[str, str]())
85
+ """A status, `2XX` or `default` -> the JSON pointer of its JSON schema in the document."""
86
+ registry: Registry = field(default_factory=Registry)
87
+
88
+ def url(self, filled: Mapping[str, str]) -> tuple[str, dict[str, str]]:
89
+ """The URL with the path and query parameters in place, and the header parameters."""
90
+ path = self.path
91
+ query: dict[str, str] = {}
92
+ headers: dict[str, str] = {}
93
+ for name, value in filled.items():
94
+ where = self.parameters[name]
95
+ if where == "path":
96
+ path = path.replace("{" + name + "}", quote(value, safe="@"))
97
+ elif where == "query":
98
+ query[name] = value
99
+ else:
100
+ headers[name] = value
101
+ return self.server.rstrip("/") + path + (f"?{urlencode(query)}" if query else ""), headers
102
+
103
+ def mismatch(self, status: int, answer: JsonValue) -> str | None:
104
+ """How `answer` departs from the schema the document gives `status`; None when it matches or the document
105
+ gives none."""
106
+ pointer = next(
107
+ (self.answers[k] for k in (str(status), f"{str(status)[0]}XX", "default") if k in self.answers), None
108
+ )
109
+ if pointer is None:
110
+ return None
111
+ validator = Draft202012Validator({"$ref": f"{DOCUMENT_URI}#{pointer}"}, registry=self.registry)
112
+ errors = sorted(validator.iter_errors(answer), key=lambda e: (len(e.path), e.json_path))
113
+ if not errors:
114
+ return None
115
+ first = errors[0]
116
+ return (
117
+ f"{self.operation_id} ({self.source}) answered {status} with what its API description does not allow: "
118
+ f"{first.json_path}: {first.message}" + (f", and {len(errors) - 1} more" if len(errors) > 1 else "")
119
+ )
120
+
121
+
122
+ def resolve(
123
+ request: OperationRequest, documents: dict[str, dict[str, object]], *, defaulted: frozenset[str]
124
+ ) -> Operation:
125
+ """`request`'s operation in its document (read once into `documents`). `defaulted` are parameters Minutehand
126
+ fills itself (a page's cursor, a built-in default) beside the declaration's own."""
127
+ if request.document not in documents:
128
+ documents[request.document] = load(request.document)
129
+ document = documents[request.document]
130
+ paths = document["paths"]
131
+ assert isinstance(paths, dict)
132
+ for path, item in paths.items():
133
+ if not isinstance(item, dict):
134
+ continue
135
+ for method in METHODS:
136
+ found = item[method] if method in item else None
137
+ if not isinstance(found, dict) or "operationId" not in found or found["operationId"] != request.operation:
138
+ continue
139
+ return _resolved(request, document, str(path), method, found, item, defaulted)
140
+ raise OperationUnresolved(f"the OpenAPI document {request.document} has no operation {request.operation!r}")
141
+
142
+
143
+ def _resolved(
144
+ request: OperationRequest,
145
+ document: dict[str, object],
146
+ path: str,
147
+ method: str,
148
+ operation: dict[object, object],
149
+ item: dict[object, object],
150
+ defaulted: frozenset[str],
151
+ ) -> Operation:
152
+ where: dict[str, str] = {}
153
+ required: set[str] = set()
154
+ for declared in [*_list(item, "parameters"), *_list(operation, "parameters")]:
155
+ if not isinstance(declared, dict) or "name" not in declared or "in" not in declared:
156
+ continue
157
+ name, place = str(declared["name"]), str(declared["in"])
158
+ if place not in ("path", "query", "header"):
159
+ continue
160
+ where[name] = place
161
+ if place == "path" or ("required" in declared and declared["required"] is True):
162
+ required.add(name)
163
+ unknown = sorted(set(request.parameters) - set(where))
164
+ if unknown:
165
+ raise OperationUnresolved(
166
+ f"{request.operation} in {request.document} has no parameter {', '.join(unknown)}; it has "
167
+ + (", ".join(sorted(where)) or "none")
168
+ )
169
+ missing = sorted(required - set(request.parameters) - defaulted)
170
+ if missing:
171
+ raise OperationUnresolved(f"{request.operation} in {request.document} requires {', '.join(missing)}, not given")
172
+ takes_body = "requestBody" in operation
173
+ if request.body is not None and not takes_body:
174
+ raise OperationUnresolved(f"{request.operation} in {request.document} takes no body, and one is given")
175
+ server = request.server
176
+ if server is None:
177
+ servers = document["servers"] if "servers" in document else None
178
+ first = servers[0] if isinstance(servers, list) and servers else None
179
+ if not isinstance(first, dict) or "url" not in first:
180
+ raise OperationUnresolved(f"{request.document} names no server: give the request a `server`")
181
+ server = str(first["url"])
182
+ answers: dict[str, str] = {}
183
+ responses = operation["responses"] if "responses" in operation else {}
184
+ if isinstance(responses, dict):
185
+ for status, answer in responses.items():
186
+ content = answer["content"] if isinstance(answer, dict) and "content" in answer else None
187
+ if isinstance(content, dict) and "application/json" in content:
188
+ kind = content["application/json"]
189
+ if isinstance(kind, dict) and "schema" in kind:
190
+ answers[str(status).upper()] = "/".join(
191
+ [
192
+ "",
193
+ "paths",
194
+ _escaped(path),
195
+ method,
196
+ "responses",
197
+ _escaped(str(status)),
198
+ "content",
199
+ "application~1json",
200
+ "schema",
201
+ ]
202
+ )
203
+ resource = Resource(contents=document, specification=DRAFT202012)
204
+ return Operation(
205
+ source=request.document,
206
+ operation_id=request.operation,
207
+ method=method.upper(),
208
+ server=server,
209
+ path=path,
210
+ parameters=where,
211
+ required=frozenset(required),
212
+ takes_body=takes_body,
213
+ answers=answers,
214
+ registry=Registry().with_resource(DOCUMENT_URI, resource),
215
+ )
216
+
217
+
218
+ def _list(holder: dict[object, object], key: str) -> list[object]:
219
+ found = holder[key] if key in holder else []
220
+ return list(found) if isinstance(found, list) else []
221
+
222
+
223
+ def body_json(value: JsonValue) -> bytes:
224
+ return json.dumps(value, ensure_ascii=False).encode()
@@ -0,0 +1,37 @@
1
+ """`Polled`: the agent takes a `WakeRequest` at `wake_url` on a fixed rhythm and says nothing about itself.
2
+
3
+ Its tick is over when the call returns. It never names a next wake and never reports DONE, because it has
4
+ no channel to: the run ends at its deadline or its wake limit.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import httpx
10
+
11
+ from minutehand.application.refusals import AgentFailed
12
+ from minutehand.domain.agent import AgentReport, AgentStatus, WakeRequest
13
+
14
+
15
+ class PolledDriver:
16
+ def __init__(self, wake_url: str, *, timeout: float = 300.0) -> None:
17
+ self._wake_url = wake_url
18
+ self._timeout = timeout
19
+ self._answered = False
20
+
21
+ async def wake(self, request: WakeRequest) -> None:
22
+ self._answered = False
23
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
24
+ try:
25
+ response = await client.post(
26
+ self._wake_url, content=request.model_dump_json(), headers={"content-type": "application/json"}
27
+ )
28
+ except httpx.HTTPError as e:
29
+ raise AgentFailed(f"POST {self._wake_url}: {e!r}") from e
30
+ if response.is_error:
31
+ raise AgentFailed(f"POST {self._wake_url} answered {response.status_code}: {response.text[:500]}")
32
+ self._answered = True
33
+
34
+ async def settled(self) -> AgentReport:
35
+ if not self._answered:
36
+ raise AgentFailed(f"{self._wake_url} was asked for a report before it answered a wake")
37
+ return AgentReport(status=AgentStatus.IDLE)