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.
- minutehand/__init__.py +6 -0
- minutehand/adapters/__init__.py +0 -0
- minutehand/adapters/agent/__init__.py +1 -0
- minutehand/adapters/agent/command.py +54 -0
- minutehand/adapters/agent/inboxes.py +324 -0
- minutehand/adapters/agent/openapi.py +224 -0
- minutehand/adapters/agent/polled.py +37 -0
- minutehand/adapters/agent/reach.py +55 -0
- minutehand/adapters/agent/replies.py +138 -0
- minutehand/adapters/agent/reported.py +85 -0
- minutehand/adapters/answering.py +251 -0
- minutehand/adapters/control/__init__.py +0 -0
- minutehand/adapters/control/app.py +646 -0
- minutehand/adapters/control/wire.py +599 -0
- minutehand/adapters/database/__init__.py +0 -0
- minutehand/adapters/database/postgres/__init__.py +0 -0
- minutehand/adapters/database/postgres/client.py +258 -0
- minutehand/adapters/database/postgres/conversation.py +405 -0
- minutehand/adapters/database/postgres/relay.py +447 -0
- minutehand/adapters/database/postgres/wire.py +168 -0
- minutehand/adapters/emulator/__init__.py +0 -0
- minutehand/adapters/emulator/answers.py +58 -0
- minutehand/adapters/emulator/fleet.py +77 -0
- minutehand/adapters/emulator/process.py +254 -0
- minutehand/adapters/emulator/relay.py +208 -0
- minutehand/adapters/mcp/__init__.py +0 -0
- minutehand/adapters/mcp/results.py +193 -0
- minutehand/adapters/mcp/server.py +387 -0
- minutehand/adapters/model/__init__.py +0 -0
- minutehand/adapters/model/openai_compatible.py +222 -0
- minutehand/adapters/providers/__init__.py +0 -0
- minutehand/adapters/providers/asana/CLAIMS.md +65 -0
- minutehand/adapters/providers/asana/__init__.py +5 -0
- minutehand/adapters/providers/asana/app.py +1284 -0
- minutehand/adapters/providers/asana/manifest.py +27 -0
- minutehand/adapters/providers/asana/provider.py +164 -0
- minutehand/adapters/providers/asana/seed.py +596 -0
- minutehand/adapters/providers/asana/state.py +451 -0
- minutehand/adapters/providers/asana/wire.py +1299 -0
- minutehand/adapters/providers/aws/__init__.py +1 -0
- minutehand/adapters/providers/aws/manifest.py +28 -0
- minutehand/adapters/providers/aws/provider.py +418 -0
- minutehand/adapters/providers/aws/schedule.py +134 -0
- minutehand/adapters/providers/aws/wire.py +386 -0
- minutehand/adapters/providers/github/CLAIMS.md +97 -0
- minutehand/adapters/providers/github/README.md +57 -0
- minutehand/adapters/providers/github/__init__.py +5 -0
- minutehand/adapters/providers/github/app.py +793 -0
- minutehand/adapters/providers/github/content.py +161 -0
- minutehand/adapters/providers/github/graphql.py +826 -0
- minutehand/adapters/providers/github/manifest.py +16 -0
- minutehand/adapters/providers/github/provider.py +51 -0
- minutehand/adapters/providers/github/search.py +169 -0
- minutehand/adapters/providers/github/seed.py +398 -0
- minutehand/adapters/providers/github/state.py +169 -0
- minutehand/adapters/providers/github/wire.py +631 -0
- minutehand/adapters/providers/google_cloud_tasks/CLAIMS.md +40 -0
- minutehand/adapters/providers/google_cloud_tasks/__init__.py +0 -0
- minutehand/adapters/providers/google_cloud_tasks/manifest.py +21 -0
- minutehand/adapters/providers/google_cloud_tasks/provider.py +701 -0
- minutehand/adapters/providers/google_cloud_tasks/wire.py +224 -0
- minutehand/adapters/providers/google_workspace/CLAIMS.md +154 -0
- minutehand/adapters/providers/google_workspace/__init__.py +5 -0
- minutehand/adapters/providers/google_workspace/access.py +53 -0
- minutehand/adapters/providers/google_workspace/app.py +1742 -0
- minutehand/adapters/providers/google_workspace/calendar_wire.py +283 -0
- minutehand/adapters/providers/google_workspace/calendars.py +734 -0
- minutehand/adapters/providers/google_workspace/docs.py +1761 -0
- minutehand/adapters/providers/google_workspace/gmail.py +690 -0
- minutehand/adapters/providers/google_workspace/gmail_query.py +239 -0
- minutehand/adapters/providers/google_workspace/gmail_wire.py +338 -0
- minutehand/adapters/providers/google_workspace/manifest.py +34 -0
- minutehand/adapters/providers/google_workspace/provider.py +83 -0
- minutehand/adapters/providers/google_workspace/query.py +405 -0
- minutehand/adapters/providers/google_workspace/seed.py +399 -0
- minutehand/adapters/providers/google_workspace/slides.py +1252 -0
- minutehand/adapters/providers/google_workspace/state.py +632 -0
- minutehand/adapters/providers/google_workspace/wire.py +1015 -0
- minutehand/adapters/providers/jira/CLAIMS.md +70 -0
- minutehand/adapters/providers/jira/README.md +79 -0
- minutehand/adapters/providers/jira/__init__.py +2 -0
- minutehand/adapters/providers/jira/app.py +1398 -0
- minutehand/adapters/providers/jira/jql.py +329 -0
- minutehand/adapters/providers/jira/manifest.py +22 -0
- minutehand/adapters/providers/jira/moves.py +443 -0
- minutehand/adapters/providers/jira/provider.py +220 -0
- minutehand/adapters/providers/jira/search.py +540 -0
- minutehand/adapters/providers/jira/seed.py +675 -0
- minutehand/adapters/providers/jira/state.py +441 -0
- minutehand/adapters/providers/jira/wire.py +1027 -0
- minutehand/adapters/providers/microsoft/CLAIMS.md +128 -0
- minutehand/adapters/providers/microsoft/README.md +156 -0
- minutehand/adapters/providers/microsoft/__init__.py +8 -0
- minutehand/adapters/providers/microsoft/app.py +372 -0
- minutehand/adapters/providers/microsoft/cards.py +143 -0
- minutehand/adapters/providers/microsoft/common.py +103 -0
- minutehand/adapters/providers/microsoft/connector.py +432 -0
- minutehand/adapters/providers/microsoft/docx.py +64 -0
- minutehand/adapters/providers/microsoft/graph_calendar.py +707 -0
- minutehand/adapters/providers/microsoft/graph_files.py +1105 -0
- minutehand/adapters/providers/microsoft/graph_mail.py +710 -0
- minutehand/adapters/providers/microsoft/graph_teams.py +496 -0
- minutehand/adapters/providers/microsoft/inbound.py +577 -0
- minutehand/adapters/providers/microsoft/keys.py +106 -0
- minutehand/adapters/providers/microsoft/manifest.py +36 -0
- minutehand/adapters/providers/microsoft/provider.py +283 -0
- minutehand/adapters/providers/microsoft/seed.py +715 -0
- minutehand/adapters/providers/microsoft/signin.py +446 -0
- minutehand/adapters/providers/microsoft/state.py +733 -0
- minutehand/adapters/providers/microsoft/subscriptions.py +250 -0
- minutehand/adapters/providers/microsoft/tokens.py +138 -0
- minutehand/adapters/providers/microsoft/wire.py +1200 -0
- minutehand/adapters/providers/notion/CLAIMS.md +27 -0
- minutehand/adapters/providers/notion/README.md +130 -0
- minutehand/adapters/providers/notion/__init__.py +1 -0
- minutehand/adapters/providers/notion/app.py +943 -0
- minutehand/adapters/providers/notion/edits.py +496 -0
- minutehand/adapters/providers/notion/manifest.py +21 -0
- minutehand/adapters/providers/notion/provider.py +184 -0
- minutehand/adapters/providers/notion/query.py +382 -0
- minutehand/adapters/providers/notion/seed.py +654 -0
- minutehand/adapters/providers/notion/state.py +529 -0
- minutehand/adapters/providers/notion/webhooks.py +118 -0
- minutehand/adapters/providers/notion/wire.py +1356 -0
- minutehand/adapters/providers/slack/CLAIMS.md +70 -0
- minutehand/adapters/providers/slack/README.md +93 -0
- minutehand/adapters/providers/slack/__init__.py +8 -0
- minutehand/adapters/providers/slack/app.py +990 -0
- minutehand/adapters/providers/slack/inbound.py +533 -0
- minutehand/adapters/providers/slack/interactive.py +422 -0
- minutehand/adapters/providers/slack/manifest.py +15 -0
- minutehand/adapters/providers/slack/provider.py +122 -0
- minutehand/adapters/providers/slack/seed.py +500 -0
- minutehand/adapters/providers/slack/socket_mode.py +208 -0
- minutehand/adapters/providers/slack/state.py +566 -0
- minutehand/adapters/providers/slack/wire.py +1381 -0
- minutehand/adapters/providers/youtrack/CLAIMS.md +105 -0
- minutehand/adapters/providers/youtrack/__init__.py +5 -0
- minutehand/adapters/providers/youtrack/access.py +100 -0
- minutehand/adapters/providers/youtrack/activities.py +257 -0
- minutehand/adapters/providers/youtrack/app.py +1005 -0
- minutehand/adapters/providers/youtrack/fields.py +191 -0
- minutehand/adapters/providers/youtrack/hub.py +224 -0
- minutehand/adapters/providers/youtrack/manifest.py +22 -0
- minutehand/adapters/providers/youtrack/present.py +290 -0
- minutehand/adapters/providers/youtrack/provider.py +203 -0
- minutehand/adapters/providers/youtrack/query.py +365 -0
- minutehand/adapters/providers/youtrack/search.py +339 -0
- minutehand/adapters/providers/youtrack/seed.py +640 -0
- minutehand/adapters/providers/youtrack/state.py +607 -0
- minutehand/adapters/providers/youtrack/wire.py +1164 -0
- minutehand/adapters/proxy/__init__.py +0 -0
- minutehand/adapters/proxy/addon.py +1450 -0
- minutehand/adapters/proxy/base_url.py +168 -0
- minutehand/adapters/proxy/capture.py +683 -0
- minutehand/adapters/proxy/connect.py +66 -0
- minutehand/adapters/proxy/credentials.py +129 -0
- minutehand/adapters/proxy/edit.py +117 -0
- minutehand/adapters/proxy/hosts.py +61 -0
- minutehand/adapters/proxy/local.py +201 -0
- minutehand/adapters/proxy/mcp.py +77 -0
- minutehand/adapters/proxy/model_calls.py +372 -0
- minutehand/adapters/proxy/modeled.py +68 -0
- minutehand/adapters/proxy/policy.py +118 -0
- minutehand/adapters/proxy/redact.py +109 -0
- minutehand/adapters/proxy/redirected.py +167 -0
- minutehand/adapters/proxy/registry.py +115 -0
- minutehand/adapters/proxy/server.py +182 -0
- minutehand/adapters/proxy/trust.py +42 -0
- minutehand/adapters/proxy/tunnel.py +134 -0
- minutehand/adapters/proxy/worlds.py +129 -0
- minutehand/adapters/store/__init__.py +0 -0
- minutehand/adapters/store/sqlite.py +1076 -0
- minutehand/adapters/telemetry/__init__.py +0 -0
- minutehand/adapters/telemetry/forward.py +105 -0
- minutehand/adapters/telemetry/otel.py +401 -0
- minutehand/adapters/telemetry/otlp.py +285 -0
- minutehand/adapters/telemetry/receiver.py +403 -0
- minutehand/adapters/web/__init__.py +0 -0
- minutehand/adapters/web/app.py +515 -0
- minutehand/adapters/web/responses.py +253 -0
- minutehand/adapters/web/static/vendor/d3-7.9.0/LICENSE.txt +13 -0
- minutehand/adapters/web/static/vendor/d3-7.9.0/d3.min.js +2 -0
- minutehand/adapters/web/static/vendor/plot-0.6.17/LICENSE.txt +13 -0
- minutehand/adapters/web/static/vendor/plot-0.6.17/plot.umd.min.js +2 -0
- minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/LICENSE.Apache-2.0.txt +176 -0
- minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/LICENSE.MIT.txt +22 -0
- minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/THIRD-PARTY.txt +235 -0
- minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/vis-timeline-graph2d.min.css +2 -0
- minutehand/adapters/web/static/vendor/vis-timeline-8.5.4/vis-timeline-graph2d.min.js +50 -0
- minutehand/adapters/web/static/viewer.css +260 -0
- minutehand/adapters/web/static/viewer.js +1359 -0
- minutehand/adapters/web/viewer.html +24 -0
- minutehand/agent_api.py +122 -0
- minutehand/application/__init__.py +0 -0
- minutehand/application/around_proxy.py +119 -0
- minutehand/application/cases.py +253 -0
- minutehand/application/checkpoint.py +198 -0
- minutehand/application/databases.py +294 -0
- minutehand/application/dues.py +235 -0
- minutehand/application/emulators.py +122 -0
- minutehand/application/files.py +193 -0
- minutehand/application/forks.py +684 -0
- minutehand/application/further_seed.py +193 -0
- minutehand/application/inboxes.py +252 -0
- minutehand/application/library.py +87 -0
- minutehand/application/machine.py +61 -0
- minutehand/application/model_calls.py +265 -0
- minutehand/application/orchestrator.py +1252 -0
- minutehand/application/outbound.py +166 -0
- minutehand/application/refusals.py +49 -0
- minutehand/application/replier_model.py +359 -0
- minutehand/application/replier_scripted.py +214 -0
- minutehand/application/restore.py +543 -0
- minutehand/application/rewind.py +462 -0
- minutehand/application/run_clock.py +41 -0
- minutehand/application/sandbox.py +105 -0
- minutehand/application/standing.py +824 -0
- minutehand/application/state_hooks.py +87 -0
- minutehand/application/steps.py +188 -0
- minutehand/application/watching.py +73 -0
- minutehand/checks/__init__.py +0 -0
- minutehand/checks/_waits.py +289 -0
- minutehand/checks/acted_after_deadline.py +45 -0
- minutehand/checks/acted_on_repeated_wake.py +86 -0
- minutehand/checks/acted_without_approval.py +96 -0
- minutehand/checks/agent_contract_changed.py +37 -0
- minutehand/checks/around_proxy.py +58 -0
- minutehand/checks/chased_absent_person.py +39 -0
- minutehand/checks/duplicate_ticket.py +56 -0
- minutehand/checks/effectiveness.py +137 -0
- minutehand/checks/expectations.py +338 -0
- minutehand/checks/idle_wake.py +57 -0
- minutehand/checks/judged/__init__.py +1 -0
- minutehand/checks/judged/asked_about.py +101 -0
- minutehand/checks/judged/ticket_is_actionable.py +108 -0
- minutehand/checks/kept_chasing_after_done.py +64 -0
- minutehand/checks/late_follow_up.py +55 -0
- minutehand/checks/ledger.py +346 -0
- minutehand/checks/nagged.py +82 -0
- minutehand/checks/near_miss_name.py +66 -0
- minutehand/checks/no_follow_up.py +73 -0
- minutehand/checks/patterns.py +100 -0
- minutehand/checks/planned_past_due.py +83 -0
- minutehand/checks/repeated_message.py +85 -0
- minutehand/checks/reported_against_world.py +157 -0
- minutehand/checks/runner.py +498 -0
- minutehand/checks/slow_to_react.py +53 -0
- minutehand/checks/unmatched_call.py +43 -0
- minutehand/cli.py +1091 -0
- minutehand/doctor.py +398 -0
- minutehand/domain/__init__.py +0 -0
- minutehand/domain/absence.py +41 -0
- minutehand/domain/agent.py +356 -0
- minutehand/domain/checks.py +284 -0
- minutehand/domain/clock.py +116 -0
- minutehand/domain/conversation.py +40 -0
- minutehand/domain/database.py +238 -0
- minutehand/domain/emulator.py +189 -0
- minutehand/domain/errors.py +66 -0
- minutehand/domain/experiment.py +80 -0
- minutehand/domain/inboxes.py +365 -0
- minutehand/domain/jsonpath.py +186 -0
- minutehand/domain/library.py +154 -0
- minutehand/domain/outbound.py +335 -0
- minutehand/domain/people.py +139 -0
- minutehand/domain/provider.py +224 -0
- minutehand/domain/run.py +144 -0
- minutehand/domain/scenario.py +1141 -0
- minutehand/domain/storage.py +43 -0
- minutehand/domain/telemetry.py +144 -0
- minutehand/domain/templates.py +65 -0
- minutehand/domain/world.py +544 -0
- minutehand/library/approval_rejected.yaml +40 -0
- minutehand/library/approver_never_decides.yaml +38 -0
- minutehand/library/deadline_moves_earlier.yaml +33 -0
- minutehand/library/person_answers_late.yaml +30 -0
- minutehand/library/person_answers_when_reminded.yaml +30 -0
- minutehand/library/person_away_with_delegate.yaml +44 -0
- minutehand/library/person_goes_quiet.yaml +26 -0
- minutehand/library/planned_wake_dropped.yaml +27 -0
- minutehand/library/planned_wake_late.yaml +28 -0
- minutehand/library/planned_wake_twice.yaml +32 -0
- minutehand/library/someone_else_writes_while_waiting.yaml +41 -0
- minutehand/mcp_relay.py +75 -0
- minutehand/ports/__init__.py +0 -0
- minutehand/ports/agent.py +41 -0
- minutehand/ports/clock.py +16 -0
- minutehand/ports/database.py +52 -0
- minutehand/ports/inboxes.py +32 -0
- minutehand/ports/model.py +60 -0
- minutehand/ports/people.py +21 -0
- minutehand/ports/provider.py +365 -0
- minutehand/ports/store.py +168 -0
- minutehand/ports/telemetry.py +37 -0
- minutehand/py.typed +0 -0
- minutehand/serve.py +1192 -0
- minutehand/session.py +1728 -0
- minutehand/testing/__init__.py +10 -0
- minutehand/testing/background.py +50 -0
- minutehand/testing/client.py +542 -0
- minutehand/testing/plugin.py +59 -0
- minutehand/testing/world.py +463 -0
- minutehand-0.0.1.dist-info/METADATA +179 -0
- minutehand-0.0.1.dist-info/RECORD +308 -0
- minutehand-0.0.1.dist-info/WHEEL +4 -0
- minutehand-0.0.1.dist-info/entry_points.txt +5 -0
- 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)
|