Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### 🔧 Changed

- **Delivery results are now the delivered content, in one shape** (breaking, [#250](https://github.com/valory-xyz/mech-client/issues/250))
- `send_request` now returns a single `deliveries[request_id]` key holding a
`DeliveryResult`, replacing `delivery_results`. Previously the on-chain flow returned
a URL string and the off-chain flow returned the mech's raw envelope, so a caller
could not write one handler.
- The on-chain URL addressed the delivery *directory*, which serves an HTML listing;
the result file inside is named after the request ID in decimal. Both watchers now
build that full path and read the file themselves.
- `DeliveryResult.data` is the parsed result file, `None` if the gateway could not be
read. `DeliveryResult.url` is the result-file URL to retry with, `None` for off-chain
mechs that answer inline. Pairing content with its location in one object keeps the
two from drifting apart per request.
- `mechx request` prints the mech's answer (decoding the JSON-encoded `result` field
when the payload has one, otherwise the payload itself) followed by the result-file
URL.
- **NVM Subscription Purchase**: Now uses layered architecture with strategy patterns
- Supports both agent mode (Safe multisig) and client mode (EOA)
- Chain-specific payment handling (native xDAI for Gnosis, USDC for Base)
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,8 @@ You can also use the Mech Client as a library on your Python project.
3. Edit `my_script.py` as follows:

```python
import json

from mech_client.services import MarketplaceService
from mech_client.domain.payment import PaymentType
from mech_client.infrastructure.config import get_mech_config
Expand Down Expand Up @@ -469,8 +471,21 @@ You can also use the Mech Client as a library on your Python project.
)

print(f"Transaction hash: {result['tx_hash']}")
print(f"Request ID: {result['request_ids'][0]}")
print(f"Result: {result.get('result')}")

# `deliveries` maps each request ID to a DeliveryResult: `.data` is the
# parsed content the mech delivered, `.url` the IPFS URL it was read from.
# Same shape whether delivery was on-chain or off-chain.
for request_id, delivery in result["deliveries"].items():
print(f"Request {request_id}: {delivery.url}")
payload = delivery.data
if payload is None:
print(" result file could not be read")
continue
# Mechs typically put the answer in a `result` field, JSON-encoded as a
# string. That is a convention rather than a guarantee, so fall back to
# the payload itself for tools that do not follow it.
answer = payload.get("result") if isinstance(payload, dict) else None
print(f" {json.loads(answer) if isinstance(answer, str) else payload}")
```

**Note:** See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for architecture details and more examples.
Expand Down
10 changes: 10 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,9 @@ no ECDSA over the SafeTx hash is needed.
#### Delivery Watchers (`domain/delivery/`)
Handle response delivery mechanisms:
- `onchain_watcher.py`: On-chain event watching
- `offchain_watcher.py`: Offchain endpoint polling
- `base.py`: Delivery watcher interface
- `models.py`: `DeliveryResult`, the shape both watchers return

**Key Abstractions**:
```python
Expand All @@ -362,6 +364,11 @@ class DeliveryWatcher(ABC):
"""Watch for delivery. Returns results by request_id."""
```

Both watchers resolve the delivery to its content before returning, so the
on-chain and offchain paths hand callers the same `DeliveryResult` (parsed
`data` plus the gateway `url` it came from) rather than a URL on one path and
an endpoint envelope on the other.

#### Tool Managers (`domain/tools/`)
Handle tool metadata and discovery:
- `marketplace_manager.py`: Marketplace tool operations
Expand Down Expand Up @@ -639,7 +646,10 @@ class OnchainDeliveryWatcher(DeliveryWatcher):
|-----------|-------|---------|
| `DeliveryWatcher` | Domain | Abstract delivery interface |
| `OnchainDeliveryWatcher` | Domain | On-chain event watching |
| `OffchainDeliveryWatcher` | Domain | Offchain endpoint polling |
| `DeliveryResult` | Domain | Resolved delivery (content + URL) |
| `wait_for_receipt` | Infrastructure | Transaction receipt polling |
| `result_file` | Infrastructure | Reads delivered results from the IPFS gateway |

### Tool Components

Expand Down
8 changes: 7 additions & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,13 @@ def test_request_command_success() -> None:
return_value={
"tx_hash": "0xabc123...",
"request_ids": [1],
"delivery_results": {1: "ipfs://Qm..."},
"deliveries": {
1: DeliveryResult(
"1",
data={"result": '"answer"'},
url="https://gateway.../ipfs/f0170.../1",
)
},
}
)
mock_service.return_value = mock_service_instance
Expand Down
12 changes: 11 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,17 @@ Replace the placeholders as follows:

**Note:** If using agent mode (`AGENT_MODE = True`), you must provide a valid `SAFE_ADDRESS`. For client mode, set `AGENT_MODE = False` and `SAFE_ADDRESS = ""`.

The variable **result** contains the response of the mech.
The variable **result** contains the response of the mech:

`result["deliveries"]` maps each request ID to a `DeliveryResult` — the same shape for
on-chain and off-chain requests alike. Each one carries two fields:

- `.data` is the parsed content the mech delivered. Mechs typically put their answer in
the payload's `result` field as a JSON-encoded string, so reading it takes a
`json.loads`; treat that as a convention rather than a guarantee and handle payloads
that omit the field. It is `None` if the result file could not be read.
- `.url` is the IPFS URL that content was read from, so you can re-fetch or link to it.
It is `None` for off-chain mechs that answer inline instead of pinning a result file.


## 2. Tool Management
Expand Down
51 changes: 40 additions & 11 deletions mech_client/cli/commands/request_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,50 @@
from click import ClickException
from mech_client.cli.common import common_wallet_options, setup_wallet_command
from mech_client.cli.validators import validate_chain_config, validate_ethereum_address
from mech_client.infrastructure.config import IPFS_URL_TEMPLATE
from mech_client.services.marketplace_service import MarketplaceService
from mech_client.utils.errors.handlers import handle_cli_errors
from mech_client.utils.types import JSONValue
from mech_client.utils.validators import (
validate_batch_sizes_match,
validate_extra_attributes,
validate_timeout,
)


def _format_delivery_output(delivery_data: Any) -> str:
"""Format delivery data for CLI output with parity across delivery modes."""
if isinstance(delivery_data, dict):
task_result = delivery_data.get("task_result")
if isinstance(task_result, str) and task_result:
return IPFS_URL_TEMPLATE.format(task_result)
return json.dumps(delivery_data, ensure_ascii=True, indent=2, sort_keys=True)
def _dump(value: JSONValue) -> str:
"""
Render a value parsed out of a delivered result file for CLI output.

:param value: A value decoded from JSON, so always JSON-serialisable.
:return: Indented JSON.
"""
return json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True)
Comment thread
OjusWiZard marked this conversation as resolved.


def _format_delivery_output(delivery_data: JSONValue) -> str:
"""
Format delivery data for CLI output with parity across delivery modes.

:param delivery_data: Parsed content of the delivered result file.
:return: The mech's answer, ready to print.
"""
if delivery_data is None:
return "unavailable — could not read the result file"

if isinstance(delivery_data, dict) and "result" in delivery_data:
result = delivery_data["result"]
# Mechs store `result` as a JSON-encoded string; decode it so the
# answer prints as itself rather than as an escaped blob.
if isinstance(result, str):
try:
result = json.loads(result)
except ValueError:
return result
return result if isinstance(result, str) else _dump(result)

if isinstance(delivery_data, (dict, list)):
return _dump(delivery_data)

return str(delivery_data)


Expand Down Expand Up @@ -226,9 +253,11 @@ def request(
# Display results
click.echo(f"\n✓ Transaction hash: {result['tx_hash']}")
click.echo(f"✓ Request IDs: {result['request_ids']}")
if result.get("delivery_results"):
if result.get("deliveries"):
click.echo("\n✓ Delivery results:")
for request_id, delivery_data in result["delivery_results"].items():
for request_id, delivery in result["deliveries"].items():
click.echo(
f" Request {request_id}: {_format_delivery_output(delivery_data)}"
f" Request {request_id}: {_format_delivery_output(delivery.data)}"
)
if delivery.url:
click.echo(f" Result file: {delivery.url}")
2 changes: 2 additions & 0 deletions mech_client/domain/delivery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@

from mech_client.domain.delivery.base import DeliveryWatcher
from mech_client.domain.delivery.constants import DEFAULT_TIMEOUT, WAIT_SLEEP
from mech_client.domain.delivery.models import DeliveryResult
from mech_client.domain.delivery.offchain_watcher import OffchainDeliveryWatcher
from mech_client.domain.delivery.onchain_watcher import OnchainDeliveryWatcher

__all__ = [
"DeliveryResult",
"DeliveryWatcher",
"OffchainDeliveryWatcher",
"OnchainDeliveryWatcher",
Expand Down
11 changes: 8 additions & 3 deletions mech_client/domain/delivery/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
"""Base delivery watcher interface."""

from abc import ABC, abstractmethod
from typing import Any, Dict, List
from typing import Dict, List

from mech_client.domain.delivery.models import DeliveryResult


class DeliveryWatcher(ABC): # pylint: disable=too-few-public-methods
Expand All @@ -39,11 +41,14 @@ def __init__(self, timeout: float):
self.timeout = timeout

@abstractmethod
async def watch(self, request_ids: List[str]) -> Dict[str, Any]:
async def watch(self, request_ids: List[str]) -> Dict[str, DeliveryResult]:
"""
Watch for delivery of mech responses.

Implementations resolve the delivery to its content before returning,
so every mechanism hands callers the same shape.

:param request_ids: List of request IDs to watch for
:return: Dictionary mapping request ID to delivery data
:return: Dictionary mapping request ID to its delivery result
"""
...
43 changes: 43 additions & 0 deletions mech_client/domain/delivery/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# ------------------------------------------------------------------------------
#
# Copyright 2026 Valory AG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# ------------------------------------------------------------------------------

"""Models shared by the delivery watchers."""

from dataclasses import dataclass
from typing import Optional

from mech_client.utils.types import JSONValue


@dataclass(frozen=True)
class DeliveryResult:
"""A mech delivery, resolved to its content.

Both watchers return this, so callers get the same shape whether the
response was delivered on-chain or off-chain.

``data`` holds the parsed content of the delivered result file, or
``None`` when the gateway could not be read (``url`` still points at it).
``url`` is the gateway URL ``data`` was read from; it is ``None`` for
offchain mechs that answer inline instead of pinning a result file.
"""

request_id: str
Comment thread
OjusWiZard marked this conversation as resolved.
data: JSONValue = None
url: Optional[str] = None
Comment thread
OjusWiZard marked this conversation as resolved.
Comment thread
OjusWiZard marked this conversation as resolved.
Loading