diff --git a/.bumpversion.toml b/.bumpversion.toml index e53b3c48..3af8812f 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -3,7 +3,7 @@ # https://peps.python.org/pep-0440/ [tool.bumpversion] - current_version = "0.3.4" + current_version = "0.4.2" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/.env.exemple b/.env.exemple new file mode 100644 index 00000000..aadffa21 --- /dev/null +++ b/.env.exemple @@ -0,0 +1,129 @@ +######################################### +# Server Settings for Archetype Module +######################################### + +# ══ Channel Settings ════════════════════════════════════════════════════════════════ # + +# Host address on which the module gRPC server listens. +# Default: [::] (all IPv6 & IPv4 interfaces) +SERVER_CHANNEL_HOST=[::] + +# TCP port for the module gRPC server. +# Default: 50055 +SERVER_CHANNEL_PORT=50055 + +# Execution mode of the server. Possible values: async | sync +# Default: async +SERVER_CHANNEL_CONTROL_FLOW=async + +# Security mode of the server. Possible values: insecure | secure +# Default: insecure +SERVER_CHANNEL_SECURITY=insecure + +# Enable mutual TLS for incoming clients: "true" or "false" +# Default: false +SERVER_CHANNEL_MTLS=false + +#SERVER_CHANNEL_CREDENTIALS__KEY_PATH=server.key +#SERVER_CHANNEL_CREDENTIALS__CERT_PATH=cert.key +#SERVER_CHANNEL_CREDENTIALS__ROOT_CERT_PATH=ca.crt + +# Hostname or IP address that the server advertises to clients for connection. +# Default: digitalkin-ada-archetype +SERVER_CHANNEL_ADVERTISE_HOST= + +# ══ gRPC Settings ════════════════════════════════════════════════════════════════════ # + +# Compression algorithm for gRPC messages. Possible values: gzip, deflate, snappy, zstd, or none +# Default: gzip +SERVER_GRPC_COMPRESSION=gzip + +# ── Option Grpc ───────────────────────────────────────────────────────────────────── # +# Time (in milliseconds) after which a keepalive ping is sent if the connection is idle. +# Default: 120000 (2 minutes) +SERVER_GRPC_OPTIONS_KEEPALIVE_TIME=120000 + +# Time (in milliseconds) the server waits for a keepalive ping ack before closing the connection. +# Default: 20000 (20 seconds) +SERVER_GRPC_OPTIONS_KEEPALIVE_TIMEOUT=20000 + +# Minimum time (in milliseconds) between client pings. +# Default: 10000 (10 seconds) +SERVER_GRPC_OPTIONS_MIN_PING_INTERVAL=10000 + +# Maximum message size (in bytes) the server can receive. +# Default: 4194304 (4MB) +SERVER_GRPC_OPTIONS_MAX_RECEIVE_MESSAGE_LENGTH=4194304 + +# Maximum message size (in bytes) the server can send. +# Default: 4194304 (4MB) +SERVER_GRPC_OPTIONS_MAX_SEND_MESSAGE_LENGTH=4194304 + +# Maximum number of pings the server allows without receiving any data from the client. +# Default: 0 (unlimited) +SERVER_GRPC_OPTIONS_MAX_PINGS_WITHOUT_DATA=0 + +# Allow keepalive pings when there are no active calls. "true" or "false" +# Default: true +SERVER_GRPC_OPTIONS_KEEPALIVE_PERMIT_WITHOUT_CALLS=true + +######################################### +# gRPC Client Configuration Services Provider +######################################### + +# Host address on which the services provider gRPC server listens. +# Default: [::] (all IPv6 & IPv4 interfaces) +SERVICES_PROVIDER_URL=[::] + +# TCP port for the services provider gRPC server. +# Default: 50151 +SERVICES_PROVIDER_PORT=50151 + +# Execution mode of the services provider. Possible values: async | sync +# Default: async +SERVICES_PROVIDER_MODE=async + +# Security mode of the services provider. Possible values: insecure | secure +# Default: insecure +SERVICES_PROVIDER_SECURITY=insecure + +# Enable mutual TLS for outgoing calls: "true" or "false" +# Default: false +SERVICES_PROVIDER_MTLS=false + +GRPC_DNS_RESOLVER="native" + +############################################# +# Other Configuration +############################################# + +MODULE_ID_TOOLKIT_RAG="modules:1" + +OPENAI_API_KEY=sk-xxxx + +######################################## +# Certificate Settings (CERTIFICATE_) +######################################## + +# Directory where server/client certificates are stored +# (default: "/certificates" / local: "./certs") +CERTIFICATE_CERT_VOLUME=/certificates + +# Directory where registry CA certificates are stored +# (default: "/certificates" / local: "./certs") +CERTIFICATE_REGISTRY_CERT_VOLUME=/certificates + +# (Optional) Directory for services_provider client.key, client.crt, and ca.crt +# If unset, defaults to CERTIFICATE_CERT_VOLUME +CERTIFICATE_SERVICES_PROVIDER_CERT_VOLUME=/certificates + +######################################## +# Langfuse Tracing Configuration +######################################## + +# Langfuse credentials for OTEL tracing +# Note: Authentication is handled programmatically using Base64-encoded Basic auth +# (public_key:secret_key), not via OTEL_EXPORTER_OTLP_HEADERS environment variable. +LANGFUSE_SECRET_KEY=sk-lf-xxxx +LANGFUSE_PUBLIC_KEY=pk-lf-xxxx +LANGFUSE_BASE_URL=https://langfuse.staging.digitalkin.com diff --git a/.gitignore b/.gitignore index fdcb008d..c1b69d49 100644 --- a/.gitignore +++ b/.gitignore @@ -183,10 +183,12 @@ cython_debug/ # IDE .vscode/ .idea/ +.DS_Store # Project requirements.txt certs/ .report.json +docker-compose.override.yml CLAUDE.md \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9400641a..d3c2d0df 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,14 +16,14 @@ repos: - id: check-toml - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.14 + rev: v0.15.11 hooks: - id: ruff-check args: [--fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.19.1 + rev: v1.20.2 hooks: - id: mypy additional_dependencies: [types-protobuf] @@ -34,7 +34,7 @@ repos: hooks: - id: pytest name: pytest - entry: docker compose run --rm -T tests -m 'not integration' + entry: docker compose run --rm -T tests pytest tests/ -m 'not integration' language: system pass_filenames: false types: [python] diff --git a/docs/changelog/0.3.4_to_0.3.5.dev0.md b/docs/changelog/0.3.4_to_0.3.5.dev0.md new file mode 100644 index 00000000..3c55e869 --- /dev/null +++ b/docs/changelog/0.3.4_to_0.3.5.dev0.md @@ -0,0 +1,49 @@ +## Migration config serveur: `server_config` retire + +A partir de `0.3.5.dev0`, la creation/configuration du serveur ne passe plus par `server_config` au moment de creer un module. +Les settings serveur sont lus directement via `ServerSettings` integres dans `src/digitalkin/grpc_servers/_base_server.py`. + +## Variables changees (renommees) + +| Ancienne variable | Nouvelle variable | Valeur par defaut | +|---|---|---| +| `MODULE_SERVER_HOST` | `SERVER_CHANNEL_HOST` | `[::]` | +| `MODULE_SERVER_MODE` | `SERVER_CHANNEL_CONTROL_FLOW` | `async` | +| `MODULE_SERVER_SECURITY` | `SERVER_CHANNEL_SECURITY` | `insecure` | +| `MODULE_SERVER_MTLS` | `SERVER_CHANNEL_MTLS` | `false` | +| `MODULE_SERVER_ADVERTISE_HOST` | `SERVER_CHANNEL_ADVERTISE_HOST` | `null` (non defini) | + +## Variables ajoutees + +| Variable | Valeur par defaut | +|---|---| +| `SERVER_CHANNEL_PORT` | `50055` | +| `SERVER_GRPC_COMPRESSION` | `gzip` | +| `SERVER_GRPC_OPTIONS_KEEPALIVE_TIME` | `120000` | +| `SERVER_GRPC_OPTIONS_KEEPALIVE_TIMEOUT` | `20000` | +| `SERVER_GRPC_OPTIONS_MIN_PING_INTERVAL` | `10000` | +| `SERVER_GRPC_OPTIONS_MAX_RECEIVE_MESSAGE_LENGTH` | `4194304` | +| `SERVER_GRPC_OPTIONS_MAX_SEND_MESSAGE_LENGTH` | `4194304` | +| `SERVER_GRPC_OPTIONS_MAX_PINGS_WITHOUT_DATA` | `0` | +| `SERVER_GRPC_OPTIONS_KEEPALIVE_PERMIT_WITHOUT_CALLS` | `true` | + +## Exemple `.env` minimal (nouveau format) + +```env +SERVER_CHANNEL_HOST=[::] +SERVER_CHANNEL_PORT=50055 +SERVER_CHANNEL_CONTROL_FLOW=async +SERVER_CHANNEL_SECURITY=insecure +SERVER_CHANNEL_MTLS=false +# Optionnel +# SERVER_CHANNEL_ADVERTISE_HOST=archetype-ada-new.railway.internal + +SERVER_GRPC_COMPRESSION=gzip +SERVER_GRPC_OPTIONS_KEEPALIVE_TIME=120000 +SERVER_GRPC_OPTIONS_KEEPALIVE_TIMEOUT=20000 +SERVER_GRPC_OPTIONS_MIN_PING_INTERVAL=10000 +SERVER_GRPC_OPTIONS_MAX_RECEIVE_MESSAGE_LENGTH=4194304 +SERVER_GRPC_OPTIONS_MAX_SEND_MESSAGE_LENGTH=4194304 +SERVER_GRPC_OPTIONS_MAX_PINGS_WITHOUT_DATA=0 +SERVER_GRPC_OPTIONS_KEEPALIVE_PERMIT_WITHOUT_CALLS=true +``` diff --git a/docs/changelog/0.3.5_to_0.4.0.md b/docs/changelog/0.3.5_to_0.4.0.md new file mode 100644 index 00000000..9e5752b1 --- /dev/null +++ b/docs/changelog/0.3.5_to_0.4.0.md @@ -0,0 +1,139 @@ +## Migration AG-UI : nouveau protocole de streaming + +A partir de `0.4.0`, le SDK adopte le protocole **AG-UI** pour le streaming des evenements agent. +Les anciens mixins `UserMessageMixin` (callback_mixin) et `ChatHistoryMixin` sont **deprecated** et seront supprimes dans une version future. + +## Nouveaux composants + +### `AgUiMixin` + +Mixin integre dans `BaseMixin` (donc disponible dans tous les `TriggerHandler`). +Convertit les evenements agent DigitalKin en evenements AG-UI et les envoie via `context.callbacks`. + +```python +from digitalkin.mixins import AgUiMixin + +class MyTrigger(TriggerHandler, AgUiMixin): + async def handle(self, input_data, setup_data, context): + for event in events: + await self.send_message(context, event) +``` + +Evenements supportes : +- **Run lifecycle** : `RunStarted`, `RunCompleted`, `RunError` +- **Text messages** : `TextMessageStart`, `TextMessageContent`, `TextMessageEnd` +- **Reasoning** : `ReasoningStart`, `ReasoningMessageContent`, `ReasoningEnd` +- **Tool calls** : `ToolCallStart`, `ToolCallArgs`, `ToolCallEnd`, `ToolCallResult` + +### `AgnoStreamAdapter` (community) + +Adaptateur pour convertir les evenements Agno en evenements DigitalKin. +Gere automatiquement le cycle de vie (ouverture/fermeture des sequences text/reasoning). + +```python +from digitalkin.community.agno import AgnoStreamAdapter + +adapter = AgnoStreamAdapter() + +async for raw_event in agent.arun(message, stream=True, stream_events=True): + for event in adapter.to_digitalkin_events(raw_event): + await self.send_message(context, event) + +# Fermer les sequences encore ouvertes en fin de stream +for event in adapter.flush(): + await self.send_message(context, event) +``` + +### Modeles d'evenements (`digitalkin.models.events`) + +Evenements framework-agnostic pour les runs agent : + +| Classe | Description | +|---|---| +| `RunStartedEvent` | Debut d'un run agent | +| `RunContentEvent` | Contenu texte streame | +| `RunCompletedEvent` | Fin d'un run | +| `RunErrorEvent` | Erreur pendant le run | +| `TextMessageStartedEvent` | Debut d'un message texte | +| `TextMessageCompletedEvent` | Fin d'un message texte | +| `ReasoningStartedEvent` | Debut d'une phase de raisonnement | +| `ReasoningContentDeltaEvent` | Delta de contenu raisonnement | +| `ReasoningStepEvent` | Etape de raisonnement | +| `ReasoningCompletedEvent` | Fin de la phase de raisonnement | +| `ToolCallStartedEvent` | Debut d'un appel outil | +| `ToolCallCompletedEvent` | Fin d'un appel outil | +| `ToolCallErrorEvent` | Erreur d'un appel outil | + +### Modeles de sortie AG-UI (`digitalkin.models.module.ag_ui`) + +40+ types de sortie AG-UI avec serialisation camelCase automatique et union discriminee via le champ `protocol`. + +## Architecture + +``` +Framework (Agno, ...) + | + v +AgnoStreamAdapter -- convertit les events framework -> DigitalKin + | + v +DigitalKin Events -- modeles framework-agnostic (agent_events.py) + | + v +AgUiMixin.send_message() -- convertit DigitalKin events -> AG-UI protocol + | + v +context.callbacks -- envoie vers le client via gRPC stream +``` + +## Exemple complet (Agno) + +```python +from digitalkin.community.agno import AgnoStreamAdapter +from digitalkin.models.events import BaseAgentRunEvent +from digitalkin.modules.trigger_handler import TriggerHandler + + +@MyModule.register +class MessageTrigger(TriggerHandler): + + protocol = "agui_stream" + input_format = AgUiStreamInput + output_format = AgUiEventOutput + + async def handle(self, input_data, setup_data, context): + message = extract_user_message(input_data) + + adapter = AgnoStreamAdapter() + + async def send_event(event: BaseAgentRunEvent) -> None: + await self.send_message(context, event) + + async for raw_event in agent.arun(message, stream=True, stream_events=True): + for event in adapter.to_digitalkin_events(raw_event): + await send_event(event) + + for event in adapter.flush(): + await send_event(event) +``` + +## Deprecations + +| Ancien | Nouveau | Notes | +|---|---|---| +| `UserMessageMixin` | `AgUiMixin` | Emettra un `DeprecationWarning` a l'heritage | +| `ChatHistoryMixin` | `AgUiMixin` | Emettra un `DeprecationWarning` a l'heritage | +| `context.callbacks.send_message(output)` directement | `self.send_message(context, event)` via `AgUiMixin` | Les events passent maintenant par le pipeline AG-UI | + +## `BaseMixin` mis a jour + +```python +# Avant (0.3.5) +class BaseMixin(CostMixin, ChatHistoryMixin, FileHistoryMixin, LoggerMixin): ... + +# Apres (0.4.0) +class BaseMixin(CostMixin, AgUiMixin, FileHistoryMixin, LoggerMixin): ... +``` + +`AgUiMixin` remplace `ChatHistoryMixin` dans la chaine d'heritage de `BaseMixin`. +Tous les `TriggerHandler` ont donc acces a `self.send_message(context, event)` sans import supplementaire. diff --git a/docs/changelog/0.4.1.md b/docs/changelog/0.4.1.md new file mode 100644 index 00000000..8fc99c76 --- /dev/null +++ b/docs/changelog/0.4.1.md @@ -0,0 +1,386 @@ +# 0.4.0 → 0.4.1 — AG-UI Frontend Tools (HITL) + +## Summary + +The AG-UI protocol lets the front declare its own **tools** in `RunAgentInput.tools` — tools that the LLM can "call" but whose actual execution happens on the client side (widgets, UI pickers, local API calls, user input prompts…). This release adds native support for these **frontend tools** in `digitalkin.community.agno` via a Human-In-The-Loop (HITL) runner that: + +- registers frontend tools as `Function(external_execution=True)` on the Agno agent, +- pauses the run when the LLM calls a frontend tool, streams the `ToolCallStart/Args/End` events to the client, and persists the `RunOutput` via `StorageStrategy`, +- emits an AG-UI `RunFinished` with `result.status="awaiting_tool_result"` to tell the front it must execute the tool and send the result back, +- automatically resumes the run on the next request as soon as a matching `ToolMessage` arrives in the message history, regardless of which replica handles the request (process-stateless). + +No breaking change: everything is additive and opt-in. + +## What changes + +### 1. `AgnoStreamAdapter` — `RunEvent.run_paused` support + +Agno has a non-trivial behavior: for tools marked `external_execution=True`, it **does not** emit `RunEvent.tool_call_started` or `RunEvent.tool_call_completed`. It yields a `RunPausedEvent` directly, carrying the list `tools: list[ToolExecution]` (see `agno/models/base.py:2585`). Consequence: without dedicated handling, the front **never** sees `ToolCallStart/Args/End` events for frontend tools — it only receives an orphan `RunFinished`. + +`AgnoStreamAdapter` now handles `RunEvent.run_paused` and **synthesizes** one `ToolCallStartedEvent` + `ToolCallCompletedEvent(content=None)` pair per pending tool, reconstructed from `RunPausedEvent.tools[*]` (`tool_call_id`, `tool_name`, `tool_args`). The downstream `AgUiMixin` bridge then emits `ToolCallStart` + `ToolCallArgs` + `ToolCallEnd` normally, **without** a `ToolCallResult` (guarded by `if result_content:` — passing `content=None` disables it, which is the correct behavior since the tool is not executed server-side). + +The adapter additionally exposes three properties that consumers can read after streaming to detect the pause: + +| Property | Type | Description | +|---|---|---| +| `is_paused` | `bool` | `True` if a `RunPausedEvent` was seen during the last streaming session | +| `paused_tool_executions` | `list[ToolExecution]` | Tools awaiting external execution | +| `paused_requirements` | `list[RunRequirement]` | Matching Agno `RunRequirement` objects | + +**Important**: this fix is additive and benefits **all** consumers of `AgnoStreamAdapter`, not just HITL. Any module that uses `external_execution=True` with the adapter will now see the `ToolCall*` events correctly. + +### 1b. `AgnoStreamAdapter` — `RunEvent.reasoning_step` auto-wrapping + +Agno's `ReasoningTools` toolkit (the `think` / `analyze` tools) emits `RunEvent.reasoning_step` events **without** the surrounding `reasoning_started` / `reasoning_completed` lifecycle that the AG-UI protocol requires. Previously, the adapter forwarded these as orphaned `REASONING_MESSAGE_CONTENT` events — no `REASONING_START` before, no `REASONING_END` after, empty `messageId`. The front had no way to render them correctly. + +The adapter now **auto-opens a reasoning sequence** when a `reasoning_step` arrives while `_reasoning_active` is `False`: + +1. Closes any active text message (`_close_content`). +2. Generates a fresh `reasoning_id`, sets `_reasoning_active = True`. +3. Emits `ReasoningStartedEvent` (→ `REASONING_START` + `REASONING_MESSAGE_START` via `AgUiMixin`). +4. Then emits the `ReasoningStepEvent` content as usual (→ `REASONING_MESSAGE_CONTENT`). + +The reasoning sequence is **auto-closed** by the next non-reasoning event (`_handle_run_content`, `_handle_tool_call_started`, `_handle_run_completed`) or by `flush()`, which emit `REASONING_MESSAGE_END` + `REASONING_END`. + +**Result**: the front now receives a proper lifecycle for tool-based reasoning: +``` +TOOL_CALL_RESULT (analyze) +REASONING_START ← auto-opened +REASONING_MESSAGE_START +REASONING_MESSAGE_CONTENT ← the step content (no longer orphaned) +REASONING_MESSAGE_END ← auto-closed by next event +REASONING_END +TEXT_MESSAGE_START +``` + +This fix benefits any module using Agno's `ReasoningTools` (`think`, `analyze`) — not just HITL. + +### 2. New module `community/agno/agui_tools.py` + +Two helpers to wire frontend tools onto an Agno agent: + +```python +from digitalkin.community.agno import agui_tool_to_external_function, make_tools_factory +``` + +| Function | Role | +|---|---| +| `agui_tool_to_external_function(tool: AgUiTool) -> Function` | Wraps an `ag_ui.core.types.Tool` into an `agno.tools.function.Function` marked `external_execution=True`, passing the AG-UI JSON Schema through as `parameters`. | +| `make_tools_factory(base_tools, dependency_key="agui_tools") -> Callable[[RunContext], list]` | Builds a factory for the `Agent`'s `tools=` parameter. On every run, the factory reads `run_context.dependencies[dependency_key]` (the list of `AgUiTool` passed via `agent.arun(dependencies={...})`), converts each entry with `agui_tool_to_external_function`, and concatenates the result with `base_tools`. | + +⚠️ **Critical requirement**: the `Agent` using this factory **must** be built with `cache_callables=False`. Otherwise Agno caches the result of the first factory call and subsequent runs will never see new frontend tools (`agno/utils/callables.py:260-284`). + +Why use `dependencies` as a transport channel? It's a repurposed but intentional use: the tools factory receives the `RunContext`, which inherits the `dependencies` passed to `arun(dependencies=...)` **before** tool resolution (verified at `agno/agent/_run.py:2563`). It's the only Agno-native mechanism that lets us inject per-run tools without mutating the `Agent` instance. + +### 3. New module `community/agno/hitl.py` + +The full HITL runtime. Everything is re-exported from `digitalkin.community.agno`. + +| Symbol | Type | Role | +|---|---|---| +| `PausedRunRecord` | `BaseModel` | Pydantic schema for the `paused_runs` storage collection. Holds `thread_id`, `run_id`, `pending_tool_call_ids`, `payload` (= `RunOutput.to_dict()`). | +| `HITL_STORAGE_CONFIG` | `dict` | Ready-to-merge config fragment for `services_config_params["storage"]["config"]` — registers `{"paused_runs": PausedRunRecord}`. | +| `PauseInfo` | `dataclass` | Returned by the runner when a run pauses. Holds `thread_id`, `run_id`, `pending_tool_call_ids`. | +| `PausedRunStore` | `class` | Typed wrapper around `StorageStrategy`: `save(run_output, thread_id)`, `load(thread_id)`, `delete(thread_id)`. Handles serialization via `RunOutput.to_dict()` / `RunOutput.from_dict()`. | +| `AgnoHitlRunner` | `class` | Main runner. Three-level API: `run()` / `continue_paused_run()` (low-level), `try_resume()` (mid-level), `handle_agui_input()` (all-in-one for triggers). | +| `emit_awaiting_tool_result()` | `async func` | Helper that emits the AG-UI `RunFinished` with `result={"status": "awaiting_tool_result", "pending_tool_call_ids": [...]}` via `context.callbacks.send_message`. | + +The runner supports all five HITL cases: + +- **Fresh run**: extracts the last `UserMessage` from `input_data.messages`, launches `agent.arun()` with `dependencies={"agui_tools": input_data.tools}`. +- **Resume**: detects a `ToolMessage` whose `tool_call_id` matches a stored `pending_tool_call_id`, loads the paused `RunOutput`, injects the result directly onto `run_output.tools[i].result`, and resumes via `agent.acontinue_run()`. +- **Partial resume** (guard): if only k/N pending tool results are provided, the runner emits a `RUN_STARTED` + `RUN_ERROR` with `error_type="partial_tool_results"` and a message listing missing `tool_call_id`s. The paused record is preserved so the client can retry with all results. This prevents a cryptic Agno retry loop. +- **Abandon**: if the front sends a new `UserMessage` while a tool is pending, the paused record is dropped and a fresh run starts. +- **Cascade**: if the agent calls another frontend tool after a resume, the runner re-persists and emits a new `awaiting_tool_result`. + +### Agno quirks handled transparently + +Two Agno behaviors required workarounds in the runner (invisible to consumers): + +**`RunOutput.to_dict()` / `from_dict()` breaks object identity.** After a storage round-trip, `run_output.tools[i]` and `run_output.requirements[i].tool_execution` are two **different** `ToolExecution` instances with the same data. Agno's `handle_tool_call_updates` (`_tools.py:762`) iterates `run_response.tools`, not `requirements`. The runner writes `tool.result` directly onto `run_output.tools[i]` — not via `RunRequirement.set_external_execution_result()` — so the injected results are visible to Agno at resume time. + +**`acontinue_run` emits `RunContinued`, not `RunStarted`.** The AG-UI protocol requires every run to begin with a `RUN_STARTED` event. Agno's resume path emits `RunContinued` instead, and the `AgnoStreamAdapter` has no handler for it. The runner emits a synthetic `RunStartedEvent` (with the current AG-UI `run_id` and `thread_id`) before draining the Agno stream — `continue_paused_run` accepts an optional `run_id` parameter for this purpose. + +## Architecture + +``` +Front (AG-UI) → RunAgentInput(tools=[...], messages=[...]) + ↓ +Trigger.handle → AgnoHitlRunner.handle_agui_input(input_data, send, context) + ↓ + ┌─ Resume? (a ToolMessage matches a pending_tool_call_id) + │ ↓ yes ↓ no + │ continue_paused_run run(message, agui_tools) + │ ↓ ↓ + │ Agno.acontinue_run Agno.arun(dependencies={"agui_tools": [...]}) + │ ↓ ↓ + │ factory resolves tools factory resolves base_tools + external tools + │ ↓ ↓ + │ AgnoStreamAdapter drains the stream + │ ├── normal events → send() → AgUiMixin → front + │ └── RunPausedEvent → synthesize ToolCallStart/End + is_paused=True + │ ↓ + │ if adapter.is_paused: + │ PausedRunStore.save(RunOutput, thread_id) via StorageStrategy + │ ↓ + └─ emit_awaiting_tool_result(context, ...) + → AG-UI RunFinished(result.status="awaiting_tool_result") +``` + +## Front ↔ back protocol (pause / resume) + +**Pause signal** — the back emits an AG-UI `RunFinished` with a non-empty `result`: + +```json +{ + "type": "RUN_FINISHED", + "threadId": "thread-123", + "runId": "run-abc", + "result": { + "status": "awaiting_tool_result", + "pending_tool_call_ids": ["toolu_01AnKEg33agtt..."] + } +} +``` + +**Resume** — the front sends a new `RunAgentInput` with: + +- **the same `threadId`** (the resume key on the back), +- **`tools`** = the same list as the previous turn (the schemas must stay declared), +- **`messages`** = history + one `ToolMessage` per pending `tool_call_id`: + +```json +{ + "id": "uuid-front", + "role": "tool", + "toolCallId": "toolu_01AnKEg33agtt...", + "content": "{\"temp_c\": 14, \"conditions\": \"cloudy\"}" +} +``` + +The `content` must be a **string** (JSON-stringified if structured) — that's the standard AG-UI / OpenAI constraint on `ToolMessage`. + +**Abandon** — if the front sends a new `UserMessage` as the last entry (without a `ToolMessage`), the back drops the paused record and starts a normal run. No error. + +**Cascade** — if the LLM calls another frontend tool after a resume, a new `TOOL_CALL_*` + `RUN_FINISHED(awaiting_tool_result)` sequence is emitted with new `pending_tool_call_ids`. The front loops again. + +## Tutorial — integrating into an archetype + +Five steps to wire HITL into an Agno module (example inspired by `template-archetype`). + +### 1. Register the storage collection + +In the `Module` that inherits from `ArchetypeModule`, add `HITL_STORAGE_CONFIG` to the storage collections mapping: + +```python +# src/my_module/module.py +from digitalkin.community.agno import HITL_STORAGE_CONFIG +from digitalkin.modules.archetype_module import ArchetypeModule + +from my_module.models import AgnoSession, MyInput, MyOutput, MySecret, MySetup + + +class MyModule(ArchetypeModule[MyInput, MyOutput, MySetup, MySecret]): + name = "MyModule" + # ... + + services_config_params: ClassVar[dict] = { + "storage": { + "config": { + "agno_sessions": AgnoSession, + "chat_history": ChatHistory, + **HITL_STORAGE_CONFIG, + }, + "client_config": client_config, + }, + # ... other services + } +``` + +`HITL_STORAGE_CONFIG` registers the `paused_runs` collection with the `PausedRunRecord` schema. + +### 2. Inject the tools factory into the Agno agent + +Wherever you build the `Agent`, replace `tools=[...]` with `tools=make_tools_factory(...)` and add `cache_callables=False`: + +```python +# src/my_module/agents/helpers.py +from agno.agent import Agent +from agno.models.litellm import LiteLLM +from digitalkin.community.agno import make_tools_factory + + +def create_my_agent(config, storage, cost, *, session_id=None) -> Agent: + base_tools = [AsyncDuckDuckGoTools(), MyCustomToolkit()] + + return Agent( + model=LiteLLM(...), + tools=make_tools_factory(base_tools), + cache_callables=False, # CRITICAL: otherwise the factory is cached + instructions=config.instructions, + # ... rest of the config + ) +``` + +`base_tools` stay available at all times. Frontend tools sent by the client are dynamically added on each run. + +### 3. Instantiate the `AgnoHitlRunner` + +In the module's agent wrapper, keep a reference to the runner and expose a `handle_agui_input` method that delegates to it: + +```python +# src/my_module/agents/my_agent.py +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from digitalkin.community.agno import AgnoHitlRunner, PauseInfo + +from my_module.agents.helpers import create_my_agent + +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + + from agno.agent import Agent + from digitalkin.models.events import BaseAgentRunEvent + from digitalkin.models.module import ModuleContext + from digitalkin.services.storage import StorageStrategy + + +class MyAgent: + def __init__(self, agent: Agent, storage: StorageStrategy) -> None: + self.agent = agent + self.runner = AgnoHitlRunner(agent=agent, storage=storage) + + @classmethod + async def create(cls, config, storage, cost) -> "MyAgent": + agent = create_my_agent(config, storage, cost, session_id=config.session.mission_id) + return cls(agent=agent, storage=storage) + + async def handle_agui_input( + self, + input_data: Any, + *, + send: Callable[[BaseAgentRunEvent], Coroutine[Any, Any, None]], + context: ModuleContext, + ) -> PauseInfo | None: + return await self.runner.handle_agui_input( + input_data=input_data, + send=send, + context=context, + ) +``` + +### 4. Wire the trigger + +The `TriggerHandler` listening on the `agui_stream` protocol becomes minimal — all logic lives in the runner: + +```python +# src/my_module/triggers/message_trigger.py +import logging + +from digitalkin.models.events import BaseAgentRunEvent +from digitalkin.models.module import ModuleContext +from digitalkin.models.module.ag_ui import AgUiOutput +from digitalkin.modules.trigger_handler import TriggerHandler + +from my_module.models import AgUiStreamInput, MySetup +from my_module.module import MyModule + +logger = logging.getLogger(__name__) + + +@MyModule.register +class MessageTrigger(TriggerHandler): + protocol = "agui_stream" + input_format = AgUiStreamInput + output_format = AgUiOutput + + async def handle( + self, + input_data: AgUiStreamInput, + setup_data: MySetup, # noqa: ARG002 + context: ModuleContext, + ) -> None: + agent = context.state.my_agent + + async def send_event(event: BaseAgentRunEvent) -> None: + await self.send_message(context, event) + + try: + await agent.handle_agui_input( + input_data=input_data, + send=send_event, + context=context, + ) + except Exception: + logger.exception("Error during agent invocation") + raise +``` + +That's it. `handle_agui_input` takes care of: + +- detecting whether it's a resume (a `ToolMessage` matches a `pending_tool_call_id`) or a fresh run (last `UserMessage`), +- dropping orphan records on HITL abandon, +- streaming digitalkin events via `send_event` (themselves translated to AG-UI by `AgUiMixin`), +- persisting the paused `RunOutput` in the `paused_runs` collection, +- emitting the `RunFinished` with `awaiting_tool_result` on pause (fresh or cascade). + +### 5. End-to-end verification + +With an AG-UI front that sends at least one test tool in `tools` (e.g. `get_weather(city: string)`): + +- [ ] **Pause**: user prompt "show me the weather in Lyon" → the stream contains `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, then a `RUN_FINISHED` whose `result.status === "awaiting_tool_result"` and `pending_tool_call_ids` is non-empty. +- [ ] **Persistence**: `await context.storage.read("paused_runs", thread_id)` returns a `PausedRunRecord` with the correct `thread_id` and `pending_tool_call_ids`. +- [ ] **Resume**: send a new `RunAgentInput` with the same `thread_id`, identical `tools`, and `messages` enriched with a `ToolMessage(tool_call_id, content="{\"temp\":14}")` → the agent resumes, emits the final `TEXT_MESSAGE_*`, then a standard `RUN_FINISHED` (**without** `result.status`). +- [ ] **Cleanup**: after a successful resume, `storage.read("paused_runs", thread_id)` returns `None`. +- [ ] **Abandon**: during a pause, send a new `UserMessage` without a `ToolMessage` → the record is dropped and a fresh run starts. +- [ ] **Cascade**: if the agent calls another frontend tool after a resume, confirm a new `RUN_FINISHED(awaiting_tool_result)` is emitted with the new IDs and the record is re-persisted. + +## Public API exported + +All these symbols are available from `digitalkin.community.agno`: + +| Symbol | Type | Primary use | +|---|---|---| +| `AgnoStreamAdapter` | `class` | Agno → DigitalKin event conversion (unchanged, except the new `run_paused` handler) | +| `agui_tool_to_external_function` | `func` | Convert a single `AgUiTool` — advanced use | +| `make_tools_factory` | `func` | Build the `tools=` factory for `Agent` | +| `AgnoHitlRunner` | `class` | High-level HITL runner — the one to use in modules | +| `PausedRunStore` | `class` | `StorageStrategy` wrapper — advanced use | +| `PausedRunRecord` | `BaseModel` | Storage schema — rarely imported directly | +| `HITL_STORAGE_CONFIG` | `dict` | Ready-to-merge fragment for `services_config_params` | +| `PauseInfo` | `dataclass` | Return type of the runner on pause | +| `emit_awaiting_tool_result` | `async func` | Manual emission of the pause `RunFinished` — advanced use | + +## Design notes + +**Why use `dependencies` as a transport channel for frontend tools?** The `tools=` factory receives the `RunContext` at the moment Agno resolves tools for the run (`aresolve_callable_tools`). The `RunContext` carries the `dependencies` passed to `arun(dependencies=...)`, and they are populated **before** tool resolution (`agno/agent/_run.py:2563`). It's therefore the only per-run injection point supported natively by Agno without mutating the `Agent` instance. The actual tool registration still goes through the factory (i.e. the `tools=` parameter), not through `dependencies`, which only conveys the list. + +**Why is the storage key `thread_id` and not `tool_call_id`?** One pause per thread at a time, which is the standard HITL model. If several frontend tools are called in the same run, they are all gathered in the same `RunPausedEvent` and stored under a single record. *Nested pauses* (a thread with several pauses stacked in parallel) are not supported — this is not an Agno limitation, it's a deliberate simplicity choice. + +**Why `cache_callables=False` is non-negotiable.** `aresolve_callable_tools` caches the factory result by default (`agno/utils/callables.py:260-284`) — without explicitly disabling it, the first call freezes the tools for the entire lifetime of the `Agent` instance and subsequent runs never see different frontend tools. The factory is designed to be cheap (just a concatenation), so there's no penalty in disabling the cache. + +**Process-stateless by design.** All the paused state lives in `StorageStrategy` (an external gRPC service). Any replica can handle the resume request for the same `thread_id` — no need for sticky routing or in-process caching. + +## No breaking change + +The `AgnoStreamAdapter` patch is additive: modules that don't use `external_execution=True` see no difference in the streamed events. The new `agui_tools` and `hitl` modules are opt-in — you have to explicitly import them and wire the `AgnoHitlRunner` to enable HITL. No deprecation. + +## Migration from a homegrown HITL module + +If you had already implemented frontend-tools support with local code (custom `PausedRun` schema, custom converter, resume logic in the trigger), you can now remove: + +- The local Pydantic model for the `paused_runs` collection → use `PausedRunRecord` via `HITL_STORAGE_CONFIG`. +- Any local `AgUiTool` → `Function` conversion file → use `make_tools_factory`. +- The `_try_resume` / `_maybe_emit_awaiting` logic in your trigger → use `handle_agui_input`. +- The manual `RunPausedEvent` detection in your agent wrapper → the SDK adapter handles it now. + +Concrete example of the refactor on `template-archetype` (before/after): + +| Before | After | +|---|---| +| `models/storage.py::PausedRun` (local model) | Removed — replaced by `HITL_STORAGE_CONFIG` | +| `agents/frontend_tools.py` (local converter) | Removed — replaced by `make_tools_factory` | +| `agents/agno_agent.py` ~250 lines with homemade pause detection | ~60 lines, just a wrapper around `AgnoHitlRunner` | +| `triggers/message_trigger.py` ~210 lines with `_try_resume` / `_maybe_emit_awaiting` | ~55 lines, just a call to `agent.handle_agui_input(...)` | diff --git a/examples/headers_example.py b/examples/headers_example.py index 4f469c68..745dac3a 100644 --- a/examples/headers_example.py +++ b/examples/headers_example.py @@ -38,9 +38,8 @@ from digitalkin.grpc_servers.module_server import ModuleServer from digitalkin.models.grpc_servers.models import ( ModuleServerConfig, - SecurityMode, - ServerMode, ) +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode logging.basicConfig( level=logging.INFO, @@ -74,7 +73,7 @@ async def start_server() -> ModuleServer: config = ModuleServerConfig( host="[::]", port=50055, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, max_workers=10, credentials=None, diff --git a/examples/modules/archetype_with_tools_module.py b/examples/modules/archetype_with_tools_module.py index ddc4661b..49123a40 100644 --- a/examples/modules/archetype_with_tools_module.py +++ b/examples/modules/archetype_with_tools_module.py @@ -5,10 +5,11 @@ from pydantic import BaseModel, Field -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.grpc_servers.models import ClientConfig from digitalkin.models.module.module_context import ModuleContext from digitalkin.models.module.setup_types import SetupModel from digitalkin.models.module.tool_reference import ToolReference +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.modules._base_module import BaseModule # noqa: PLC2701 from digitalkin.services.services_models import ServicesStrategy @@ -98,7 +99,7 @@ class ArchetypeSecret(BaseModel): client_config = ClientConfig( host="[::]", port=50152, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, credentials=None, ) diff --git a/pyproject.toml b/pyproject.toml index 575a544c..eb9c40a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ ] dependencies = [ + "ag-ui-protocol>=0.1.14", "agentic-mesh-protocol==0.2.3", "anyio==4.13.0", "grpcio-health-checking==1.78.0", @@ -34,7 +35,7 @@ "grpcio-status==1.78.0", "pydantic==2.12.5", ] - version = "0.3.4" + version = "0.4.2" [project.optional-dependencies] profiling = [ @@ -60,13 +61,12 @@ [dependency-groups] dev = [ - "build==1.4.1", + "build==1.4.2", "bump-my-version==1.2.7", - "cryptography==46.0.5", - "mypy==1.19.1", + "cryptography==46.0.6", + "mypy==1.20.2", "pre-commit==4.5.1", - "pyright==1.1.408", - "ruff==0.15.7", + "ruff==0.15.11", "twine==6.2.0", "types-grpcio-health-checking==1.0.0.20250506", "types-grpcio-reflection==1.0.0.20250506", @@ -96,7 +96,7 @@ "mkdocs==1.6.1", "mkdocstrings-python==2.0.3", "mkdocstrings==1.0.3", - "tomli==2.4.0", + "tomli==2.4.1", ] tests = [ "freezegun==1.5.5", @@ -265,10 +265,6 @@ quote-style = "double" skip-magic-trailing-comma = false -[tool.pyright] - exclude = [ "**/.*", "**/.venv", "**/__pycache__", "**/node_modules" ] - include = [ "src" ] - [tool.mypy] exclude = [ "examples", "tests" ] diff --git a/src/digitalkin/__version__.py b/src/digitalkin/__version__.py index 42066e3c..1d93645d 100644 --- a/src/digitalkin/__version__.py +++ b/src/digitalkin/__version__.py @@ -5,4 +5,4 @@ try: __version__ = version("digitalkin") except PackageNotFoundError: - __version__ = "0.3.4" + __version__ = "0.4.2" diff --git a/src/digitalkin/community/__init__.py b/src/digitalkin/community/__init__.py new file mode 100644 index 00000000..ebf06ea7 --- /dev/null +++ b/src/digitalkin/community/__init__.py @@ -0,0 +1,5 @@ +"""Community integrations for DigitalKin. + +This package contains community-contributed integrations with various AI frameworks +and tools (Agno, LangChain, etc.). +""" diff --git a/src/digitalkin/community/agno/__init__.py b/src/digitalkin/community/agno/__init__.py new file mode 100644 index 00000000..05340050 --- /dev/null +++ b/src/digitalkin/community/agno/__init__.py @@ -0,0 +1,43 @@ +"""Agno framework integration for DigitalKin. + +Adapters, converters, and HITL helpers for building DigitalKin modules +on top of the Agno agent framework. Exports: + +- :class:`AgnoStreamAdapter` — Agno streaming events → DigitalKin events. +- :func:`agui_tool_to_external_function` / :func:`make_tools_factory` — + register AG-UI client-side (frontend) tools as Agno external Functions. +- :class:`AgnoHitlRunner`, :class:`PausedRunStore`, :class:`PauseInfo`, + :class:`PausedRunRecord`, :data:`HITL_STORAGE_CONFIG`, + :func:`emit_awaiting_tool_result` — human-in-the-loop (HITL) runner + that persists a paused Agno run via the module's + :class:`~digitalkin.services.storage.StorageStrategy` and resumes it + when the front replies with a ``ToolMessage``. +""" + +from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter +from digitalkin.community.agno.agui_tools import ( + agui_tool_to_external_function, + make_tools_factory, +) +from digitalkin.community.agno.hitl import ( + HITL_STORAGE_CONFIG, + AgnoHitlRunner, + PausedRunRecord, + PausedRunStore, + PauseInfo, + emit_awaiting_tool_result, + emit_messages_snapshot, +) + +__all__ = [ + "HITL_STORAGE_CONFIG", + "AgnoHitlRunner", + "AgnoStreamAdapter", + "PauseInfo", + "PausedRunRecord", + "PausedRunStore", + "agui_tool_to_external_function", + "emit_awaiting_tool_result", + "emit_messages_snapshot", + "make_tools_factory", +] diff --git a/src/digitalkin/community/agno/agno_adapter.py b/src/digitalkin/community/agno/agno_adapter.py new file mode 100644 index 00000000..3dbcb17b --- /dev/null +++ b/src/digitalkin/community/agno/agno_adapter.py @@ -0,0 +1,945 @@ +"""Adapter to convert Agno events to DigitalKin framework-agnostic events. + +This adapter bridges Agno-specific events to the DigitalKin event model, +allowing the core DigitalKin SDK to remain independent of Agno. + +The adapter owns ALL state management: tracking reasoning/content lifecycle, +generating message_id and reasoning_id on each phase start, and emitting +proper start/completed events for text message and reasoning sequences. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import TYPE_CHECKING, Any, TypeAlias + +if TYPE_CHECKING: + from collections.abc import Callable + + from agno.run.agent import ( + BaseAgentRunEvent as _AgentBase, + ) + from agno.run.agent import ( + ReasoningCompletedEvent as _AgentReasoningCompleted, + ) + from agno.run.agent import ( + ReasoningContentDeltaEvent as _AgentReasoningContentDelta, + ) + from agno.run.agent import ( + ReasoningStartedEvent as _AgentReasoningStarted, + ) + from agno.run.agent import ( + ReasoningStepEvent as _AgentReasoningStep, + ) + from agno.run.agent import ( + RunCompletedEvent as _AgentRunCompleted, + ) + from agno.run.agent import ( + RunContentEvent as _AgentRunContent, + ) + from agno.run.agent import ( + RunErrorEvent as _AgentRunError, + ) + from agno.run.agent import ( + RunPausedEvent as _AgentRunPaused, + ) + from agno.run.agent import ( + RunStartedEvent as _AgentRunStarted, + ) + from agno.run.agent import ( + ToolCallCompletedEvent as _AgentToolCallCompleted, + ) + from agno.run.agent import ( + ToolCallErrorEvent as _AgentToolCallError, + ) + from agno.run.agent import ( + ToolCallStartedEvent as _AgentToolCallStarted, + ) + from agno.run.team import ( + BaseTeamRunEvent as _TeamBase, + ) + from agno.run.team import ( + ReasoningCompletedEvent as _TeamReasoningCompleted, + ) + from agno.run.team import ( + ReasoningContentDeltaEvent as _TeamReasoningContentDelta, + ) + from agno.run.team import ( + ReasoningStartedEvent as _TeamReasoningStarted, + ) + from agno.run.team import ( + ReasoningStepEvent as _TeamReasoningStep, + ) + from agno.run.team import ( + RunCompletedEvent as _TeamRunCompleted, + ) + from agno.run.team import ( + RunContentEvent as _TeamRunContent, + ) + from agno.run.team import ( + RunErrorEvent as _TeamRunError, + ) + from agno.run.team import ( + RunPausedEvent as _TeamRunPaused, + ) + from agno.run.team import ( + RunStartedEvent as _TeamRunStarted, + ) + from agno.run.team import ( + ToolCallCompletedEvent as _TeamToolCallCompleted, + ) + from agno.run.team import ( + ToolCallErrorEvent as _TeamToolCallError, + ) + from agno.run.team import ( + ToolCallStartedEvent as _TeamToolCallStarted, + ) + + AgnoRunEvent: TypeAlias = _AgentBase | _TeamBase + AgnoRunStartedEvent: TypeAlias = _AgentRunStarted | _TeamRunStarted + AgnoRunContentEvent: TypeAlias = _AgentRunContent | _TeamRunContent + AgnoRunCompletedEvent: TypeAlias = _AgentRunCompleted | _TeamRunCompleted + AgnoRunErrorEvent: TypeAlias = _AgentRunError | _TeamRunError + AgnoRunPausedEvent: TypeAlias = _AgentRunPaused | _TeamRunPaused + AgnoReasoningStartedEvent: TypeAlias = _AgentReasoningStarted | _TeamReasoningStarted + AgnoReasoningContentDeltaEvent: TypeAlias = _AgentReasoningContentDelta | _TeamReasoningContentDelta + AgnoReasoningStepEvent: TypeAlias = _AgentReasoningStep | _TeamReasoningStep + AgnoReasoningCompletedEvent: TypeAlias = _AgentReasoningCompleted | _TeamReasoningCompleted + AgnoToolCallStartedEvent: TypeAlias = _AgentToolCallStarted | _TeamToolCallStarted + AgnoToolCallCompletedEvent: TypeAlias = _AgentToolCallCompleted | _TeamToolCallCompleted + AgnoToolCallErrorEvent: TypeAlias = _AgentToolCallError | _TeamToolCallError + +from digitalkin.models.events import ( + AgentRunEvent, + BaseAgentRunEvent, + ReasoningCompletedEvent, + ReasoningContentDeltaEvent, + ReasoningStartedEvent, + ReasoningStepEvent, + RunCompletedEvent, + RunContentEvent, + RunErrorEvent, + RunStartedEvent, + TextMessageCompletedEvent, + TextMessageStartedEvent, + ToolCallCompletedEvent, + ToolCallErrorEvent, + ToolCallStartedEvent, + ToolInfo, +) + +logger = logging.getLogger(__name__) + + +class AgnoStreamAdapter: + """Stateful converter: Agno streaming events -> DigitalKin events. + + Tracks reasoning and content state so that events arriving on + ``RunEvent.run_content`` are automatically wrapped in proper + lifecycle events (TextMessageStarted/Completed, ReasoningStarted/Completed). + + Usage:: + + adapter = AgnoStreamAdapter() + async for raw_event in agent.arun(..., stream=True, stream_events=True): + for event in adapter.to_digitalkin_events(raw_event): + await send(event) + for event in adapter.flush(): + await send(event) + """ + + def __init__(self) -> None: + """Initialize the AgnoStreamAdapter.""" + self._reasoning_active: bool = False + self._current_reasoning_id: str | None = None + + self._content_active: bool = False + self._current_message_id: str | None = None + + self._closed_tool_call_ids: set[str] = set() + + self._active_run_id: str | None = None + self._completed_run_ids: set[str] = set() + + # HITL pause state — populated when a RunPausedEvent is seen + # (tools with external_execution=True). Callers can inspect these + # after streaming to decide whether to persist and resume later. + self._is_paused: bool = False + self._paused_tool_executions: list[Any] = [] + self._paused_requirements: list[Any] = [] + + self._dispatch: dict[Any, Callable[..., list[BaseAgentRunEvent]]] | None = None + self._team_enum: type | None = None + + self._last_metadata: dict[str, Any] | None = None + + @property + def is_paused(self) -> bool: + """Whether the last stream ended on a run_paused event (external tool HITL).""" + return self._is_paused + + @property + def paused_tool_executions(self) -> list[Any]: + """Agno ``ToolExecution`` objects awaiting external execution (HITL).""" + return list(self._paused_tool_executions) + + @property + def paused_requirements(self) -> list[Any]: + """Agno ``RunRequirement`` objects carried by the paused run.""" + return list(self._paused_requirements) + + @staticmethod + def _build_metadata(agno_event: AgnoRunEvent, *, is_team: bool) -> dict[str, Any]: + """Extract identity info from a raw Agno event. + + Team leader events carry ``team_id``/``team_name``; agent events carry + ``agent_id``/``agent_name``. Member events set ``parent_run_id`` to the + team's run id so the client can group deltas by speaker. + + ``run_id`` is intentionally absent: it is already carried by the typed + event fields (``RunStartedEvent.run_id`` etc.) and must not be + duplicated in ``metadata``. + + Args: + agno_event: Raw Agno event (Pydantic model or equivalent). + is_team: Whether the event originates from a team-level context. + + Returns: + Dict with ``source``, ``name``, ``id`` and ``parent_run_id`` — + ready to hand to the ``metadata`` field of a DigitalKin event. + """ + data = agno_event.__dict__ + if is_team: + return { + "source": "team", + "name": data.get("team_name"), + "id": data.get("team_id"), + "parent_run_id": data.get("parent_run_id"), + } + return { + "source": "agent", + "name": data.get("agent_name"), + "id": data.get("agent_id"), + "parent_run_id": data.get("parent_run_id"), + } + + def _build_dispatch(self) -> dict[Any, Callable[..., list[BaseAgentRunEvent]]]: + """Import Agno enums lazily and populate the dispatch table. + + Also caches ``TeamRunEvent`` in ``self._team_enum`` for source detection. + + Returns: + Dispatch table mapping Agno event enum members to handlers. + + Raises: + ImportError: If the optional 'agno' dependency is not installed. + """ + try: + from agno.run.agent import RunEvent # pylint: disable=C0415 + from agno.run.team import TeamRunEvent # pylint: disable=C0415 + except ImportError as exc: + message = "The 'agno' package is required to use AgnoStreamAdapter. Install it with: pip install agno" + raise ImportError(message) from exc + + self._team_enum = TeamRunEvent + + handler_by_name: dict[str, Callable[..., list[BaseAgentRunEvent]]] = { + "run_started": self._handle_run_started, + "run_content": self._handle_run_content, + "run_completed": self._handle_run_completed, + "run_error": self._handle_run_error, + "run_paused": self._handle_run_paused, + "reasoning_started": self._handle_reasoning_started, + "reasoning_content_delta": self._handle_reasoning_content_delta, + "reasoning_step": self._handle_reasoning_step, + "reasoning_completed": self._handle_reasoning_completed, + "tool_call_started": self._handle_tool_call_started, + "tool_call_completed": self._handle_tool_call_completed, + "tool_call_error": self._handle_tool_call_error, + } + dispatch = { + enum_cls[name]: handler + for enum_cls in (RunEvent, TeamRunEvent) + for name, handler in handler_by_name.items() + } + self._dispatch = dispatch + return dispatch + + def to_digitalkin_events(self, agno_event: AgnoRunEvent) -> list[BaseAgentRunEvent]: + """Convert one Agno event into one or more DigitalKin events. + + Args: + agno_event: Event from Agno's streaming API. + + Returns: + List of corresponding DigitalKin events (may be empty). + + Raises: + ImportError: If the optional 'agno' dependency is not installed. + """ + dispatch = self._dispatch if self._dispatch is not None else self._build_dispatch() + + event_type = agno_event.event + logger.debug("Converting Agno event: %s", event_type) + + handler = dispatch.get(event_type) + if handler is None: + logger.debug("Skipping unhandled Agno event type: %s", event_type) + return [] + + is_team = self._team_enum is not None and isinstance(event_type, self._team_enum) + self._last_metadata = self._build_metadata(agno_event, is_team=is_team) + + return handler(agno_event, agno_event.__dict__.get("timestamp")) + + # ── Run Lifecycle Handlers ─────────────────────────────────────────── + + def _handle_run_started(self, agno_event: AgnoRunStartedEvent, timestamp: Any) -> list[BaseAgentRunEvent]: + """Handle RunEvent.run_started. + + Nested runs (a team member's own run, or a team invoked from a + workflow) carry a non-empty ``parent_run_id``. The AG-UI protocol + only accepts a single ``RUN_STARTED`` per stream, so we drop + nested ones — content/tool events from members still propagate + and carry ``metadata.parent_run_id`` for client-side routing. + + Returns: + List containing a RunStartedEvent, or empty for duplicates / nested runs. + """ + parent_run_id = getattr(agno_event, "parent_run_id", None) + run_id = agno_event.run_id + + if parent_run_id: + logger.info( + "[agno-adapter] DROP nested run_started run_id=%s parent_run_id=%s agent=%s/%s", + run_id, + parent_run_id, + getattr(agno_event, "agent_id", None), + getattr(agno_event, "agent_name", None), + ) + return [] + + if run_id and run_id == self._active_run_id: + logger.info("[agno-adapter] DROP duplicate run_started run_id=%s", run_id) + return [] + + logger.info( + "[agno-adapter] EMIT run_started run_id=%s session_id=%s active_was=%s metadata=%s", + run_id, + getattr(agno_event, "session_id", None), + self._active_run_id, + self._last_metadata, + ) + self._active_run_id = run_id + return [ + RunStartedEvent( + event=AgentRunEvent.RUN_STARTED, + run_id=run_id, + thread_id=agno_event.session_id, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ] + + def _handle_run_completed(self, agno_event: AgnoRunCompletedEvent, timestamp: Any) -> list[BaseAgentRunEvent]: + """Handle RunEvent.run_completed. + + Mirrors ``_handle_run_started``: nested runs are silently dropped + so the outer run's ``RUN_COMPLETED`` stays the single top-level + closure on the stream. + + Returns: + List of closing events followed by a RunCompletedEvent, + or empty for nested / duplicate events. + """ + parent_run_id = getattr(agno_event, "parent_run_id", None) + run_id = agno_event.run_id + + if parent_run_id: + # Close the subagent's text/reasoning bubble so the main agent's + # continuation gets a fresh message_id. Inject a "\n---\n" footer + # on the same message before the TextMessageCompletedEvent so the + # frontend can visually separate subagent content from the rest. + events: list[BaseAgentRunEvent] = [] + if self._content_active: + events.append( + RunContentEvent( + event=AgentRunEvent.RUN_CONTENT, + content=" \n\n --- \n\n ", + message_id=self._current_message_id, + reasoning_content=None, + content_type=None, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + events.extend(self._close_content(timestamp)) + if self._reasoning_active: + events.extend(self._close_reasoning(timestamp)) + logger.info( + "[agno-adapter] DROP nested run_completed run_id=%s parent_run_id=%s closed=%d", + run_id, + parent_run_id, + len(events), + ) + return events + + if run_id and run_id in self._completed_run_ids and run_id != self._active_run_id: + logger.info("[agno-adapter] DROP duplicate run_completed run_id=%s", run_id) + return [] + + logger.info( + "[agno-adapter] EMIT run_completed run_id=%s active_run_id=%s", + run_id, + self._active_run_id, + ) + + events = [] + + if self._content_active: + events.extend(self._close_content(timestamp)) + if self._reasoning_active: + events.extend(self._close_reasoning(timestamp)) + + if run_id: + self._completed_run_ids.add(run_id) + self._active_run_id = None + + content = agno_event.content + events.append( + RunCompletedEvent( + event=AgentRunEvent.RUN_COMPLETED, + run_id=run_id, + final_content=str(content) if content else None, + usage=None, + message_id=None, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + return events + + def _handle_run_error(self, agno_event: AgnoRunErrorEvent, timestamp: Any) -> list[BaseAgentRunEvent]: + """Handle RunEvent.run_error. + + Returns: + List containing a RunErrorEvent. + """ + content = agno_event.content + return [ + RunErrorEvent( + event=AgentRunEvent.RUN_ERROR, + error_type=agno_event.error_type, + content=str(content) if content else None, + error_details=None, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ] + + # ── Reasoning Handlers (native Agno reasoning models) ─────────────── + + def _handle_reasoning_started( + self, agno_event: AgnoReasoningStartedEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: + """Handle RunEvent.reasoning_started. + + Returns: + List with an optional TextMessageCompletedEvent and a ReasoningStartedEvent. + """ + _ = agno_event + events: list[BaseAgentRunEvent] = [] + + if self._content_active: + events.extend(self._close_content(timestamp)) + + self._current_reasoning_id = str(uuid.uuid4()) + self._reasoning_active = True + logger.debug("Reasoning started, id=%s", self._current_reasoning_id) + events.append( + ReasoningStartedEvent( + event=AgentRunEvent.REASONING_STARTED, + reasoning_id=self._current_reasoning_id, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + return events + + def _handle_reasoning_content_delta( + self, agno_event: AgnoReasoningContentDeltaEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: + """Handle RunEvent.reasoning_content_delta. + + Returns: + List containing a ReasoningContentDeltaEvent. + """ + return [ + ReasoningContentDeltaEvent( + event=AgentRunEvent.REASONING_CONTENT_DELTA, + delta=agno_event.reasoning_content or "", + reasoning_id=self._current_reasoning_id, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ] + + def _handle_reasoning_step(self, agno_event: AgnoReasoningStepEvent, timestamp: Any) -> list[BaseAgentRunEvent]: + """Handle ``RunEvent.reasoning_step`` — emitted by Agno's ``ReasoningTools``. + + Unlike the native reasoning events (``reasoning_started`` / + ``reasoning_content_delta`` / ``reasoning_completed``), a + ``reasoning_step`` may arrive without a preceding + ``reasoning_started``. This happens when the LLM calls tool-based + reasoning (``think`` / ``analyze`` from ``ReasoningTools``) rather + than using the model's built-in extended thinking. + + To comply with the AG-UI protocol — which requires every + ``REASONING_MESSAGE_CONTENT`` to be wrapped in a + ``REASONING_START`` … ``REASONING_END`` lifecycle — we auto-open + a reasoning sequence here if none is active. The sequence is + auto-closed by the next non-reasoning event (``_handle_run_content``, + ``_handle_tool_call_started``, etc.) or by ``flush()``. + + Returns: + List of events: optionally a ``ReasoningStartedEvent`` (if + auto-opened), followed by the ``ReasoningStepEvent``. + """ + events: list[BaseAgentRunEvent] = [] + + content = getattr(agno_event, "reasoning_content", "") + if not content: + return events + + # Close active text message if transitioning to reasoning + if self._content_active: + events.extend(self._close_content(timestamp)) + + # Auto-open reasoning lifecycle if not already active + if not self._reasoning_active: + self._current_reasoning_id = str(uuid.uuid4()) + self._reasoning_active = True + logger.debug("Reasoning auto-started (from reasoning_step), id=%s", self._current_reasoning_id) + events.append( + ReasoningStartedEvent( + event=AgentRunEvent.REASONING_STARTED, + reasoning_id=self._current_reasoning_id, + timestamp=timestamp, + metadata=None, + ) + ) + + events.append( + ReasoningStepEvent( + event=AgentRunEvent.REASONING_STEP, + delta=content, + reasoning_id=self._current_reasoning_id, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + return events + + def _handle_reasoning_completed( + self, agno_event: AgnoReasoningCompletedEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: + """Handle RunEvent.reasoning_completed. + + Returns: + List containing a ReasoningCompletedEvent if reasoning was active. + """ + _ = agno_event + logger.debug("Reasoning completed") + return self._close_reasoning(timestamp) + + # ── Tool Call Handlers ────────────────────────────────────────────── + + def _handle_tool_call_started( + self, agno_event: AgnoToolCallStartedEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: + """Handle RunEvent.tool_call_started. + + Returns: + List of any needed closing events and a ToolCallStartedEvent. + """ + events: list[BaseAgentRunEvent] = [] + + if self._reasoning_active: + logger.debug("Reasoning auto-completed (tool call started)") + events.extend(self._close_reasoning(timestamp)) + if self._content_active: + events.extend(self._close_content(timestamp)) + + tool = agno_event.tool + tool_info = None + if tool: + tool_info = ToolInfo( + tool_call_id=tool.tool_call_id, + tool_name=tool.tool_name, + tool_args=tool.tool_args, + result=None, + ) + events.append( + ToolCallStartedEvent( + event=AgentRunEvent.TOOL_CALL_STARTED, + tool=tool_info, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + return events + + def _handle_tool_call_completed( + self, agno_event: AgnoToolCallCompletedEvent, timestamp: Any + ) -> list[BaseAgentRunEvent]: + """Handle RunEvent.tool_call_completed. + + Returns: + List containing a ToolCallCompletedEvent. + """ + tool = agno_event.tool + tool_info = None + tool_call_id = None + if tool: + tool_call_id = tool.tool_call_id + tool_info = ToolInfo( + tool_call_id=tool_call_id, + tool_name=tool.tool_name, + tool_args=tool.tool_args, + result=tool.result, + ) + + if tool_call_id: + self._closed_tool_call_ids.add(tool_call_id) + + content = agno_event.content + return [ + ToolCallCompletedEvent( + event=AgentRunEvent.TOOL_CALL_COMPLETED, + tool=tool_info, + content=str(content) if content else None, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ] + + def _handle_run_paused(self, agno_event: AgnoRunPausedEvent, timestamp: Any) -> list[BaseAgentRunEvent]: + """Handle ``RunEvent.run_paused`` — HITL pause on external tool execution. + + Agno does NOT emit ``tool_call_started`` / ``tool_call_completed`` for + tools declared with ``external_execution=True`` (see + ``agno/models/base.py`` where the emission is short-circuited). The + front therefore never sees the corresponding AG-UI ``ToolCallStart`` + / ``ToolCallArgs`` / ``ToolCallEnd`` events unless we synthesize them. + + This handler: + + 1. Closes any active reasoning / content sequence. + 2. Iterates ``RunPausedEvent.tools`` and emits one pair of + ``ToolCallStartedEvent`` + ``ToolCallCompletedEvent`` per tool. + The ``ToolCallCompletedEvent`` carries ``content=None`` and + ``tool.result=None`` so the downstream AG-UI bridge emits + ``ToolCallEnd`` *without* a ``ToolCallResult`` (guarded by the + ``if result_content:`` check in ``AgUiMixin``). + 3. Records pause state on the adapter (``is_paused``, + ``paused_tool_executions``, ``paused_requirements``) so callers + can detect the pause after streaming and persist the run for + later resumption. + + Returns: + Synthesized tool-call events for the paused tools. The caller + is responsible for subsequently emitting the AG-UI + ``RunFinished`` with ``result.status = "awaiting_tool_result"`` + — this adapter stays protocol-agnostic. + """ + events: list[BaseAgentRunEvent] = [] + + if self._reasoning_active: + events.extend(self._close_reasoning(timestamp)) + if self._content_active: + events.extend(self._close_content(timestamp)) + + tools = getattr(agno_event, "tools", None) or [] + requirements = getattr(agno_event, "requirements", None) or [] + + self._is_paused = True + self._paused_tool_executions = list(tools) + self._paused_requirements = list(requirements) + + # RunPausedEvent.tools contains ALL tools from run_response.tools + # (both server-side tools already executed and external ones awaiting + # client execution). We must only synthesize events for external tools + # — the server-side ones (e.g. ReasoningTools' think/analyze) were + # already streamed via the normal tool_call_started/completed path. + seen_ids: set[str] = set() + for tool_exec in tools: + if not getattr(tool_exec, "external_execution_required", False): + continue + tool_call_id = getattr(tool_exec, "tool_call_id", None) + if not tool_call_id or tool_call_id in seen_ids: + continue + seen_ids.add(tool_call_id) + tool_info = ToolInfo( + tool_call_id=tool_call_id, + tool_name=getattr(tool_exec, "tool_name", None), + tool_args=getattr(tool_exec, "tool_args", None), + result=None, + ) + logger.debug( + "Synthesizing tool-call events for external_execution tool %s (id=%s)", + tool_info.tool_name, + tool_call_id, + ) + events.extend(( + ToolCallStartedEvent( + event=AgentRunEvent.TOOL_CALL_STARTED, + tool=tool_info, + timestamp=timestamp, + metadata=None, + ), + ToolCallCompletedEvent( + event=AgentRunEvent.TOOL_CALL_COMPLETED, + tool=tool_info, + content=None, + timestamp=timestamp, + metadata=None, + ), + )) + self._closed_tool_call_ids.add(tool_call_id) + + return events + + def _handle_tool_call_error(self, agno_event: AgnoToolCallErrorEvent, timestamp: Any) -> list[BaseAgentRunEvent]: + """Handle RunEvent.tool_call_error. + + Returns: + List containing a ToolCallErrorEvent, or empty if already closed. + """ + tool = agno_event.tool + tool_call_id = tool.tool_call_id if tool else None + + if tool_call_id and tool_call_id in self._closed_tool_call_ids: + logger.debug("Skipping duplicate ToolCallError for tool %s", tool_call_id) + return [] + + tool_info = None + if tool: + tool_info = ToolInfo( + tool_call_id=tool_call_id, + tool_name=tool.tool_name, + tool_args=None, + result=None, + ) + + if tool_call_id: + self._closed_tool_call_ids.add(tool_call_id) + + content = agno_event.content + return [ + ToolCallErrorEvent( + event=AgentRunEvent.TOOL_CALL_ERROR, + tool=tool_info, + error_message=str(content) if content else None, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ] + + def flush(self) -> list[BaseAgentRunEvent]: + """Emit closing events for any active sequences at end of stream. + + Returns: + List of closing events (empty if nothing is active). + """ + events: list[BaseAgentRunEvent] = [] + if self._content_active: + logger.debug("Flushing active content sequence") + events.extend(self._close_content(None)) + if self._reasoning_active: + logger.debug("Flushing active reasoning sequence") + events.extend(self._close_reasoning(None)) + return events + + # ── Private Helpers ────────────────────────────────────────────────── + + def _close_reasoning(self, timestamp: Any) -> list[BaseAgentRunEvent]: + """Close active reasoning sequence. + + Returns: + List of closing events (empty if reasoning is not active). + """ + if not self._reasoning_active: + return [] + + events: list[BaseAgentRunEvent] = [ + ReasoningCompletedEvent( + event=AgentRunEvent.REASONING_COMPLETED, + reasoning_id=self._current_reasoning_id, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ] + self._reasoning_active = False + self._current_reasoning_id = None + return events + + def _close_content(self, timestamp: Any) -> list[BaseAgentRunEvent]: + """Close active text message sequence. + + Returns: + List of closing events (empty if content is not active). + """ + if not self._content_active: + return [] + + events: list[BaseAgentRunEvent] = [ + TextMessageCompletedEvent( + event=AgentRunEvent.TEXT_MESSAGE_COMPLETED, + message_id=self._current_message_id or "", + timestamp=timestamp, + metadata=self._last_metadata, + ) + ] + self._content_active = False + self._current_message_id = None + return events + + def _handle_run_content(self, agno_event: AgnoRunContentEvent, timestamp: Any) -> list[BaseAgentRunEvent]: + """Handle RunEvent.run_content — the core state machine. + + Rules: + - reasoning_content non-empty: reasoning data (close content if transitioning) + - content non-empty: text data (close reasoning if transitioning) + - reasoning_content == "": close reasoning if active + - content == "": close content if active + - None values: ignored + + Returns: + List of DigitalKin events for this run_content chunk. + """ + events: list[BaseAgentRunEvent] = [] + + reasoning_content = agno_event.reasoning_content + content = agno_event.content + + # ── Reasoning content handling ── + if reasoning_content is not None: + events.extend(self._process_reasoning_content(reasoning_content, timestamp)) + + # ── Text content handling ── + if content is not None: + events.extend(self._process_text_content(content, timestamp)) + + # Edge case: neither reasoning_content nor content + if reasoning_content is None and content is None: + logger.debug("run_content with no content, skipping") + + return events + + def _process_reasoning_content(self, reasoning_content: str, timestamp: Any) -> list[BaseAgentRunEvent]: + """Process reasoning_content from a run_content event. + + Returns: + List of reasoning lifecycle and content events. + """ + events: list[BaseAgentRunEvent] = [] + + if not reasoning_content: + # Empty string "" → signal to close reasoning + if self._reasoning_active: + events.extend(self._close_reasoning(timestamp)) + return events + + # Non-empty string → reasoning data + # Close text message if transitioning from content to reasoning + if self._content_active: + events.extend(self._close_content(timestamp)) + + # Auto-open reasoning on first chunk + if not self._reasoning_active: + self._current_reasoning_id = str(uuid.uuid4()) + logger.debug("Reasoning auto-started, id=%s", self._current_reasoning_id) + events.append( + ReasoningStartedEvent( + event=AgentRunEvent.REASONING_STARTED, + reasoning_id=self._current_reasoning_id, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + self._reasoning_active = True + + events.append( + ReasoningContentDeltaEvent( + event=AgentRunEvent.REASONING_CONTENT_DELTA, + delta=reasoning_content, + reasoning_id=self._current_reasoning_id, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + return events + + def _process_text_content(self, content: str, timestamp: Any) -> list[BaseAgentRunEvent]: + """Process text content from a run_content event. + + Returns: + List of text message lifecycle and content events. + """ + events: list[BaseAgentRunEvent] = [] + + if not content: + # Empty string "" → signal to close text message + if self._content_active: + events.extend(self._close_content(timestamp)) + return events + + # Non-empty string → text data + # Close reasoning if transitioning from reasoning to content + if self._reasoning_active: + logger.debug("Reasoning auto-completed (text content arrived)") + events.extend(self._close_reasoning(timestamp)) + + # Auto-open text message on first chunk + if not self._content_active: + self._current_message_id = str(uuid.uuid4()) + events.append( + TextMessageStartedEvent( + event=AgentRunEvent.TEXT_MESSAGE_STARTED, + message_id=self._current_message_id, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + self._content_active = True + + # Inject "--- ---" header when the newly-opened + # bubble belongs to a team member (nested agent event). + meta = self._last_metadata or {} + if meta.get("parent_run_id") and meta.get("source") == "agent": + name = meta.get("name") or "member" + events.append( + RunContentEvent( + event=AgentRunEvent.RUN_CONTENT, + content=f"\n --- \n ### {name} \n\n", + message_id=self._current_message_id, + reasoning_content=None, + content_type=None, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + + events.append( + RunContentEvent( + event=AgentRunEvent.RUN_CONTENT, + content=str(content), + message_id=self._current_message_id, + reasoning_content=None, + content_type=None, + timestamp=timestamp, + metadata=self._last_metadata, + ) + ) + return events diff --git a/src/digitalkin/community/agno/agui_tools.py b/src/digitalkin/community/agno/agui_tools.py new file mode 100644 index 00000000..076a099c --- /dev/null +++ b/src/digitalkin/community/agno/agui_tools.py @@ -0,0 +1,127 @@ +"""AG-UI frontend tools → Agno external Functions. + +The AG-UI protocol lets the client declare its own tools in +``RunAgentInput.tools``. Those tools are meant to be executed on the +frontend (a UI widget, a browser-local API call, a user prompt, …) rather +than by the agent process. This module provides the glue to expose them +to an Agno :class:`~agno.agent.Agent` as regular :class:`~agno.tools.function.Function` +objects marked with ``external_execution=True``: when the LLM "calls" one, +Agno pauses the run (via :class:`~agno.run.agent.RunPausedEvent`) instead +of executing an entrypoint — letting the caller stream the tool-call +events to the front and resume later via :meth:`~agno.agent.Agent.acontinue_run`. + +Usage:: + + from digitalkin.community.agno import make_tools_factory + from agno.agent import Agent + + agent = Agent( + tools=make_tools_factory([AsyncDuckDuckGoTools()]), + cache_callables=False, # critical — see make_tools_factory + ... + ) + + async for ev in agent.arun( + message, + dependencies={"agui_tools": input_data.tools}, + stream=True, + stream_events=True, + ): + ... + +Notes: + ``dependencies`` is Agno's standard per-run injection bus. We use it + as a transport channel to hand the frontend tools to the tools + factory on every run — the tools themselves are actually registered + through the ``tools=factory`` mechanism, not through ``dependencies``. + ``cache_callables=False`` is required so the factory is re-invoked on + each run (otherwise the first resolved tool list is cached forever and + subsequent requests would not see new frontend tools). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + + from ag_ui.core.types import Tool as AgUiTool + from agno.run.base import RunContext + from agno.tools.function import Function + +_DEFAULT_DEPENDENCY_KEY = "agui_tools" + + +def _unreachable_entrypoint(**_: Any) -> None: + """Placeholder — never invoked because ``external_execution=True`` pauses the run.""" + + +def agui_tool_to_external_function(tool: AgUiTool) -> Function: + """Wrap an AG-UI tool definition as an Agno external ``Function``. + + The resulting :class:`Function` carries the AG-UI schema as-is (Agno + accepts raw JSON Schema via ``parameters``) and is marked with + ``external_execution=True`` so Agno emits the tool-call events but + skips the entrypoint and pauses the run when the LLM invokes it. + + Args: + tool: An :class:`ag_ui.core.types.Tool` from ``RunAgentInput.tools``. + + Returns: + An :class:`agno.tools.function.Function` ready to be plugged into + an Agno agent's tool list. + """ + from agno.tools.function import Function + + parameters = tool.parameters or {"type": "object", "properties": {}, "required": []} + return Function( + name=tool.name, + description=tool.description, + parameters=parameters, + entrypoint=_unreachable_entrypoint, + external_execution=True, + skip_entrypoint_processing=True, + ) + + +def make_tools_factory( + base_tools: list[Any], + dependency_key: str = _DEFAULT_DEPENDENCY_KEY, +) -> Callable[[RunContext], list[Any]]: + """Build an Agno ``tools`` factory that merges base tools with per-run AG-UI tools. + + The returned callable is the value you pass to ``Agent(tools=...)``. On + every run, Agno resolves the factory with the current + :class:`~agno.run.base.RunContext` (see + :func:`agno.utils.callables.aresolve_callable_tools`). The factory + reads ``run_context.dependencies[dependency_key]`` — the list of + :class:`~ag_ui.core.types.Tool` you passed via + ``agent.arun(dependencies={dependency_key: [...]})`` — converts them to + external :class:`Function` objects, and concatenates them with the + ``base_tools``. + + Args: + base_tools: Toolkits / Functions always available to the agent + (e.g. ``AsyncDuckDuckGoTools()``). Passed through unchanged. + dependency_key: The key in ``run_context.dependencies`` under which + the caller places the per-run AG-UI tool list. Defaults to + ``"agui_tools"``. + + Returns: + A callable suitable for :class:`agno.agent.Agent`'s ``tools=`` + parameter. Set ``cache_callables=False`` on the ``Agent`` so this + factory is re-invoked on every run. + """ + + def factory(run_context: RunContext | None = None) -> list[Any]: + # Agno may call the factory without arguments during Agent init or + # validation (observed in agno>=2.5.10). When that happens, return + # just the base tools — no frontend tools are available yet anyway. + if run_context is None: + return list(base_tools) + deps = getattr(run_context, "dependencies", None) or {} + agui_tools: list[AgUiTool] = deps.get(dependency_key) or [] + return [*base_tools, *[agui_tool_to_external_function(t) for t in agui_tools]] + + return factory diff --git a/src/digitalkin/community/agno/hitl.py b/src/digitalkin/community/agno/hitl.py new file mode 100644 index 00000000..30fd6d63 --- /dev/null +++ b/src/digitalkin/community/agno/hitl.py @@ -0,0 +1,876 @@ +"""Human-in-the-loop (HITL) runner for Agno agents with AG-UI frontend tools. + +This module provides the high-level glue to build an Agno-powered module +that supports AG-UI *frontend tools* — tools declared by the AG-UI client +and executed on the front rather than on the agent process. The flow is: + +1. The front sends ``RunAgentInput`` with a ``tools`` list. +2. The LLM calls one of those tools. +3. Agno emits ``RunPausedEvent`` (its HITL signal) and freezes the run. +4. We persist the paused :class:`~agno.run.agent.RunOutput` via the + module's :class:`~digitalkin.services.storage.StorageStrategy`, keyed by + ``thread_id``. +5. We emit an AG-UI ``RunFinished`` with + ``result={"status": "awaiting_tool_result", "pending_tool_call_ids": [...]}`` + so the front knows to execute the tool and reply. +6. On the next ``RunAgentInput`` carrying a matching ``ToolMessage``, we + load the paused run, inject the result into the corresponding + :class:`~agno.run.requirement.RunRequirement`, and resume via + :meth:`~agno.agent.Agent.acontinue_run`. + +The design keeps the process stateless (every replica can resume any +thread) because all the state lives in the storage service. + +Typical usage inside a module trigger:: + + from digitalkin.community.agno import ( + AgnoHitlRunner, + HITL_STORAGE_CONFIG, + make_tools_factory, + ) + + # In your Module class — register the storage schema + services_config_params = { + "storage": { + "config": { + **HITL_STORAGE_CONFIG, + "agno_sessions": AgnoSession, + ... + }, + ... + }, + ... + } + + # In your agent factory + agent = Agent( + tools=make_tools_factory([MyBaseToolkit()]), + cache_callables=False, + ... + ) + + # In your trigger handler + runner = AgnoHitlRunner(agent=agent, storage=context.storage) + pause_info = await runner.handle_agui_input( + input_data=input_data, + send=send, + context=context, # enables auto-emission of awaiting RunFinished + ) + +``handle_agui_input`` will figure out whether this is a fresh user +message, a resume of a paused run, or an abandon (new user message while +a tool was pending) and dispatch accordingly. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, ClassVar + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + + from ag_ui.core.types import Message as AgUiMessage + from ag_ui.core.types import Tool as AgUiTool + from agno.agent import Agent + from agno.run.agent import RunOutput + + from digitalkin.models.events import BaseAgentRunEvent + from digitalkin.models.module import ModuleContext + from digitalkin.services.storage import StorageStrategy + +logger = logging.getLogger(__name__) + +_PAUSED_RUNS_COLLECTION = "paused_runs" +_AWAITING_STATUS = "awaiting_tool_result" + + +# ── Storage schema ────────────────────────────────────────────────────────── + + +class PausedRunRecord(BaseModel): + """Persistent snapshot of an Agno run paused on external tool execution. + + Stored in the ``paused_runs`` collection keyed by ``thread_id``. The + ``payload`` field holds ``RunOutput.to_dict()`` verbatim so + :meth:`agno.run.agent.RunOutput.from_dict` can round-trip the run on + any replica when the front replies with the tool result(s). + """ + + model_config = ConfigDict(extra="allow") + + thread_id: str + run_id: str + pending_tool_call_ids: list[str] = Field(default_factory=list) + payload: dict[str, Any] = Field(default_factory=dict, description="RunOutput.to_dict()") + + +HITL_STORAGE_CONFIG: dict[str, type[BaseModel]] = {_PAUSED_RUNS_COLLECTION: PausedRunRecord} +"""Drop-in storage config fragment — merge into your module's ``services_config_params``. + +Example:: + + services_config_params = { + "storage": { + "config": {**HITL_STORAGE_CONFIG, "my_other_collection": MyModel}, + ... + }, + } +""" + + +# ── Return type ───────────────────────────────────────────────────────────── + + +@dataclass +class PauseInfo: + """Summary of a paused Agno run. + + Returned by :meth:`AgnoHitlRunner.run` and related methods whenever + the run paused on one or more external tool calls. Callers typically + use it to emit the AG-UI awaiting-tool-result event to the front. + + ``new_messages`` carries the AG-UI messages generated by Agno during + the paused run (user echoes, the assistant message with ``tool_calls``, + and any tool results emitted before the pause). It's provided because + Agno does not emit stream events from which the front can reconstruct + the assistant-with-tool-calls message — in particular, when the LLM + goes straight from reasoning to a frontend tool call without emitting + any text. Consumers typically push these messages to the front via a + :class:`~ag_ui.core.events.MessagesSnapshotEvent` so the client has an + authoritative view of the conversation. + """ + + thread_id: str + run_id: str + pending_tool_call_ids: list[str] + new_messages: list[AgUiMessage] = field(default_factory=list) + + +# ── Storage wrapper ───────────────────────────────────────────────────────── + + +class PausedRunStore: + """Thin wrapper around :class:`StorageStrategy` for the ``paused_runs`` collection. + + Owns serialization of :class:`~agno.run.agent.RunOutput` and keying by + ``thread_id``. Instances are cheap — create one per trigger handler. + """ + + COLLECTION: ClassVar[str] = _PAUSED_RUNS_COLLECTION + + def __init__(self, storage: StorageStrategy) -> None: + """Initialize the store. + + Args: + storage: The module's storage strategy. The collection + ``paused_runs`` must be registered with + :class:`PausedRunRecord` — use :data:`HITL_STORAGE_CONFIG`. + """ + self._storage = storage + + async def save(self, run_output: RunOutput, thread_id: str) -> PauseInfo: + """Serialize and store a paused ``RunOutput``. + + Args: + run_output: The paused Agno run (``is_paused=True`` with + populated ``requirements``). + thread_id: AG-UI thread identifier (the record key). + + Returns: + A :class:`PauseInfo` describing what was persisted. + """ + # Extract pending tool_call_ids from run_output.tools (not requirements). + # Agno's requirements list may be incomplete: it only appends a + # RunRequirement for the LAST tool in each paused batch + # (tool_executions_list[-1] in _response.py), so when N external tools + # pause in the same turn, only the last one gets a requirement. + # run_output.tools contains ALL tools (server-side + external), so we + # filter by external_execution_required and deduplicate. + seen: set[str] = set() + pending: list[str] = [] + for tool in run_output.tools or []: + tid = getattr(tool, "tool_call_id", None) + if tid and tid not in seen and getattr(tool, "external_execution_required", False): + seen.add(tid) + pending.append(tid) + record = PausedRunRecord( + thread_id=thread_id, + run_id=run_output.run_id or "", + pending_tool_call_ids=pending, + payload=run_output.to_dict(), + ) + await self._storage.upsert( + collection=self.COLLECTION, + record_id=thread_id, + data=record.model_dump(), + ) + logger.info( + "PausedRunStore: saved thread_id=%s run_id=%s pending=%s", + thread_id, + record.run_id, + pending, + ) + return PauseInfo( + thread_id=thread_id, + run_id=record.run_id, + pending_tool_call_ids=pending, + ) + + async def load(self, thread_id: str) -> PausedRunRecord | None: + """Fetch the paused run record for a thread. + + Args: + thread_id: AG-UI thread identifier. + + Returns: + The :class:`PausedRunRecord` if one exists, otherwise ``None``. + """ + record = await self._storage.read(collection=self.COLLECTION, record_id=thread_id) + if record is None: + return None + return PausedRunRecord.model_validate(record.data) + + async def delete(self, thread_id: str) -> None: + """Remove the paused run record for a thread.""" + await self._storage.remove(collection=self.COLLECTION, record_id=thread_id) + + +# ── Agno → AG-UI message conversion ──────────────────────────────────────── + + +def _agno_messages_to_agui(agno_messages: list[Any]) -> list[AgUiMessage]: + """Convert ``agno.models.message.Message`` instances into AG-UI messages. + + Drops system/developer/reasoning messages (which the front should not + receive) and normalizes the rest. Assistant messages carry their + ``tool_calls`` list reshaped into AG-UI :class:`~ag_ui.core.types.ToolCall` + objects; tool messages keep their ``tool_call_id`` + ``content`` pair. + + Args: + agno_messages: Value of ``RunOutput.messages`` at pause time. + + Returns: + A list of AG-UI :class:`~ag_ui.core.types.Message` instances ready + to be embedded in a :class:`~ag_ui.core.events.MessagesSnapshotEvent`. + """ + from ag_ui.core.types import ( + AssistantMessage as AgUiAssistantMessage, + ) + from ag_ui.core.types import ( + FunctionCall as AgUiFunctionCall, + ) + from ag_ui.core.types import ( + ToolCall as AgUiToolCall, + ) + from ag_ui.core.types import ( + ToolMessage as AgUiToolMessage, + ) + from ag_ui.core.types import ( + UserMessage as AgUiUserMessage, + ) + + result: list[AgUiMessage] = [] + for msg in agno_messages or []: + role = getattr(msg, "role", None) + msg_id = getattr(msg, "id", None) or "" + content = getattr(msg, "content", None) + # Agno content may be a list of parts for multimodal — stringify for AG-UI + if isinstance(content, list): + content = " ".join(str(part) for part in content if part is not None) + + if role == "user": + result.append(AgUiUserMessage(id=msg_id, role="user", content=content or "")) + elif role == "assistant": + raw_calls = getattr(msg, "tool_calls", None) or [] + agui_tool_calls: list[AgUiToolCall] = [] + for tc in raw_calls: + # Agno stores tool_calls as dicts shaped like the OpenAI API. + tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None) + func = tc.get("function") if isinstance(tc, dict) else getattr(tc, "function", None) + if not tc_id or func is None: + continue + func_name = func.get("name") if isinstance(func, dict) else getattr(func, "name", None) + func_args = func.get("arguments") if isinstance(func, dict) else getattr(func, "arguments", None) + if not isinstance(func_args, str): + func_args = json.dumps(func_args) if func_args is not None else "{}" + agui_tool_calls.append( + AgUiToolCall( + id=tc_id, + type="function", + function=AgUiFunctionCall(name=func_name or "", arguments=func_args), + ) + ) + result.append( + AgUiAssistantMessage( + id=msg_id, + role="assistant", + content=content if isinstance(content, str) else None, + tool_calls=agui_tool_calls or None, + ) + ) + elif role == "tool": + tool_call_id = getattr(msg, "tool_call_id", None) + if not tool_call_id: + continue + result.append( + AgUiToolMessage( + id=msg_id, + role="tool", + tool_call_id=tool_call_id, + content=content if isinstance(content, str) else "", + ) + ) + # system / developer / reasoning → dropped (not meant for the client) + return result + + +# ── AG-UI convenience ─────────────────────────────────────────────────────── + + +async def emit_messages_snapshot( + context: ModuleContext, + messages: list[AgUiMessage], +) -> None: + """Emit an AG-UI ``MessagesSnapshot`` event. + + Typically called just before :func:`emit_awaiting_tool_result` on a + paused run so the front has an authoritative view of the conversation + (including the assistant message carrying the frontend ``tool_calls``, + which cannot be reconstructed from the streamed tool-call events alone). + + Args: + context: Current module context. + messages: List of AG-UI messages, typically produced by + :func:`_agno_messages_to_agui` from ``RunOutput.messages``. + """ + if not messages: + return + + from ag_ui.core.events import MessagesSnapshotEvent as AgUiMessagesSnapshotEvent + + from digitalkin.models.module.ag_ui import ( + AgUiMessagesSnapshotOutput, + AgUiOutput, + ) + + output = AgUiOutput( + root=AgUiMessagesSnapshotOutput( + event=AgUiMessagesSnapshotEvent(messages=messages), + ) + ) + await context.callbacks.send_message(output) + logger.info("emit_messages_snapshot: sent %d message(s)", len(messages)) + + +async def emit_awaiting_tool_result( + context: ModuleContext, + *, + thread_id: str, + run_id: str, + pending_tool_call_ids: list[str], +) -> None: + """Emit an AG-UI ``RunFinished`` with ``status="awaiting_tool_result"``. + + This is the protocol signal telling the front "the run paused on a + client-side tool; execute it and reply with a ``ToolMessage``". It + goes out via ``context.callbacks.send_message`` (bypassing the + standard :class:`~digitalkin.mixins.agui_mixin.AgUiMixin` event + mapping, which has no notion of an "awaiting" status). + + Args: + context: Current module context. + thread_id: AG-UI thread identifier. + run_id: Run identifier to echo back in the finished event. + pending_tool_call_ids: The ``tool_call_id`` values the front must + execute and resolve — echoed in ``result.pending_tool_call_ids`` + so the front can match them. + """ + from ag_ui.core.events import RunFinishedEvent as AgUiRunFinishedEvent + + from digitalkin.models.module.ag_ui import ( + AgUiOutput, + AgUiRunFinishedOutput, + ) + + output = AgUiOutput( + root=AgUiRunFinishedOutput( + event=AgUiRunFinishedEvent( + thread_id=thread_id, + run_id=run_id, + result={ + "status": _AWAITING_STATUS, + "pending_tool_call_ids": pending_tool_call_ids, + }, + ) + ) + ) + await context.callbacks.send_message(output) + logger.info( + "emit_awaiting_tool_result: thread_id=%s pending=%s", + thread_id, + pending_tool_call_ids, + ) + + +# ── Main runner ───────────────────────────────────────────────────────────── + + +class AgnoHitlRunner: + """High-level runner for an Agno agent with AG-UI frontend-tool support. + + Wraps a configured :class:`~agno.agent.Agent` and a + :class:`PausedRunStore`, and exposes three levels of API: + + - :meth:`run` / :meth:`continue_paused_run` — low-level: stream one + Agno run (fresh or resumed) and return a :class:`PauseInfo` if it + paused on an external tool. + - :meth:`try_resume` — inspects an AG-UI input and resumes iff a + matching :class:`~ag_ui.core.types.ToolMessage` is present. + - :meth:`handle_agui_input` — all-in-one: detects resume vs fresh + message, dispatches, and (optionally) emits the awaiting + ``RunFinished`` event on pause. Use this one from a trigger. + """ + + def __init__( + self, + *, + agent: Agent, + storage: StorageStrategy | None = None, + store: PausedRunStore | None = None, + dependency_key: str = "agui_tools", + ) -> None: + """Initialize the runner. + + Args: + agent: The Agno agent. It **must** be built with + ``tools=make_tools_factory(base_tools)`` and + ``cache_callables=False`` — otherwise the frontend tools + injected per-run won't reach the LLM. + storage: Convenience: if provided and ``store`` is not, a + :class:`PausedRunStore` is constructed automatically. + store: Pre-built paused-run store. Wins over ``storage``. + dependency_key: The Agno ``dependencies`` key under which the + runner passes the per-run AG-UI tool list. Must match the + key used by :func:`make_tools_factory`. Defaults to + ``"agui_tools"``. + + Raises: + ValueError: If neither ``storage`` nor ``store`` is provided. + """ + if store is None: + if storage is None: + msg = "AgnoHitlRunner requires either `storage` or `store`." + raise ValueError(msg) + store = PausedRunStore(storage) + self._agent = agent + self._store = store + self._dependency_key = dependency_key + + # ── Low-level: run / resume one Agno run ────────────────────────────── + + async def run( + self, + message: str, + *, + send: Callable[[BaseAgentRunEvent], Coroutine[Any, Any, None]], + thread_id: str, + agui_tools: list[AgUiTool] | None = None, + images: list[Any] | None = None, + ) -> PauseInfo | None: + """Stream a fresh Agno run. + + Args: + message: User prompt to send to the agent. + send: Async callback invoked for each digitalkin event + produced by :class:`AgnoStreamAdapter`. Typically maps + through :meth:`AgUiMixin.send_message`. + thread_id: AG-UI thread identifier (used as the paused-run + storage key if the run pauses). + agui_tools: Frontend tools declared by the AG-UI client for + this run. Merged with the agent's base tools through the + factory; ``None`` or empty is equivalent to "no frontend + tools this turn". + images: Optional multimodal inputs forwarded to Agno. + + Returns: + ``None`` on normal completion. A :class:`PauseInfo` if the run + paused on one or more external tool calls — the caller is + responsible for emitting the awaiting ``RunFinished`` (use + :func:`emit_awaiting_tool_result` or let + :meth:`handle_agui_input` do it). + """ + from agno.run.agent import RunOutput + + logger.info("AgnoHitlRunner.run: starting (thread_id=%s, msg_len=%d)", thread_id, len(message)) + + stream = self._agent.arun( + message, + images=images, + stream=True, + stream_events=True, + yield_run_output=True, + dependencies={self._dependency_key: agui_tools or []}, + ) + return await self._drive(stream=stream, send=send, thread_id=thread_id, run_output_cls=RunOutput) + + async def continue_paused_run( + self, + thread_id: str, + tool_results: dict[str, str], + *, + send: Callable[[BaseAgentRunEvent], Coroutine[Any, Any, None]], + run_id: str | None = None, + agui_tools: list[AgUiTool] | None = None, + ) -> PauseInfo | None: + """Resume a previously paused run. + + Loads the persisted :class:`~agno.run.agent.RunOutput`, injects + the tool results into the matching + :class:`~agno.run.requirement.RunRequirement` entries, and calls + :meth:`~agno.agent.Agent.acontinue_run`. On normal completion the + storage record is removed; on re-pause it is refreshed. + + Args: + thread_id: AG-UI thread identifier (the storage key). + tool_results: Mapping of ``tool_call_id`` → serialized result + (typically a JSON string). Every pending tool must be + resolved — unresolved requirements will stall the run. + send: Digitalkin-event callback (same contract as :meth:`run`). + run_id: AG-UI run identifier for this resume turn. Used to + emit a synthetic ``RUN_STARTED`` before streaming — Agno + emits ``RunContinued`` (not ``RunStarted``) on resume. + agui_tools: Frontend tool definitions for the resumed run. + The AG-UI client should re-send the same list it provided + at the original turn so tool schemas stay registered. + + Returns: + ``None`` on final completion. A fresh :class:`PauseInfo` when + the resumed run paused again (cascading frontend tools). If + no paused record exists for ``thread_id``, returns ``None``. + """ + from agno.run.agent import RunOutput + + record = await self._store.load(thread_id) + if record is None: + logger.warning("continue_paused_run: no paused record for thread_id=%s", thread_id) + return None + + run_output = RunOutput.from_dict(record.payload) + logger.info( + "continue_paused_run: resuming thread_id=%s run_id=%s with %d result(s)", + thread_id, + record.run_id, + len(tool_results), + ) + + # AG-UI contract: every run must start with a RUN_STARTED event. Agno + # emits RunContinued (not RunStarted) on acontinue_run, and the adapter + # has no handler for it, so without this the front rejects with "First + # event must be RUN_STARTED". + from digitalkin.models.events import AgentRunEvent, RunStartedEvent + + await send( + RunStartedEvent( + event=AgentRunEvent.RUN_STARTED, + run_id=run_id, + thread_id=thread_id, + timestamp=None, + metadata=None, + ) + ) + + # Agno's _acontinue_run takes its tool state from `run_response.tools` + # when a `run_response` is provided — the `updated_tools` / + # `requirements` kwargs are only applied on the `run_id` code path + # (agno/agent/_run.py:3618-3665). After a RunOutput round-trip through + # to_dict/from_dict, `run_output.tools[i]` and `run_output.requirements + # [i].tool_execution` are DIFFERENT ToolExecution instances, so using + # set_external_execution_result on requirements mutates the wrong + # objects. Fix: write results directly onto run_output.tools. + for tool in run_output.tools or []: + tid = getattr(tool, "tool_call_id", None) + if tid and tid in tool_results: + tool.result = tool_results[tid] + + # Keep requirements in sync for completeness (Agno doesn't read them + # on the run_response path, but consistency is good for debugging). + for req in run_output.requirements or []: + tool_exec = req.tool_execution + if ( + tool_exec is not None + and getattr(req, "needs_external_execution", False) + and tool_exec.tool_call_id in tool_results + ): + req.set_external_execution_result(tool_results[tool_exec.tool_call_id]) + + stream = self._agent.acontinue_run( + run_response=run_output, + stream=True, + stream_events=True, + yield_run_output=True, + dependencies={self._dependency_key: agui_tools or []}, + ) + pause_info = await self._drive(stream=stream, send=send, thread_id=thread_id, run_output_cls=RunOutput) + + if pause_info is None: + await self._store.delete(thread_id) + logger.info("continue_paused_run: thread_id=%s completed, record cleared", thread_id) + return pause_info + + # ── Mid-level: resume detection ─────────────────────────────────────── + + async def try_resume( + self, + input_data: Any, + *, + send: Callable[[BaseAgentRunEvent], Coroutine[Any, Any, None]], + ) -> tuple[bool, PauseInfo | None]: + """Try to resume a paused run from an AG-UI input. + + The ``input_data`` only needs to duck-type ``thread_id``, + ``messages``, and ``tools`` (typically an ``AgUiStreamInput``). + This method: + + 1. Loads the paused record for ``input_data.thread_id``. Returns + ``(False, None)`` if there is none. + 2. Looks for ``ToolMessage`` entries in ``input_data.messages`` + whose ``tool_call_id`` matches a pending one. + 3. If any match → dispatches :meth:`continue_paused_run` and + returns ``(True, pause_info_or_none)``. + 4. If no match but the last message is a fresh ``UserMessage``, + drops the stale record (HITL abandon) and returns + ``(False, None)``. + + Returns: + ``(resumed, pause_info)``: + + - ``(False, None)``: no resume, caller should run the + fresh-message path. + - ``(True, None)``: resume ran to normal completion. + - ``(True, PauseInfo)``: resume paused again (cascading tools). + """ + from ag_ui.core.types import ToolMessage, UserMessage + + thread_id = getattr(input_data, "thread_id", None) + if not thread_id: + return False, None + + record = await self._store.load(thread_id) + if record is None: + return False, None + + messages = getattr(input_data, "messages", None) or [] + pending = set(record.pending_tool_call_ids) + + tool_results: dict[str, str] = {} + for msg in messages: + if isinstance(msg, ToolMessage) and msg.tool_call_id in pending: + tool_results[msg.tool_call_id] = msg.content + + if not tool_results: + last = messages[-1] if messages else None + if isinstance(last, UserMessage): + logger.info( + "try_resume: dropping stale paused record for thread_id=%s (new user message)", + thread_id, + ) + await self._store.delete(thread_id) + return False, None + + # Partial resolution guard: Agno's acontinue_run expects ALL external + # tool calls to have `.result` set. If we only received some, the + # resume would raise "Tool X requires external execution, cannot + # continue run". Emit a clear RUN_ERROR and keep the record so the + # client can retry with all results in a single request. + missing = pending - set(tool_results.keys()) + if missing: + from digitalkin.models.events import AgentRunEvent, RunErrorEvent, RunStartedEvent + + input_run_id = getattr(input_data, "run_id", None) + logger.warning( + "try_resume: partial tool results for thread_id=%s — resolved %d/%d, missing=%s", + thread_id, + len(tool_results), + len(pending), + sorted(missing), + ) + await send( + RunStartedEvent( + event=AgentRunEvent.RUN_STARTED, + run_id=input_run_id, + thread_id=thread_id, + timestamp=None, + metadata=None, + ) + ) + await send( + RunErrorEvent( + event=AgentRunEvent.RUN_ERROR, + error_type="partial_tool_results", + content=( + f"Partial tool results: {len(tool_results)}/{len(pending)} " + f"resolved, missing {sorted(missing)}. The agent is paused on " + f"{len(pending)} frontend tool call(s); all pending ToolMessages " + "must be provided in a single RunAgentInput for the run to " + "resume. The paused state has been preserved — retry once all " + "tool results are available." + ), + error_details=None, + timestamp=None, + metadata=None, + ) + ) + return True, None + + logger.info( + "try_resume: resuming thread_id=%s with %d tool result(s)", + thread_id, + len(tool_results), + ) + pause_info = await self.continue_paused_run( + thread_id=thread_id, + tool_results=tool_results, + send=send, + run_id=getattr(input_data, "run_id", None), + agui_tools=getattr(input_data, "tools", None), + ) + return True, pause_info + + # ── High-level: trigger entry point ────────────────────────────────── + + async def handle_agui_input( + self, + input_data: Any, + *, + send: Callable[[BaseAgentRunEvent], Coroutine[Any, Any, None]], + context: ModuleContext | None = None, + message: str | None = None, + images: list[Any] | None = None, + ) -> PauseInfo | None: + """One-shot dispatch of an AG-UI ``RunAgentInput``. + + Handles the three cases in order: + + 1. Resume a paused run if the input carries a matching + ``ToolMessage`` (see :meth:`try_resume`). + 2. Drop a stale paused record if the input is a new + ``UserMessage`` while a tool was pending (HITL abandon). + 3. Fresh run on the last ``UserMessage`` in ``input_data.messages`` + (or on the explicit ``message`` argument). + + When a run pauses (fresh or resumed) and ``context`` is provided, + this method also emits the AG-UI ``RunFinished`` with + ``status="awaiting_tool_result"`` via + :func:`emit_awaiting_tool_result`. Pass ``context=None`` if you + want to emit it yourself. + + Args: + input_data: Any object with ``thread_id``, ``messages``, and + ``tools`` attributes (typically an ``AgUiStreamInput``). + send: Digitalkin-event callback (e.g. wrapping + ``self.send_message(context, event)`` in a trigger). + context: If provided, the awaiting ``RunFinished`` is emitted + automatically on pause. + message: Override the user prompt extraction. Normally left + as ``None`` — the runner picks the last ``UserMessage`` + content from ``input_data.messages``. + images: Optional multimodal inputs forwarded to Agno. + + Returns: + ``None`` on normal completion (or when no actionable input + was found). A :class:`PauseInfo` on pause (already emitted to + the front if ``context`` was provided). + """ + from ag_ui.core.types import UserMessage + + # 1. Resume path + resumed, pause_info = await self.try_resume(input_data=input_data, send=send) + if resumed: + if pause_info is not None and context is not None: + await emit_awaiting_tool_result( + context, + thread_id=pause_info.thread_id, + run_id=pause_info.run_id, + pending_tool_call_ids=pause_info.pending_tool_call_ids, + ) + return pause_info + + # 2. Fresh run path + if message is None: + messages = getattr(input_data, "messages", None) or [] + user_messages = [m for m in messages if isinstance(m, UserMessage)] + if not user_messages: + logger.warning("handle_agui_input: no user message in input, nothing to do") + return None + content = user_messages[-1].content + if isinstance(content, list): + content = " ".join(getattr(p, "text", "") for p in content if hasattr(p, "text")) + message = content + + pause_info = await self.run( + message=message, + send=send, + thread_id=getattr(input_data, "thread_id", ""), + agui_tools=getattr(input_data, "tools", None), + images=images, + ) + if pause_info is not None and context is not None: + await emit_awaiting_tool_result( + context, + thread_id=pause_info.thread_id, + run_id=pause_info.run_id, + pending_tool_call_ids=pause_info.pending_tool_call_ids, + ) + return pause_info + + # ── Internals ───────────────────────────────────────────────────────── + + async def _drive( + self, + *, + stream: Any, + send: Callable[[BaseAgentRunEvent], Coroutine[Any, Any, None]], + thread_id: str, + run_output_cls: type, + ) -> PauseInfo | None: + """Drain an Agno stream, forward events, detect pause, persist. + + Uses :class:`AgnoStreamAdapter` for event translation. The adapter + already synthesizes tool-call events on ``run_paused`` (so the + front sees the frontend tool call) and sets ``adapter.is_paused`` + — we just need to capture the final :class:`RunOutput` + (from ``yield_run_output=True``) and hand it to the store. + + Returns: + :class:`PauseInfo` when the stream paused, ``None`` otherwise. + """ + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + final_run_output: RunOutput | None = None + + async for raw_event in stream: + if isinstance(raw_event, run_output_cls): + final_run_output = raw_event + continue + for event in adapter.to_digitalkin_events(raw_event): + await send(event) + + for event in adapter.flush(): + await send(event) + + if adapter.is_paused and final_run_output is not None and getattr(final_run_output, "is_paused", False): + pause_info = await self._store.save(run_output=final_run_output, thread_id=thread_id) + # Attach the AG-UI-shaped messages that Agno built during this run + # (notably the assistant message carrying `tool_calls`). The stream + # events alone don't give the front a way to materialise this + # message, so `handle_agui_input` will push it as a MessagesSnapshot. + pause_info.new_messages = _agno_messages_to_agui(final_run_output.messages or []) + return pause_info + + return None diff --git a/src/digitalkin/grpc_servers/_base_server.py b/src/digitalkin/grpc_servers/_base_server.py index 2028adfe..78e01709 100644 --- a/src/digitalkin/grpc_servers/_base_server.py +++ b/src/digitalkin/grpc_servers/_base_server.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence from concurrent import futures from pathlib import Path -from typing import Any, cast +from typing import Any, ClassVar, cast import grpc from grpc import aio as grpc_aio @@ -20,8 +20,9 @@ ) from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper from digitalkin.logger import logger -from digitalkin.models.grpc_servers.models import SecurityMode, ServerConfig, ServerMode from digitalkin.models.grpc_servers.types import GrpcServer, ServiceDescriptor, T +from digitalkin.models.settings.server.server import ServerSettings +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode class BaseServer(abc.ABC): @@ -32,25 +33,23 @@ class BaseServer(abc.ABC): communication modes. Attributes: - config: The server configuration. server: The gRPC server instance (either sync or async). _servicers: List of registered servicers. _service_names: List of service names for reflection. _health_servicer: Optional health check servicer. """ + _server_settings: ClassVar[ServerSettings] = ServerSettings() + def __init__( self, - config: ServerConfig, interceptors: Sequence[Any] | None = None, ) -> None: """Initialize the base gRPC server. Args: - config: The server configuration. interceptors: Optional sequence of gRPC server interceptors. """ - self.config = config self.server: GrpcServer | None = None self._servicers: list[Any] = [] self._service_names: list[str] = [] # Track service names for reflection @@ -119,7 +118,7 @@ def _add_reflection(self) -> None: Raises: ReflectionError: If reflection initialization fails. """ - if not self.config.enable_reflection or self.server is None or not self._service_names: + if not self._server_settings.reflection or self.server is None or not self._service_names: return try: @@ -194,17 +193,17 @@ def _add_health_service(self) -> None: logger.warning("Failed to enable health service: %s", e) def _create_server(self) -> GrpcServer: - """Create a gRPC server instance based on the configuration. + """Create a gRPC server instance based on the server settings. Returns: A configured gRPC server instance. Raises: - ConfigurationError: If the server configuration is invalid. + ConfigurationError: If the server settings are invalid. """ try: # Create the server based on mode - grpc_compression = self.config.compression.to_grpc() + grpc_compression = self._server_settings.grpc.compression.to_grpc() # Machine capabilities try: @@ -215,36 +214,36 @@ def _create_server(self) -> GrpcServer: logger.info("CPU count: %d", cpu_count) # Compute defaults from machine capabilities, overridable via env vars - max_concurrent_rpcs = int(os.environ.get("DIGITALKIN_MAX_CONCURRENT_RPCS", str(cpu_count * 200))) - thread_pool_workers = int(os.environ.get("DIGITALKIN_THREAD_POOL_WORKERS", str(min(4, cpu_count)))) logger.info( - "gRPC server config: cpus=%d, max_concurrent_rpcs=%d, thread_pool_workers=%d, mode=%s", + "gRPC server settings.server: cpus=%d, max_concurrent_rpcs=%d, thread_pool_workers=%d, mode=%s", cpu_count, - max_concurrent_rpcs, - thread_pool_workers, - self.config.mode.value, + self._server_settings.max_concurrent_rpcs, + self._server_settings.thread_pool_workers, + self._server_settings.channel.communication_mode.value, ) - if self.config.mode == ServerMode.ASYNC: + if self._server_settings.channel.communication_mode == ControlFlow.ASYNC: server = grpc_aio.server( - options=self.config.server_options, + options=self._server_settings.grpc.options, compression=grpc_compression, interceptors=self._interceptors or None, - maximum_concurrent_rpcs=max_concurrent_rpcs, - migration_thread_pool=futures.ThreadPoolExecutor(max_workers=thread_pool_workers), + maximum_concurrent_rpcs=self._server_settings.max_concurrent_rpcs, + migration_thread_pool=futures.ThreadPoolExecutor( + max_workers=self._server_settings.thread_pool_workers + ), ) else: server = grpc.server( # type: ignore[assignment] # sync grpc.Server assigned to GrpcServer union - futures.ThreadPoolExecutor(max_workers=self.config.max_workers), - options=self.config.server_options, + futures.ThreadPoolExecutor(max_workers=self._server_settings.max_workers), + options=self._server_settings.grpc.options, compression=grpc_compression, interceptors=self._interceptors or None, - maximum_concurrent_rpcs=max_concurrent_rpcs, + maximum_concurrent_rpcs=self._server_settings.max_concurrent_rpcs, ) # Add the appropriate port - if self.config.security == SecurityMode.SECURE: + if self._server_settings.channel.security == SecurityMode.SECURE: self._add_secure_port(server) else: self._add_insecure_port(server) @@ -264,19 +263,26 @@ def _add_secure_port(self, server: GrpcServer) -> None: Raises: SecurityError: If credentials are not configured correctly. """ - if not self.config.credentials: + if not self._server_settings.channel.credentials: msg = "Credentials must be provided for secure server" raise SecurityError(msg) try: # Read key and certificate files - private_key = Path(self.config.credentials.server_key_path).read_bytes() - certificate_chain = Path(self.config.credentials.server_cert_path).read_bytes() + if ( + self._server_settings.channel.credentials.key_path + and self._server_settings.channel.credentials.cert_path + ): + private_key = Path(self._server_settings.channel.credentials.key_path).read_bytes() + certificate_chain = Path(self._server_settings.channel.credentials.cert_path).read_bytes() + else: + msg = "Key path and certificate path must be provided for secure server" + raise SecurityError(msg) # Read root certificate if provided root_certificates = None - if self.config.credentials.root_cert_path: - root_certificates = Path(self.config.credentials.root_cert_path).read_bytes() + if self._server_settings.channel.credentials.root_cert_path: + root_certificates = Path(self._server_settings.channel.credentials.root_cert_path).read_bytes() except OSError as e: msg = f"Failed to read credential files: {e}" raise SecurityError(msg) from e @@ -290,16 +296,16 @@ def _add_secure_port(self, server: GrpcServer) -> None: ) # Add secure port to server - if self.config.mode == ServerMode.ASYNC: + if self._server_settings.channel.communication_mode == ControlFlow.ASYNC: async_server = cast("grpc_aio.Server", server) - async_server.add_secure_port(self.config.address, server_credentials) + async_server.add_secure_port(self._server_settings.channel.address, server_credentials) else: sync_server = cast("grpc.Server", server) - sync_server.add_secure_port(self.config.address, server_credentials) + sync_server.add_secure_port(self._server_settings.channel.address, server_credentials) - logger.debug("Added secure port %s", self.config.address) + logger.debug("Added secure port %s", self._server_settings.channel.address) except Exception as e: - msg = f"Failed to configure secure port: {e}" + msg = f"Failed to configure with actual settings secure port: {e}" raise SecurityError(msg) from e def _add_insecure_port(self, server: GrpcServer) -> None: @@ -312,14 +318,14 @@ def _add_insecure_port(self, server: GrpcServer) -> None: ConfigurationError: If adding the insecure port fails. """ try: - if self.config.mode == ServerMode.ASYNC: + if self._server_settings.channel.communication_mode == ControlFlow.ASYNC: async_server = cast("grpc_aio.Server", server) - async_server.add_insecure_port(self.config.address) + async_server.add_insecure_port(self._server_settings.channel.address) else: sync_server = cast("grpc.Server", server) - sync_server.add_insecure_port(self.config.address) + sync_server.add_insecure_port(self._server_settings.channel.address) - logger.debug("Added insecure port %s", self.config.address) + logger.debug("Added insecure port %s", self._server_settings.channel.address) except Exception as e: msg = f"Failed to add insecure port: {e}" raise ConfigurationError(msg) from e @@ -343,9 +349,11 @@ def start(self) -> None: self._add_reflection() # Start the server - logger.debug("Starting gRPC server on %s", self.config.address, extra={"config": self.config}) + logger.debug( + "Starting gRPC server on %s", self._server_settings.channel.address, extra={"config": ServerSettings} + ) try: - if self.config.mode == ServerMode.ASYNC: + if self._server_settings.channel.communication_mode == ControlFlow.ASYNC: # For async server, use the event loop loop = asyncio.get_event_loop() if loop.is_closed(): @@ -356,7 +364,7 @@ def start(self) -> None: # For sync server, directly call start sync_server = cast("grpc.Server", self.server) sync_server.start() - logger.debug("✅ gRPC server started on %s", self.config.address) + logger.debug("✅ gRPC server started on %s", self._server_settings.channel.address) except Exception as e: logger.exception("❎ Error starting server") msg = f"Failed to start server: {e}" @@ -393,15 +401,15 @@ async def start_async(self) -> None: self._add_reflection() # Start the server - logger.debug("Starting gRPC server on %s", self.config.address) + logger.debug("Starting gRPC server on %s", self._server_settings.channel.address) try: - if self.config.mode == ServerMode.ASYNC: + if self._server_settings.channel.communication_mode == ControlFlow.ASYNC: await self._start_async() else: # For sync server in async context sync_server = cast("grpc.Server", self.server) sync_server.start() - logger.debug("✅ gRPC server started on %s", self.config.address) + logger.debug("✅ gRPC server started on %s", self._server_settings.channel.address) except Exception as e: logger.exception("❎ Error starting server") msg = f"Failed to start server: {e}" @@ -431,7 +439,7 @@ def stop(self, grace: float | None = None) -> None: return logger.debug("Stopping gRPC server...") - if self.config.mode == ServerMode.ASYNC: + if self._server_settings.channel.communication_mode == ControlFlow.ASYNC: # We'll use a different approach that works whether we're in a running event loop or not try: # Get the current event loop @@ -507,7 +515,7 @@ async def stop_async(self, grace: float | None = None) -> None: return logger.debug("Stopping gRPC server asynchronously...") - if self.config.mode == ServerMode.ASYNC: + if self._server_settings.channel.communication_mode == ControlFlow.ASYNC: await self._stop_async(grace) else: # For sync server, we can just call stop @@ -533,7 +541,7 @@ def wait_for_termination(self) -> None: logger.warning("Attempted to wait for termination, but no server is running") return - if self.config.mode == ServerMode.SYNC: + if self._server_settings.channel.communication_mode == ControlFlow.SYNC: # For sync server sync_server = cast("grpc.Server", self.server) sync_server.wait_for_termination() @@ -548,7 +556,7 @@ async def await_termination(self) -> None: This method should only be used with async servers. """ - if self.config.mode == ServerMode.SYNC: + if self._server_settings.channel.communication_mode == ControlFlow.SYNC: logger.warning( "Called await_termination on sync server. Use wait_for_termination instead for sync servers.", ) diff --git a/src/digitalkin/grpc_servers/module_server.py b/src/digitalkin/grpc_servers/module_server.py index 4579fc84..f474f584 100644 --- a/src/digitalkin/grpc_servers/module_server.py +++ b/src/digitalkin/grpc_servers/module_server.py @@ -13,7 +13,6 @@ from digitalkin.logger import logger from digitalkin.models.grpc_servers.models import ( ClientConfig, - ModuleServerConfig, ) from digitalkin.modules._base_module import BaseModule from digitalkin.services.registry import GrpcRegistry @@ -38,7 +37,6 @@ class ModuleServer(BaseServer): def __init__( self, module_class: type[BaseModule], - server_config: ModuleServerConfig, client_config: ClientConfig | None = None, interceptors: Sequence[Any] | None = None, ) -> None: @@ -46,13 +44,11 @@ def __init__( Args: module_class: The module instance to be served. - server_config: Server configuration. client_config: Client configuration used by services and registry connection. interceptors: Optional sequence of gRPC server interceptors. """ - super().__init__(server_config, interceptors=interceptors) + super().__init__(interceptors=interceptors) self.module_class = module_class - self.server_config = server_config self.client_config = client_config self.module_servicer: ModuleServicer | None = None self.registry: RegistryStrategy | None = None @@ -108,7 +104,7 @@ def start(self) -> None: """Start the module server and register with the registry if configured.""" import asyncio - logger.info("Starting module server", extra={"server_config": self.server_config}) + logger.info("Starting module server", extra={"server_config": self._server_settings}) super().start() try: @@ -119,7 +115,7 @@ def start(self) -> None: async def start_async(self) -> None: """Start the module server and register with the registry if configured.""" - logger.info("Starting module server", extra={"server_config": self.server_config}) + logger.info("Starting module server", extra={"server_config": self._server_settings}) await super().start_async() # module_servicer is now set by _register_servicers() during super().start_async() @@ -176,7 +172,13 @@ async def stop_async(self, grace: float | None = None) -> None: await super().stop_async(grace) async def _register_with_registry(self) -> None: - """Register this module with the registry server.""" + """Register this module with the registry server. + + Probes the services-provider channel for readiness (1s max) before + attempting registration. When the provider is unreachable the module + still starts — it just won't be discoverable until the next restart + or a manual re-registration. + """ if not self.registry: logger.debug("No registry configured, skipping registration") return @@ -191,14 +193,27 @@ async def _register_with_registry(self) -> None: ) return - advertise_address = self.server_config.advertise_host or self.server_config.host + advertise_address = self._server_settings.channel.advertise_host or self._server_settings.channel.host + + # Fast connectivity probe — detect DOWN in ≤1 s + if not await self.registry.wait_for_ready(timeout=1.0): + logger.error( + "Services provider is DOWN — channel not ready after 1 s, " + "skipping registration (module will start without registry)", + extra={ + "module_id": module_id, + "address": advertise_address, + "port": self._server_settings.channel.port, + }, + ) + return logger.info( "Attempting to register module with registry", extra={ "module_id": module_id, "address": advertise_address, - "port": self.server_config.port, + "port": self._server_settings.channel.port, "version": version, }, ) @@ -206,7 +221,7 @@ async def _register_with_registry(self) -> None: result = await self.registry.register( module_id=module_id, address=advertise_address, - port=self.server_config.port, + port=self._server_settings.channel.port, version=version, ) @@ -216,7 +231,7 @@ async def _register_with_registry(self) -> None: extra={ "module_id": result.module_id, "address": advertise_address, - "port": self.server_config.port, + "port": self._server_settings.channel.port, }, ) else: diff --git a/src/digitalkin/grpc_servers/utils/grpc_client_wrapper.py b/src/digitalkin/grpc_servers/utils/grpc_client_wrapper.py index 2e3f5ff9..cfc1e0e6 100644 --- a/src/digitalkin/grpc_servers/utils/grpc_client_wrapper.py +++ b/src/digitalkin/grpc_servers/utils/grpc_client_wrapper.py @@ -11,7 +11,8 @@ from digitalkin.grpc_servers.utils.exceptions import ServerError from digitalkin.logger import logger -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.settings.utils.channel import SecurityMode class GrpcClientWrapper: @@ -32,6 +33,15 @@ class GrpcClientWrapper: _channel_cache: ClassVar[dict[str, grpc.aio.Channel]] = {} _ref_counts: ClassVar[dict[str, int]] = {} + _RETRYABLE_CODES: ClassVar[set[grpc.StatusCode]] = { + grpc.StatusCode.UNAVAILABLE, + grpc.StatusCode.INTERNAL, + grpc.StatusCode.DEADLINE_EXCEEDED, + } + _QUERY_MAX_RETRIES: ClassVar[int] = int(os.environ.get("DIGITALKIN_GRPC_QUERY_MAX_RETRIES", "2")) + _QUERY_BACKOFF_BASE_MS: ClassVar[float] = float(os.environ.get("DIGITALKIN_GRPC_QUERY_BACKOFF_BASE_MS", "50")) + _QUERY_DEFAULT_TIMEOUT: ClassVar[float] = float(os.environ.get("DIGITALKIN_GRPC_QUERY_TIMEOUT", "30")) + @staticmethod def _build_channel_credentials(config: ClientConfig) -> grpc.ChannelCredentials | None: """Build SSL channel credentials from config if secure mode. @@ -140,13 +150,26 @@ async def close_all_cached_channels(cls) -> None: cls._channel_cache.clear() cls._ref_counts.clear() - _RETRYABLE_CODES: ClassVar[set[grpc.StatusCode]] = { - grpc.StatusCode.UNAVAILABLE, - grpc.StatusCode.INTERNAL, - grpc.StatusCode.DEADLINE_EXCEEDED, - } - _QUERY_MAX_RETRIES: ClassVar[int] = int(os.environ.get("DIGITALKIN_GRPC_QUERY_MAX_RETRIES", "2")) - _QUERY_BACKOFF_BASE_MS: ClassVar[float] = float(os.environ.get("DIGITALKIN_GRPC_QUERY_BACKOFF_BASE_MS", "50")) + async def wait_for_ready(self, timeout: float = 1.0) -> bool: + """Check if the gRPC channel can connect within timeout. + + Uses channel_ready() which resolves when the HTTP/2 connection is + established and the server is accepting RPCs. + + Args: + timeout: Max seconds to wait for connectivity. + + Returns: + True if channel reached READY state, False if timeout or no channel. + """ + if self._channel is None: + return False + try: + await asyncio.wait_for(self._channel.channel_ready(), timeout=timeout) + except asyncio.TimeoutError: + return False + else: + return True async def exec_grpc_query( self, @@ -163,7 +186,8 @@ async def exec_grpc_query( Arguments: query_endpoint: rpc query name (e.g., "GetSetup", "CreateSetupVersion") request: gRPC protobuf request object - timeout: Optional per-call timeout in seconds (passed to gRPC stub call) + timeout: Per-call timeout in seconds. Falls back to _QUERY_DEFAULT_TIMEOUT + (env DIGITALKIN_GRPC_QUERY_TIMEOUT, default 30s) when None. Returns: gRPC protobuf response object. @@ -171,6 +195,7 @@ async def exec_grpc_query( Raises: ServerError: gRPC error with status code and details for caller to handle. """ + effective_timeout = timeout if timeout is not None else self._QUERY_DEFAULT_TIMEOUT max_retries = self._QUERY_MAX_RETRIES backoff_delays = tuple(self._QUERY_BACKOFF_BASE_MS / 1000 * (2**i) for i in range(max_retries)) last_error: grpc.RpcError | None = None @@ -181,7 +206,7 @@ async def exec_grpc_query( try: # getattr unavoidable: gRPC stubs expose RPC methods as dynamic attributes - response = await getattr(self.stub, query_endpoint)(request, timeout=timeout) + response = await getattr(self.stub, query_endpoint)(request, timeout=effective_timeout) except grpc.RpcError as e: last_error = e if e.code() not in self._RETRYABLE_CODES or attempt == max_retries: diff --git a/src/digitalkin/mixins/__init__.py b/src/digitalkin/mixins/__init__.py index 61ec0083..d0d73c6f 100644 --- a/src/digitalkin/mixins/__init__.py +++ b/src/digitalkin/mixins/__init__.py @@ -1,19 +1,17 @@ """Mixin definitions.""" +from digitalkin.mixins.agui_mixin import AgUiMixin from digitalkin.mixins.base_mixin import BaseMixin -from digitalkin.mixins.callback_mixin import UserMessageMixin -from digitalkin.mixins.chat_history_mixin import ChatHistoryMixin from digitalkin.mixins.cost_mixin import CostMixin from digitalkin.mixins.filesystem_mixin import FilesystemMixin from digitalkin.mixins.logger_mixin import LoggerMixin from digitalkin.mixins.storage_mixin import StorageMixin __all__ = [ + "AgUiMixin", "BaseMixin", - "ChatHistoryMixin", "CostMixin", "FilesystemMixin", "LoggerMixin", "StorageMixin", - "UserMessageMixin", ] diff --git a/src/digitalkin/mixins/agui_mixin.py b/src/digitalkin/mixins/agui_mixin.py new file mode 100644 index 00000000..2fa160c1 --- /dev/null +++ b/src/digitalkin/mixins/agui_mixin.py @@ -0,0 +1,467 @@ +"""AG-UI event streaming mixin for DigitalKin modules. + +This mixin provides utilities to convert framework-agnostic agent events +into AG-UI protocol events and send them through the module context callbacks. + +The mixin is a stateless emitter: it receives events with all necessary info +(including IDs) and emits the corresponding AG-UI protocol events. +All state management (ID generation, lifecycle tracking) belongs in the adapter layer. +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, ClassVar + +from digitalkin.models.events import ( + AgentRunEvent, + BaseAgentRunEvent, + CustomEvent, + ReasoningCompletedEvent, + ReasoningContentDeltaEvent, + ReasoningStartedEvent, + ReasoningStepEvent, + RunCompletedEvent, + RunContentEvent, + RunErrorEvent, + RunStartedEvent, + TextMessageCompletedEvent, + TextMessageStartedEvent, + ToolCallCompletedEvent, + ToolCallErrorEvent, + ToolCallStartedEvent, +) + +if TYPE_CHECKING: + from digitalkin.models.module.ag_ui import AgUiEventOutput + from digitalkin.models.module.module_context import ModuleContext + + +class AgUiMixin: + """Mixin for converting agent events to AG-UI protocol and sending them. + + This mixin is a stateless emitter: each handler reads IDs from the event + and emits the corresponding AG-UI event(s). The adapter is responsible for + generating IDs and managing event lifecycle (start/complete sequences). + + Usage:: + + class MyTrigger(BaseTrigger, AgUiMixin): + async def execute(self, context, input_data): + async for event in agent.run(input_data.message, stream=True): + await self.agui_send_message(context, event) + """ + + def __init__(self) -> None: + """Initialize AG-UI mixin.""" + super().__init__() + self._thread_id: str = "" + self._run_id: str = "" + + async def _send_agui( # noqa: PLR6301 + self, + context: ModuleContext, + output: AgUiEventOutput, + ) -> None: + from digitalkin.models.module.ag_ui import AgUiOutput # pylint: disable=C0415 + + await context.callbacks.send_message(AgUiOutput(root=output)) + + async def send_message( + self, + context: ModuleContext, + event: BaseAgentRunEvent, + ) -> None: + """Convert agent event to AG-UI protocol and send via context callbacks. + + Args: + context: Module context containing the callbacks strategy. + event: Agent run event to process and convert. + """ + context.callbacks.logger.debug( + "AG-UI event: %s thread_id=%s run_id=%s", + event.event, + self._thread_id, + self._run_id, + extra=context.session.current_ids(), + ) + + handler_name = self._AGUI_HANDLER_MAP.get(event.event) + if handler_name: + await getattr(self, handler_name)(context, event) + + _AGUI_HANDLER_MAP: ClassVar[dict[str, str]] = { + AgentRunEvent.RUN_STARTED: "_handle_run_started", + AgentRunEvent.TEXT_MESSAGE_STARTED: "_handle_text_message_started", + AgentRunEvent.RUN_CONTENT: "_handle_run_content", + AgentRunEvent.TEXT_MESSAGE_COMPLETED: "_handle_text_message_completed", + AgentRunEvent.RUN_COMPLETED: "_handle_run_completed", + AgentRunEvent.RUN_ERROR: "_handle_run_error", + AgentRunEvent.TOOL_CALL_STARTED: "_handle_tool_call_started", + AgentRunEvent.TOOL_CALL_COMPLETED: "_handle_tool_call_completed", + AgentRunEvent.TOOL_CALL_ERROR: "_handle_tool_call_error", + AgentRunEvent.REASONING_STARTED: "_handle_reasoning_started", + AgentRunEvent.REASONING_CONTENT_DELTA: "_handle_reasoning_delta", + AgentRunEvent.REASONING_STEP: "_handle_reasoning_step", + AgentRunEvent.REASONING_COMPLETED: "_handle_reasoning_completed", + AgentRunEvent.CUSTOM: "_handle_custom", + } + + # ── Private Event Handlers ─────────────────────────────────────────────── + + async def _handle_run_started( + self, + context: ModuleContext, + event: RunStartedEvent, + ) -> None: + """Handle run started event - emit AG-UI RunStarted.""" + from ag_ui.core.events import RunStartedEvent as AgUiRunStartedEvent # pylint: disable=C0415 + + from digitalkin.models.module.ag_ui import AgUiRunStartedOutput # pylint: disable=C0415 + + if not self._run_id: + self._run_id = event.run_id or str(uuid.uuid4()) + if not self._thread_id: + self._thread_id = event.thread_id or str(uuid.uuid4()) + + context.callbacks.logger.info( + "[agui-mixin] RUN_STARTED thread_id=%s run_id=%s event_run_id=%s event_thread_id=%s metadata=%s", + self._thread_id, + self._run_id, + event.run_id, + event.thread_id, + event.metadata, + extra=context.session.current_ids(), + ) + + output = AgUiRunStartedOutput( + event=AgUiRunStartedEvent( + thread_id=self._thread_id, + run_id=self._run_id, + ) + ) + await self._send_agui(context, output) + + async def _handle_text_message_started( + self, + context: ModuleContext, + event: TextMessageStartedEvent, + ) -> None: + """Handle text message started event - emit AG-UI TextMessageStart.""" + from ag_ui.core.events import ( # pylint: disable=C0415 + TextMessageStartEvent as AgUiTextMessageStartEvent, + ) + + from digitalkin.models.module.ag_ui import AgUiTextMessageStartOutput # pylint: disable=C0415 + + output = AgUiTextMessageStartOutput( + event=AgUiTextMessageStartEvent( + message_id=event.message_id, + role="assistant", + ) + ) + await self._send_agui(context, output) + + async def _handle_run_content( + self, + context: ModuleContext, + event: RunContentEvent, + ) -> None: + """Handle run content event - emit AG-UI TextMessageContent.""" + from ag_ui.core.events import ( # pylint: disable=C0415 + TextMessageContentEvent as AgUiTextMessageContentEvent, + ) + + from digitalkin.models.module.ag_ui import AgUiTextMessageContentOutput # pylint: disable=C0415 + + content = event.content + if not content: + return + + message_id = event.message_id or "" + + output = AgUiTextMessageContentOutput( + event=AgUiTextMessageContentEvent( + message_id=message_id, + delta=content, + ) + ) + await self._send_agui(context, output) + + async def _handle_text_message_completed( + self, + context: ModuleContext, + event: TextMessageCompletedEvent, + ) -> None: + """Handle text message completed event - emit AG-UI TextMessageEnd.""" + from ag_ui.core.events import TextMessageEndEvent as AgUiTextMessageEndEvent # pylint: disable=C0415 + + from digitalkin.models.module.ag_ui import AgUiTextMessageEndOutput # pylint: disable=C0415 + + output = AgUiTextMessageEndOutput( + event=AgUiTextMessageEndEvent(message_id=event.message_id), + ) + await self._send_agui(context, output) + + async def _handle_run_completed( + self, + context: ModuleContext, + event: RunCompletedEvent, + ) -> None: + """Handle run completed event - emit AG-UI RunFinished.""" + from ag_ui.core.events import RunFinishedEvent as AgUiRunFinishedEvent # pylint: disable=C0415 + + from digitalkin.models.module.ag_ui import AgUiRunFinishedOutput # pylint: disable=C0415 + + run_id = self._run_id or event.run_id or str(uuid.uuid4()) + context.callbacks.logger.info( + "[agui-mixin] RUN_FINISHED thread_id=%s event_run_id=%s self._run_id=%s resolved=%s metadata=%s", + self._thread_id, + event.run_id, + self._run_id, + run_id, + event.metadata, + extra=context.session.current_ids(), + ) + output = AgUiRunFinishedOutput( + event=AgUiRunFinishedEvent( + thread_id=self._thread_id, + run_id=run_id, + ) + ) + await self._send_agui(context, output) + + async def _handle_run_error( + self, + context: ModuleContext, + event: RunErrorEvent, + ) -> None: + """Handle run error event - emit AG-UI RunError.""" + from ag_ui.core.events import RunErrorEvent as AgUiRunErrorEvent # pylint: disable=C0415 + + from digitalkin.models.module.ag_ui import AgUiRunErrorOutput # pylint: disable=C0415 + + error_msg = event.content or "Agent run failed" + output = AgUiRunErrorOutput( + event=AgUiRunErrorEvent( + message=error_msg, + code=event.error_type, + ) + ) + await self._send_agui(context, output) + + async def _handle_tool_call_started( + self, + context: ModuleContext, + event: ToolCallStartedEvent, + ) -> None: + """Handle tool call started event - emit AG-UI ToolCallStart.""" + import json # pylint: disable=C0415 + + from ag_ui.core.events import ToolCallArgsEvent as AgUiToolCallArgsEvent # pylint: disable=C0415 + from ag_ui.core.events import ToolCallStartEvent as AgUiToolCallStartEvent # pylint: disable=C0415 + + from digitalkin.models.module.ag_ui import ( # pylint: disable=C0415 + AgUiToolCallArgsOutput, + AgUiToolCallStartOutput, + ) + + tool = event.tool + if not tool or not tool.tool_name: + return + + tool_call_id = tool.tool_call_id or str(uuid.uuid4()) + + start_output = AgUiToolCallStartOutput( + event=AgUiToolCallStartEvent( + tool_call_id=tool_call_id, + tool_call_name=tool.tool_name, + ) + ) + await self._send_agui(context, start_output) + + if tool.tool_args: + args_str = json.dumps(tool.tool_args) if isinstance(tool.tool_args, dict) else str(tool.tool_args) + args_output = AgUiToolCallArgsOutput( + event=AgUiToolCallArgsEvent( + tool_call_id=tool_call_id, + delta=args_str, + ) + ) + await self._send_agui(context, args_output) + + async def _handle_tool_call_completed( + self, + context: ModuleContext, + event: ToolCallCompletedEvent, + ) -> None: + """Handle tool call completed event - emit AG-UI ToolCallEnd and ToolCallResult.""" + from ag_ui.core.events import ToolCallEndEvent as AgUiToolCallEndEvent # pylint: disable=C0415 + from ag_ui.core.events import ToolCallResultEvent as AgUiToolCallResultEvent # pylint: disable=C0415 + + from digitalkin.models.module.ag_ui import ( # pylint: disable=C0415 + AgUiToolCallEndOutput, + AgUiToolCallResultOutput, + ) + + tool = event.tool + if not tool: + return + + tool_call_id = tool.tool_call_id or str(uuid.uuid4()) + + end_output = AgUiToolCallEndOutput(event=AgUiToolCallEndEvent(tool_call_id=tool_call_id)) + await self._send_agui(context, end_output) + + result_content = tool.result or str(event.content or "") + if result_content: + result_msg_id = str(uuid.uuid4()) + result_output = AgUiToolCallResultOutput( + event=AgUiToolCallResultEvent( + message_id=result_msg_id, + tool_call_id=tool_call_id, + content=result_content, + role="tool", + ) + ) + await self._send_agui(context, result_output) + + async def _handle_tool_call_error( + self, + context: ModuleContext, + event: ToolCallErrorEvent, + ) -> None: + """Handle tool call error event - emit AG-UI ToolCallEnd.""" + from ag_ui.core.events import ToolCallEndEvent as AgUiToolCallEndEvent # pylint: disable=C0415 + + from digitalkin.models.module.ag_ui import AgUiToolCallEndOutput # pylint: disable=C0415 + + tool = event.tool + if not tool: + return + + tool_call_id = tool.tool_call_id or str(uuid.uuid4()) + output = AgUiToolCallEndOutput(event=AgUiToolCallEndEvent(tool_call_id=tool_call_id)) + await self._send_agui(context, output) + + async def _handle_reasoning_started( + self, + context: ModuleContext, + event: ReasoningStartedEvent, + ) -> None: + """Handle reasoning started event - emit AG-UI ReasoningStart + ReasoningMessageStart.""" + from ag_ui.core.events import ( # pylint: disable=import-outside-toplevel + ReasoningMessageStartEvent as AgUiReasoningMessageStartEvent, + ) + from ag_ui.core.events import ( # pylint: disable=import-outside-toplevel + ReasoningStartEvent as AgUiReasoningStartEvent, + ) + + from digitalkin.models.module.ag_ui import ( # pylint: disable=C0415 + AgUiReasoningMessageStartOutput, + AgUiReasoningStartOutput, + ) + + reasoning_id = event.reasoning_id or str(uuid.uuid4()) + + start_output = AgUiReasoningStartOutput( + event=AgUiReasoningStartEvent(message_id=reasoning_id), + ) + await self._send_agui(context, start_output) + + message_start_output = AgUiReasoningMessageStartOutput( + event=AgUiReasoningMessageStartEvent(message_id=reasoning_id, role="reasoning") + ) + await self._send_agui(context, message_start_output) + + async def _handle_reasoning_delta( + self, + context: ModuleContext, + event: ReasoningContentDeltaEvent, + ) -> None: + """Handle reasoning content delta event - emit AG-UI ReasoningMessageContent.""" + from ag_ui.core.events import ( # pylint: disable=import-outside-toplevel + ReasoningMessageContentEvent as AgUiReasoningMessageContentEvent, + ) + + from digitalkin.models.module.ag_ui import AgUiReasoningMessageContentOutput # pylint: disable=C0415 + + delta = event.delta + if not delta: + return + + reasoning_id = event.reasoning_id or "" + + output = AgUiReasoningMessageContentOutput( + event=AgUiReasoningMessageContentEvent(message_id=reasoning_id, delta=delta) + ) + await self._send_agui(context, output) + + async def _handle_reasoning_step( + self, + context: ModuleContext, + event: ReasoningStepEvent, + ) -> None: + """Handle reasoning step event - emit AG-UI ReasoningMessageContent.""" + from ag_ui.core.events import ( # pylint: disable=import-outside-toplevel + ReasoningMessageContentEvent as AgUiReasoningMessageContentEvent, + ) + + from digitalkin.models.module.ag_ui import AgUiReasoningMessageContentOutput # pylint: disable=C0415 + + delta = event.delta + if not delta: + return + + reasoning_id = event.reasoning_id or "" + + output = AgUiReasoningMessageContentOutput( + event=AgUiReasoningMessageContentEvent(message_id=reasoning_id, delta=delta) + ) + await self._send_agui(context, output) + + async def _handle_reasoning_completed( + self, + context: ModuleContext, + event: ReasoningCompletedEvent, + ) -> None: + """Handle reasoning completed event - emit AG-UI ReasoningMessageEnd + ReasoningEnd.""" + from ag_ui.core.events import ( # pylint: disable=import-outside-toplevel + ReasoningEndEvent as AgUiReasoningEndEvent, + ) + from ag_ui.core.events import ( # pylint: disable=import-outside-toplevel + ReasoningMessageEndEvent as AgUiReasoningMessageEndEvent, + ) + + from digitalkin.models.module.ag_ui import ( # pylint: disable=C0415 + AgUiReasoningEndOutput, + AgUiReasoningMessageEndOutput, + ) + + reasoning_id = event.reasoning_id or "" + + message_end_output = AgUiReasoningMessageEndOutput(event=AgUiReasoningMessageEndEvent(message_id=reasoning_id)) + await self._send_agui(context, message_end_output) + + end_output = AgUiReasoningEndOutput( + event=AgUiReasoningEndEvent(message_id=reasoning_id), + ) + await self._send_agui(context, end_output) + + async def _handle_custom( + self, + context: ModuleContext, + event: CustomEvent, + ) -> None: + """Handle custom event - emit AG-UI CustomEvent.""" + from ag_ui.core.events import CustomEvent as AgUiCustomEvent # pylint: disable=C0415 + + from digitalkin.models.module.ag_ui import AgUiCustomEventOutput # pylint: disable=C0415 + + output = AgUiCustomEventOutput( + event=AgUiCustomEvent( + name=event.name, + value=event.value, + ) + ) + await self._send_agui(context, output) diff --git a/src/digitalkin/mixins/base_mixin.py b/src/digitalkin/mixins/base_mixin.py index 3a3114c1..a71f1153 100644 --- a/src/digitalkin/mixins/base_mixin.py +++ b/src/digitalkin/mixins/base_mixin.py @@ -1,10 +1,10 @@ """Simple toolkit class with basic and simple API access in the Triggers.""" -from digitalkin.mixins.chat_history_mixin import ChatHistoryMixin +from digitalkin.mixins.agui_mixin import AgUiMixin from digitalkin.mixins.cost_mixin import CostMixin from digitalkin.mixins.file_history_mixin import FileHistoryMixin from digitalkin.mixins.logger_mixin import LoggerMixin -class BaseMixin(CostMixin, ChatHistoryMixin, FileHistoryMixin, LoggerMixin): +class BaseMixin(CostMixin, AgUiMixin, FileHistoryMixin, LoggerMixin): """Base Mixin to access to minimum Module Context functionnalities in the Triggers.""" diff --git a/src/digitalkin/mixins/callback_mixin.py b/src/digitalkin/mixins/callback_mixin.py index 8b7b7102..7730de9b 100644 --- a/src/digitalkin/mixins/callback_mixin.py +++ b/src/digitalkin/mixins/callback_mixin.py @@ -1,18 +1,32 @@ -"""User callback to send a message from the Trigger.""" +"""User callback to send a message from the Trigger. -from typing import Generic +.. deprecated:: + Use :class:`digitalkin.mixins.agui_mixin.AgUiMixin` instead. +""" + +import warnings +from typing import Any, Generic from digitalkin.models.module.module_context import ModuleContext from digitalkin.models.module.module_types import OutputModelT class UserMessageMixin(Generic[OutputModelT]): - """Mixin providing callback operations through the callbacks . + """Mixin providing callback operations through the callbacks. - This mixin wraps callback strategy calls to provide a cleaner API - for direct messaging in trigger handlers. + .. deprecated:: + Use :class:`digitalkin.mixins.agui_mixin.AgUiMixin` instead. """ + def __init_subclass__(cls, **kwargs: Any) -> None: + """Deprecated warning.""" + super().__init_subclass__(**kwargs) + warnings.warn( + f"{cls.__name__} inherits from UserMessageMixin which is deprecated. Use AgUiMixin.send_message instead.", + DeprecationWarning, + stacklevel=2, + ) + @staticmethod async def send_message(context: ModuleContext, output: OutputModelT) -> None: """Send a message using the callbacks strategy. diff --git a/src/digitalkin/mixins/chat_history_mixin.py b/src/digitalkin/mixins/chat_history_mixin.py index fbc41a5f..456fc5d4 100644 --- a/src/digitalkin/mixins/chat_history_mixin.py +++ b/src/digitalkin/mixins/chat_history_mixin.py @@ -1,11 +1,12 @@ """Context mixins providing ergonomic access to service strategies. -This module provides mixins that wrap service strategy calls with cleaner APIs, -following Django/FastAPI patterns where context is passed explicitly to each method. +.. deprecated:: + Use :class:`digitalkin.mixins.agui_mixin.AgUiMixin` instead. """ import asyncio import os +import warnings from typing import Any, Generic from digitalkin.logger import logger @@ -20,14 +21,19 @@ class ChatHistoryMixin(UserMessageMixin, StorageMixin, LoggerMixin, Generic[InputModelT, OutputModelT]): """Mixin providing chat history operations through storage strategy. - Chat histories are cached in memory after first load to avoid redundant - gRPC reads. Known-persisted keys use update_storage (1 call) instead of - upsert_storage (2 calls). - - Writes are batched: messages accumulate in the cache and are flushed when - the batch threshold is reached or flush_chat_history() is called. + .. deprecated:: + Use :class:`digitalkin.mixins.agui_mixin.AgUiMixin` instead. """ + def __init_subclass__(cls, **kwargs: Any) -> None: + """Deprecated warning.""" + super().__init_subclass__(**kwargs) + warnings.warn( + f"{cls.__name__} inherits from ChatHistoryMixin which is deprecated. Use AgUiMixin.send_message instead.", + DeprecationWarning, + stacklevel=2, + ) + CHAT_HISTORY_COLLECTION = "chat_history" CHAT_HISTORY_RECORD_ID = "full_chat_history" diff --git a/src/digitalkin/models/__init__.py b/src/digitalkin/models/__init__.py index 00234421..cd97d74e 100644 --- a/src/digitalkin/models/__init__.py +++ b/src/digitalkin/models/__init__.py @@ -1,8 +1,40 @@ """This package contains the models for DigitalKin.""" +from digitalkin.models.events import ( + AgentRunEvent, + BaseAgentRunEvent, + ReasoningCompletedEvent, + ReasoningContentDeltaEvent, + ReasoningStartedEvent, + ReasoningStepEvent, + RunCompletedEvent, + RunContentEvent, + RunErrorEvent, + RunStartedEvent, + ToolCallCompletedEvent, + ToolCallErrorEvent, + ToolCallStartedEvent, + ToolInfo, +) from digitalkin.models.module.module import Module, ModuleStatus __all__ = [ + # Agent events + "AgentRunEvent", + "BaseAgentRunEvent", + # Module "Module", "ModuleStatus", + "ReasoningCompletedEvent", + "ReasoningContentDeltaEvent", + "ReasoningStartedEvent", + "ReasoningStepEvent", + "RunCompletedEvent", + "RunContentEvent", + "RunErrorEvent", + "RunStartedEvent", + "ToolCallCompletedEvent", + "ToolCallErrorEvent", + "ToolCallStartedEvent", + "ToolInfo", ] diff --git a/src/digitalkin/models/events/README.md b/src/digitalkin/models/events/README.md new file mode 100644 index 00000000..0ef757ec --- /dev/null +++ b/src/digitalkin/models/events/README.md @@ -0,0 +1,141 @@ +# DigitalKin Agent Events + +Ce module définit des modèles d'événements Pydantic **autonomes et framework-agnostiques** pour les exécutions d'agents IA. + +## Motivation + +Avant cette architecture, DigitalKin dépendait directement d'Agno pour les événements d'agent. Cette dépendance créait un couplage fort qui : +- Rendait difficile l'utilisation d'autres frameworks (LangChain, AutoGen, etc.) +- Liait l'évolution de DigitalKin à celle d'Agno +- Compliquait les tests et la maintenance + +## Architecture + +### Modèles d'événements (`agent_events.py`) + +Les événements sont organisés hiérarchiquement : + +``` +BaseAgentRunEvent (base) +├── RunStartedEvent +├── RunContentEvent +├── RunCompletedEvent +├── RunErrorEvent +├── ReasoningContentDeltaEvent +├── ToolCallStartedEvent +├── ToolCallCompletedEvent +└── ToolCallErrorEvent +``` + +Tous les événements partagent : +- `event`: Type d'événement (enum `AgentRunEvent`) +- `timestamp`: Horodatage optionnel +- `metadata`: Métadonnées additionnelles optionnelles + +### Types d'événements + +#### Lifecycle Events +- **RunStartedEvent**: Début d'une exécution d'agent +- **RunCompletedEvent**: Fin réussie d'une exécution +- **RunErrorEvent**: Erreur durant l'exécution + +#### Content Events +- **RunContentEvent**: Contenu produit par l'agent (texte, reasoning) + +#### Reasoning Events +- **ReasoningContentDeltaEvent**: Contenu de raisonnement en streaming + +#### Tool Call Events +- **ToolCallStartedEvent**: Début d'un appel d'outil +- **ToolCallCompletedEvent**: Fin réussie d'un appel d'outil +- **ToolCallErrorEvent**: Erreur durant l'appel d'outil + +## Utilisation + +### 1. Dans un adaptateur de framework (ex: Agno) + +```python +from digitalkin.models.events import RunStartedEvent, RunContentEvent +from agno.run.agent import BaseAgentRunEvent as AgnoEvent + +def agno_to_digitalkin_event(agno_event: AgnoEvent) -> BaseAgentRunEvent: + """Convertit un événement Agno en événement DigitalKin.""" + if agno_event.event == RunEvent.run_started: + return RunStartedEvent( + run_id=agno_event.run_id, + thread_id=agno_event.thread_id, + ) + elif agno_event.event == RunEvent.run_content: + return RunContentEvent( + content=str(agno_event.content), + reasoning_content=agno_event.reasoning_content, + ) + # ... +``` + +### 2. Dans un trigger avec AgUiMixin + +```python +from digitalkin.mixins import AgUiMixin +from template_archetype.agents.agno_adapter import agno_to_digitalkin_event + +class MessageTrigger(BaseTrigger, AgUiMixin): + async def execute(self, context, input_data): + # Stream events from Agno agent + async for agno_event in agent.arun(message, stream=True): + # Convert Agno event to DigitalKin event + dk_event = agno_to_digitalkin_event(agno_event) + + # Send via AgUiMixin (converts to AG-UI protocol) + await self.send_message(context, dk_event) +``` + +## Avantages + +### 🔓 Découplage +DigitalKin ne dépend plus d'Agno ou de tout autre framework spécifique. + +### 🔌 Extensibilité +Support facile de nouveaux frameworks via des adaptateurs : +- Agno → DigitalKin (implémenté) +- LangChain → DigitalKin (à implémenter) +- Custom Agent → DigitalKin (à implémenter) + +### 🧪 Testabilité +Les événements peuvent être créés et testés indépendamment. + +### 🎯 Clarté +Interface claire et documentée pour les événements d'agent. + +## Extension + +Pour ajouter un nouveau type d'événement : + +1. **Ajouter l'enum** dans `AgentRunEvent` : +```python +class AgentRunEvent(str, Enum): + # ... + NEW_EVENT_TYPE = "new_event_type" +``` + +2. **Créer le modèle** dans `agent_events.py` : +```python +class NewEventTypeEvent(BaseAgentRunEvent): + event: AgentRunEvent = Field(AgentRunEvent.NEW_EVENT_TYPE) + custom_field: str = Field(..., description="...") +``` + +3. **Exporter** dans `__init__.py` + +4. **Adapter dans AgUiMixin** (si nécessaire pour AG-UI) + +## Roadmap + +- [x] **Implémenter les événements de reasoning complets** ✅ + - `ReasoningStartedEvent` + - `ReasoningContentDeltaEvent` + - `ReasoningStepEvent` + - `ReasoningCompletedEvent` +- [ ] Ajouter les événements de step/stage +- [ ] Créer des adaptateurs pour LangChain, AutoGen +- [ ] Ajouter des événements de métriques (latence, tokens) diff --git a/src/digitalkin/models/events/__init__.py b/src/digitalkin/models/events/__init__.py new file mode 100644 index 00000000..416e6f26 --- /dev/null +++ b/src/digitalkin/models/events/__init__.py @@ -0,0 +1,45 @@ +"""Agent run event models for DigitalKin. + +This module provides framework-agnostic event models for agent runs. +These models can be used as a common interface across different AI frameworks. +""" + +from digitalkin.models.events.agent_events import ( + AgentRunEvent, + BaseAgentRunEvent, + CustomEvent, + ReasoningCompletedEvent, + ReasoningContentDeltaEvent, + ReasoningStartedEvent, + ReasoningStepEvent, + RunCompletedEvent, + RunContentEvent, + RunErrorEvent, + RunStartedEvent, + TextMessageCompletedEvent, + TextMessageStartedEvent, + ToolCallCompletedEvent, + ToolCallErrorEvent, + ToolCallStartedEvent, + ToolInfo, +) + +__all__ = [ + "AgentRunEvent", + "BaseAgentRunEvent", + "CustomEvent", + "ReasoningCompletedEvent", + "ReasoningContentDeltaEvent", + "ReasoningStartedEvent", + "ReasoningStepEvent", + "RunCompletedEvent", + "RunContentEvent", + "RunErrorEvent", + "RunStartedEvent", + "TextMessageCompletedEvent", + "TextMessageStartedEvent", + "ToolCallCompletedEvent", + "ToolCallErrorEvent", + "ToolCallStartedEvent", + "ToolInfo", +] diff --git a/src/digitalkin/models/events/agent_events.py b/src/digitalkin/models/events/agent_events.py new file mode 100644 index 00000000..e87af5cf --- /dev/null +++ b/src/digitalkin/models/events/agent_events.py @@ -0,0 +1,173 @@ +"""Framework-agnostic agent run event models. + +These models define a common interface for agent execution events that can be +used across different AI frameworks (Agno, LangChain, custom agents, etc.). +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class AgentRunEvent(str, Enum): + """Agent run event types.""" + + RUN_STARTED = "run_started" + RUN_CONTENT = "run_content" + RUN_COMPLETED = "run_completed" + RUN_ERROR = "run_error" + + REASONING_STARTED = "reasoning_started" + REASONING_CONTENT_DELTA = "reasoning_content_delta" + REASONING_STEP = "reasoning_step" + REASONING_COMPLETED = "reasoning_completed" + + TEXT_MESSAGE_STARTED = "text_message_started" + TEXT_MESSAGE_COMPLETED = "text_message_completed" + + TOOL_CALL_STARTED = "tool_call_started" + TOOL_CALL_COMPLETED = "tool_call_completed" + TOOL_CALL_ERROR = "tool_call_error" + + CUSTOM = "custom" + + +class BaseAgentRunEvent(BaseModel): + """Base class for all agent run events.""" + + event: AgentRunEvent = Field(..., description="Type of the event") + timestamp: float | None = Field(None, description="Event timestamp (Unix time)") + metadata: dict[str, Any] | None = Field(None, description="Additional event metadata") + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + + +class RunStartedEvent(BaseAgentRunEvent): + """Event emitted when an agent run starts.""" + + event: AgentRunEvent = Field(AgentRunEvent.RUN_STARTED, description="Event type") + run_id: str | None = Field(None, description="Unique identifier for this run") + thread_id: str | None = Field(None, description="Thread/conversation identifier") + + +class TextMessageStartedEvent(BaseAgentRunEvent): + """Event emitted when a new text message sequence begins.""" + + event: AgentRunEvent = Field(AgentRunEvent.TEXT_MESSAGE_STARTED, description="Event type") + message_id: str = Field(..., description="Unique ID for this text message") + + +class TextMessageCompletedEvent(BaseAgentRunEvent): + """Event emitted when a text message sequence ends.""" + + event: AgentRunEvent = Field(AgentRunEvent.TEXT_MESSAGE_COMPLETED, description="Event type") + message_id: str = Field(..., description="ID of the text message being closed") + + +class RunContentEvent(BaseAgentRunEvent): + """Event emitted when the agent produces content (text, reasoning, etc.).""" + + event: AgentRunEvent = Field(AgentRunEvent.RUN_CONTENT, description="Event type") + content: str | None = Field(None, description="Text content produced by the agent") + reasoning_content: str | None = Field(None, description="Reasoning content (if extended thinking is enabled)") + content_type: str | None = Field(None, description="Type of content (text, json, etc.)") + message_id: str | None = Field(None, description="ID of the parent text message") + + +class RunCompletedEvent(BaseAgentRunEvent): + """Event emitted when an agent run completes successfully.""" + + event: AgentRunEvent = Field(AgentRunEvent.RUN_COMPLETED, description="Event type") + run_id: str | None = Field(None, description="Unique identifier for this run") + final_content: str | None = Field(None, description="Final accumulated content") + usage: dict[str, Any] | None = Field(None, description="Token usage statistics") + message_id: str | None = Field(None, description="ID of the text message to close, if any") + + +class RunErrorEvent(BaseAgentRunEvent): + """Event emitted when an agent run encounters an error.""" + + event: AgentRunEvent = Field(AgentRunEvent.RUN_ERROR, description="Event type") + error_type: str | None = Field(None, description="Type/category of error") + content: str | None = Field(None, description="Error message") + error_details: dict[str, Any] | None = Field(None, description="Additional error details") + + +class ReasoningStartedEvent(BaseAgentRunEvent): + """Event emitted when a reasoning phase starts.""" + + event: AgentRunEvent = Field(AgentRunEvent.REASONING_STARTED, description="Event type") + reasoning_id: str | None = Field(None, description="Unique ID for this reasoning phase") + + +class ReasoningContentDeltaEvent(BaseAgentRunEvent): + """Event emitted during extended thinking/reasoning phases.""" + + event: AgentRunEvent = Field(AgentRunEvent.REASONING_CONTENT_DELTA, description="Event type") + delta: str = Field(..., description="Delta of reasoning content") + reasoning_id: str | None = Field(None, description="ID of the parent reasoning phase") + + +class ReasoningStepEvent(BaseAgentRunEvent): + """Event emitted for intermediate reasoning steps.""" + + event: AgentRunEvent = Field(AgentRunEvent.REASONING_STEP, description="Event type") + delta: str = Field(..., description="Reasoning step content") + reasoning_id: str | None = Field(None, description="ID of the parent reasoning phase") + + +class ReasoningCompletedEvent(BaseAgentRunEvent): + """Event emitted when a reasoning phase completes.""" + + event: AgentRunEvent = Field(AgentRunEvent.REASONING_COMPLETED, description="Event type") + reasoning_id: str | None = Field(None, description="ID of the reasoning phase being closed") + + +class ToolInfo(BaseModel): + """Information about a tool call.""" + + tool_call_id: str | None = Field(None, description="Unique identifier for this tool call") + tool_name: str | None = Field(None, description="Name of the tool being called") + tool_args: dict[str, Any] | str | None = Field(None, description="Arguments passed to the tool") + result: str | None = Field(None, description="Result returned by the tool") + + +class ToolCallStartedEvent(BaseAgentRunEvent): + """Event emitted when a tool call starts.""" + + event: AgentRunEvent = Field(AgentRunEvent.TOOL_CALL_STARTED, description="Event type") + tool: ToolInfo | None = Field(None, description="Tool information") + + +class ToolCallCompletedEvent(BaseAgentRunEvent): + """Event emitted when a tool call completes successfully.""" + + event: AgentRunEvent = Field(AgentRunEvent.TOOL_CALL_COMPLETED, description="Event type") + tool: ToolInfo | None = Field(None, description="Tool information including result") + content: str | None = Field(None, description="Tool execution result content") + + +class ToolCallErrorEvent(BaseAgentRunEvent): + """Event emitted when a tool call encounters an error.""" + + event: AgentRunEvent = Field(AgentRunEvent.TOOL_CALL_ERROR, description="Event type") + tool: ToolInfo | None = Field(None, description="Tool information") + error_message: str | None = Field(None, description="Error message") + + +class CustomEvent(BaseAgentRunEvent): + """Event emitted for application-defined custom events. + + Carries an application-specific ``name`` that discriminates the custom + event subtype and a free-form ``value`` payload for metadata transfer. + """ + + event: AgentRunEvent = Field(AgentRunEvent.CUSTOM, description="Event type") + name: str = Field(..., description="Application-defined event name (discriminator)") + value: Any = Field(..., description="Application-defined payload") diff --git a/src/digitalkin/models/grpc_servers/models.py b/src/digitalkin/models/grpc_servers/models.py index 51e472b2..2f33ee04 100644 --- a/src/digitalkin/models/grpc_servers/models.py +++ b/src/digitalkin/models/grpc_servers/models.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field, ValidationInfo, field_validator from digitalkin.grpc_servers.utils.exceptions import ConfigurationError, SecurityError +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode class GrpcCompression(str, Enum): @@ -39,61 +40,6 @@ def to_grpc(self) -> grpc.Compression: return grpc.Compression.Deflate -class ServerMode(str, Enum): - """Enum for server operation mode.""" - - SYNC = "sync" - ASYNC = "async" - - -class SecurityMode(str, Enum): - """Enum for server security mode.""" - - SECURE = "secure" - INSECURE = "insecure" - - -class ServerCredentials(BaseModel): - """Model for server credentials in secure mode. - - Attributes: - server_key_path: Path to the server private key - server_cert_path: Path to the server certificate - root_cert_path: Optional path to the root certificate - """ - - server_key_path: Path = Field(..., description="Path to the server private key") - server_cert_path: Path = Field(..., description="Path to the server certificate") - root_cert_path: Path | None = Field(None, description="Path to the root certificate") - - # Enable __slots__ for memory efficiency - model_config = { - "extra": "forbid", - "arbitrary_types_allowed": True, - "validate_assignment": True, - "frozen": True, - } - - @field_validator("server_key_path", "server_cert_path", "root_cert_path") - @classmethod - def check_path_exists(cls, v: Path | None) -> Path | None: - """Validate that the file path exists. - - Args: - v: Path to validate - - Returns: - The validated path - - Raises: - SecurityError: If the path does not exist - """ - if v is not None and not v.exists(): - msg = f"File not found: {v}" - raise SecurityError(msg) - return v - - class RetryPolicy(BaseModel): """gRPC retry policy configuration for resilient connections. @@ -202,7 +148,7 @@ class ChannelConfig(BaseModel): description="Host address to bind the client to", ) # Bind to all interfaces by design port: int = Field(50051, description="Port to listen on") - mode: ServerMode = Field(ServerMode.SYNC, description="Client operation mode (sync/async)") + mode: ControlFlow = Field(ControlFlow.SYNC, description="Client operation mode (sync/async)") security: SecurityMode = Field(SecurityMode.INSECURE, description="Security mode (secure/insecure)") # Enable __slots__ for memory efficiency @@ -315,101 +261,3 @@ def grpc_options(self) -> list[tuple[str, Any]]: Full list of gRPC channel options. """ return [*self.channel_options, ("grpc.service_config", self.retry_policy.to_service_config_json())] - - -class ServerConfig(ChannelConfig): - """Base configuration for gRPC servers. - - Attributes: - host: Host address to bind the server to - port: Port to listen on - max_workers: Maximum number of workers for sync mode - mode: Server operation mode (sync/async) - security: Security mode (secure/insecure) - credentials: Server credentials for secure mode - server_options: Additional server options - enable_reflection: Enable reflection for the server - compression: gRPC compression algorithm for server-level compression - """ - - max_workers: int = Field(10, description="Maximum number of workers for sync mode") - credentials: ServerCredentials | None = Field(None, description="Server credentials for secure mode") - compression: GrpcCompression = Field(GrpcCompression.GZIP, description="gRPC compression algorithm") - server_options: list[tuple[str, Any]] = Field( - default_factory=lambda: [ - ("grpc.max_receive_message_length", 100 * 1024 * 1024), - ("grpc.max_send_message_length", 100 * 1024 * 1024), - # === Server-Side Keepalive (Keeps Connections Alive Through Proxies) === - # Server sends keepalive pings to detect dead clients and keep - # proxy connections (e.g. Railway) alive during long-running RPCs. - ( - "grpc.keepalive_time_ms", - int(os.environ.get("DIGITALKIN_GRPC_SERVER_KEEPALIVE_TIME_MS", "120000")), - ), - ( - "grpc.keepalive_timeout_ms", - int(os.environ.get("DIGITALKIN_GRPC_SERVER_KEEPALIVE_TIMEOUT_MS", "20000")), - ), - # === Keepalive Permission (Required for Client Keepalive) === - # Allow clients to send keepalive pings without active RPCs - # Without this, server rejects client keepalives with GOAWAY - ("grpc.keepalive_permit_without_calls", True), - # Allow unlimited pings without data (required for long-running streams) - ("grpc.http2.max_pings_without_data", 0), - # Minimum interval server allows between client pings - # Prevents "too_many_pings" GOAWAY errors - # Must match or be less than client's http2.min_time_between_pings_ms - ( - "grpc.http2.min_ping_interval_without_data_ms", - int(os.environ.get("DIGITALKIN_GRPC_SERVER_MIN_PING_INTERVAL_MS", "10000")), - ), - ], - description="gRPC server options with keepalive support", - ) - enable_reflection: bool = Field(default=True, description="Enable reflection for the server") - enable_health_check: bool = Field(default=True, description="Enable health check service") - - @field_validator("credentials") - @classmethod - def validate_credentials(cls, v: ServerCredentials | None, info: ValidationInfo) -> ServerCredentials | None: - """Validate that credentials are provided when in secure mode. - - Args: - v: The credentials value - info: ValidationInfo containing other field values - - Returns: - The validated credentials - - Raises: - ConfigurationError: If credentials are missing in secure mode - """ - # Access security mode from the info.data dictionary - security = info.data.get("security") - - if security == SecurityMode.SECURE and v is None: - msg = "Credentials must be provided when using secure mode" - raise ConfigurationError(msg) - return v - - -class ModuleServerConfig(ServerConfig): - """Configuration for Module gRPC server. - - Attributes: - advertise_host: Public hostname/IP sent to registry for discovery. Falls back to host if not set. - """ - - advertise_host: str | None = Field( - None, description="Public hostname/IP sent to registry for discovery. Falls back to host if not set." - ) - - -class RegistryServerConfig(ServerConfig): - """Configuration for Registry gRPC server. - - Attributes: - database_url: Database URL for registry data storage - """ - - database_url: str | None = Field(None, description="Database URL for registry data storage") diff --git a/src/digitalkin/models/module/__init__.py b/src/digitalkin/models/module/__init__.py index 6c90e398..ef7f9e84 100644 --- a/src/digitalkin/models/module/__init__.py +++ b/src/digitalkin/models/module/__init__.py @@ -1,5 +1,8 @@ """This module contains the models for the modules.""" +# Import module_types first to avoid circular import with ag_ui +# Note: AgUiEventOutput and AgUiOutput are not imported here to avoid circular imports. +# Import them directly from digitalkin.models.module.ag_ui if needed. from digitalkin.models.module.module_context import ModuleContext from digitalkin.models.module.module_types import ( DataModel, @@ -26,6 +29,8 @@ ) __all__ = [ + # Note: AgUiEventOutput and AgUiOutput removed to avoid circular imports + # Import them directly from digitalkin.models.module.ag_ui if needed "DataModel", "DataTrigger", "EndOfStreamOutput", diff --git a/src/digitalkin/models/module/ag_ui.py b/src/digitalkin/models/module/ag_ui.py new file mode 100644 index 00000000..df903626 --- /dev/null +++ b/src/digitalkin/models/module/ag_ui.py @@ -0,0 +1,377 @@ +# pylint: disable=C0301 +"""Output model for the Template module.""" + +from typing import Annotated, Literal, TypeAlias + +from ag_ui.core.events import ( + ActivityDeltaEvent, + ActivitySnapshotEvent, + CustomEvent, + MessagesSnapshotEvent, + RawEvent, + ReasoningEncryptedValueEvent, + ReasoningEndEvent, + ReasoningMessageChunkEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + RunErrorEvent, + RunFinishedEvent, + RunStartedEvent, + StateDeltaEvent, + StateSnapshotEvent, + StepFinishedEvent, + StepStartedEvent, + TextMessageChunkEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ThinkingEndEvent, + ThinkingStartEvent, + ThinkingTextMessageContentEvent, + ThinkingTextMessageEndEvent, + ThinkingTextMessageStartEvent, + ToolCallArgsEvent, + ToolCallChunkEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) +from pydantic import ConfigDict, Field +from pydantic.alias_generators import to_camel + +from digitalkin.models.module.module_types import DataModel, DataTrigger + +# ── AG-UI base class with camelCase aliases ────────────────────────────────── + + +class AgUiDataTrigger(DataTrigger): + """DataTrigger subclass that serializes wrapper fields as camelCase. + + AG-UI events must be serialized with camelCase field names. Since the SDK + calls ``model_dump(mode="json")`` without ``by_alias=True``, we define + camelCase aliases here and activate them via ``TemplateOutput.model_dump``. + """ + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +# ── AG-UI text message event outputs ───────────────────────────────────────── + + +class AgUiTextMessageStartOutput(AgUiDataTrigger): + """AG-UI TextMessageStart event - signals start of a text message.""" + + protocol: Literal["agui_text_message_start"] = "agui_text_message_start" # type: ignore[misc] + event: TextMessageStartEvent = Field(..., description="AG-UI TextMessageStart event payload") + + +class AgUiTextMessageContentOutput(AgUiDataTrigger): + """AG-UI TextMessageContent event - carries a text delta chunk.""" + + protocol: Literal["agui_text_message_content"] = "agui_text_message_content" # type: ignore[misc] + event: TextMessageContentEvent = Field(..., description="AG-UI TextMessageContent event payload") + + +class AgUiTextMessageEndOutput(AgUiDataTrigger): + """AG-UI TextMessageEnd event - signals end of a text message.""" + + protocol: Literal["agui_text_message_end"] = "agui_text_message_end" # type: ignore[misc] + event: TextMessageEndEvent = Field(..., description="AG-UI TextMessageEnd event payload") + + +class AgUiTextMessageChunkOutput(AgUiDataTrigger): + """AG-UI TextMessageChunk event - aggregated text message chunk.""" + + protocol: Literal["agui_text_message_chunk"] = "agui_text_message_chunk" # type: ignore[misc] + event: TextMessageChunkEvent = Field(..., description="AG-UI TextMessageChunk event payload") + + +# ── AG-UI thinking text message event outputs ───────────────────────────────── + + +class AgUiThinkingTextMessageStartOutput(AgUiDataTrigger): + """AG-UI ThinkingTextMessageStart event - signals start of internal thinking.""" + + protocol: Literal["agui_thinking_text_message_start"] = "agui_thinking_text_message_start" # type: ignore[misc] + event: ThinkingTextMessageStartEvent = Field(..., description="AG-UI ThinkingTextMessageStart event payload") + + +class AgUiThinkingTextMessageContentOutput(AgUiDataTrigger): + """AG-UI ThinkingTextMessageContent event - carries a thinking text delta chunk.""" + + protocol: Literal["agui_thinking_text_message_content"] = "agui_thinking_text_message_content" # type: ignore[misc] + event: ThinkingTextMessageContentEvent = Field(..., description="AG-UI ThinkingTextMessageContent event payload") + + +class AgUiThinkingTextMessageEndOutput(AgUiDataTrigger): + """AG-UI ThinkingTextMessageEnd event - signals end of internal thinking.""" + + protocol: Literal["agui_thinking_text_message_end"] = "agui_thinking_text_message_end" # type: ignore[misc] + event: ThinkingTextMessageEndEvent = Field(..., description="AG-UI ThinkingTextMessageEnd event payload") + + +# ── AG-UI tool call event outputs ───────────────────────────────────────────── + + +class AgUiToolCallStartOutput(AgUiDataTrigger): + """AG-UI ToolCallStart event - signals start of a tool invocation.""" + + protocol: Literal["agui_tool_call_start"] = "agui_tool_call_start" # type: ignore[misc] + event: ToolCallStartEvent = Field(..., description="AG-UI ToolCallStart event payload") + + +class AgUiToolCallArgsOutput(AgUiDataTrigger): + """AG-UI ToolCallArgs event - carries streamed tool call arguments delta.""" + + protocol: Literal["agui_tool_call_args"] = "agui_tool_call_args" # type: ignore[misc] + event: ToolCallArgsEvent = Field(..., description="AG-UI ToolCallArgs event payload") + + +class AgUiToolCallEndOutput(AgUiDataTrigger): + """AG-UI ToolCallEnd event - signals end of tool call argument streaming.""" + + protocol: Literal["agui_tool_call_end"] = "agui_tool_call_end" # type: ignore[misc] + event: ToolCallEndEvent = Field(..., description="AG-UI ToolCallEnd event payload") + + +class AgUiToolCallChunkOutput(AgUiDataTrigger): + """AG-UI ToolCallChunk event - aggregated tool call chunk.""" + + protocol: Literal["agui_tool_call_chunk"] = "agui_tool_call_chunk" # type: ignore[misc] + event: ToolCallChunkEvent = Field(..., description="AG-UI ToolCallChunk event payload") + + +class AgUiToolCallResultOutput(AgUiDataTrigger): + """AG-UI ToolCallResult event - carries the result of a completed tool call.""" + + protocol: Literal["agui_tool_call_result"] = "agui_tool_call_result" # type: ignore[misc] + event: ToolCallResultEvent = Field(..., description="AG-UI ToolCallResult event payload") + + +# ── AG-UI state and message snapshot outputs ────────────────────────────────── + + +class AgUiStateSnapshotOutput(AgUiDataTrigger): + """AG-UI StateSnapshot event - full agent state snapshot.""" + + protocol: Literal["agui_state_snapshot"] = "agui_state_snapshot" # type: ignore[misc] + event: StateSnapshotEvent = Field(..., description="AG-UI StateSnapshot event payload") + + +class AgUiStateDeltaOutput(AgUiDataTrigger): + """AG-UI StateDelta event - JSON Patch (RFC 6902) operations on agent state.""" + + protocol: Literal["agui_state_delta"] = "agui_state_delta" # type: ignore[misc] + event: StateDeltaEvent = Field(..., description="AG-UI StateDelta event payload") + + +class AgUiMessagesSnapshotOutput(AgUiDataTrigger): + """AG-UI MessagesSnapshot event - full conversation messages snapshot.""" + + protocol: Literal["agui_messages_snapshot"] = "agui_messages_snapshot" # type: ignore[misc] + event: MessagesSnapshotEvent = Field(..., description="AG-UI MessagesSnapshot event payload") + + +# ── AG-UI activity event outputs ────────────────────────────────────────────── + + +class AgUiActivitySnapshotOutput(AgUiDataTrigger): + """AG-UI ActivitySnapshot event - full activity message snapshot.""" + + protocol: Literal["agui_activity_snapshot"] = "agui_activity_snapshot" # type: ignore[misc] + event: ActivitySnapshotEvent = Field(..., description="AG-UI ActivitySnapshot event payload") + + +class AgUiActivityDeltaOutput(AgUiDataTrigger): + """AG-UI ActivityDelta event - JSON Patch delta for an activity message.""" + + protocol: Literal["agui_activity_delta"] = "agui_activity_delta" # type: ignore[misc] + event: ActivityDeltaEvent = Field(..., description="AG-UI ActivityDelta event payload") + + +# ── AG-UI run lifecycle event outputs ───────────────────────────────────────── + + +class AgUiRunStartedOutput(AgUiDataTrigger): + """AG-UI RunStarted event - signals that an agent run has begun.""" + + protocol: Literal["agui_run_started"] = "agui_run_started" # type: ignore[misc] + event: RunStartedEvent = Field(..., description="AG-UI RunStarted event payload") + + +class AgUiRunFinishedOutput(AgUiDataTrigger): + """AG-UI RunFinished event - signals that an agent run has completed.""" + + protocol: Literal["agui_run_finished"] = "agui_run_finished" # type: ignore[misc] + event: RunFinishedEvent = Field(..., description="AG-UI RunFinished event payload") + + +class AgUiRunErrorOutput(AgUiDataTrigger): + """AG-UI RunError event - signals that a run encountered an error.""" + + protocol: Literal["agui_run_error"] = "agui_run_error" # type: ignore[misc] + event: RunErrorEvent = Field(..., description="AG-UI RunError event payload") + + +# ── AG-UI step event outputs ────────────────────────────────────────────────── + + +class AgUiStepStartedOutput(AgUiDataTrigger): + """AG-UI StepStarted event - signals start of a named agent step.""" + + protocol: Literal["agui_step_started"] = "agui_step_started" # type: ignore[misc] + event: StepStartedEvent = Field(..., description="AG-UI StepStarted event payload") + + +class AgUiStepFinishedOutput(AgUiDataTrigger): + """AG-UI StepFinished event - signals completion of a named agent step.""" + + protocol: Literal["agui_step_finished"] = "agui_step_finished" # type: ignore[misc] + event: StepFinishedEvent = Field(..., description="AG-UI StepFinished event payload") + + +# ── AG-UI reasoning event outputs ───────────────────────────────────────────── + + +class AgUiReasoningStartOutput(AgUiDataTrigger): + """AG-UI ReasoningStart event - signals start of a reasoning phase.""" + + protocol: Literal["agui_reasoning_start"] = "agui_reasoning_start" # type: ignore[misc] + event: ReasoningStartEvent = Field(..., description="AG-UI ReasoningStart event payload") + + +class AgUiReasoningMessageStartOutput(AgUiDataTrigger): + """AG-UI ReasoningMessageStart event - signals start of a reasoning message.""" + + protocol: Literal["agui_reasoning_message_start"] = "agui_reasoning_message_start" # type: ignore[misc] + event: ReasoningMessageStartEvent = Field(..., description="AG-UI ReasoningMessageStart event payload") + + +class AgUiReasoningMessageContentOutput(AgUiDataTrigger): + """AG-UI ReasoningMessageContent event - carries a reasoning content delta.""" + + protocol: Literal["agui_reasoning_message_content"] = "agui_reasoning_message_content" # type: ignore[misc] + event: ReasoningMessageContentEvent = Field(..., description="AG-UI ReasoningMessageContent event payload") + + +class AgUiReasoningMessageEndOutput(AgUiDataTrigger): + """AG-UI ReasoningMessageEnd event - signals end of a reasoning message.""" + + protocol: Literal["agui_reasoning_message_end"] = "agui_reasoning_message_end" # type: ignore[misc] + event: ReasoningMessageEndEvent = Field(..., description="AG-UI ReasoningMessageEnd event payload") + + +class AgUiReasoningMessageChunkOutput(AgUiDataTrigger): + """AG-UI ReasoningMessageChunk event - aggregated reasoning message chunk.""" + + protocol: Literal["agui_reasoning_message_chunk"] = "agui_reasoning_message_chunk" # type: ignore[misc] + event: ReasoningMessageChunkEvent = Field(..., description="AG-UI ReasoningMessageChunk event payload") + + +class AgUiReasoningEndOutput(AgUiDataTrigger): + """AG-UI ReasoningEnd event - signals end of a reasoning phase.""" + + protocol: Literal["agui_reasoning_end"] = "agui_reasoning_end" # type: ignore[misc] + event: ReasoningEndEvent = Field(..., description="AG-UI ReasoningEnd event payload") + + +class AgUiReasoningEncryptedValueOutput(AgUiDataTrigger): + """AG-UI ReasoningEncryptedValue event - carries an encrypted reasoning value.""" + + protocol: Literal["agui_reasoning_encrypted_value"] = "agui_reasoning_encrypted_value" # type: ignore[misc] + event: ReasoningEncryptedValueEvent = Field(..., description="AG-UI ReasoningEncryptedValue event payload") + + +# ── AG-UI thinking step event outputs ──────────────────────────────────────── + + +class AgUiThinkingStartOutput(AgUiDataTrigger): + """AG-UI ThinkingStart event - signals start of a high-level thinking step.""" + + protocol: Literal["agui_thinking_start"] = "agui_thinking_start" # type: ignore[misc] + event: ThinkingStartEvent = Field(..., description="AG-UI ThinkingStart event payload") + + +class AgUiThinkingEndOutput(AgUiDataTrigger): + """AG-UI ThinkingEnd event - signals end of a high-level thinking step.""" + + protocol: Literal["agui_thinking_end"] = "agui_thinking_end" # type: ignore[misc] + event: ThinkingEndEvent = Field(..., description="AG-UI ThinkingEnd event payload") + + +# ── AG-UI generic event outputs ─────────────────────────────────────────────── + + +class AgUiRawEventOutput(AgUiDataTrigger): + """AG-UI RawEvent event - passes through a raw/untyped event payload.""" + + protocol: Literal["agui_raw"] = "agui_raw" # type: ignore[misc] + event: RawEvent = Field(..., description="AG-UI RawEvent event payload") + + +class AgUiCustomEventOutput(AgUiDataTrigger): + """AG-UI CustomEvent event - carries an application-defined custom event.""" + + protocol: Literal["agui_custom"] = "agui_custom" # type: ignore[misc] + event: CustomEvent = Field(..., description="AG-UI CustomEvent event payload") + + +AgUiEventOutput: TypeAlias = Annotated[ + ( + AgUiTextMessageStartOutput + | AgUiTextMessageContentOutput + | AgUiTextMessageEndOutput + | AgUiTextMessageChunkOutput + | AgUiThinkingTextMessageStartOutput + | AgUiThinkingTextMessageContentOutput + | AgUiThinkingTextMessageEndOutput + | AgUiToolCallStartOutput + | AgUiToolCallArgsOutput + | AgUiToolCallEndOutput + | AgUiToolCallChunkOutput + | AgUiToolCallResultOutput + | AgUiStateSnapshotOutput + | AgUiStateDeltaOutput + | AgUiMessagesSnapshotOutput + | AgUiActivitySnapshotOutput + | AgUiActivityDeltaOutput + | AgUiRunStartedOutput + | AgUiRunFinishedOutput + | AgUiRunErrorOutput + | AgUiStepStartedOutput + | AgUiStepFinishedOutput + | AgUiReasoningStartOutput + | AgUiReasoningMessageStartOutput + | AgUiReasoningMessageContentOutput + | AgUiReasoningMessageEndOutput + | AgUiReasoningMessageChunkOutput + | AgUiReasoningEndOutput + | AgUiReasoningEncryptedValueOutput + | AgUiThinkingStartOutput + | AgUiThinkingEndOutput + | AgUiRawEventOutput + | AgUiCustomEventOutput + ), + Field(discriminator="protocol"), +] + + +# ── Root output discriminated union ─────────────────────────────────────────── + + +class AgUiOutput(DataModel): + """Output model for the Template module with discriminated union.""" + + def model_dump(self, **kwargs: object) -> dict[str, object]: + """Serialize with camelCase aliases and exclude None fields by default. + + Returns: + Serialized model dictionary with camelCase keys and no null values. + """ + kwargs.setdefault("by_alias", True) + kwargs.setdefault("exclude_none", True) + return super().model_dump(**kwargs) # type: ignore[arg-type] + + root: AgUiEventOutput diff --git a/src/digitalkin/models/settings/__init__.py b/src/digitalkin/models/settings/__init__.py new file mode 100644 index 00000000..9dd09be5 --- /dev/null +++ b/src/digitalkin/models/settings/__init__.py @@ -0,0 +1 @@ +"""This package contain settings of sdk.""" diff --git a/src/digitalkin/models/settings/server/__init__.py b/src/digitalkin/models/settings/server/__init__.py new file mode 100644 index 00000000..6742f562 --- /dev/null +++ b/src/digitalkin/models/settings/server/__init__.py @@ -0,0 +1 @@ +"""Package for server settings.""" diff --git a/src/digitalkin/models/settings/server/channel.py b/src/digitalkin/models/settings/server/channel.py new file mode 100644 index 00000000..d002fc35 --- /dev/null +++ b/src/digitalkin/models/settings/server/channel.py @@ -0,0 +1,36 @@ +"""Server channel settings.""" + +from typing import Any + +from pydantic import Field +from pydantic_settings import SettingsConfigDict + +from digitalkin.models.settings.utils.channel import BaseChannelSettings + + +class ServerChannelSettings(BaseChannelSettings): + """Settings for a server channel. + + Attributes: + advertise_host (str | None): Public hostname/IP sent to registry for discovery. Falls back to host if not set. + database_url (str | None): Database URL for registry data storage + + """ + + model_config = SettingsConfigDict( + env_prefix="SERVER_CHANNEL_", + env_nested_delimiter="__", + extra="forbid", + arbitrary_types_allowed=True, + validate_assignment=True, + ) + + advertise_host: str | None = Field( + None, description="Public hostname/IP sent to registry for discovery. Falls back to host if not set." + ) + + database_url: str | None = Field(None, description="Database URL for registry data storage") + + def __init__(self, **values: Any) -> None: + """Initialize ServerChannelSettings with default credentials if not provided.""" + super().__init__(**values) diff --git a/src/digitalkin/models/settings/server/grpc.py b/src/digitalkin/models/settings/server/grpc.py new file mode 100644 index 00000000..c42057c0 --- /dev/null +++ b/src/digitalkin/models/settings/server/grpc.py @@ -0,0 +1,99 @@ +"""gRPC server settings for the SDK.""" + +from typing import Any + +from pydantic import Field, NonNegativeFloat +from pydantic_settings import BaseSettings, SettingsConfigDict + +from digitalkin.models.grpc_servers.models import GrpcCompression + + +class GrpcServerSettings(BaseSettings): + """gRPC tuning settings on the SDK side. + + Attributes: + compression (GrpcCompression): gRPC compression algorithm to use for server responses. + keepalive_time (NonNegativeFloat): Interval for server keepalive pings, in milliseconds. + keepalive_timeout (NonNegativeFloat): Timeout for server keepalive pings, in milliseconds. + min_ping_interval (NonNegativeFloat): Minimum interval between HTTP/2 pings on the server side, in milliseconds. + max_receive_message_lenght (NonNegativeFloat): Maximum message size the server can receive, in bytes. + max_send_message_length (NonNegativeFloat): Maximum message size the server can send, in bytes. + max_pings_without_data (NonNegativeFloat): Maximum number of pings the server allows without receiving any data. + keepalive_permit_without_calls (bool): Allow clients to send keepalive pings even when there are no active RPCs. + + """ + + model_config = SettingsConfigDict( + env_prefix="SERVER_GRPC_", extra="forbid", arbitrary_types_allowed=True, validate_assignment=True + ) + + compression: GrpcCompression = Field(GrpcCompression.GZIP, description="gRPC compression algorithm") + + # ── Options ───────────────────────────────────────────────────────────────────── # + + keepalive_time: NonNegativeFloat = Field( + 120000, description="Interval for server keepalive pings.", alias="SERVER_GRPC_OPTIONS_KEEPALIVE_TIME" + ) + keepalive_timeout: NonNegativeFloat = Field( + 20000, description="Timeout for server keepalive pings.", alias="SERVER_GRPC_OPTIONS_KEEPALIVE_TIMEOUT" + ) + min_ping_interval: NonNegativeFloat = Field( + 10000, + description="Minimum interval between HTTP/2 pings on the server side.", + alias="SERVER_GRPC_OPTIONS_MIN_PING_INTERVAL", + ) + max_receive_message_lenght: NonNegativeFloat = Field( + 100 * 1024 * 1024, + description="Maximum message size the server can receive, in bytes.", + alias="SERVER_GRPC_OPTIONS_MAX_RECEIVE_MESSAGE_LENGTH", + ) + max_send_message_length: NonNegativeFloat = Field( + 100 * 1024 * 1024, + description="Maximum message size the server can send, in bytes.", + alias="SERVER_GRPC_OPTIONS_MAX_SEND_MESSAGE_LENGTH", + ) + max_pings_without_data: NonNegativeFloat = Field( + 0, + description="Maximum number of pings the server allows without receiving any data. " + "Setting to 0 allows unlimited pings, " + "which is important for long-running streams.", + alias="SERVER_GRPC_OPTIONS_MAX_PINGS_WITHOUT_DATA", + ) + keepalive_permit_without_calls: bool = Field( + default=True, + description="Allow clients to send keepalive pings even when there are no active RPCs. " + "This is important for keeping connections " + "alive through proxies and detecting dead clients.", + alias="SERVER_GRPC_OPTIONS_KEEPALIVE_PERMIT_WITHOUT_CALLS", + ) + + @property + def options(self) -> list[tuple[str, Any]]: + """Convert settings to gRPC server options format. + + Returns: + List of tuples containing gRPC server options and their corresponding values. + """ + return [ + ("grpc.max_receive_message_length", self.max_receive_message_lenght), + ("grpc.max_send_message_length", self.max_send_message_length), + # === Server-Side Keepalive (Keeps Connections Alive Through Proxies) === + # Server sends keepalive pings to detect dead clients and keep + # proxy connections (e.g. Railway) alive during long-running RPCs. + ("grpc.keepalive_time_ms", self.keepalive_time), + ("grpc.keepalive_timeout_ms", self.keepalive_timeout), + # === Keepalive Permission (Required for Client Keepalive) === + # Allow clients to send keepalive pings without active RPCs + # Without this, server rejects client keepalives with GOAWAY + ("grpc.keepalive_permit_without_calls", self.keepalive_permit_without_calls), + # Allow unlimited pings without data (required for long-running streams) + ("grpc.http2.max_pings_without_data", self.max_pings_without_data), + # Minimum interval server allows between client pings + # Prevents "too_many_pings" GOAWAY errors + # Must match or be less than client's http2.min_time_between_pings_ms + ("grpc.http2.min_ping_interval_without_data_ms", self.min_ping_interval), + ] + + def __init__(self, **values: Any) -> None: + """Initialize gRPC server settings.""" + super().__init__(**values) diff --git a/src/digitalkin/models/settings/server/server.py b/src/digitalkin/models/settings/server/server.py new file mode 100644 index 00000000..23a9fb82 --- /dev/null +++ b/src/digitalkin/models/settings/server/server.py @@ -0,0 +1,47 @@ +"""Server settings for the DigitalKin application.""" + +import os +from typing import Any + +from pydantic import Field, NonNegativeInt +from pydantic_settings import BaseSettings, SettingsConfigDict + +from digitalkin.models.settings.server.channel import ServerChannelSettings +from digitalkin.models.settings.server.grpc import GrpcServerSettings + + +class ServerSettings(BaseSettings): + """Settings for the DigitalKin server. + + Attributes: + channel (ServerChannelSettings): Settings for the server channel. + grpc (GrpcServerSettings): Settings for the gRPC server. + health_check (bool): Whether to enable the health check service. + reflection (bool): Whether to enable reflection for the server. + max_concurrent_rpcs (NonNegativeInt): Maximum number of RPCs handled in parallel by the server. + max_workers (NonNegativeInt): Maximum number of workers for sync mode. + thread_pool_workers (NonNegativeInt): Number of workers in the server thread pool. + + """ + + model_config = SettingsConfigDict(env_prefix="SERVER_", case_sensitive=False) + + channel: ServerChannelSettings = Field(default_factory=ServerChannelSettings) + + grpc: GrpcServerSettings = Field(default_factory=GrpcServerSettings) + + health_check: bool = Field(default=True, description="Enable health check service") + reflection: bool = Field(default=True, description="Enable reflection for the server") + max_concurrent_rpcs: NonNegativeInt = Field( + (os.cpu_count() or 1) * 200, + description="Maximum number of RPCs handled in parallel by the server.", + ) + max_workers: NonNegativeInt = Field(10, description="Maximum number of workers for sync mode") + thread_pool_workers: NonNegativeInt = Field( + min(4, os.cpu_count() or 1), + description="Number of workers in the server thread pool.", + ) + + def __init__(self, **values: Any) -> None: + """Initialize the ServerSettings instance.""" + super().__init__(**values) diff --git a/src/digitalkin/models/settings/utils/__init__.py b/src/digitalkin/models/settings/utils/__init__.py new file mode 100644 index 00000000..c497bf34 --- /dev/null +++ b/src/digitalkin/models/settings/utils/__init__.py @@ -0,0 +1 @@ +"""This package contain channel base.""" diff --git a/src/digitalkin/models/settings/utils/channel.py b/src/digitalkin/models/settings/utils/channel.py new file mode 100644 index 00000000..fb7ae873 --- /dev/null +++ b/src/digitalkin/models/settings/utils/channel.py @@ -0,0 +1,148 @@ +"""This file define channelBase for grpc config.""" + +from enum import Enum +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from digitalkin.grpc_servers.utils.exceptions import ConfigurationError, SecurityError + + +class ControlFlow(str, Enum): + """Enum for server operation mode.""" + + SYNC = "sync" + ASYNC = "async" + + +class SecurityMode(str, Enum): + """Enum for server security mode.""" + + SECURE = "secure" + INSECURE = "insecure" + + +class Credentials(BaseModel): + """Model for server credentials in secure mode. + + Attributes: + key_path: Path to the server private key + cert_path: Path to the server certificate + root_cert_path: Optional path to the root certificate + """ + + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True, validate_assignment=True, frozen=True) + + key_path: Path | None = Field(default=None, description="Path to the private key") + cert_path: Path | None = Field(default=None, description="Path to the certificate") + root_cert_path: Path | None = Field(default=None, description="Path to the root certificate") + + def __init__(self, /, **data: Any) -> None: + """Initialize the Credentials model.""" + super().__init__(**data) + + @field_validator("key_path", "cert_path", "root_cert_path") + @classmethod + def check_path_exists(cls, v: Path | None) -> Path | None: + """Validate that the file path exists. + + Args: + v: Path to validate + + Returns: + The validated path + + Raises: + SecurityError: If the path does not exist + """ + if v is not None and not v.exists(): + msg = f"File not found: {v}" + raise SecurityError(msg) + return v + + +class BaseChannelSettings(BaseSettings): + """Base settings model for gRPC channel configuration.""" + + model_config = SettingsConfigDict(extra="forbid", arbitrary_types_allowed=True, validate_assignment=True) + + host: str = Field("[::]", description="Host address to bind the client to") + port: NonNegativeInt = Field(50055, description="Port to listen on") + communication_mode: ControlFlow = Field(ControlFlow.ASYNC, description="Client/Server operation mode (sync/async)") + credentials: Credentials | None = Field(None, description="Client credentials for secure mode") + security: SecurityMode = Field(SecurityMode.INSECURE, description="Security mode (secure/insecure)") + mtls: bool = Field(default=False, description="Enable mutual TLS") + + def __init__(self, **values: Any) -> None: + """Initialize the BaseChannelSettings model.""" + super().__init__(**values) + + @property + def address(self) -> str: + """Get the server address. + + Returns: + The formatted address string + """ + return f"{self.host}:{self.port}" + + @model_validator(mode="after") + def validate_credentials(self) -> "BaseChannelSettings": + """Validate that credentials are provided when in secure mode. + + Returns: + The validated credentials + + Raises: + ConfigurationError: If credentials are missing in secure mode + """ + # Access security mode from the info.data dictionary + if self.security == SecurityMode.SECURE and self.credentials is None: + msg = "Credentials must be provided when using secure mode" + raise ConfigurationError(msg) + return self + + @field_validator("port") + @classmethod + def validate_port(cls, v: int) -> int: + """Validate that the port is in a valid range. + + Args: + v: Port number to validate + + Returns: + The validated port number + + Raises: + ConfigurationError: If port is outside valid range + """ + if not 0 < v < 65536: # TCP port range constant # noqa: PLR2004 + msg = f"Port must be between 1 and 65535, got {v}" + raise ConfigurationError(msg) + return v + + @field_validator("communication_mode", mode="before") + @classmethod + def _normalize_mode(cls, v: str | ControlFlow) -> ControlFlow: + """Normalize mode value. + + Returns: + The normalized ServerMode. + """ + if isinstance(v, ControlFlow): + return v + return ControlFlow.SYNC if str(v).lower() == "sync" else ControlFlow.ASYNC + + @field_validator("security", mode="before") + @classmethod + def _normalize_security(cls, v: str | SecurityMode) -> SecurityMode: + """Normalize security value. + + Returns: + The normalized SecurityMode. + """ + if isinstance(v, SecurityMode): + return v + return SecurityMode.SECURE if str(v).lower() == "secure" else SecurityMode.INSECURE diff --git a/src/digitalkin/modules/_base_module.py b/src/digitalkin/modules/_base_module.py index 23712c09..c172f48a 100644 --- a/src/digitalkin/modules/_base_module.py +++ b/src/digitalkin/modules/_base_module.py @@ -491,7 +491,6 @@ async def run( setup_data, self.context, ) - await handler_instance.flush_chat_history(self.context) await handler_instance.flush_file_history(self.context) @abstractmethod @@ -622,7 +621,6 @@ async def stop(self) -> None: try: for handlers in self.trigger_handlers.values(): for handler in handlers: - await handler.flush_chat_history(self.context) await handler.flush_file_history(self.context) except Exception: logger.warning("Failed to flush handler history during stop", exc_info=True) diff --git a/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py b/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py index e18d943b..262febac 100644 --- a/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py +++ b/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py @@ -44,4 +44,4 @@ async def handle( elapsed = datetime.now(tz=context.session.timezone) - self._request_time latency_ms = elapsed.total_seconds() * 1000 output = HealthcheckPingOutput(latency_ms=latency_ms) - await self.send_message(context, output) + await context.callbacks.send_message(output) diff --git a/src/digitalkin/modules/triggers/healthcheck_services_trigger.py b/src/digitalkin/modules/triggers/healthcheck_services_trigger.py index c73c4430..76c51f14 100644 --- a/src/digitalkin/modules/triggers/healthcheck_services_trigger.py +++ b/src/digitalkin/modules/triggers/healthcheck_services_trigger.py @@ -26,7 +26,7 @@ def __init__(self, context: ModuleContext) -> None: """Initialize the handler.""" super().__init__(context) - async def handle( + async def handle( # noqa: PLR6301 self, input_data: HealthcheckServicesInput, # Healthcheck needs no input data # noqa: ARG002 setup_data: Any, # Module-agnostic setup; healthcheck ignores it # noqa: ARG002 @@ -67,4 +67,4 @@ async def handle( services=services_status, overall_status=overall_status, # type: ignore[arg-type] # String value matches Literal type at runtime ) - await self.send_message(context, output) + await context.callbacks.send_message(output) diff --git a/src/digitalkin/modules/triggers/healthcheck_status_trigger.py b/src/digitalkin/modules/triggers/healthcheck_status_trigger.py index 053b18ae..afcdbccd 100644 --- a/src/digitalkin/modules/triggers/healthcheck_status_trigger.py +++ b/src/digitalkin/modules/triggers/healthcheck_status_trigger.py @@ -51,4 +51,4 @@ async def handle( "setup_version_id": context.session.setup_version_id, }, ) - await self.send_message(context, output) + await context.callbacks.send_message(output) diff --git a/src/digitalkin/services/registry/registry_strategy.py b/src/digitalkin/services/registry/registry_strategy.py index 8cccdba3..4be7cbee 100644 --- a/src/digitalkin/services/registry/registry_strategy.py +++ b/src/digitalkin/services/registry/registry_strategy.py @@ -103,6 +103,18 @@ async def get_setup(self, setup_id: str) -> SetupInfo | None: """Get setup info.""" ... + async def wait_for_ready(self, timeout: float = 1.0) -> bool: # noqa: PLR6301 + """Check if the registry backend is reachable. + + Args: + timeout: Max seconds to wait for connectivity. + + Returns: + True if ready. Default implementation always returns True. + """ + _ = timeout + return True + @abstractmethod async def deregister(self, module_id: str) -> bool: """Deregister a module from the registry. diff --git a/src/digitalkin/utils/conditional_schema.py b/src/digitalkin/utils/conditional_schema.py index f35b2889..213155d2 100644 --- a/src/digitalkin/utils/conditional_schema.py +++ b/src/digitalkin/utils/conditional_schema.py @@ -207,9 +207,8 @@ class Config(ConditionalSchemaMixin, BaseModel): # } """ - model_fields: ClassVar[ - dict[str, FieldInfo] - ] # Pydantic ClassVar redeclaration for mixin type access # type: ignore[misc] + model_fields: ClassVar[dict[str, FieldInfo]] + # Pydantic ClassVar redeclaration for mixin type access # type: ignore[misc] @classmethod def __get_pydantic_json_schema__( diff --git a/tests/community/__init__.py b/tests/community/__init__.py new file mode 100644 index 00000000..9c2311f1 --- /dev/null +++ b/tests/community/__init__.py @@ -0,0 +1 @@ +"""Tests for digitalkin.community integrations.""" diff --git a/tests/community/agno/__init__.py b/tests/community/agno/__init__.py new file mode 100644 index 00000000..c2f3ae74 --- /dev/null +++ b/tests/community/agno/__init__.py @@ -0,0 +1 @@ +"""Tests for digitalkin.community.agno integration.""" diff --git a/tests/community/agno/test_agno_adapter.py b/tests/community/agno/test_agno_adapter.py new file mode 100644 index 00000000..d09da9ee --- /dev/null +++ b/tests/community/agno/test_agno_adapter.py @@ -0,0 +1,1525 @@ +"""Full coverage tests for AgnoStreamAdapter. + +The ``agno`` package is an optional dependency and is not installed in the +test environment. These tests inject fake ``agno.run.agent`` and +``agno.run.team`` modules into ``sys.modules`` so the adapter's lazy import +resolves to controllable enum members and namespace objects. +""" + +from __future__ import annotations + +import sys +import types +from enum import Enum +from typing import Any + +import pytest + +from digitalkin.models.events import ( + AgentRunEvent, + ReasoningCompletedEvent, + ReasoningContentDeltaEvent, + ReasoningStartedEvent, + ReasoningStepEvent, + RunCompletedEvent, + RunContentEvent, + RunErrorEvent, + RunStartedEvent, + TextMessageCompletedEvent, + TextMessageStartedEvent, + ToolCallCompletedEvent, + ToolCallErrorEvent, + ToolCallStartedEvent, +) + + +class _FakeRunEvent(str, Enum): + """Mirror of ``agno.run.agent.RunEvent`` for tests.""" + + run_started = "RunStarted" + run_content = "RunContent" + run_completed = "RunCompleted" + run_error = "RunError" + run_paused = "RunPaused" + reasoning_started = "ReasoningStarted" + reasoning_content_delta = "ReasoningContentDelta" + reasoning_step = "ReasoningStep" + reasoning_completed = "ReasoningCompleted" + tool_call_started = "ToolCallStarted" + tool_call_completed = "ToolCallCompleted" + tool_call_error = "ToolCallError" + + +class _FakeTeamRunEvent(str, Enum): + """Mirror of ``agno.run.team.TeamRunEvent`` for tests.""" + + run_started = "TeamRunStarted" + run_content = "TeamRunContent" + run_completed = "TeamRunCompleted" + run_error = "TeamRunError" + run_paused = "TeamRunPaused" + reasoning_started = "TeamReasoningStarted" + reasoning_content_delta = "TeamReasoningContentDelta" + reasoning_step = "TeamReasoningStep" + reasoning_completed = "TeamReasoningCompleted" + tool_call_started = "TeamToolCallStarted" + tool_call_completed = "TeamToolCallCompleted" + tool_call_error = "TeamToolCallError" + + +@pytest.fixture(autouse=True) +def fake_agno_modules() -> Any: + """Install fake ``agno.run.agent`` and ``agno.run.team`` modules. + + Yields: + Tuple ``(_FakeRunEvent, _FakeTeamRunEvent)`` for convenience. + """ + saved = {k: sys.modules.get(k) for k in ("agno", "agno.run", "agno.run.agent", "agno.run.team")} + + agno_pkg = types.ModuleType("agno") + agno_run_pkg = types.ModuleType("agno.run") + agno_run_agent = types.ModuleType("agno.run.agent") + agno_run_agent.RunEvent = _FakeRunEvent # type: ignore[attr-defined] + agno_run_team = types.ModuleType("agno.run.team") + agno_run_team.TeamRunEvent = _FakeTeamRunEvent # type: ignore[attr-defined] + + sys.modules["agno"] = agno_pkg + sys.modules["agno.run"] = agno_run_pkg + sys.modules["agno.run.agent"] = agno_run_agent + sys.modules["agno.run.team"] = agno_run_team + + try: + yield _FakeRunEvent, _FakeTeamRunEvent + finally: + for key, mod in saved.items(): + if mod is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = mod + + +_EVENT_DEFAULTS: dict[str, Any] = { + "timestamp": 1234.5, + "run_id": None, + "session_id": None, + "parent_run_id": None, + "content": None, + "reasoning_content": None, + "tool": None, + "tools": None, + "requirements": None, + "error_type": None, + "team_name": None, + "team_id": None, + "agent_name": None, + "agent_id": None, +} + +_TOOL_DEFAULTS: dict[str, Any] = { + "tool_call_id": None, + "tool_name": None, + "tool_args": None, + "result": None, +} + +_TOOL_EXEC_DEFAULTS: dict[str, Any] = { + "tool_call_id": None, + "tool_name": None, + "tool_args": None, + "external_execution_required": False, +} + + +def _make_event(event: Any, **attrs: Any) -> types.SimpleNamespace: + """Build a namespace mimicking an Agno event object with Pydantic-like defaults.""" + data = {**_EVENT_DEFAULTS, **attrs} + return types.SimpleNamespace(event=event, **data) + + +def _make_tool(**attrs: Any) -> types.SimpleNamespace: + """Build a namespace mimicking an Agno ``ToolExecution`` (for tool_call_* events).""" + data = {**_TOOL_DEFAULTS, **attrs} + return types.SimpleNamespace(**data) + + +def _make_tool_execution(**attrs: Any) -> types.SimpleNamespace: + """Build a namespace mimicking an Agno ``ToolExecution`` attached to ``RunPausedEvent``.""" + data = {**_TOOL_EXEC_DEFAULTS, **attrs} + return types.SimpleNamespace(**data) + + +# ── Import / ImportError ──────────────────────────────────────────────────── + + +def test_import_error_when_agno_missing(monkeypatch: pytest.MonkeyPatch) -> None: + """First conversion raises ImportError with install hint if agno absent.""" + for key in ("agno", "agno.run", "agno.run.agent", "agno.run.team"): + monkeypatch.delitem(sys.modules, key, raising=False) + + real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__ # type: ignore[index] + + def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name.startswith("agno"): + raise ImportError(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", _blocked_import) + + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + event = _make_event("anything") + + with pytest.raises(ImportError, match="agno"): + adapter.to_digitalkin_events(event) + + +def test_dispatch_is_built_once() -> None: + """Dispatch table is lazily initialized on first call, reused on subsequent ones.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter._dispatch is None + + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + dispatch_first = adapter._dispatch + assert dispatch_first is not None + + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_error)) + assert adapter._dispatch is dispatch_first + + +def test_unhandled_event_returns_empty() -> None: + """An event type absent from the dispatch table yields no DigitalKin events.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + + result = adapter.to_digitalkin_events(_make_event("unknown_event_type")) + assert result == [] + + +# ── Run lifecycle ─────────────────────────────────────────────────────────── + + +def test_run_started_emits_event() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_started, run_id="r1", session_id="t1"), + ) + + assert len(result) == 1 + event = result[0] + assert isinstance(event, RunStartedEvent) + assert event.run_id == "r1" + assert event.thread_id == "t1" + assert event.timestamp == 1234.5 + assert adapter._active_run_id == "r1" + + +def test_run_started_deduplicates_same_run_id() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + + duplicate = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + assert duplicate == [] + + +def test_run_started_without_run_id_is_not_deduped() -> None: + """``run_id is None`` bypasses the dedup guard and always emits.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + first = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id=None)) + second = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id=None)) + assert len(first) == 1 + assert len(second) == 1 + + +def test_run_completed_closes_active_sequences_and_emits() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + assert adapter._content_active is True + assert adapter._reasoning_active is False + + # Re-open reasoning so run_completed has both to close. + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think2", content=None), + ) + assert adapter._content_active is False + assert adapter._reasoning_active is True + + completed = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id="r1", content="final"), + ) + + kinds = [type(e) for e in completed] + assert ReasoningCompletedEvent in kinds + assert RunCompletedEvent in kinds + final = next(e for e in completed if isinstance(e, RunCompletedEvent)) + assert final.final_content == "final" + assert final.run_id == "r1" + assert "r1" in adapter._completed_run_ids + assert adapter._active_run_id is None + + +def test_run_completed_closes_only_active_text() -> None: + """When only text is active at run end, run_completed closes it then emits RunCompleted.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + assert adapter._content_active is True + assert adapter._reasoning_active is False + + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id="r1", content="final"), + ) + kinds = [type(e) for e in result] + assert TextMessageCompletedEvent in kinds + assert RunCompletedEvent in kinds + + +def test_run_completed_without_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id="r1", content=None), + ) + event = next(e for e in result if isinstance(e, RunCompletedEvent)) + assert event.final_content is None + + +def test_run_completed_deduplicates() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_completed, run_id="r1", content=None)) + + duplicate = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id="r1", content=None), + ) + assert duplicate == [] + + +def test_nested_run_started_is_dropped() -> None: + """A member agent's run (``parent_run_id`` set) must not surface as a new top-level run.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + + nested = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_started, + run_id="member-r1", + parent_run_id="team-r1", + agent_id="a1", + agent_name="Alice", + ), + ) + assert nested == [] + # Outer run state preserved + assert adapter._active_run_id == "team-r1" + + +def test_nested_run_completed_is_dropped() -> None: + """A member agent's ``run_completed`` must not close the outer team run.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + + nested = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_completed, + run_id="member-r1", + parent_run_id="team-r1", + content="member reply", + ), + ) + assert nested == [] + # Outer run still active + assert adapter._active_run_id == "team-r1" + assert "member-r1" not in adapter._completed_run_ids + + +def test_nested_member_content_still_propagates_with_metadata() -> None: + """Nested member content events keep flowing and carry ``parent_run_id`` in metadata.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="member reply", + parent_run_id="team-r1", + agent_id="a1", + agent_name="Alice", + ), + ) + assert any(isinstance(e, RunContentEvent) for e in result) + content_event = next(e for e in result if isinstance(e, RunContentEvent)) + metadata = content_event.metadata or {} + assert metadata["source"] == "agent" + assert metadata["name"] == "Alice" + assert metadata["parent_run_id"] == "team-r1" + + +def test_nested_run_completed_closes_open_subagent_text() -> None: + """Nested run_completed with active subagent text emits ``---`` footer then TextMessageCompleted.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + # Subagent text chunk opens a bubble (auto text_message_started + header). + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="hello from member", + parent_run_id="team-r1", + agent_name="Alice", + ), + ) + + closed = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_completed, + run_id="member-r1", + parent_run_id="team-r1", + content="member reply", + ), + ) + + kinds = [type(e) for e in closed] + # Order: footer RunContent("\n---\n") → TextMessageCompleted. No RunCompleted (nested is dropped). + assert kinds[0] is RunContentEvent + assert kinds[1] is TextMessageCompletedEvent + assert closed[0].content == " \n\n --- \n\n " + # Same message_id on both footer and close. + assert closed[0].message_id == closed[1].message_id + # Metadata still reflects the subagent. + metadata = closed[1].metadata or {} + assert metadata["parent_run_id"] == "team-r1" + # The nested run must NOT appear in completed ids (outer run keeps going). + assert "member-r1" not in adapter._completed_run_ids + + +def test_subagent_first_text_emits_header_delimiter() -> None: + """First subagent text chunk opens TextMessage + ``--- SubAgent ---`` header + real content.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="subagent text", + parent_run_id="team-r1", + agent_name="Alice", + ), + ) + + kinds = [type(e) for e in result] + # Order: TextMessageStarted → RunContent("--- SubAgent Alice ---\n") → RunContent(actual text). + assert kinds[0] is TextMessageStartedEvent + assert kinds[1] is RunContentEvent + assert kinds[2] is RunContentEvent + assert result[1].content == "\n --- \n ### Alice \n\n" + assert result[2].content == "subagent text" + # All three share the auto-minted subagent message_id. + assert result[0].message_id == result[1].message_id == result[2].message_id + + +def test_main_agent_text_after_subagent_gets_fresh_message_id_without_header() -> None: + """Main agent's continuation opens a NEW TextMessage with NO subagent header.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + sub = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="subagent text", + parent_run_id="team-r1", + agent_name="Alice", + ), + ) + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_completed, + run_id="member-r1", + parent_run_id="team-r1", + content="member reply", + ), + ) + main = adapter.to_digitalkin_events( + _make_event( + _FakeTeamRunEvent.run_content, + content="main text", + team_name="Leader", + ), + ) + + main_kinds = [type(e) for e in main] + # Main agent opens a fresh bubble with NO header — just TextMessageStarted then its content. + assert main_kinds[0] is TextMessageStartedEvent + assert main_kinds[1] is RunContentEvent + assert main[1].content == "main text" + # New message_id, different from the subagent's. + sub_message_id = sub[0].message_id + main_message_id = main[0].message_id + assert sub_message_id != main_message_id + + +def test_nested_run_completed_without_open_content_still_returns_empty() -> None: + """Nested run_completed with no active text/reasoning returns empty (regression guard).""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="team-r1")) + # No content opened beforehand. + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_completed, + run_id="member-r1", + parent_run_id="team-r1", + content=None, + ), + ) + assert result == [] + + +def test_run_completed_without_run_id_still_emits() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id=None)) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_completed, run_id=None, content=None), + ) + assert any(isinstance(e, RunCompletedEvent) for e in result) + + +def test_run_error_emits_error_event() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_error, error_type="ValueError", content="boom"), + ) + assert len(result) == 1 + event = result[0] + assert isinstance(event, RunErrorEvent) + assert event.error_type == "ValueError" + assert event.content == "boom" + + +def test_run_error_without_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_error, error_type=None, content=None), + ) + event = result[0] + assert isinstance(event, RunErrorEvent) + assert event.content is None + + +# ── Reasoning explicit handlers ───────────────────────────────────────────── + + +def test_reasoning_started_closes_active_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hello"), + ) + assert adapter._content_active is True + + result = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + + kinds = [type(e) for e in result] + assert TextMessageCompletedEvent in kinds + assert ReasoningStartedEvent in kinds + assert adapter._reasoning_active is True + assert adapter._current_reasoning_id is not None + + +def test_reasoning_content_delta_passes_through() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_content_delta, reasoning_content="step"), + ) + assert len(result) == 1 + assert isinstance(result[0], ReasoningContentDeltaEvent) + assert result[0].delta == "step" + assert result[0].reasoning_id == adapter._current_reasoning_id + + +def test_reasoning_content_delta_without_content_defaults_to_empty() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_content_delta, reasoning_content=None), + ) + assert isinstance(result[0], ReasoningContentDeltaEvent) + assert result[0].delta == "" + + +def test_reasoning_step_reuses_active_reasoning() -> None: + """When reasoning is already active, reasoning_step appends without reopening.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + rid = adapter._current_reasoning_id + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="step body"), + ) + assert len(result) == 1 + assert isinstance(result[0], ReasoningStepEvent) + assert result[0].delta == "step body" + assert result[0].reasoning_id == rid + + +def test_reasoning_step_auto_opens_lifecycle() -> None: + """A reasoning_step without prior reasoning_started must auto-open the lifecycle.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="step"), + ) + kinds = [type(e) for e in result] + assert kinds == [ReasoningStartedEvent, ReasoningStepEvent] + assert adapter._reasoning_active is True + + +def test_reasoning_step_closes_active_content() -> None: + """A reasoning_step while text is active closes the text message first.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + assert adapter._content_active is True + + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="step"), + ) + kinds = [type(e) for e in result] + assert kinds[0] is TextMessageCompletedEvent + assert ReasoningStartedEvent in kinds + assert ReasoningStepEvent in kinds + + +def test_reasoning_step_empty_content_ignored() -> None: + """A reasoning_step with empty/absent content produces no events.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content=""), + ) + assert result == [] + assert adapter._reasoning_active is False + + +def test_multiple_reasoning_steps_share_lifecycle() -> None: + """Two consecutive reasoning_steps produce one lifecycle.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + first = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="a"), + ) + second = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="b"), + ) + kinds_first = [type(e) for e in first] + kinds_second = [type(e) for e in second] + assert kinds_first == [ReasoningStartedEvent, ReasoningStepEvent] + assert kinds_second == [ReasoningStepEvent] + + +def test_reasoning_completed_when_active() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + result = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_completed)) + assert len(result) == 1 + assert isinstance(result[0], ReasoningCompletedEvent) + assert adapter._reasoning_active is False + + +def test_reasoning_completed_when_inactive_returns_empty() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_completed)) + assert result == [] + + +# ── Tool call handlers ────────────────────────────────────────────────────── + + +def test_tool_call_started_closes_reasoning_and_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + adapter._reasoning_active = True + adapter._current_reasoning_id = "rid" + + tool = _make_tool(tool_call_id="tc1", tool_name="search", tool_args={"q": "x"}) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_started, tool=tool), + ) + + kinds = [type(e) for e in result] + assert ReasoningCompletedEvent in kinds + assert TextMessageCompletedEvent in kinds + assert ToolCallStartedEvent in kinds + started = next(e for e in result if isinstance(e, ToolCallStartedEvent)) + assert started.tool is not None + assert started.tool.tool_call_id == "tc1" + assert started.tool.tool_name == "search" + assert started.tool.tool_args == {"q": "x"} + + +def test_tool_call_started_without_tool() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_started, tool=None), + ) + assert len(result) == 1 + assert isinstance(result[0], ToolCallStartedEvent) + assert result[0].tool is None + + +def test_tool_call_completed() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool(tool_call_id="tc1", tool_name="search", tool_args={"q": "x"}, result="ok") + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_completed, tool=tool, content="ok"), + ) + assert len(result) == 1 + event = result[0] + assert isinstance(event, ToolCallCompletedEvent) + assert event.tool is not None + assert event.tool.result == "ok" + assert event.content == "ok" + assert "tc1" in adapter._closed_tool_call_ids + + +def test_tool_call_completed_without_tool_or_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_completed, tool=None, content=None), + ) + event = result[0] + assert isinstance(event, ToolCallCompletedEvent) + assert event.tool is None + assert event.content is None + + +def test_tool_call_error_first_time() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool(tool_call_id="tc1", tool_name="search") + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_error, tool=tool, content="boom"), + ) + assert len(result) == 1 + event = result[0] + assert isinstance(event, ToolCallErrorEvent) + assert event.error_message == "boom" + assert "tc1" in adapter._closed_tool_call_ids + + +def test_tool_call_error_after_completed_is_deduped() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool(tool_call_id="tc1", tool_name="search", tool_args=None, result="ok") + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_completed, tool=tool, content="ok"), + ) + duplicate = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_error, tool=tool, content="boom"), + ) + assert duplicate == [] + + +def test_tool_call_error_without_tool() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_error, tool=None, content="boom"), + ) + event = result[0] + assert isinstance(event, ToolCallErrorEvent) + assert event.tool is None + assert event.error_message == "boom" + + +def test_tool_call_error_without_tool_call_id_and_without_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool(tool_call_id=None, tool_name="search") + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.tool_call_error, tool=tool, content=None), + ) + event = result[0] + assert isinstance(event, ToolCallErrorEvent) + assert event.tool is not None + assert event.tool.tool_call_id is None + assert event.error_message is None + + +# ── HITL pause (run_paused) ───────────────────────────────────────────────── + + +def test_run_paused_synthesizes_tool_events_for_external_tool() -> None: + """A single external tool yields a synthesized ToolCallStarted + ToolCallCompleted pair.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool_execution( + tool_call_id="ext1", + tool_name="get_weather", + tool_args={"city": "Lyon"}, + external_execution_required=True, + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[tool], requirements=[]), + ) + kinds = [type(e) for e in result] + assert kinds == [ToolCallStartedEvent, ToolCallCompletedEvent] + started = result[0] + completed = result[1] + assert isinstance(started, ToolCallStartedEvent) + assert isinstance(completed, ToolCallCompletedEvent) + assert started.tool is not None + assert started.tool.tool_name == "get_weather" + assert completed.content is None + assert completed.tool is not None + assert completed.tool.result is None + assert adapter.is_paused is True + assert "ext1" in adapter._closed_tool_call_ids + + +def test_run_paused_skips_backend_only_tools() -> None: + """Server-side tools (external_execution_required=False) must not be synthesized.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + backend = _make_tool_execution( + tool_call_id="tc-think", + tool_name="think", + external_execution_required=False, + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[backend], requirements=[]), + ) + tool_events = [e for e in result if isinstance(e, (ToolCallStartedEvent, ToolCallCompletedEvent))] + assert tool_events == [] + assert adapter.is_paused is True + + +def test_run_paused_deduplicates_repeated_tool_call_ids() -> None: + """Agno may accumulate tools across yields; duplicate ids emit only one pair.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + ext1 = _make_tool_execution( + tool_call_id="ext-ask", + tool_name="ask_question", + external_execution_required=True, + ) + ext1_dup = _make_tool_execution( + tool_call_id="ext-ask", + tool_name="ask_question", + external_execution_required=True, + ) + ext2 = _make_tool_execution( + tool_call_id="ext-map", + tool_name="show_map", + external_execution_required=True, + ) + backend = _make_tool_execution( + tool_call_id="tc-think", + tool_name="think", + external_execution_required=False, + ) + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_paused, + tools=[backend, ext1, ext1_dup, ext2], + requirements=[], + ), + ) + kinds = [type(e) for e in result] + assert kinds == [ + ToolCallStartedEvent, + ToolCallCompletedEvent, + ToolCallStartedEvent, + ToolCallCompletedEvent, + ] + names = [e.tool.tool_name for e in result if e.tool is not None] + assert names == ["ask_question", "ask_question", "show_map", "show_map"] + + +def test_run_paused_skips_external_tool_without_tool_call_id() -> None: + """External tools missing ``tool_call_id`` cannot be identified; they are skipped.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + tool = _make_tool_execution( + tool_call_id=None, + tool_name="anon", + external_execution_required=True, + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[tool], requirements=[]), + ) + tool_events = [e for e in result if isinstance(e, (ToolCallStartedEvent, ToolCallCompletedEvent))] + assert tool_events == [] + assert adapter.is_paused is True + + +def test_run_paused_closes_active_content_and_reasoning() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + # Open text content + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="thinking"), + ) + # Force reasoning active too to exercise both branches + adapter._reasoning_active = True + adapter._current_reasoning_id = "rid" + + tool = _make_tool_execution( + tool_call_id="ext1", + tool_name="ask", + external_execution_required=True, + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[tool], requirements=[]), + ) + kinds = [type(e) for e in result] + assert kinds[0] is ReasoningCompletedEvent + assert TextMessageCompletedEvent in kinds + idx_close_text = kinds.index(TextMessageCompletedEvent) + idx_start_tool = kinds.index(ToolCallStartedEvent) + assert idx_close_text < idx_start_tool + + +def test_run_paused_records_paused_state_and_properties() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter.is_paused is False + assert adapter.paused_tool_executions == [] + assert adapter.paused_requirements == [] + + tool = _make_tool_execution( + tool_call_id="ext1", + tool_name="ask", + external_execution_required=True, + ) + requirement = types.SimpleNamespace(tool_execution=tool) + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[tool], requirements=[requirement]), + ) + + assert adapter.is_paused is True + assert len(adapter.paused_tool_executions) == 1 + assert adapter.paused_tool_executions[0] is tool + assert len(adapter.paused_requirements) == 1 + assert adapter.paused_requirements[0] is requirement + + +def test_run_paused_with_null_tools_and_requirements_lists() -> None: + """``tools=None`` and ``requirements=None`` are normalised to empty lists.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=None, requirements=None), + ) + assert result == [] + assert adapter.is_paused is True + assert adapter.paused_tool_executions == [] + assert adapter.paused_requirements == [] + + +# ── run_content state machine ─────────────────────────────────────────────── + + +def test_run_content_text_auto_opens_and_deltas() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hello"), + ) + assert isinstance(result[0], TextMessageStartedEvent) + assert isinstance(result[1], RunContentEvent) + assert result[1].content == "hello" + assert result[1].message_id == adapter._current_message_id + + result2 = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content=" world"), + ) + assert len(result2) == 1 + assert isinstance(result2[0], RunContentEvent) + + +def test_run_content_reasoning_auto_opens_and_deltas() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + assert isinstance(result[0], ReasoningStartedEvent) + assert isinstance(result[1], ReasoningContentDeltaEvent) + + result2 = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="more", content=None), + ) + assert len(result2) == 1 + assert isinstance(result2[0], ReasoningContentDeltaEvent) + + +def test_run_content_transition_reasoning_to_text_closes_reasoning() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + kinds = [type(e) for e in result] + assert ReasoningCompletedEvent in kinds + assert TextMessageStartedEvent in kinds + assert RunContentEvent in kinds + + +def test_run_content_transition_text_to_reasoning_closes_text() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + kinds = [type(e) for e in result] + assert TextMessageCompletedEvent in kinds + assert ReasoningStartedEvent in kinds + assert ReasoningContentDeltaEvent in kinds + + +def test_run_content_empty_content_closes_active_text() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content=""), + ) + assert len(result) == 1 + assert isinstance(result[0], TextMessageCompletedEvent) + assert adapter._content_active is False + + +def test_run_content_empty_content_when_inactive_is_noop() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content=""), + ) + assert result == [] + + +def test_run_content_empty_reasoning_closes_active_reasoning() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="", content=None), + ) + assert len(result) == 1 + assert isinstance(result[0], ReasoningCompletedEvent) + assert adapter._reasoning_active is False + + +def test_run_content_empty_reasoning_when_inactive_is_noop() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="", content=None), + ) + assert result == [] + + +def test_run_content_both_none_is_debug_noop() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content=None), + ) + assert result == [] + + +def test_run_content_reasoning_after_explicit_started() -> None: + """When reasoning_started already fired, a reasoning delta must not re-open.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeRunEvent.reasoning_started)) + rid = adapter._current_reasoning_id + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="step", content=None), + ) + assert len(result) == 1 + assert isinstance(result[0], ReasoningContentDeltaEvent) + assert adapter._current_reasoning_id == rid + + +# ── flush() ───────────────────────────────────────────────────────────────── + + +def test_close_content_noop_when_inactive() -> None: + """Direct call returns empty list (defensive early exit in ``_close_content``).""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter._close_content(None) == [] + + +def test_close_reasoning_noop_when_inactive() -> None: + """Direct call returns empty list (defensive early exit in ``_close_reasoning``).""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter._close_reasoning(None) == [] + + +def test_flush_empty() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + assert adapter.flush() == [] + + +def test_flush_closes_active_content() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + result = adapter.flush() + assert len(result) == 1 + assert isinstance(result[0], TextMessageCompletedEvent) + assert adapter._content_active is False + + +def test_flush_closes_active_reasoning() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + result = adapter.flush() + assert len(result) == 1 + assert isinstance(result[0], ReasoningCompletedEvent) + assert adapter._reasoning_active is False + + +def test_flush_closes_both_when_both_forced() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content="think", content=None), + ) + # Force content active too (normally mutually exclusive) to exercise both branches + adapter._content_active = True + adapter._current_message_id = "m1" + + result = adapter.flush() + kinds = [type(e) for e in result] + assert TextMessageCompletedEvent in kinds + assert ReasoningCompletedEvent in kinds + + +def test_double_flush_is_idempotent() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="hi"), + ) + adapter.flush() + assert adapter.flush() == [] + + +# ── Team events share the same dispatch ───────────────────────────────────── + + +def test_team_run_started_dispatches() -> None: + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_started, run_id="team-r1", session_id="t-t1"), + ) + assert isinstance(result[0], RunStartedEvent) + assert result[0].run_id == "team-r1" + assert result[0].thread_id == "t-t1" + + +def test_team_events_route_like_agent_events() -> None: + """Smoke-test that each TeamRunEvent key resolves to the matching handler.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.run_started, run_id="r1")) + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_content, reasoning_content="r", content=None), + ) + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.reasoning_step, reasoning_content="s"), + ) + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.reasoning_completed)) + tool = _make_tool(tool_call_id="tc", tool_name="t", tool_args=None, result="r") + adapter.to_digitalkin_events(_make_event(_FakeTeamRunEvent.tool_call_started, tool=tool)) + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.tool_call_completed, tool=tool, content=None), + ) + err_tool = _make_tool(tool_call_id="tc2", tool_name="t") + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.tool_call_error, tool=err_tool, content="x"), + ) + adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_error, error_type=None, content=None), + ) + ext_tool = _make_tool_execution( + tool_call_id="ext-team", + tool_name="frontend", + external_execution_required=True, + ) + paused = adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_paused, tools=[ext_tool], requirements=[]), + ) + assert any(isinstance(e, ToolCallStartedEvent) for e in paused) + completed = adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_completed, run_id="r1", content=None), + ) + assert any(isinstance(e, RunCompletedEvent) for e in completed) + + +# ── Metadata propagation ──────────────────────────────────────────────────── + + +def test_metadata_agent_event_has_source_and_identity() -> None: + """Agent-scoped events populate ``metadata`` with agent identity. + + Uses ``run_content`` (not ``run_started``) because nested ``run_started`` + events are dropped at the adapter boundary to preserve the single- + ``RUN_STARTED`` AG-UI contract; ``run_content`` propagates through. + """ + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + content="hello", + agent_id="a1", + agent_name="Alice", + parent_run_id="team-r1", + ), + ) + content_event = next(e for e in result if isinstance(e, RunContentEvent)) + assert content_event.metadata == { + "source": "agent", + "name": "Alice", + "id": "a1", + "parent_run_id": "team-r1", + } + + +def test_metadata_team_event_has_source_and_identity() -> None: + """Team-scoped events populate ``metadata`` with team identity.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event( + _FakeTeamRunEvent.run_started, + run_id="tr1", + team_id="t1", + team_name="CrewA", + ), + ) + assert result[0].metadata == { + "source": "team", + "name": "CrewA", + "id": "t1", + "parent_run_id": None, + } + + +def test_metadata_does_not_duplicate_run_id() -> None: + """``run_id`` is already on typed fields and must not appear in metadata.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_started, run_id="r1", agent_name="Alice"), + ) + event = result[0] + metadata = event.metadata or {} + assert "run_id" not in metadata + assert isinstance(event, RunStartedEvent) + assert event.run_id == "r1" + + +def test_metadata_not_polluted_by_unhandled_event() -> None: + """Unhandled events must not overwrite the cached ``_last_metadata``.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_started, run_id="r1", agent_name="Alice", agent_id="a1"), + ) + snapshot = dict(adapter._last_metadata or {}) + assert snapshot["name"] == "Alice" + + adapter.to_digitalkin_events(_make_event("completely_unknown_event")) + + assert adapter._last_metadata == snapshot + + +def test_metadata_propagates_to_text_message_events() -> None: + """Text message sequence events inherit the emitter's metadata.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_started, run_id="r1", agent_name="Alice", agent_id="a1"), + ) + result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + agent_name="Alice", + agent_id="a1", + content="hello", + ), + ) + for event in result: + assert (event.metadata or {}).get("name") == "Alice" + assert (event.metadata or {}).get("source") == "agent" + + +def test_metadata_switches_between_team_and_agent_events() -> None: + """Switching emitters between events yields distinct metadata dicts.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + team_result = adapter.to_digitalkin_events( + _make_event(_FakeTeamRunEvent.run_started, run_id="tr1", team_id="t1", team_name="CrewA"), + ) + assert (team_result[0].metadata or {})["source"] == "team" + + agent_result = adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.run_content, + agent_id="a1", + agent_name="Alice", + parent_run_id="tr1", + content="hi", + ), + ) + for event in agent_result: + metadata = event.metadata or {} + assert metadata["source"] == "agent" + assert metadata["name"] == "Alice" + assert metadata["parent_run_id"] == "tr1" + + +# ── Full realistic sequences ──────────────────────────────────────────────── + + +def test_realistic_sequence_with_reasoning_text_tool_and_pause() -> None: + """think → reasoning_step → text → search → analyze → reasoning_step → text → frontend pause.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + events: list[Any] = [] + + events.extend(adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1"))) + events.extend( + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.tool_call_started, + tool=_make_tool(tool_call_id="tc-think", tool_name="think"), + ), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.tool_call_completed, + tool=_make_tool(tool_call_id="tc-think", tool_name="think", result="planned"), + content="planned", + ), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="## Plan"), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="Searching..."), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.tool_call_started, + tool=_make_tool(tool_call_id="tc-search", tool_name="web_search"), + ), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event( + _FakeRunEvent.tool_call_completed, + tool=_make_tool(tool_call_id="tc-search", tool_name="web_search", result="results"), + content="results", + ), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.reasoning_step, reasoning_content="## Analysis"), + ), + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_content, reasoning_content=None, content="Here!"), + ), + ) + backend = _make_tool_execution( + tool_call_id="tc-think", + tool_name="think", + external_execution_required=False, + ) + frontend = _make_tool_execution( + tool_call_id="ext-show", + tool_name="show_sources", + external_execution_required=True, + ) + events.extend( + adapter.to_digitalkin_events( + _make_event(_FakeRunEvent.run_paused, tools=[backend, frontend], requirements=[]), + ), + ) + + kinds = [type(e) for e in events] + + # Reasoning lifecycle: each REASONING_STARTED has a matching REASONING_COMPLETED + starts = [i for i, k in enumerate(kinds) if k is ReasoningStartedEvent] + ends = [i for i, k in enumerate(kinds) if k is ReasoningCompletedEvent] + assert len(starts) == len(ends) + for start, end in zip(starts, ends, strict=True): + assert start < end + + # Text message lifecycle balanced + text_starts = [i for i, k in enumerate(kinds) if k is TextMessageStartedEvent] + text_ends = [i for i, k in enumerate(kinds) if k is TextMessageCompletedEvent] + assert len(text_starts) == len(text_ends) + + # Paused at the end with exactly one synthesised external tool pair + assert adapter.is_paused is True + synthesised = [ + e for e in events[-2:] if isinstance(e, (ToolCallStartedEvent, ToolCallCompletedEvent)) + ] + assert len(synthesised) == 2 + + +# ── Enum value exposure ───────────────────────────────────────────────────── + + +def test_event_types_use_enum_values_in_serialization() -> None: + """Pydantic config ``use_enum_values`` keeps payloads as plain strings.""" + from digitalkin.community.agno.agno_adapter import AgnoStreamAdapter + + adapter = AgnoStreamAdapter() + result = adapter.to_digitalkin_events(_make_event(_FakeRunEvent.run_started, run_id="r1")) + assert result[0].event == AgentRunEvent.RUN_STARTED.value diff --git a/tests/conftest.py b/tests/conftest.py index b54e6d9c..0a1537fc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,12 +6,8 @@ import pytest from _pytest.fixtures import SubRequest -from digitalkin.models.grpc_servers.models import ( - SecurityMode, - ServerConfig, - ServerCredentials, - ServerMode, -) +from digitalkin.grpc_servers._base_server import BaseServer +from digitalkin.models.settings.server.server import ServerSettings # Register fixture plugins pytest_plugins = [ @@ -26,29 +22,26 @@ @pytest.fixture -def server_config_sync_insecure(): - """Create a sync insecure server configuration.""" - return ServerConfig( - host="localhost", - port=50051, - mode=ServerMode.SYNC, - security=SecurityMode.INSECURE, - ) +def server_config_sync_insecure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SERVER_CHANNEL_HOST", "localhost") + monkeypatch.setenv("SERVER_CHANNEL_PORT", "50051") + monkeypatch.setenv("SERVER_CHANNEL_COMMUNICATION_MODE", "sync") + monkeypatch.setenv("SERVER_CHANNEL_SECURITY", "insecure") + BaseServer._server_settings = ServerSettings() @pytest.fixture -def server_config_async_insecure(): +def server_config_async_insecure(monkeypatch: pytest.MonkeyPatch): """Create an async insecure server configuration.""" - return ServerConfig( - host="localhost", - port=50052, - mode=ServerMode.ASYNC, - security=SecurityMode.INSECURE, - ) + monkeypatch.setenv("SERVER_CHANNEL_HOST", "localhost") + monkeypatch.setenv("SERVER_CHANNEL_PORT", "50052") + monkeypatch.setenv("SERVER_CHANNEL_COMMUNICATION_MODE", "async") + monkeypatch.setenv("SERVER_CHANNEL_SECURITY", "insecure") + BaseServer._server_settings = ServerSettings() @pytest.fixture -def dummy_certs(tmp_path): +def dummy_certs(tmp_path, monkeypatch: pytest.MonkeyPatch): """Create dummy certificates for testing secure connections.""" # Create certificate files server_key = tmp_path / "server.key" @@ -60,39 +53,33 @@ def dummy_certs(tmp_path): server_cert.write_text("DUMMY CERT CONTENT") ca_cert.write_text("DUMMY CA CERT CONTENT") - return { - "server_key_path": server_key, - "server_cert_path": server_cert, - "root_cert_path": ca_cert, - } + monkeypatch.setenv("SERVER_CHANNEL_CREDENTIALS__KEY_PATH", str(server_key)) + monkeypatch.setenv("SERVER_CHANNEL_CREDENTIALS__CERT_PATH", str(server_cert)) + monkeypatch.setenv("SERVER_CHANNEL_CREDENTIALS__ROOT_CERT_PATH", str(ca_cert)) + + return server_key, server_cert, ca_cert @pytest.fixture -def server_config_sync_secure(dummy_certs): +def server_config_sync_secure(dummy_certs, monkeypatch: pytest.MonkeyPatch): """Create a sync secure server configuration.""" - credentials = ServerCredentials(**dummy_certs) + monkeypatch.setenv("SERVER_CHANNEL_HOST", "localhost") + monkeypatch.setenv("SERVER_CHANNEL_PORT", "50053") + monkeypatch.setenv("SERVER_CHANNEL_COMMUNICATION_MODE", "sync") + monkeypatch.setenv("SERVER_CHANNEL_SECURITY", "secure") - return ServerConfig( - host="localhost", - port=50053, - mode=ServerMode.SYNC, - security=SecurityMode.SECURE, - credentials=credentials, - ) + BaseServer._server_settings = ServerSettings() @pytest.fixture -def server_config_async_secure(dummy_certs): +def server_config_async_secure(dummy_certs, monkeypatch: pytest.MonkeyPatch): """Create an async secure server configuration.""" - credentials = ServerCredentials(**dummy_certs) - - return ServerConfig( - host="localhost", - port=50054, - mode=ServerMode.ASYNC, - security=SecurityMode.SECURE, - credentials=credentials, - ) + monkeypatch.setenv("SERVER_CHANNEL_HOST", "localhost") + monkeypatch.setenv("SERVER_CHANNEL_PORT", "50054") + monkeypatch.setenv("SERVER_CHANNEL_COMMUNICATION_MODE", "async") + monkeypatch.setenv("SERVER_CHANNEL_SECURITY", "secure") + + BaseServer._server_settings = ServerSettings() @pytest.fixture(scope="module") diff --git a/tests/grpc_server/test_base_server.py b/tests/grpc_server/test_base_server.py index b69018b9..f6009b23 100644 --- a/tests/grpc_server/test_base_server.py +++ b/tests/grpc_server/test_base_server.py @@ -14,6 +14,7 @@ ServerStateError, ServicerError, ) +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode # Create a concrete implementation of BaseServer for testing @@ -37,28 +38,19 @@ class TestBaseServerInitialization: def test_base_server_init(self, server_config_sync_insecure) -> None: """Test initialization of BaseServer.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() - if server.config != server_config_sync_insecure: - pytest.fail(f"Expected config to be {server_config_sync_insecure}, got {server.config}") - - if server.server is not None: - pytest.fail(f"Expected server to be None, got {server.server}") - - if server._servicers != []: - pytest.fail(f"Expected _servicers to be empty list, got {server._servicers}") - - if server._service_names != []: - pytest.fail(f"Expected _service_names to be empty list, got {server._service_names}") - - if server._health_servicer is not None: - pytest.fail(f"Expected _health_servicer to be None, got {server._health_servicer}") + assert server._server_settings.channel.host == 'localhost' + assert server._server_settings.channel.port == 50051 + assert server._server_settings.channel.communication_mode is ControlFlow.SYNC + assert server._server_settings.channel.security is SecurityMode.INSECURE + assert server.server is None + assert server._servicers == [] + assert server._service_names == [] + assert server._health_servicer is None def test_base_server_register_servicers_is_abstract(self) -> None: """Test that _register_servicers is an abstract method that must be implemented.""" - from digitalkin.models.grpc_servers.models import ServerConfig # noqa: PLC0415 - - config = ServerConfig() # type: ignore # Create a class that doesn't implement _register_servicers class BadServer(BaseServer): @@ -66,7 +58,7 @@ class BadServer(BaseServer): # Attempting to instantiate BadServer should raise TypeError try: - BadServer(config) # type: ignore + BadServer() # type: ignore pytest.fail("Expected TypeError when creating class without implementing _register_servicers") except TypeError: # This is expected - abstract method must be implemented @@ -79,7 +71,7 @@ class TestServicerRegistration: def test_register_servicer_without_server(self, server_config_sync_insecure) -> None: """Test that registering a servicer before creating the server raises an error.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() # Should raise ServicerError since server is not created with pytest.raises(ServicerError): @@ -90,7 +82,7 @@ def test_register_servicer_without_server(self, server_config_sync_insecure) -> def test_register_servicer(self, server_config_sync_insecure) -> None: """Test registering a servicer with the server.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc.Server) @@ -114,7 +106,7 @@ def test_register_servicer(self, server_config_sync_insecure) -> None: def test_register_servicer_with_explicit_names(self, server_config_sync_insecure) -> None: """Test registering a servicer with explicit service names.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc.Server) @@ -133,7 +125,7 @@ def test_register_servicer_with_explicit_names(self, server_config_sync_insecure def test_register_servicer_with_descriptor(self, server_config_sync_insecure) -> None: """Test registering a servicer with a service descriptor.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc.Server) @@ -158,7 +150,7 @@ def test_register_servicer_with_descriptor(self, server_config_sync_insecure) -> def test_register_servicer_failure(self, server_config_sync_insecure) -> None: """Test handling of registration failure.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc.Server) @@ -182,7 +174,7 @@ def test_register_servicers_checks_server_existence( ) -> None: """Test that a good implementation of _register_servicers checks for server existence.""" # MockServer already implements the check - server = MockServer(server_config_sync_insecure) + server = MockServer() # Ensure server is None server.server = None @@ -202,7 +194,7 @@ def _register_servicers(self) -> None: raise ServicerError(msg) self.method_called = True - server = FlagServer(server_config_sync_insecure) + server = FlagServer() # Mock the server mock_grpc_server = mock.MagicMock(spec=grpc.Server) @@ -233,7 +225,7 @@ def test_add_reflection(self, server_config_sync_insecure) -> None: # Directly patch the module in sys.modules with mock.patch.dict("sys.modules", {"grpc_reflection.v1alpha.reflection": mock_reflection}): # Create the server - server = MockServer(server_config_sync_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc.Server) @@ -253,7 +245,7 @@ def test_add_reflection(self, server_config_sync_insecure) -> None: def test_add_reflection_import_error(self, server_config_sync_insecure) -> None: """Test handling of import error for reflection.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc.Server) @@ -278,7 +270,7 @@ class TestServerCreation: def test_create_server_sync(self, server_config_sync_insecure) -> None: """Test creating a synchronous server.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() with ( mock.patch("digitalkin.grpc_servers._base_server.grpc.server") as mock_server, @@ -287,7 +279,7 @@ def test_create_server_sync(self, server_config_sync_insecure) -> None: result = server._create_server() # Verify server was created with correct parameters - mock_executor.assert_called_once_with(max_workers=server_config_sync_insecure.max_workers) + mock_executor.assert_called_once_with(max_workers=server._server_settings.max_workers) mock_server.assert_called_once() # Verify result is the mock server @@ -296,14 +288,14 @@ def test_create_server_sync(self, server_config_sync_insecure) -> None: def test_create_server_async(self, server_config_async_insecure) -> None: """Test creating an asynchronous server.""" - server = MockServer(server_config_async_insecure) + server = MockServer() with mock.patch("digitalkin.grpc_servers._base_server.grpc_aio.server") as mock_server: result = server._create_server() # Verify server was created with correct parameters mock_server.assert_called_once_with( - options=server_config_async_insecure.server_options, + options=server._server_settings.grpc.options, compression=grpc.Compression.Gzip, interceptors=None, maximum_concurrent_rpcs=mock.ANY, @@ -321,28 +313,28 @@ class TestPortConfiguration: def test_add_insecure_port_sync(self, server_config_sync_insecure) -> None: """Test adding an insecure port to a sync server.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() mock_grpc_server = mock.MagicMock(spec=grpc.Server) server._add_insecure_port(mock_grpc_server) # Verify add_insecure_port was called - mock_grpc_server.add_insecure_port.assert_called_once_with(server_config_sync_insecure.address) + mock_grpc_server.add_insecure_port.assert_called_once_with(server._server_settings.channel.address) def test_add_insecure_port_async(self, server_config_async_insecure) -> None: """Test adding an insecure port to an async server.""" - server = MockServer(server_config_async_insecure) + server = MockServer() mock_grpc_server = mock.MagicMock(spec=grpc_aio.Server) server._add_insecure_port(mock_grpc_server) # Verify add_insecure_port was called - mock_grpc_server.add_insecure_port.assert_called_once_with(server_config_async_insecure.address) + mock_grpc_server.add_insecure_port.assert_called_once_with(server._server_settings.channel.address) @mock.patch("digitalkin.grpc_servers._base_server.grpc.ssl_server_credentials") def test_add_secure_port_sync(self, mock_ssl_creds, server_config_sync_secure) -> None: """Test adding a secure port to a sync server.""" - server = MockServer(server_config_sync_secure) + server = MockServer() mock_grpc_server = mock.MagicMock(spec=grpc.Server) # Mock the SSL credentials @@ -353,11 +345,11 @@ def test_add_secure_port_sync(self, mock_ssl_creds, server_config_sync_secure) - server._add_secure_port(mock_grpc_server) # Verify add_secure_port was called - mock_grpc_server.add_secure_port.assert_called_once_with(server_config_sync_secure.address, "mock_credentials") + mock_grpc_server.add_secure_port.assert_called_once_with(server._server_settings.channel.address, "mock_credentials") def test_add_secure_port_no_credentials(self, server_config_sync_insecure) -> None: """Test error when adding secure port with no credentials.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() mock_grpc_server = mock.MagicMock(spec=grpc.Server) # Call add_secure_port @@ -372,7 +364,7 @@ class TestServerLifecycle: def test_start_sync(self, server_config_sync_insecure) -> None: """Test starting a synchronous server.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() with ( mock.patch.object(server, "_create_server") as mock_create, @@ -402,7 +394,7 @@ def test_start_sync(self, server_config_sync_insecure) -> None: def test_start_error(self, server_config_sync_insecure) -> None: """Test error handling when starting server fails.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() with mock.patch.object(server, "_create_server") as mock_create: # Create a mock server that raises an exception @@ -416,7 +408,7 @@ def test_start_error(self, server_config_sync_insecure) -> None: def test_stop_sync(self, server_config_sync_insecure) -> None: """Test stopping a synchronous server.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc.Server) @@ -434,7 +426,7 @@ def test_stop_sync(self, server_config_sync_insecure) -> None: def test_stop_no_server(self, server_config_sync_insecure) -> None: """Test stopping when no server is running.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() # Call stop (should not raise an exception) server.stop() @@ -447,7 +439,7 @@ class TestAsyncOperations: @pytest.mark.asyncio async def test_start_async_method(self, server_config_async_insecure) -> None: """Test the _start_async method.""" - server = MockServer(server_config_async_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc_aio.Server) @@ -462,7 +454,7 @@ async def test_start_async_method(self, server_config_async_insecure) -> None: @pytest.mark.asyncio async def test_start_async_server(self, server_config_async_insecure) -> None: """Test the start_async method.""" - server = MockServer(server_config_async_insecure) + server = MockServer() with ( mock.patch.object(server, "_create_server") as mock_create, @@ -492,7 +484,7 @@ async def test_start_async_server(self, server_config_async_insecure) -> None: @pytest.mark.asyncio async def test_stop_async_method(self, server_config_async_insecure) -> None: """Test the _stop_async method.""" - server = MockServer(server_config_async_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc_aio.Server) @@ -507,7 +499,7 @@ async def test_stop_async_method(self, server_config_async_insecure) -> None: @pytest.mark.asyncio async def test_stop_async_server(self, server_config_async_insecure) -> None: """Test the stop_async method.""" - server = MockServer(server_config_async_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc_aio.Server) @@ -530,7 +522,7 @@ class TestTermination: def test_wait_for_termination_sync(self, server_config_sync_insecure) -> None: """Test wait_for_termination with a sync server.""" - server = MockServer(server_config_sync_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc.Server) @@ -545,7 +537,7 @@ def test_wait_for_termination_sync(self, server_config_sync_insecure) -> None: @pytest.mark.asyncio async def test_await_termination_async(self, server_config_async_insecure) -> None: """Test await_termination with an async server.""" - server = MockServer(server_config_async_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc_aio.Server) @@ -570,7 +562,7 @@ def test_add_health_service_alternative(self, server_config_sync_insecure) -> No except ImportError: pytest.skip("grpc_health package not installed") - server = MockServer(server_config_sync_insecure) + server = MockServer() # Create a mock server mock_grpc_server = mock.MagicMock(spec=grpc.Server) diff --git a/tests/grpc_server/utils/test_grpc_client_wrapper.py b/tests/grpc_server/utils/test_grpc_client_wrapper.py index f924d3f5..86a228f2 100644 --- a/tests/grpc_server/utils/test_grpc_client_wrapper.py +++ b/tests/grpc_server/utils/test_grpc_client_wrapper.py @@ -5,7 +5,8 @@ import pytest from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper -from digitalkin.models.grpc_servers.models import ClientConfig, GrpcCompression, SecurityMode, ServerMode +from digitalkin.models.grpc_servers.models import ClientConfig, GrpcCompression +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode @pytest.fixture(autouse=True) @@ -23,7 +24,7 @@ def _make_config(host: str = "localhost", port: int = 50051) -> ClientConfig: return ClientConfig( host=host, port=port, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, compression=GrpcCompression.GZIP, ) diff --git a/tests/grpc_server/utils/test_models.py b/tests/grpc_server/utils/test_models.py index a8947d9e..227c9ab2 100644 --- a/tests/grpc_server/utils/test_models.py +++ b/tests/grpc_server/utils/test_models.py @@ -3,14 +3,8 @@ import pytest from digitalkin.grpc_servers.utils.exceptions import ConfigurationError, SecurityError -from digitalkin.models.grpc_servers.models import ( - ModuleServerConfig, - RegistryServerConfig, - SecurityMode, - ServerConfig, - ServerCredentials, - ServerMode, -) +from digitalkin.models.settings.server.server import ServerSettings +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode, Credentials @pytest.mark.grpc @@ -20,36 +14,29 @@ class TestEnums: def test_server_mode_enum(self) -> None: """Test the ServerMode enum.""" # Check SYNC value - if ServerMode.SYNC != "sync": - pytest.fail(f"Expected ServerMode.SYNC to be 'sync', got '{ServerMode.SYNC}'") + assert ControlFlow.SYNC == "sync" # Check ASYNC value - if ServerMode.ASYNC != "async": - pytest.fail(f"Expected ServerMode.ASYNC to be 'async', got '{ServerMode.ASYNC}'") + assert ControlFlow.ASYNC == "async" # Check all enum members - expected_members = [ServerMode.SYNC, ServerMode.ASYNC] - actual_members = list(ServerMode) + expected_members = [ControlFlow.SYNC, ControlFlow.ASYNC] + actual_members = list(ControlFlow) - if actual_members != expected_members: - pytest.fail(f"Expected ServerMode enum to have members {expected_members}, got {actual_members}") + assert actual_members == expected_members def test_security_mode_enum(self) -> None: """Test the SecurityMode enum.""" # Check SECURE value - if SecurityMode.SECURE != "secure": - pytest.fail(f"Expected SecurityMode.SECURE to be 'secure', got '{SecurityMode.SECURE}'") - + assert SecurityMode.SECURE == "secure" # Check INSECURE value - if SecurityMode.INSECURE != "insecure": - pytest.fail(f"Expected SecurityMode.INSECURE to be 'insecure', got '{SecurityMode.INSECURE}'") + assert SecurityMode.INSECURE == "insecure" # Check all enum members expected_members = [SecurityMode.SECURE, SecurityMode.INSECURE] actual_members = list(SecurityMode) - if actual_members != expected_members: - pytest.fail(f"Expected SecurityMode enum to have members {expected_members}, got {actual_members}") + assert actual_members == expected_members @pytest.mark.grpc @@ -69,33 +56,29 @@ def test_server_credentials_validation(self, tmp_path) -> None: ca_cert.write_text("TEST CA CERT") # Test valid credentials - creds = ServerCredentials( - server_key_path=server_key, - server_cert_path=server_cert, + creds = Credentials( + key_path=server_key, + cert_path=server_cert, root_cert_path=ca_cert, ) # Check key path - if creds.server_key_path != server_key: - pytest.fail(f"Expected server_key_path to be {server_key}, got {creds.server_key_path}") + assert creds.key_path == server_key # Check cert path - if creds.server_cert_path != server_cert: - pytest.fail(f"Expected server_cert_path to be {server_cert}, got {creds.server_cert_path}") + assert creds.cert_path == server_cert # Check root cert path - if creds.root_cert_path != ca_cert: - pytest.fail(f"Expected root_cert_path to be {ca_cert}, got {creds.root_cert_path}") + assert creds.root_cert_path == ca_cert # Test optional root cert - creds_no_ca = ServerCredentials( - server_key_path=server_key, - server_cert_path=server_cert, + creds_no_ca = Credentials( + key_path=server_key, + cert_path=server_cert, ) # Check root cert is None - if creds_no_ca.root_cert_path is not None: - pytest.fail(f"Expected root_cert_path to be None, got {creds_no_ca.root_cert_path}") + assert creds_no_ca.root_cert_path is None def test_server_credentials_validation_errors(self, tmp_path) -> None: """Test validation errors in ServerCredentials.""" @@ -105,9 +88,9 @@ def test_server_credentials_validation_errors(self, tmp_path) -> None: # Missing certificate file should raise error with pytest.raises(SecurityError): - ServerCredentials( - server_key_path=server_key, - server_cert_path=tmp_path / "nonexistent.crt", + Credentials( + key_path=server_key, + cert_path=tmp_path / "nonexistent.crt", ) @@ -118,199 +101,161 @@ class TestServerConfig: def test_server_config_defaults(self) -> None: """Test default values for ServerConfig.""" - config = ServerConfig() + config = ServerSettings() # Check host - if config.host != "0.0.0.0": # noqa: S104 - pytest.fail(f"Expected default host to be '0.0.0.0', got '{config.host}'") - + assert config.channel.host == "[::]" # Check port - if config.port != 50051: - pytest.fail(f"Expected default port to be 50051, got {config.port}") - + assert config.channel.port == 50055 # Check max_workers - if config.max_workers != 10: - pytest.fail(f"Expected default max_workers to be 10, got {config.max_workers}") - + assert config.max_workers == 10 # Check mode - if config.mode != ServerMode.SYNC: - pytest.fail(f"Expected default mode to be {ServerMode.SYNC}, got {config.mode}") - + assert config.channel.communication_mode == ControlFlow.ASYNC # Check security - if config.security != SecurityMode.INSECURE: - pytest.fail(f"Expected default security to be {SecurityMode.INSECURE}, got {config.security}") - + assert config.channel.security == SecurityMode.INSECURE # Check credentials - if config.credentials is not None: - pytest.fail(f"Expected default credentials to be None, got {config.credentials}") + assert config.channel.credentials is None # Check server_options (message limits + keepalive support) expected_server_options = [ - ("grpc.max_receive_message_length", 100 * 1024 * 1024), - ("grpc.max_send_message_length", 100 * 1024 * 1024), - ("grpc.keepalive_time_ms", 120000), - ("grpc.keepalive_timeout_ms", 20000), - ("grpc.keepalive_permit_without_calls", True), - ("grpc.http2.max_pings_without_data", 0), - ("grpc.http2.min_ping_interval_without_data_ms", 10000), + ('grpc.max_receive_message_length', 104857600), + ('grpc.max_send_message_length', 104857600), + ('grpc.keepalive_time_ms', 120000), + ('grpc.keepalive_timeout_ms', 20000), + ('grpc.keepalive_permit_without_calls', True), + ('grpc.http2.max_pings_without_data', 0), + ('grpc.http2.min_ping_interval_without_data_ms', 10000) ] - if config.server_options != expected_server_options: - pytest.fail(f"Expected default server_options to match resilient defaults, got {config.server_options}") - + assert config.grpc.options == expected_server_options # Check enable_reflection - if config.enable_reflection is not True: - pytest.fail(f"Expected default enable_reflection to be True, got {config.enable_reflection}") - + assert config.reflection is True # Check enable_health_check - if config.enable_health_check is not True: - pytest.fail(f"Expected default enable_health_check to be True, got {config.enable_health_check}") + assert config.health_check is True - def test_server_config_custom(self) -> None: + def test_server_config_custom(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test custom values for ServerConfig.""" - expected_options = [("grpc.max_receive_message_length", 10 * 1024 * 1024)] - - config = ServerConfig( - host="localhost", - port=8000, - max_workers=4, - mode=ServerMode.ASYNC, - security=SecurityMode.INSECURE, - server_options=expected_options, - enable_reflection=False, - enable_health_check=False, - ) + expected_message_lenght = 10 * 1024 * 1024 - # Check host - if config.host != "localhost": - pytest.fail(f"Expected host to be 'localhost', got '{config.host}'") + monkeypatch.setenv("SERVER_CHANNEL_HOST", "localhost") + monkeypatch.setenv("SERVER_CHANNEL_PORT", "8000") + monkeypatch.setenv("SERVER_CHANNEL_COMMUNICATION_MODE", "async") + monkeypatch.setenv("SERVER_CHANNEL_SECURITY", "insecure") + monkeypatch.setenv("SERVER_GRPC_OPTIONS_MAX_SEND_MESSAGE_LENGTH", str(expected_message_lenght)) + monkeypatch.setenv("SERVER_MAX_WORKERS", "4") + monkeypatch.setenv("SERVER_REFLECTION", "false") + monkeypatch.setenv("SERVER_HEALTH_CHECK", "false") - # Check port - if config.port != 8000: - pytest.fail(f"Expected port to be 8000, got {config.port}") + config = ServerSettings() + # Check host + assert config.channel.host == "localhost" + # Check port + assert config.channel.port == 8000 # Check max_workers - if config.max_workers != 4: - pytest.fail(f"Expected max_workers to be 4, got {config.max_workers}") - + assert config.max_workers == 4 # Check mode - if config.mode != ServerMode.ASYNC: - pytest.fail(f"Expected mode to be {ServerMode.ASYNC}, got {config.mode}") - + assert config.channel.communication_mode == ControlFlow.ASYNC # Check security - if config.security != SecurityMode.INSECURE: - pytest.fail(f"Expected security to be {SecurityMode.INSECURE}, got {config.security}") - + assert config.channel.security == SecurityMode.INSECURE # Check server_options - if config.server_options != expected_options: - pytest.fail(f"Expected server_options to be {expected_options}, got {config.server_options}") - + assert config.grpc.max_send_message_length == expected_message_lenght # Check enable_reflection - if config.enable_reflection is not False: - pytest.fail(f"Expected enable_reflection to be False, got {config.enable_reflection}") - + assert config.reflection is False # Check enable_health_check - if config.enable_health_check is not False: - pytest.fail(f"Expected enable_health_check to be False, got {config.enable_health_check}") + assert config.health_check is False - def test_server_config_secure_without_credentials(self) -> None: + def test_server_config_secure_without_credentials(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test error when secure mode is specified without credentials.""" # When creating a ServerConfig with secure mode but no credentials, # it should raise ConfigurationError with pytest.raises(ConfigurationError, match="Credentials must be provided when using secure mode"): - ServerConfig( - security=SecurityMode.SECURE, - credentials=None, - ) + monkeypatch.setenv("SERVER_CHANNEL_SECURITY", "secure") + monkeypatch.delenv("SERVER_CHANNEL_CREDENTIALS", raising=False) + ServerSettings() - def test_server_config_secure_with_credentials(self, dummy_certs) -> None: + def test_server_config_secure_with_credentials(self, dummy_certs, monkeypatch: pytest.MonkeyPatch) -> None: """Test that secure mode with proper credentials is valid.""" - credentials = ServerCredentials(**dummy_certs) - - # This should not raise an exception - config = ServerConfig( - security=SecurityMode.SECURE, - credentials=credentials, - ) + monkeypatch.setenv("SERVER_CHANNEL_SECURITY", "secure") - if config.security != SecurityMode.SECURE: - pytest.fail(f"Expected security to be {SecurityMode.SECURE}, got {config.security}") + config = ServerSettings() - if config.credentials != credentials: - pytest.fail(f"Expected credentials to match input, got {config.credentials}") + assert config.channel.security == SecurityMode.SECURE + assert config.channel.credentials.key_path == dummy_certs[0] + assert config.channel.credentials.cert_path == dummy_certs[1] + assert config.channel.credentials.root_cert_path == dummy_certs[2] - def test_server_config_insecure_with_credentials(self, dummy_certs) -> None: + def test_server_config_insecure_with_credentials(self, dummy_certs, monkeypatch: pytest.MonkeyPatch) -> None: """Test that insecure mode can have credentials (though not required).""" - credentials = ServerCredentials(**dummy_certs) - - # This should not raise an exception - config = ServerConfig( - security=SecurityMode.INSECURE, - credentials=credentials, - ) + monkeypatch.setenv("SERVER_CHANNEL_SECURITY", "insecure") - if config.security != SecurityMode.INSECURE: - pytest.fail(f"Expected security to be {SecurityMode.INSECURE}, got {config.security}") + config = ServerSettings() - if config.credentials != credentials: - pytest.fail(f"Expected credentials to match input, got {config.credentials}") + assert config.channel.security == SecurityMode.INSECURE + assert config.channel.credentials.key_path == dummy_certs[0] + assert config.channel.credentials.cert_path == dummy_certs[1] + assert config.channel.credentials.root_cert_path == dummy_certs[2] - def test_server_config_insecure_without_credentials(self) -> None: + def test_server_config_insecure_without_credentials(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that insecure mode without credentials is valid.""" # This should not raise an exception - config = ServerConfig( - security=SecurityMode.INSECURE, - credentials=None, - ) + monkeypatch.setenv("SERVER_CHANNEL_SECURITY", "insecure") + monkeypatch.delenv("SERVER_CHANNEL_CREDENTIALS", raising=False) + + config = ServerSettings() - if config.security != SecurityMode.INSECURE: - pytest.fail(f"Expected security to be {SecurityMode.INSECURE}, got {config.security}") + assert config.channel.security == SecurityMode.INSECURE + assert config.channel.credentials is None - if config.credentials is not None: - pytest.fail(f"Expected credentials to be None, got {config.credentials}") + def test_server_config_port_validation(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test port assignment behavior in ServerSettings. - def test_server_config_port_validation(self) -> None: - """Test port validation in ServerConfig.""" - # Valid ports + The current model declares ``port`` as ``int`` without explicit range checks, + so out-of-range values are accepted. + """ + # Common ports try: - ServerConfig(port=1) - ServerConfig(port=65535) + monkeypatch.setenv("SERVER_CHANNEL_PORT", "1") + ServerSettings() + monkeypatch.setenv("SERVER_CHANNEL_PORT", "65535") + ServerSettings() except Exception as e: pytest.fail(f"Valid port validation failed: {e}") - # Invalid ports - keep pytest.raises as it's not an assertion - with pytest.raises(ConfigurationError): - ServerConfig(port=0) + # Out-of-range values are currently accepted (no explicit bounds on `port`). + with pytest.raises(ConfigurationError, match="Port must be between 1 and 65535, got 0"): + monkeypatch.setenv("SERVER_CHANNEL_PORT", "0") + config_low = ServerSettings() - with pytest.raises(ConfigurationError): - ServerConfig(port=65536) + with pytest.raises(ConfigurationError, match="Port must be between 1 and 65535, got 65536"): + monkeypatch.setenv("SERVER_CHANNEL_PORT", "65536") + config_low = ServerSettings() - def test_server_config_address(self) -> None: - """Test the address property of ServerConfig.""" - config = ServerConfig(host="localhost", port=8000) + def test_server_config_address(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test the address property of ServerSettings.""" + monkeypatch.setenv("SERVER_CHANNEL_HOST", "localhost") + monkeypatch.setenv("SERVER_CHANNEL_PORT", "8000") + config = ServerSettings() expected_address = "localhost:8000" - if config.address != expected_address: - pytest.fail(f"Expected address to be '{expected_address}', got '{config.address}'") + assert config.channel.address == expected_address @pytest.mark.grpc class TestServerConfigSubclasses: """Tests for server configuration subclasses (ModuleServerConfig and RegistryServerConfig).""" - def test_module_server_config(self) -> None: + def test_module_server_config(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test ModuleServerConfig specific properties.""" - config = ModuleServerConfig(advertise_host="digitalkin-sandbox-agno-server") - expected_advertise_host = "digitalkin-sandbox-agno-server" + monkeypatch.setenv("SERVER_CHANNEL_ADVERTISE_HOST", "digitalkin-test-archetype-server") + config = ServerSettings() + expected_advertise_host = "digitalkin-test-archetype-server" - if config.advertise_host != expected_advertise_host: - pytest.fail( - f"Expected advertise_host to be '{expected_advertise_host}', got '{config.advertise_host}'" - ) + assert config.channel.advertise_host == expected_advertise_host - def test_registry_server_config(self) -> None: + def test_registry_server_config(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test RegistryServerConfig specific properties.""" - config = RegistryServerConfig(database_url="sqlite:///registry.db") + monkeypatch.setenv("SERVER_CHANNEL_DATABASE_URL", "sqlite:///registry.db") + config = ServerSettings() expected_database_url = "sqlite:///registry.db" - if config.database_url != expected_database_url: - pytest.fail(f"Expected database_url to be '{expected_database_url}', got '{config.database_url}'") + assert config.channel.database_url == expected_database_url diff --git a/tests/mixins/test_agui_mixin.py b/tests/mixins/test_agui_mixin.py new file mode 100644 index 00000000..9e013c51 --- /dev/null +++ b/tests/mixins/test_agui_mixin.py @@ -0,0 +1,219 @@ +"""Tests for ``AgUiMixin`` AG-UI run_id / thread_id propagation. + +The mixin is the single point where two distinct identifiers meet: + +* the **AG-UI client** ULID, primed by the trigger from ``RunAgentInput``; +* the **agno** UUID4, attached by the adapter to every wrapped event. + +The AG-UI protocol requires that ``RUN_FINISHED`` echoes the same ``run_id`` as +``RUN_STARTED``. These tests pin the resolution policy: + +#. If the trigger primed ``self._run_id`` (or ``self._thread_id``), keep it — + never let an incoming event overwrite it. +#. Otherwise fall back to ``event.run_id`` / ``event.thread_id``. +#. If both are empty, mint a fresh ``uuid4``. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from digitalkin.mixins.agui_mixin import AgUiMixin +from digitalkin.models.events import ( + AgentRunEvent, + RunCompletedEvent, + RunStartedEvent, +) +from digitalkin.models.module.ag_ui import AgUiRunFinishedOutput, AgUiRunStartedOutput + +CLIENT_THREAD_ID = "missions:01kpwyz1xm5t0xc847mwkr1tm0" +CLIENT_RUN_ID = "01kpwyz3xpncrkccnsa5a9g5fh" +AGNO_RUN_ID = "97882532-f1df-4ada-bde3-c760f8be8e13" +AGNO_THREAD_ID = "agno-session-id" + + +def _make_context() -> MagicMock: + """Mock ``ModuleContext`` with the bits the mixin reads.""" + ctx = MagicMock() + ctx.callbacks = MagicMock() + ctx.callbacks.send_message = AsyncMock() + ctx.callbacks.logger = MagicMock() + ctx.session = MagicMock() + ctx.session.current_ids = MagicMock(return_value={}) + return ctx + + +def _emitted_event(ctx: MagicMock, expected_cls: type) -> Any: + """Pull the last AG-UI event sent through ``send_message``.""" + assert ctx.callbacks.send_message.await_count >= 1, "send_message was never awaited" + output = ctx.callbacks.send_message.await_args_list[-1].args[0] + assert isinstance(output.root, expected_cls), f"expected {expected_cls.__name__}, got {type(output.root).__name__}" + return output.root.event + + +def _started(*, run_id: str | None, thread_id: str | None) -> RunStartedEvent: + """Build a ``RunStartedEvent`` with explicit Nones for pyright friendliness.""" + return RunStartedEvent( + event=AgentRunEvent.RUN_STARTED, + run_id=run_id, + thread_id=thread_id, + timestamp=None, + metadata=None, + ) + + +def _completed(*, run_id: str | None) -> RunCompletedEvent: + """Build a ``RunCompletedEvent`` with explicit Nones.""" + return RunCompletedEvent( + event=AgentRunEvent.RUN_COMPLETED, + run_id=run_id, + timestamp=None, + metadata=None, + final_content=None, + usage=None, + message_id=None, + ) + + +class TestRunStartedResolution: + """``_handle_run_started`` must honour a primed ``_run_id`` / ``_thread_id``.""" + + @pytest.mark.asyncio + async def test_primed_run_id_survives_agno_event(self) -> None: + """Trigger primes ULID; event carries agno UUID4 → emit ULID.""" + mixin = AgUiMixin() + mixin._thread_id = CLIENT_THREAD_ID + mixin._run_id = CLIENT_RUN_ID + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + + emitted = _emitted_event(ctx, AgUiRunStartedOutput) + assert emitted.run_id == CLIENT_RUN_ID + assert emitted.thread_id == CLIENT_THREAD_ID + assert mixin._run_id == CLIENT_RUN_ID + assert mixin._thread_id == CLIENT_THREAD_ID + + @pytest.mark.asyncio + async def test_unprimed_falls_back_to_event_ids(self) -> None: + """No prime → event ids fill in (Ada-style flow).""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + + emitted = _emitted_event(ctx, AgUiRunStartedOutput) + assert emitted.run_id == AGNO_RUN_ID + assert emitted.thread_id == AGNO_THREAD_ID + assert mixin._run_id == AGNO_RUN_ID + assert mixin._thread_id == AGNO_THREAD_ID + + @pytest.mark.asyncio + async def test_no_ids_anywhere_mints_uuid4(self) -> None: + """Empty prime AND empty event ids → fresh uuid4 fallback.""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=None, thread_id=None)) + + emitted = _emitted_event(ctx, AgUiRunStartedOutput) + assert emitted.run_id, "run_id must not be empty" + assert emitted.thread_id, "thread_id must not be empty" + assert mixin._run_id == emitted.run_id + assert mixin._thread_id == emitted.thread_id + + +class TestRunCompletedResolution: + """``_handle_run_completed`` must echo whatever ``RUN_STARTED`` emitted.""" + + @pytest.mark.asyncio + async def test_primed_run_id_used_in_run_finished(self) -> None: + """Regression: agno UUID4 must NOT win over the primed ULID. + + This is the bug that caused ``RUN_STARTED`` to carry the AG-UI ULID + while ``RUN_FINISHED`` carried agno's UUID4 — the client could not + correlate the closure and the run looked orphaned. + """ + mixin = AgUiMixin() + mixin._thread_id = CLIENT_THREAD_ID + mixin._run_id = CLIENT_RUN_ID + ctx = _make_context() + + await mixin._handle_run_completed(ctx, _completed(run_id=AGNO_RUN_ID)) + + emitted = _emitted_event(ctx, AgUiRunFinishedOutput) + assert emitted.run_id == CLIENT_RUN_ID + assert emitted.thread_id == CLIENT_THREAD_ID + + @pytest.mark.asyncio + async def test_unprimed_falls_back_to_event_run_id(self) -> None: + """Without a prime, the agno run_id is the only id available.""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_completed(ctx, _completed(run_id=AGNO_RUN_ID)) + + emitted = _emitted_event(ctx, AgUiRunFinishedOutput) + assert emitted.run_id == AGNO_RUN_ID + + @pytest.mark.asyncio + async def test_no_ids_anywhere_mints_uuid4(self) -> None: + """Defensive fallback — never emit an empty run_id.""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_completed(ctx, _completed(run_id=None)) + + emitted = _emitted_event(ctx, AgUiRunFinishedOutput) + assert emitted.run_id, "run_id must not be empty" + + +class TestEndToEndConsistency: + """``RUN_STARTED`` and ``RUN_FINISHED`` must always carry the same ids.""" + + @pytest.mark.asyncio + async def test_primed_ids_match_through_full_lifecycle(self) -> None: + """Trigger primes → start emits ULID → finish emits same ULID.""" + mixin = AgUiMixin() + mixin._thread_id = CLIENT_THREAD_ID + mixin._run_id = CLIENT_RUN_ID + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + await mixin._handle_run_completed(ctx, _completed(run_id=AGNO_RUN_ID)) + + started = ctx.callbacks.send_message.await_args_list[0].args[0].root.event + finished = ctx.callbacks.send_message.await_args_list[1].args[0].root.event + assert started.run_id == finished.run_id == CLIENT_RUN_ID + assert started.thread_id == finished.thread_id == CLIENT_THREAD_ID + + @pytest.mark.asyncio + async def test_unprimed_ids_match_through_full_lifecycle(self) -> None: + """No prime → start adopts agno ids → finish echoes them.""" + mixin = AgUiMixin() + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + await mixin._handle_run_completed(ctx, _completed(run_id=AGNO_RUN_ID)) + + started = ctx.callbacks.send_message.await_args_list[0].args[0].root.event + finished = ctx.callbacks.send_message.await_args_list[1].args[0].root.event + assert started.run_id == finished.run_id == AGNO_RUN_ID + assert started.thread_id == finished.thread_id == AGNO_THREAD_ID + + @pytest.mark.asyncio + async def test_run_started_does_not_overwrite_existing_run_id(self) -> None: + """A second RUN_STARTED in the same stream must not clobber state.""" + mixin = AgUiMixin() + mixin._run_id = CLIENT_RUN_ID + mixin._thread_id = CLIENT_THREAD_ID + ctx = _make_context() + + await mixin._handle_run_started(ctx, _started(run_id=AGNO_RUN_ID, thread_id=AGNO_THREAD_ID)) + await mixin._handle_run_started(ctx, _started(run_id="another-spurious-id", thread_id="another-thread")) + + assert mixin._run_id == CLIENT_RUN_ID + assert mixin._thread_id == CLIENT_THREAD_ID diff --git a/tests/mixins/test_chat_history_mixin.py b/tests/mixins/test_chat_history_mixin.py deleted file mode 100644 index d140914f..00000000 --- a/tests/mixins/test_chat_history_mixin.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Tests for ChatHistoryMixin caching and storage optimization.""" - -import asyncio -from typing import Any, ClassVar, Literal -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from digitalkin.mixins.chat_history_mixin import ChatHistoryMixin -from digitalkin.models.module.module_context import ModuleContext -from digitalkin.models.services.storage import BaseMessage, BaseRole, ChatHistory -from digitalkin.modules.trigger_handler import TriggerHandler -from digitalkin.services.storage.storage_strategy import StorageRecord - - -class _ConcreteMixin(ChatHistoryMixin): # type: ignore[type-arg] - """Concrete class to test ChatHistoryMixin (abstract mixin needs host class).""" - - -def _make_context(mission_id: str = "test_mission") -> MagicMock: - """Build a mock ModuleContext with storage and session.""" - ctx = MagicMock() - ctx.session.mission_id = mission_id - ctx.storage = AsyncMock() - ctx.storage.read = AsyncMock(return_value=None) - ctx.storage.upsert = AsyncMock(return_value=MagicMock(spec=StorageRecord)) - ctx.storage.update = AsyncMock(return_value=MagicMock(spec=StorageRecord)) - return ctx - - -def _storage_record_with_history(messages: list[dict[str, Any]]) -> MagicMock: - """Create a mock StorageRecord whose .data looks like a ChatHistory.""" - record = MagicMock(spec=StorageRecord) - record.data = ChatHistory(messages=[BaseMessage(**m) for m in messages]) - return record - - -class TestChatHistoryCache: - """Tests for in-memory chat history caching.""" - - @pytest.mark.asyncio - async def test_load_reads_storage_once_then_caches(self) -> None: - """Second load_chat_history call returns cached value without gRPC read.""" - mixin = _ConcreteMixin() - ctx = _make_context() - existing = _storage_record_with_history([{"role": "user", "content": "hello"}]) - ctx.storage.read = AsyncMock(return_value=existing) - - first = await mixin.load_chat_history(ctx) - second = await mixin.load_chat_history(ctx) - - if first is not second: - pytest.fail("Expected cached ChatHistory object on second call") - ctx.storage.read.assert_awaited_once() - - @pytest.mark.asyncio - async def test_load_returns_empty_on_cache_miss(self) -> None: - """When storage has no record, returns empty ChatHistory and caches it.""" - mixin = _ConcreteMixin() - ctx = _make_context() - ctx.storage.read = AsyncMock(return_value=None) - - history = await mixin.load_chat_history(ctx) - - if len(history.messages) != 0: - pytest.fail(f"Expected empty messages, got {len(history.messages)}") - await mixin.load_chat_history(ctx) - ctx.storage.read.assert_awaited_once() - - @pytest.mark.asyncio - async def test_different_missions_cached_independently(self) -> None: - """Different mission_ids get independent cache entries.""" - mixin = _ConcreteMixin() - ctx_a = _make_context(mission_id="mission_a") - ctx_b = _make_context(mission_id="mission_b") - ctx_a.storage.read = AsyncMock(return_value=None) - ctx_b.storage.read = AsyncMock(return_value=None) - - history_a = await mixin.load_chat_history(ctx_a) - history_b = await mixin.load_chat_history(ctx_b) - - if history_a is history_b: - pytest.fail("Different missions should have separate cache entries") - - @pytest.mark.asyncio - async def test_flush_only_flushes_own_mission(self) -> None: - """Flushing mission_a must not flush mission_b's dirty entries.""" - mixin = _ConcreteMixin() - ctx_a = _make_context(mission_id="mission_a") - ctx_b = _make_context(mission_id="mission_b") - - await mixin.append_chat_history_message(ctx_a, BaseRole.USER, "hello_a") - await mixin.append_chat_history_message(ctx_b, BaseRole.USER, "hello_b") - - await mixin.flush_chat_history(ctx_a) - - # mission_a flushed via its own context - ctx_a.storage.upsert.assert_awaited_once() - # mission_b NOT flushed — its dirty entry remains - ctx_b.storage.upsert.assert_not_awaited() - key_b = mixin._get_history_key(ctx_b) - assert key_b in mixin._ch_dirty, "mission_b should still be dirty" - - @pytest.mark.asyncio - async def test_concurrent_missions_use_separate_locks(self) -> None: - """Each mission gets its own flush lock so they don't block each other.""" - mixin = _ConcreteMixin() - ctx_a = _make_context(mission_id="mission_a") - ctx_b = _make_context(mission_id="mission_b") - - await mixin.append_chat_history_message(ctx_a, BaseRole.USER, "a") - await mixin.append_chat_history_message(ctx_b, BaseRole.USER, "b") - await mixin.flush_chat_history(ctx_a) - await mixin.flush_chat_history(ctx_b) - - key_a = mixin._get_history_key(ctx_a) - key_b = mixin._get_history_key(ctx_b) - assert key_a in mixin._ch_flush_locks - assert key_b in mixin._ch_flush_locks - assert mixin._ch_flush_locks[key_a] is not mixin._ch_flush_locks[key_b] - - -class TestAppendStorageOptimization: - """Tests for upsert vs update optimization with batched flush.""" - - @pytest.mark.asyncio - async def test_first_append_uses_upsert(self) -> None: - """First append to a new key uses upsert_storage after flush.""" - mixin = _ConcreteMixin() - ctx = _make_context() - - await mixin.append_chat_history_message(ctx, BaseRole.USER, "hello") - await mixin.flush_chat_history(ctx) - - ctx.storage.upsert.assert_awaited_once() - ctx.storage.update.assert_not_awaited() - - @pytest.mark.asyncio - async def test_second_append_uses_update(self) -> None: - """After first persist, subsequent appends use update_storage (1 call).""" - mixin = _ConcreteMixin() - ctx = _make_context() - - await mixin.append_chat_history_message(ctx, BaseRole.USER, "first") - await mixin.flush_chat_history(ctx) - await mixin.append_chat_history_message(ctx, BaseRole.ASSISTANT, "second") - await mixin.flush_chat_history(ctx) - - if ctx.storage.upsert.await_count != 1: - pytest.fail(f"Expected exactly 1 upsert call, got {ctx.storage.upsert.await_count}") - if ctx.storage.update.await_count != 1: - pytest.fail(f"Expected exactly 1 update call, got {ctx.storage.update.await_count}") - - @pytest.mark.asyncio - async def test_preexisting_record_uses_update_from_start(self) -> None: - """When storage already has the record, first append uses update (not upsert).""" - mixin = _ConcreteMixin() - ctx = _make_context() - existing = _storage_record_with_history([{"role": "user", "content": "old"}]) - ctx.storage.read = AsyncMock(return_value=existing) - - await mixin.load_chat_history(ctx) - await mixin.append_chat_history_message(ctx, BaseRole.ASSISTANT, "new") - await mixin.flush_chat_history(ctx) - - ctx.storage.upsert.assert_not_awaited() - ctx.storage.update.assert_awaited_once() - - @pytest.mark.asyncio - async def test_append_accumulates_messages_in_cache(self) -> None: - """Multiple appends accumulate in the cached ChatHistory object.""" - mixin = _ConcreteMixin() - ctx = _make_context() - - await mixin.append_chat_history_message(ctx, BaseRole.USER, "msg1") - await mixin.append_chat_history_message(ctx, BaseRole.ASSISTANT, "msg2") - await mixin.append_chat_history_message(ctx, BaseRole.USER, "msg3") - - history = await mixin.load_chat_history(ctx) - if len(history.messages) != 3: - pytest.fail(f"Expected 3 messages in cache, got {len(history.messages)}") - ctx.storage.read.assert_awaited_once() - - -class TestBatchingBehavior: - """Tests for batched flush behavior.""" - - @pytest.mark.asyncio - async def test_append_does_not_write_below_threshold(self) -> None: - """Messages below threshold are buffered, not written to storage.""" - mixin = _ConcreteMixin() - ctx = _make_context() - - await mixin.append_chat_history_message(ctx, BaseRole.USER, "hello") - - ctx.storage.upsert.assert_not_awaited() - ctx.storage.update.assert_not_awaited() - - @pytest.mark.asyncio - async def test_threshold_triggers_flush(self) -> None: - """Reaching the threshold auto-flushes to storage.""" - mixin = _ConcreteMixin() - mixin._ch_flush_threshold = 3 - ctx = _make_context() - - await mixin.append_chat_history_message(ctx, BaseRole.USER, "1") - await mixin.append_chat_history_message(ctx, BaseRole.USER, "2") - ctx.storage.upsert.assert_not_awaited() - - await mixin.append_chat_history_message(ctx, BaseRole.USER, "3") - ctx.storage.upsert.assert_awaited_once() - - @pytest.mark.asyncio - async def test_flush_clears_dirty_state(self) -> None: - """After flush, dirty state is cleared — second flush is a no-op.""" - mixin = _ConcreteMixin() - ctx = _make_context() - - await mixin.append_chat_history_message(ctx, BaseRole.USER, "hello") - await mixin.flush_chat_history(ctx) - - ctx.storage.upsert.reset_mock() - await mixin.flush_chat_history(ctx) - ctx.storage.upsert.assert_not_awaited() - ctx.storage.update.assert_not_awaited() - - @pytest.mark.asyncio - async def test_flush_failure_leaves_dirty_for_retry(self) -> None: - """If flush fails, dirty state is preserved for retry on next flush.""" - mixin = _ConcreteMixin() - ctx = _make_context() - ctx.storage.upsert = AsyncMock(side_effect=RuntimeError("storage down")) - - await mixin.append_chat_history_message(ctx, BaseRole.USER, "hello") - await mixin.flush_chat_history(ctx) - - # Dirty state preserved - if not mixin._ch_dirty: - pytest.fail("Expected dirty state to be preserved after flush failure") - - # Fix storage and retry - ctx.storage.upsert = AsyncMock(return_value=MagicMock(spec=StorageRecord)) - await mixin.flush_chat_history(ctx) - - ctx.storage.upsert.assert_awaited_once() - if mixin._ch_dirty: - pytest.fail("Expected dirty state to be cleared after successful retry") - - -# --------------------------------------------------------------------------- -# Tests exercising the real user pattern: TriggerHandler subclass -# --------------------------------------------------------------------------- - -class _FakeInput: - protocol: Literal["test"] = "test" - - -class _FakeOutput: - pass - - -class _GoodTrigger(TriggerHandler): - """User handler that correctly calls super().__init__().""" - - protocol = "test" - description: ClassVar[str] = "" - input_format = _FakeInput - output_format = _FakeOutput - - def __init__(self, context: ModuleContext) -> None: - super().__init__(context) - self.custom_attr = "ok" - - async def handle(self, input_data: Any, setup_data: Any, context: ModuleContext) -> None: - pass # pragma: no cover - - -class _BadTrigger(TriggerHandler): - """User handler that forgets super().__init__() — the exact production bug.""" - - protocol = "test" - description: ClassVar[str] = "" - input_format = _FakeInput - output_format = _FakeOutput - - def __init__(self, context: ModuleContext) -> None: - # Deliberately missing super().__init__(context) - self.custom_attr = "ok" - - async def handle(self, input_data: Any, setup_data: Any, context: ModuleContext) -> None: - pass # pragma: no cover - - -class _NoInitTrigger(TriggerHandler): - """User handler that doesn't override __init__ at all (relies on inherited).""" - - protocol = "test" - description: ClassVar[str] = "" - input_format = _FakeInput - output_format = _FakeOutput - - async def handle(self, input_data: Any, setup_data: Any, context: ModuleContext) -> None: - pass # pragma: no cover - - -def _make_mock_context() -> MagicMock: - """Build a minimal mock ModuleContext for TriggerHandler instantiation.""" - ctx = MagicMock() - ctx.session.mission_id = "test_mission" - ctx.storage = AsyncMock() - ctx.storage.read = AsyncMock(return_value=None) - ctx.storage.upsert = AsyncMock(return_value=MagicMock(spec=StorageRecord)) - ctx.storage.update = AsyncMock(return_value=MagicMock(spec=StorageRecord)) - return ctx - - -class TestTriggerHandlerMixinInit: - """Verify ChatHistoryMixin works through TriggerHandler — the real user path.""" - - @pytest.mark.asyncio - async def test_good_trigger_init_sets_cache(self) -> None: - """Handler that calls super().__init__() gets mixin state via __init__ chain.""" - handler = _GoodTrigger(_make_mock_context()) - assert handler._ch_cache is not None - assert handler.custom_attr == "ok" - - @pytest.mark.asyncio - async def test_bad_trigger_lazy_init_on_load(self) -> None: - """Handler that forgets super().__init__() still works via lazy init.""" - handler = _BadTrigger(_make_mock_context()) - ctx = _make_mock_context() - - # _ch_cache is still None (sentinel) because __init__ chain was broken - history = await handler.load_chat_history(ctx) - # Lazy init kicked in — no crash, returns empty history - assert len(history.messages) == 0 - assert handler._ch_cache is not None - - @pytest.mark.asyncio - async def test_bad_trigger_append_and_flush(self) -> None: - """Full append+flush cycle works even with broken __init__ chain.""" - handler = _BadTrigger(_make_mock_context()) - ctx = _make_mock_context() - - await handler.append_chat_history_message(ctx, BaseRole.USER, "hello") - await handler.flush_chat_history(ctx) - - ctx.storage.upsert.assert_awaited_once() - - @pytest.mark.asyncio - async def test_no_init_trigger_works(self) -> None: - """Handler that doesn't override __init__ inherits working chain.""" - handler = _NoInitTrigger(_make_mock_context()) - ctx = _make_mock_context() - - await handler.append_chat_history_message(ctx, BaseRole.USER, "test") - history = await handler.load_chat_history(ctx) - assert len(history.messages) == 1 - - -class TestChCacheCleanup: - """Tests for clear_ch_mission_cache.""" - - @pytest.mark.asyncio - async def test_clear_removes_mission_state(self) -> None: - """clear_ch_mission_cache removes all state for a mission.""" - mixin = _ConcreteMixin() - ctx = _make_context() - await mixin.append_chat_history_message(ctx, BaseRole.USER, "hello") - await mixin.flush_chat_history(ctx) - - mixin.clear_ch_mission_cache(ctx) - - key = mixin._get_history_key(ctx) - assert key not in mixin._ch_cache - assert key not in mixin._ch_persisted - assert key not in mixin._ch_dirty - assert key not in mixin._ch_flush_locks - - @pytest.mark.asyncio - async def test_clear_does_not_affect_other_missions(self) -> None: - """Clearing mission_a leaves mission_b intact.""" - mixin = _ConcreteMixin() - ctx_a = _make_context(mission_id="mission_a") - ctx_b = _make_context(mission_id="mission_b") - await mixin.append_chat_history_message(ctx_a, BaseRole.USER, "a") - await mixin.append_chat_history_message(ctx_b, BaseRole.USER, "b") - - mixin.clear_ch_mission_cache(ctx_a) - - key_b = mixin._get_history_key(ctx_b) - assert key_b in mixin._ch_cache - - -class TestChConcurrentOperations: - """Tests for concurrent chat history operations.""" - - @pytest.mark.asyncio - async def test_concurrent_appends_same_mission(self) -> None: - """Multiple concurrent appends to same mission don't lose messages.""" - mixin = _ConcreteMixin() - ctx = _make_context() - - await asyncio.gather( - *(mixin.append_chat_history_message(ctx, BaseRole.USER, f"msg_{i}") for i in range(20)) - ) - - history = await mixin.load_chat_history(ctx) - assert len(history.messages) == 20 - - @pytest.mark.asyncio - async def test_concurrent_appends_different_missions(self) -> None: - """Concurrent appends to different missions don't cross-contaminate.""" - mixin = _ConcreteMixin() - ctx_a = _make_context(mission_id="a") - ctx_b = _make_context(mission_id="b") - - coros = [] - for i in range(10): - coros.append(mixin.append_chat_history_message(ctx_a, BaseRole.USER, f"a_{i}")) - coros.append(mixin.append_chat_history_message(ctx_b, BaseRole.USER, f"b_{i}")) - await asyncio.gather(*coros) - - history_a = await mixin.load_chat_history(ctx_a) - history_b = await mixin.load_chat_history(ctx_b) - assert len(history_a.messages) == 10 - assert len(history_b.messages) == 10 - assert all("a_" in str(m.content) for m in history_a.messages) - assert all("b_" in str(m.content) for m in history_b.messages) diff --git a/tests/modules/test_base_module_lifecycle.py b/tests/modules/test_base_module_lifecycle.py index fb6c0983..fdabda3a 100644 --- a/tests/modules/test_base_module_lifecycle.py +++ b/tests/modules/test_base_module_lifecycle.py @@ -484,9 +484,7 @@ async def test_flushes_instance_trigger_handlers(self) -> None: with patch.object(module, "cleanup", new_callable=AsyncMock): await module.stop() - h1.flush_chat_history.assert_awaited_once_with(module.context) h1.flush_file_history.assert_awaited_once_with(module.context) - h2.flush_chat_history.assert_awaited_once_with(module.context) h2.flush_file_history.assert_awaited_once_with(module.context) async def test_does_not_call_clear_mission_cache(self) -> None: @@ -723,6 +721,5 @@ async def test_stop_only_flushes_own_handlers(self) -> None: with patch.object(m1, "cleanup", new_callable=AsyncMock): await m1.stop() - h1.flush_chat_history.assert_awaited_once() - h2.flush_chat_history.assert_not_called() + h1.flush_file_history.assert_awaited_once() h2.flush_file_history.assert_not_called() diff --git a/tests/services/cost/test_cost_stress.py b/tests/services/cost/test_cost_stress.py index 5ad2602f..cc76be5e 100644 --- a/tests/services/cost/test_cost_stress.py +++ b/tests/services/cost/test_cost_stress.py @@ -22,15 +22,16 @@ import grpc_testing import pytest from agentic_mesh_protocol.cost.v1 import cost_service_pb2, cost_service_pb2_grpc -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext -from tests.services.cost.mock_cost_servicer import MockCostServicer -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.grpc_servers.models import ClientConfig from digitalkin.models.services.cost import AmountLimit, CostTypeEnum, QuantityLimit +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.cost.cost_strategy import CostConfig, CostServiceError from digitalkin.services.cost.default_cost import DefaultCost from digitalkin.services.cost.grpc_cost import GrpcCost +from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext from tests.fixtures.stress_reporter import StressReporter +from tests.services.cost.mock_cost_servicer import MockCostServicer # Set timeout for stress tests pytestmark = pytest.mark.timeout(60) @@ -118,7 +119,7 @@ def grpc_client( dummy_config = ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, credentials=None, ) diff --git a/tests/services/cost/test_grpc_cost.py b/tests/services/cost/test_grpc_cost.py index f1c7bffc..6e538359 100644 --- a/tests/services/cost/test_grpc_cost.py +++ b/tests/services/cost/test_grpc_cost.py @@ -13,13 +13,14 @@ import grpc_testing import pytest from agentic_mesh_protocol.cost.v1 import cost_service_pb2, cost_service_pb2_grpc -from mock_cost_servicer import MockCostServicer -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext from digitalkin.grpc_servers.utils.exceptions import ServerError -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.cost.cost_strategy import CostConfig, CostData, CostServiceError, CostType from digitalkin.services.cost.grpc_cost import GrpcCost +from mock_cost_servicer import MockCostServicer +from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext service_instance = MockCostServicer() service_name = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] @@ -133,7 +134,7 @@ def client(test_channel: grpc_testing.Channel, cost_config: dict[str, CostConfig dummy_config = ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, credentials=None, ) diff --git a/tests/services/filesystem/test_grpc_filesystem.py b/tests/services/filesystem/test_grpc_filesystem.py index 05bd1f76..57cfb5ba 100644 --- a/tests/services/filesystem/test_grpc_filesystem.py +++ b/tests/services/filesystem/test_grpc_filesystem.py @@ -16,10 +16,9 @@ ) from google.protobuf import struct_pb2 from grpc.framework.foundation import logging_pool -from mock_filesystem_servicer import MockFilesystemServicer -from tests.fixtures.grpc_fixtures import FakeContext -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.filesystem.filesystem_strategy import ( FileFilter, FilesystemRecord, @@ -27,6 +26,8 @@ UploadFileData, ) from digitalkin.services.filesystem.grpc_filesystem import GrpcFilesystem +from mock_filesystem_servicer import MockFilesystemServicer +from tests.fixtures.grpc_fixtures import FakeContext service_instance = MockFilesystemServicer() service_name = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] @@ -77,7 +78,7 @@ def client(test_channel: grpc_testing.Channel) -> GrpcFilesystem: dummy_config = ClientConfig( host="[::]", port=50151, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, credentials=None, ) diff --git a/tests/services/registry/test_grpc_registry.py b/tests/services/registry/test_grpc_registry.py index f0435068..2e4269e2 100644 --- a/tests/services/registry/test_grpc_registry.py +++ b/tests/services/registry/test_grpc_registry.py @@ -20,15 +20,16 @@ registry_service_pb2, registry_service_pb2_grpc, ) -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext -from tests.services.registry.mock_registry_servicer import MockRegistryServicer -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.grpc_servers.models import ClientConfig from digitalkin.models.services.registry import RegistryModuleStatus, RegistryModuleType +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.registry.exceptions import ( RegistryServiceError, ) from digitalkin.services.registry.grpc_registry import GrpcRegistry +from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext +from tests.services.registry.mock_registry_servicer import MockRegistryServicer # Set timeout for all tests in this file (20 seconds) pytestmark = pytest.mark.timeout(20) @@ -85,7 +86,7 @@ def dummy_client_config() -> ClientConfig: return ClientConfig( host="localhost", port=50052, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, credentials=None, ) diff --git a/tests/services/setup/test_grpc_setup.py b/tests/services/setup/test_grpc_setup.py index 4e599674..8345908b 100644 --- a/tests/services/setup/test_grpc_setup.py +++ b/tests/services/setup/test_grpc_setup.py @@ -15,12 +15,13 @@ setup_service_pb2_grpc, ) from freezegun import freeze_time -from mock_setup_servicer import MockSetupServicer -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.setup.grpc_setup import GrpcSetup from digitalkin.services.setup.setup_strategy import SetupData, SetupVersionData +from mock_setup_servicer import MockSetupServicer +from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext service_instance = MockSetupServicer() service_name = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] @@ -74,7 +75,7 @@ def client(test_channel: grpc_testing.Channel) -> GrpcSetup: dummy_config = ClientConfig( host="[::]", port=50151, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, credentials=None, ) diff --git a/tests/services/storage/test_grpc_storage.py b/tests/services/storage/test_grpc_storage.py index 3b5f9975..e485dfe1 100644 --- a/tests/services/storage/test_grpc_storage.py +++ b/tests/services/storage/test_grpc_storage.py @@ -15,12 +15,12 @@ import pytest from agentic_mesh_protocol.storage.v1 import data_pb2, storage_service_pb2, storage_service_pb2_grpc from pydantic import BaseModel, Field -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext -from tests.services.storage.mock_storage_servicer import MockStorageServicer from digitalkin.models.grpc_servers.models import ClientConfig from digitalkin.services.storage.grpc_storage import GrpcStorage from digitalkin.services.storage.storage_strategy import DataType, StorageServiceError +from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext +from tests.services.storage.mock_storage_servicer import MockStorageServicer # Set timeout for all tests in this file (20 seconds) pytestmark = pytest.mark.timeout(20) @@ -118,12 +118,13 @@ def dummy_client_config() -> ClientConfig: Returns: ClientConfig instance with test values """ - from digitalkin.models.grpc_servers.models import SecurityMode, ServerMode + from digitalkin.models.settings.utils.channel import SecurityMode + from digitalkin.models.settings.utils.channel import ControlFlow return ClientConfig( host="localhost", port=50051, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, credentials=None, ) diff --git a/tests/services/task_manager/test_grpc_task_manager.py b/tests/services/task_manager/test_grpc_task_manager.py index 7d1dce3e..5af8d098 100644 --- a/tests/services/task_manager/test_grpc_task_manager.py +++ b/tests/services/task_manager/test_grpc_task_manager.py @@ -21,13 +21,13 @@ ) from google.protobuf.struct_pb2 import Struct from google.protobuf.timestamp_pb2 import Timestamp -from mock_task_manager_servicer import MockTaskManagerServicer -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext from digitalkin.models.core.task_monitor import CancellationReason, SignalMessage, SignalType -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.task_manager.grpc_task_manager import GrpcTaskManager, _SharedPoller, _SharedSendBuffer -from digitalkin.services.task_manager.task_manager_strategy import TaskManagerServiceError +from mock_task_manager_servicer import MockTaskManagerServicer +from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext # Set timeout for all tests in this file (30 seconds) pytestmark = pytest.mark.timeout(30) @@ -102,7 +102,7 @@ def client(test_channel: grpc_testing.Channel) -> GrpcTaskManager: dummy_config = ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, credentials=None, ) @@ -112,7 +112,6 @@ def client(test_channel: grpc_testing.Channel) -> GrpcTaskManager: setup_id=SETUP_ID, setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, - poll_interval=0.1, ) # Override the stub to use the test channel @@ -566,12 +565,11 @@ async def test_subscribe_returns_sub_id_and_generator(self) -> None: """Test that subscribe returns a subscription ID and async generator.""" dummy_config = ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( mission_id=MISSION_ID, setup_id=SETUP_ID, setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, - poll_interval=0.1, ) # Mock stub.GetSignals (SharedPoller calls stub directly) client.stub = Mock() @@ -593,12 +591,11 @@ async def test_unsubscribe_stops_polling(self) -> None: """Test that unsubscribing stops the poll generator.""" dummy_config = ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( mission_id=MISSION_ID, setup_id=SETUP_ID, setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, - poll_interval=0.05, ) call_count = 0 @@ -629,12 +626,11 @@ async def test_subscribe_yields_signals(self) -> None: """Test that polling yields signals from GetSignals response.""" dummy_config = ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( mission_id=MISSION_ID, setup_id=SETUP_ID, setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, - poll_interval=0.05, ) # Build a proto task to return @@ -851,7 +847,7 @@ async def test_concurrent_subscriptions_independent(self) -> None: """Test that multiple subscriptions are independent.""" dummy_config = ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( mission_id=MISSION_ID, setup_id=SETUP_ID, @@ -893,7 +889,7 @@ async def test_close_stops_all_subscriptions(self) -> None: """Test that close() stops all active subscriptions.""" dummy_config = ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( mission_id=MISSION_ID, setup_id=SETUP_ID, @@ -928,7 +924,7 @@ async def test_close_idempotent(self) -> None: """Test that close() can be called multiple times safely.""" dummy_config = ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( mission_id=MISSION_ID, setup_id=SETUP_ID, diff --git a/tests/services/task_manager/test_shared_poller_advanced.py b/tests/services/task_manager/test_shared_poller_advanced.py index a957fdee..4be71c0a 100644 --- a/tests/services/task_manager/test_shared_poller_advanced.py +++ b/tests/services/task_manager/test_shared_poller_advanced.py @@ -16,6 +16,7 @@ import contextlib from collections import defaultdict from itertools import count +from unittest.mock import Mock import pytest from agentic_mesh_protocol.task_manager.v1 import ( @@ -24,9 +25,9 @@ ) from google.protobuf.struct_pb2 import Struct from google.protobuf.timestamp_pb2 import Timestamp -from unittest.mock import Mock -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.settings.utils.channel import ControlFlow, SecurityMode from digitalkin.services.task_manager.grpc_task_manager import GrpcTaskManager, _SharedPoller, _SharedSendBuffer pytestmark = pytest.mark.timeout(30) @@ -60,7 +61,7 @@ def _proto(task_id: str, action: str, ts: int | None = None) -> task_manager_mes def _client(poll_interval: float = 0.1, initial: float = 0.05) -> GrpcTaskManager: - cfg = ClientConfig(host="[::]", port=50051, mode=ServerMode.ASYNC, security=SecurityMode.INSECURE) + cfg = ClientConfig(host="[::]", port=50051, mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE) c = GrpcTaskManager( mission_id=_MISSION, setup_id=_SETUP, diff --git a/tests/services/user_profile/test_grpc_user_profile.py b/tests/services/user_profile/test_grpc_user_profile.py index b5a86054..5cb117e3 100644 --- a/tests/services/user_profile/test_grpc_user_profile.py +++ b/tests/services/user_profile/test_grpc_user_profile.py @@ -20,11 +20,11 @@ user_profile_service_pb2, user_profile_service_pb2_grpc, ) -from tests.fixtures.grpc_fixtures import FakeContext -from tests.services.user_profile.mock_user_profile_servicer import MockUserProfileServicer from digitalkin.models.grpc_servers.models import ClientConfig from digitalkin.services.user_profile.grpc_user_profile import GrpcUserProfile +from tests.fixtures.grpc_fixtures import FakeContext +from tests.services.user_profile.mock_user_profile_servicer import MockUserProfileServicer # Set timeout for all tests in this file (20 seconds) pytestmark = pytest.mark.timeout(20) @@ -98,12 +98,13 @@ def dummy_client_config() -> ClientConfig: Returns: ClientConfig instance with test values """ - from digitalkin.models.grpc_servers.models import SecurityMode, ServerMode + from digitalkin.models.settings.utils.channel import SecurityMode + from digitalkin.models.settings.utils.channel import ControlFlow return ClientConfig( host="[::]", port=50051, - mode=ServerMode.ASYNC, + mode=ControlFlow.ASYNC, security=SecurityMode.INSECURE, credentials=None, ) diff --git a/uv.lock b/uv.lock index 499ae224..c9f89002 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,18 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[[package]] +name = "ag-ui-protocol" +version = "0.1.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ee/319d189343e1dc67b1109c950a0d1091fe32498104b5917fbbd806ff58dd/ag_ui_protocol-0.1.14.tar.gz", hash = "sha256:d8e86b308f86a6cf6a5e18ca7154d7642895de2fe94cd2cece57723cdbba6406", size = 5687, upload-time = "2026-03-18T00:43:13.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/99/eaa83816924791fc25ebb44ac7987196b687a3fdf597b1e7a62c69306a8d/ag_ui_protocol-0.1.14-py3-none-any.whl", hash = "sha256:ec072e6a45e0d45b8714e6d54919cc9bde3d097fdc36f7e82953b2f21f1cdbef", size = 8069, upload-time = "2026-03-18T00:43:12.1Z" }, +] + [[package]] name = "agentic-mesh-protocol" version = "0.2.3" @@ -325,7 +337,7 @@ wheels = [ [[package]] name = "build" -version = "1.4.1" +version = "1.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "os_name == 'nt'" }, @@ -334,9 +346,9 @@ dependencies = [ { name = "pyproject-hooks" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/12/fa7bd9f677a2dcc58a395217c221e2a5e5cebd59ddc9756bc4f5fede8719/build-1.4.1.tar.gz", hash = "sha256:30adeb28821e573a49b556030d8c84186d112f6a38b12fa5476092c4544ae55a", size = 83276, upload-time = "2026-03-24T23:09:00.209Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/1d/ab15c8ac57f4ee8778d7633bc6685f808ab414437b8644f555389cdc875e/build-1.4.2.tar.gz", hash = "sha256:35b14e1ee329c186d3f08466003521ed7685ec15ecffc07e68d706090bf161d1", size = 83433, upload-time = "2026-03-25T14:20:27.659Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/54/8d858f562f598897a7e5e89a8da4f54de06bcd85a98add1275c84efc9ce4/build-1.4.1-py3-none-any.whl", hash = "sha256:21c81f7a0fa423f0da229335c5c2a605967fbfc9af3c4b6ecd368265ed59c6bc", size = 24633, upload-time = "2026-03-24T23:08:58.677Z" }, + { url = "https://files.pythonhosted.org/packages/4a/57/3b7d4dd193ade4641c865bc2b93aeeb71162e81fc348b8dad020215601ed/build-1.4.2-py3-none-any.whl", hash = "sha256:7a4d8651ea877cb2a89458b1b198f2e69f536c95e89129dbf5d448045d60db88", size = 24643, upload-time = "2026-03-25T14:20:26.568Z" }, ] [[package]] @@ -750,62 +762,62 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, ] [[package]] @@ -838,9 +850,10 @@ wheels = [ [[package]] name = "digitalkin" -version = "0.3.4.dev0" +version = "0.4.1.dev5" source = { editable = "." } dependencies = [ + { name = "ag-ui-protocol" }, { name = "agentic-mesh-protocol" }, { name = "anyio" }, { name = "grpcio-health-checking" }, @@ -870,7 +883,6 @@ dev = [ { name = "cryptography" }, { name = "mypy" }, { name = "pre-commit" }, - { name = "pyright" }, { name = "ruff" }, { name = "twine" }, { name = "types-grpcio" }, @@ -918,6 +930,7 @@ tests = [ [package.metadata] requires-dist = [ + { name = "ag-ui-protocol", specifier = ">=0.1.14" }, { name = "agentic-mesh-protocol", specifier = "==0.2.3" }, { name = "anyio", specifier = "==4.13.0" }, { name = "asyncio-inspector", marker = "extra == 'profiling'", specifier = "==0.1.0" }, @@ -937,13 +950,12 @@ provides-extras = ["profiling", "taskiq"] [package.metadata.requires-dev] dev = [ - { name = "build", specifier = "==1.4.1" }, + { name = "build", specifier = "==1.4.2" }, { name = "bump-my-version", specifier = "==1.2.7" }, - { name = "cryptography", specifier = "==46.0.5" }, - { name = "mypy", specifier = "==1.19.1" }, + { name = "cryptography", specifier = "==46.0.6" }, + { name = "mypy", specifier = "==1.20.2" }, { name = "pre-commit", specifier = "==4.5.1" }, - { name = "pyright", specifier = "==1.1.408" }, - { name = "ruff", specifier = "==0.15.7" }, + { name = "ruff", specifier = "==0.15.11" }, { name = "twine", specifier = "==6.2.0" }, { name = "types-grpcio", specifier = "==1.0.0.20251009" }, { name = "types-grpcio-health-checking", specifier = "==1.0.0.20250506" }, @@ -973,7 +985,7 @@ docs = [ { name = "mkdocs-section-index", specifier = "==0.3.11" }, { name = "mkdocstrings", specifier = "==1.0.3" }, { name = "mkdocstrings-python", specifier = "==2.0.3" }, - { name = "tomli", specifier = "==2.4.0" }, + { name = "tomli", specifier = "==2.4.1" }, ] tests = [ { name = "freezegun", specifier = "==1.5.5" }, @@ -2547,7 +2559,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "1.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -2556,39 +2568,51 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, ] [[package]] @@ -3377,19 +3401,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] -[[package]] -name = "pyright" -version = "1.1.408" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, -] - [[package]] name = "pytest" version = "9.0.2" @@ -3723,27 +3734,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]] @@ -3953,56 +3964,56 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]]