fetch-hackkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ """Pre-submission checks: ``hackkit doctor``.
2
+
3
+ Each check maps to a hackathon requirement (registered, discoverable via
4
+ ASI:One, chat protocol, README with badges + address) and says how to fix it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+
16
+ from .register import lookup_agent, profile_url, resolve
17
+
18
+ CHAT_PROTOCOL_DIGEST = "30a801ed3a83f9a0ff0a9f1e6fe958cb91da1fc2218b153df7b6cbf87bd33d62"
19
+ BADGES = (
20
+ "![tag:innovationlab](https://img.shields.io/badge/innovationlab-3D8BD3)",
21
+ "![tag:hackathon](https://img.shields.io/badge/hackathon-5F43F1)",
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class Check:
27
+ name: str
28
+ status: str # "ok" | "warn" | "fail"
29
+ detail: str = ""
30
+ fix: str = ""
31
+
32
+ @property
33
+ def icon(self) -> str:
34
+ return {"ok": "✅", "warn": "⚠️ ", "fail": "❌"}[self.status]
35
+
36
+
37
+ def identity_from_seed(seed: str) -> tuple[str, str]:
38
+ """``(agent address, FET wallet address)`` for a seed, exactly as uAgents derives them."""
39
+ from cosmpy.aerial.wallet import LocalWallet
40
+ from cosmpy.crypto.keypairs import PrivateKey
41
+ from uagents.config import LEDGER_PREFIX
42
+ from uagents_core.identity import Identity, derive_key_from_seed
43
+
44
+ address = Identity.from_seed(seed, 0).address
45
+ wallet = LocalWallet(
46
+ PrivateKey(derive_key_from_seed(seed, LEDGER_PREFIX, 0)), prefix=LEDGER_PREFIX
47
+ )
48
+ return address, str(wallet.address())
49
+
50
+
51
+ def check_seed() -> tuple[Check, str | None]:
52
+ seed = os.getenv("AGENT_SEED_PHRASE", "")
53
+ if not seed:
54
+ return Check(
55
+ "Seed phrase",
56
+ "fail",
57
+ "AGENT_SEED_PHRASE is not set",
58
+ "Run `hackkit seed` and put the phrase in .env",
59
+ ), None
60
+ if len(seed) < 24:
61
+ return Check(
62
+ "Seed phrase",
63
+ "warn",
64
+ "seed is short; anyone who guesses it owns your agent",
65
+ "Use `hackkit seed` for a long random phrase",
66
+ ), identity_from_seed(seed)[0]
67
+ address, wallet = identity_from_seed(seed)
68
+ return Check("Seed phrase", "ok", f"address {address}\n wallet {wallet}"), address
69
+
70
+
71
+ def check_asi1() -> Check:
72
+ key = os.getenv("ASI_ONE_API_KEY") or os.getenv("ASI1_API_KEY")
73
+ if not key:
74
+ return Check(
75
+ "ASI:One API key",
76
+ "warn",
77
+ "ASI_ONE_API_KEY is not set",
78
+ "Get one at https://asi1.ai/developer (only needed if you call the LLM)",
79
+ )
80
+ try:
81
+ resp = httpx.post(
82
+ "https://api.asi1.ai/v1/chat/completions",
83
+ headers={"Authorization": f"Bearer {key}"},
84
+ json={
85
+ "model": os.getenv("ASI_ONE_MODEL", "asi1"),
86
+ "max_tokens": 1,
87
+ "messages": [{"role": "user", "content": "ping"}],
88
+ },
89
+ timeout=30.0,
90
+ )
91
+ except httpx.HTTPError as exc:
92
+ return Check("ASI:One API key", "warn", f"could not reach ASI:One: {exc}")
93
+ if resp.status_code in (401, 403):
94
+ return Check(
95
+ "ASI:One API key",
96
+ "fail",
97
+ "key rejected",
98
+ "Create a new key at https://asi1.ai/developer",
99
+ )
100
+ if resp.status_code >= 400:
101
+ return Check("ASI:One API key", "warn", f"HTTP {resp.status_code}: {resp.text[:200]}")
102
+ return Check("ASI:One API key", "ok", "key works")
103
+
104
+
105
+ def check_registration(address: str) -> Check:
106
+ if not os.getenv("AGENTVERSE_API_KEY"):
107
+ return Check(
108
+ "Agentverse account",
109
+ "warn",
110
+ "AGENTVERSE_API_KEY is not set (cannot check)",
111
+ "Create one at https://agentverse.ai/profile/api-keys",
112
+ )
113
+ try:
114
+ record = lookup_agent(address)
115
+ except RuntimeError as exc:
116
+ return Check("Agentverse account", "fail", str(exc))
117
+ if record is None:
118
+ return Check(
119
+ "Agentverse account",
120
+ "fail",
121
+ "agent is not on your Agentverse account",
122
+ "Run the agent with AGENTVERSE_API_KEY set (auto-connect), or open the "
123
+ "inspector link and click Connect -> Mailbox",
124
+ )
125
+ return Check("Agentverse account", "ok", profile_url(address))
126
+
127
+
128
+ def check_almanac(address: str) -> Check:
129
+ try:
130
+ record = resolve(address)
131
+ except (RuntimeError, httpx.HTTPError) as exc:
132
+ return Check("Almanac / ASI:One discoverability", "warn", f"lookup failed: {exc}")
133
+ if record is None:
134
+ return Check(
135
+ "Almanac / ASI:One discoverability",
136
+ "fail",
137
+ "agent is not on the Almanac",
138
+ "Start the agent and connect it to Agentverse (mailbox)",
139
+ )
140
+ problems = []
141
+ if record.get("status") != "active":
142
+ problems.append(f"status is {record.get('status')!r} (start the agent)")
143
+ if CHAT_PROTOCOL_DIGEST not in (record.get("protocols") or []):
144
+ problems.append("chat protocol not published (include it with publish_manifest=True)")
145
+ expiry = record.get("expiry")
146
+ if expiry:
147
+ when = datetime.fromisoformat(str(expiry).replace("Z", "+00:00"))
148
+ if when < datetime.now(UTC):
149
+ problems.append("registration expired (restart the agent)")
150
+ if problems:
151
+ return Check("Almanac / ASI:One discoverability", "fail", "; ".join(problems))
152
+ return Check(
153
+ "Almanac / ASI:One discoverability",
154
+ "ok",
155
+ f"active, chat protocol published, type {record.get('type')}",
156
+ )
157
+
158
+
159
+ def check_handle(handle: str, address: str) -> Check:
160
+ try:
161
+ record = resolve(handle)
162
+ except (RuntimeError, httpx.HTTPError) as exc:
163
+ return Check("@handle", "warn", f"lookup failed: {exc}")
164
+ if record is None:
165
+ return Check(
166
+ "@handle",
167
+ "fail",
168
+ f"@{handle} is not assigned yet",
169
+ "Pass handle=... to HackAgent and reconnect to Agentverse",
170
+ )
171
+ if record.get("address") != address:
172
+ return Check(
173
+ "@handle", "fail", f"@{handle} belongs to another agent", "Choose a different handle"
174
+ )
175
+ return Check("@handle", "ok", f"@{handle} -> this agent")
176
+
177
+
178
+ def check_readme(path: Path, address: str | None) -> Check:
179
+ if not path.exists():
180
+ return Check("README", "fail", f"{path} not found", "Your repo needs a README")
181
+ text = path.read_text(encoding="utf-8")
182
+ missing = [b for b in BADGES if b not in text]
183
+ problems = []
184
+ if missing:
185
+ problems.append("missing badge(s): " + ", ".join(b.split("]")[0][2:] for b in missing))
186
+ if address and address not in text:
187
+ problems.append("agent address not listed")
188
+ if problems:
189
+ return Check(
190
+ "README",
191
+ "fail",
192
+ "; ".join(problems),
193
+ "Add the innovationlab + hackathon badges and your agent name + address",
194
+ )
195
+ return Check("README", "ok", "badges and address present")
196
+
197
+
198
+ def run(*, readme: Path, handle: str | None) -> list[Check]:
199
+ checks: list[Check] = []
200
+ seed_check, address = check_seed()
201
+ checks.append(seed_check)
202
+ checks.append(check_asi1())
203
+ if address:
204
+ checks.append(check_registration(address))
205
+ checks.append(check_almanac(address))
206
+ if handle:
207
+ checks.append(check_handle(handle.removeprefix("@"), address))
208
+ checks.append(check_readme(readme, address))
209
+ return checks
fetch_hackkit/mcp.py ADDED
@@ -0,0 +1,93 @@
1
+ """Use any remote MCP server's tools from your agent (``pip install fetch-hackkit[mcp]``).
2
+
3
+ tools = await mcp_tools("https://mcp.deepwiki.com/mcp")
4
+ answer = await ASI1().run_tools("How do uAgents handle mailboxes?", tools=tools)
5
+
6
+ Each tool call opens a short-lived connection, so there is nothing to keep
7
+ alive or clean up. To go the other way (publish an MCP server *as* an agent
8
+ on Agentverse), use ``uagents-adapter[mcp]``'s ``MCPServerAdapter``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any
15
+
16
+ from .asi1 import Tool
17
+
18
+ try:
19
+ import httpx2
20
+ from mcp import Client
21
+ from mcp.client.streamable_http import streamable_http_client
22
+ except ImportError as exc: # pragma: no cover
23
+ raise ImportError("MCP support needs the extra: pip install 'fetch-hackkit[mcp]'") from exc
24
+
25
+ # The MCP transport logs every HTTP request at INFO; keep agent logs readable.
26
+ logging.getLogger("httpx2").setLevel(logging.WARNING)
27
+
28
+
29
+ def _client(url: str, headers: dict[str, str] | None) -> Client:
30
+ if not headers:
31
+ return Client(url)
32
+ http = httpx2.AsyncClient(headers=headers, timeout=httpx2.Timeout(30.0, read=300.0))
33
+ return Client(streamable_http_client(url, http_client=http))
34
+
35
+
36
+ def _result_text(result: Any) -> Any:
37
+ if getattr(result, "structured_content", None) is not None:
38
+ return result.structured_content
39
+ parts = [getattr(block, "text", None) or str(block) for block in result.content or []]
40
+ text = "\n".join(p for p in parts if p)
41
+ if getattr(result, "is_error", False):
42
+ return {"error": text or "tool failed"}
43
+ return text
44
+
45
+
46
+ async def call_mcp_tool(
47
+ url: str,
48
+ name: str,
49
+ arguments: dict[str, Any] | None = None,
50
+ *,
51
+ headers: dict[str, str] | None = None,
52
+ ) -> Any:
53
+ """Call one tool on a remote MCP server and return its text/structured result."""
54
+ async with _client(url, headers) as client:
55
+ return _result_text(await client.call_tool(name, arguments or {}))
56
+
57
+
58
+ async def mcp_tools(
59
+ url: str,
60
+ *,
61
+ headers: dict[str, str] | None = None,
62
+ include: set[str] | None = None,
63
+ prefix: str = "",
64
+ ) -> list[Tool]:
65
+ """List a server's tools as :class:`~fetch_hackkit.asi1.Tool` objects for ``run_tools``.
66
+
67
+ Args:
68
+ url: Streamable-HTTP MCP endpoint, e.g. ``https://mcp.deepwiki.com/mcp``.
69
+ headers: Extra HTTP headers, e.g. ``{"Authorization": "Bearer ..."}``.
70
+ include: Only expose these tool names (keeps the model focused).
71
+ prefix: Prepended to tool names to avoid clashes between servers.
72
+ """
73
+ async with _client(url, headers) as client:
74
+ listed = await client.list_tools()
75
+
76
+ tools: list[Tool] = []
77
+ for spec in listed.tools:
78
+ if include and spec.name not in include:
79
+ continue
80
+
81
+ async def call(arguments: dict[str, Any], _name: str = spec.name) -> Any:
82
+ return await call_mcp_tool(url, _name, arguments, headers=headers)
83
+
84
+ schema = dict(spec.input_schema or {"type": "object", "properties": {}})
85
+ tools.append(
86
+ Tool(
87
+ name=f"{prefix}{spec.name}",
88
+ description=spec.description or spec.name,
89
+ parameters=schema,
90
+ call=call,
91
+ )
92
+ )
93
+ return tools
@@ -0,0 +1,264 @@
1
+ """Agent Payment Protocol (seller side) with on-chain FET verification.
2
+
3
+ You normally use this through ``@agent.paid(...)``::
4
+
5
+ @agent.paid("brief", amount="0.1", description="AI project brief")
6
+ async def brief(ctx, msg, sel):
7
+ return "Here is your brief..."
8
+
9
+ Flow (``uagents_core.contrib.protocols.payment``)::
10
+
11
+ you -> user : RequestPayment(accepted_funds=[0.1 FET], recipient=<agent wallet>, reference)
12
+ user -> you : CommitPayment(transaction_id, reference) (or RejectPayment)
13
+ you : verify the transfer on-chain (cosmpy)
14
+ you -> user : CompletePayment(transaction_id) and the paid result
15
+ (or CancelPayment(reason) if verification fails)
16
+
17
+ Defaults to the Fetch.ai **stable testnet** (free test FET from the faucet).
18
+ Set ``FET_NETWORK=mainnet`` for real money.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import os
25
+ import time
26
+ from dataclasses import dataclass
27
+ from decimal import Decimal
28
+ from typing import TYPE_CHECKING, Any
29
+ from uuid import uuid4
30
+
31
+ from uagents import Context, Protocol
32
+ from uagents_core.contrib.protocols.payment import (
33
+ CancelPayment,
34
+ CommitPayment,
35
+ CompletePayment,
36
+ Funds,
37
+ RejectPayment,
38
+ RequestPayment,
39
+ payment_protocol_spec,
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from .agent import ActionHandler, HackAgent, Inbound, Reply
44
+
45
+ FET_DECIMALS = 18
46
+ DEFAULT_DEADLINE_SECONDS = 600
47
+
48
+
49
+ def fet_network() -> str:
50
+ """``stable-testnet`` (default) or ``mainnet``, from ``FET_NETWORK``."""
51
+ return "mainnet" if os.getenv("FET_NETWORK", "").lower() == "mainnet" else "stable-testnet"
52
+
53
+
54
+ @dataclass
55
+ class Product:
56
+ name: str
57
+ amount: str
58
+ currency: str
59
+ description: str
60
+ handler: ActionHandler
61
+
62
+
63
+ def build_request(
64
+ *,
65
+ amount: str,
66
+ recipient_wallet: str,
67
+ description: str,
68
+ currency: str = "FET",
69
+ reference: str | None = None,
70
+ deadline_seconds: int = DEFAULT_DEADLINE_SECONDS,
71
+ network: str | None = None,
72
+ ) -> RequestPayment:
73
+ """A ``RequestPayment`` for a direct FET transfer to ``recipient_wallet``."""
74
+ network = network or fet_network()
75
+ return RequestPayment(
76
+ accepted_funds=[Funds(amount=amount, currency=currency, payment_method="fet_direct")],
77
+ recipient=recipient_wallet,
78
+ deadline_seconds=deadline_seconds,
79
+ reference=reference or str(uuid4()),
80
+ description=description,
81
+ metadata={"fet_network": network, "provider_agent_wallet": recipient_wallet},
82
+ )
83
+
84
+
85
+ def _amount_ok(amount: str, expected_base_units: int, denom: str) -> bool:
86
+ if not amount.endswith(denom):
87
+ return False
88
+ try:
89
+ return int(amount[: -len(denom)]) >= expected_base_units
90
+ except ValueError:
91
+ return False
92
+
93
+
94
+ def verify_fet_transfer(
95
+ *,
96
+ transaction_id: str,
97
+ amount: str,
98
+ recipient_wallet: str,
99
+ sender_wallet: str | None = None,
100
+ network: str | None = None,
101
+ ) -> tuple[bool, str]:
102
+ """Check on-chain that ``transaction_id`` sent at least ``amount`` FET to us.
103
+
104
+ Blocking (network I/O): call it via ``asyncio.to_thread``.
105
+ Returns ``(ok, reason)``.
106
+ """
107
+ from cosmpy.aerial.client import LedgerClient, NetworkConfig
108
+
109
+ mainnet = (network or fet_network()) == "mainnet"
110
+ denom = "afet" if mainnet else "atestfet"
111
+ expected = int(Decimal(amount) * (10**FET_DECIMALS))
112
+ config = NetworkConfig.fetchai_mainnet() if mainnet else NetworkConfig.fetchai_stable_testnet()
113
+ try:
114
+ tx = LedgerClient(config).query_tx(transaction_id)
115
+ except Exception as exc:
116
+ return False, f"transaction not found yet ({type(exc).__name__})"
117
+ if tx is None:
118
+ return False, "transaction not found"
119
+ if hasattr(tx, "is_successful") and not tx.is_successful():
120
+ return False, "transaction failed on-chain"
121
+ events = getattr(tx, "events", None)
122
+ if not isinstance(events, dict):
123
+ return False, "transaction has no events"
124
+
125
+ transfer = events.get("transfer") or {}
126
+ received = events.get("coin_received") or {}
127
+ spent = events.get("coin_spent") or {}
128
+ to_us = recipient_wallet in (transfer.get("recipient"), received.get("receiver"))
129
+ paid_enough = _amount_ok(str(transfer.get("amount", "")), expected, denom) or _amount_ok(
130
+ str(received.get("amount", "")), expected, denom
131
+ )
132
+ from_buyer = sender_wallet is None or sender_wallet in (
133
+ transfer.get("sender"),
134
+ spent.get("spender"),
135
+ )
136
+ if not to_us:
137
+ return False, "transfer was not to this agent's wallet"
138
+ if not paid_enough:
139
+ return False, f"amount is less than {amount} FET"
140
+ if not from_buyer:
141
+ return False, "transfer did not come from the buyer's wallet"
142
+ return True, "verified"
143
+
144
+
145
+ class Paywall:
146
+ """Seller-side payment protocol attached to a :class:`HackAgent`."""
147
+
148
+ def __init__(self, agent: HackAgent) -> None:
149
+ self.agent = agent
150
+ self.products: dict[str, Product] = {}
151
+ self.protocol = Protocol(spec=payment_protocol_spec, role="seller")
152
+ self.protocol.on_message(CommitPayment)(self._on_commit)
153
+ self.protocol.on_message(RejectPayment)(self._on_reject)
154
+ agent.include(self.protocol, publish_manifest=True)
155
+
156
+ @property
157
+ def wallet(self) -> str:
158
+ return str(self.agent.wallet.address())
159
+
160
+ def add(self, product: Product) -> None:
161
+ self.products[product.name] = product
162
+
163
+ async def request(self, ctx: Context, msg: Inbound, name: str, sel: dict[str, Any]) -> Reply:
164
+ product = self.products[name]
165
+ reference = str(uuid4())
166
+ ctx.storage.set(
167
+ f"hackkit:pay:{reference}",
168
+ {
169
+ "product": name,
170
+ "sender": msg.sender,
171
+ "text": msg.text,
172
+ "session": msg.session,
173
+ "selection": sel,
174
+ "expires": time.time() + DEFAULT_DEADLINE_SECONDS,
175
+ },
176
+ )
177
+ await ctx.send(
178
+ msg.sender,
179
+ build_request(
180
+ amount=product.amount,
181
+ currency=product.currency,
182
+ recipient_wallet=self.wallet,
183
+ description=product.description,
184
+ reference=reference,
185
+ ),
186
+ )
187
+ net = "testnet " if fet_network() != "mainnet" else ""
188
+ return (
189
+ f"💳 **{product.description}** costs **{product.amount} {net}{product.currency}**. "
190
+ "Approve the payment request to continue."
191
+ )
192
+
193
+ async def _on_reject(self, ctx: Context, sender: str, msg: RejectPayment) -> None:
194
+ reason = f" ({msg.reason})" if msg.reason else ""
195
+ await self.agent.reply(ctx, sender, f"No problem, payment cancelled{reason}.")
196
+
197
+ async def _on_commit(self, ctx: Context, sender: str, msg: CommitPayment) -> None:
198
+ from .agent import Inbound, text_message
199
+
200
+ tx = msg.transaction_id
201
+ key = f"hackkit:pay:{msg.reference}"
202
+ pending = ctx.storage.get(key) if msg.reference else None
203
+ if ctx.storage.get(f"hackkit:paid-tx:{tx}"):
204
+ await ctx.send(sender, CompletePayment(transaction_id=tx)) # duplicate commit
205
+ return
206
+ if not isinstance(pending, dict) or pending.get("sender") != sender:
207
+ await ctx.send(
208
+ sender, CancelPayment(transaction_id=tx, reason="Unknown payment reference")
209
+ )
210
+ return
211
+ if time.time() > float(pending.get("expires", 0)):
212
+ ctx.storage.remove(key)
213
+ await ctx.send(
214
+ sender, CancelPayment(transaction_id=tx, reason="Payment request expired")
215
+ )
216
+ await self.agent.reply(ctx, sender, "That payment request expired. Please try again.")
217
+ return
218
+ product = self.products.get(str(pending["product"]))
219
+ if product is None:
220
+ await ctx.send(sender, CancelPayment(transaction_id=tx, reason="Unknown product"))
221
+ return
222
+
223
+ buyer = (msg.metadata or {}).get("buyer_fet_wallet") or (msg.metadata or {}).get(
224
+ "buyer_fet_address"
225
+ )
226
+ ok, reason = False, "not verified"
227
+ for attempt in range(3): # the tx may take a few seconds to be indexed
228
+ ok, reason = await asyncio.to_thread(
229
+ verify_fet_transfer,
230
+ transaction_id=tx,
231
+ amount=product.amount,
232
+ recipient_wallet=self.wallet,
233
+ sender_wallet=buyer if isinstance(buyer, str) else None,
234
+ )
235
+ if ok or "not found" not in reason:
236
+ break
237
+ await asyncio.sleep(3 * (attempt + 1))
238
+ if not ok:
239
+ ctx.logger.warning(f"payment {tx} rejected: {reason}")
240
+ await ctx.send(sender, CancelPayment(transaction_id=tx, reason=reason))
241
+ await self.agent.reply(ctx, sender, f"I couldn't verify that payment: {reason}.")
242
+ return
243
+
244
+ ctx.storage.set(f"hackkit:paid-tx:{tx}", True)
245
+ ctx.storage.remove(key)
246
+ await ctx.send(sender, CompletePayment(transaction_id=tx))
247
+ ctx.logger.info(f"payment {tx} verified for {product.name}")
248
+
249
+ inbound = Inbound(
250
+ sender=sender,
251
+ text=str(pending.get("text", "")),
252
+ session=str(pending.get("session", "")),
253
+ raw=text_message(str(pending.get("text", ""))),
254
+ selection=dict(pending.get("selection") or {}),
255
+ )
256
+ try:
257
+ reply = await product.handler(ctx, inbound, inbound.selection)
258
+ except Exception:
259
+ ctx.logger.exception("paid handler failed")
260
+ reply = (
261
+ "Payment received, but something failed while preparing your result. "
262
+ f"Please contact the agent owner with transaction id {tx}."
263
+ )
264
+ await self.agent.reply(ctx, sender, reply)
fetch_hackkit/peers.py ADDED
@@ -0,0 +1,40 @@
1
+ """Talk to other agents over the chat protocol (multi-agent patterns).
2
+
3
+ answer = await ask_agent(ctx, "@weather-agent", "Forecast for Berkeley tomorrow?")
4
+
5
+ Works with any chat-protocol agent on Agentverse, including ones you did not write.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from datetime import UTC, datetime
12
+
13
+ from uagents import Context
14
+ from uagents_core.contrib.protocols.chat import ChatAcknowledgement, ChatMessage
15
+
16
+ from .agent import text_message
17
+ from .register import resolve_address
18
+
19
+
20
+ class PeerError(RuntimeError):
21
+ pass
22
+
23
+
24
+ async def ask_agent(ctx: Context, agent: str, text: str, *, timeout: int = 60) -> str:
25
+ """Send ``text`` to ``agent`` (address or ``@handle``) and wait for its text reply.
26
+
27
+ The peer's ``ChatAcknowledgement`` is skipped; its next ``ChatMessage`` in this
28
+ session is the answer. Raises :class:`PeerError` on delivery failure or timeout.
29
+ """
30
+ address = await asyncio.to_thread(resolve_address, agent)
31
+ reply, status = await ctx.send_and_receive(
32
+ address, text_message(text), response_type=ChatMessage, timeout=timeout
33
+ )
34
+ if not isinstance(reply, ChatMessage):
35
+ raise PeerError(f"{agent} did not answer: {getattr(status, 'detail', status)}")
36
+ await ctx.send(
37
+ address,
38
+ ChatAcknowledgement(timestamp=datetime.now(UTC), acknowledged_msg_id=reply.msg_id),
39
+ )
40
+ return reply.text()
fetch_hackkit/py.typed ADDED
File without changes