miti-agent-sdk 0.1.0__tar.gz

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,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: miti-agent-sdk
3
+ Version: 0.1.0
4
+ Summary: Miti Agent SDK – connect third-party agents to Miti IM
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: aiohttp>=3.9
9
+ Requires-Dist: websockets>=12.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
13
+
14
+ # miti-agent-sdk
15
+
16
+ Connect third-party agents (Hermes, OpenClaw, Claude Code, etc.) to the Miti IM platform.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install miti-agent-sdk
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ```python
27
+ from miti_agent_sdk import MitiAgent
28
+
29
+ agent = MitiAgent(
30
+ app_id="cli_xxx",
31
+ app_secret="secret_xxx",
32
+ )
33
+
34
+ # 单聊消息
35
+ @agent.on_message
36
+ async def handle(event):
37
+ user_text = event.event.message.content.get("text", "")
38
+ await event.reply(f"收到:{user_text}")
39
+
40
+ # 群 @ 消息
41
+ @agent.on_group_at
42
+ async def handle_group(event):
43
+ text = event.event.message.content.get("text", "")
44
+ await event.reply(f"群收到:{text}")
45
+
46
+ agent.run()
47
+ ```
48
+
49
+ SDK 内部自动处理:Token 获取/刷新、WebSocket 保活、指数退避重连。
50
+
51
+ ## 环境配置
52
+
53
+ `base_url` 按以下优先级决定(先找到先用):
54
+
55
+ | 优先级 | 来源 | 示例 |
56
+ |--------|------|------|
57
+ | 1(最高) | 构造函数 `base_url=` 参数 | `MitiAgent(base_url="https://...")` |
58
+ | 2 | 环境变量 `MITI_API_BASE_URL` | `export MITI_API_BASE_URL="https://..."` |
59
+ | 3(默认) | 正式环境 | `https://www.miti.chat/chat` |
60
+
61
+ 大多数场景下**不需要配置任何东西**,SDK 自动连接正式环境。
62
+
63
+ 本地开发调试时,通过环境变量指向本地 appserver 即可,无需改业务代码:
64
+
65
+ ```bash
66
+ export MITI_API_BASE_URL="http://localhost:10006/chat"
67
+ ```
68
+
69
+ 也可以在初始化时显式传入:
70
+
71
+ ```python
72
+ agent = MitiAgent(
73
+ app_id="cli_xxx",
74
+ app_secret="secret_xxx",
75
+ base_url="http://localhost:10006/chat",
76
+ )
77
+ ```
78
+
79
+ ## 拉取历史记录
80
+
81
+ ```python
82
+ @agent.on_message
83
+ async def handle(event):
84
+ history = await agent.get_history(
85
+ event.event.message.conversation_id, limit=10
86
+ )
87
+ # history: list[HistoryMessage] each has .role and .content
88
+ ```
89
+
90
+ ## 日志 & 问题排查
91
+
92
+ SDK 使用 Python 标准 `logging` 模块,logger 层级为 `miti_agent_sdk.*`。
93
+
94
+ ### 快速开启日志
95
+
96
+ ```python
97
+ import miti_agent_sdk
98
+
99
+ # INFO 级别:token 获取/刷新、WebSocket 连接/断开/重连、事件收发、消息发送
100
+ miti_agent_sdk.enable_logging()
101
+
102
+ # DEBUG 级别:额外输出每次 HTTP 请求 URL/参数、自动刷新 sleep 倒计时
103
+ miti_agent_sdk.enable_logging(logging.DEBUG)
104
+ ```
105
+
106
+ 日志输出到 **stderr**(默认),格式示例:
107
+
108
+ ```
109
+ 2026-06-18 18:00:01 [miti_agent_sdk.auth] INFO token acquired, expires_in=7200s
110
+ 2026-06-18 18:00:01 [miti_agent_sdk.gateway] INFO connecting to wss://www.miti.chat/chat/agent/gateway
111
+ 2026-06-18 18:00:02 [miti_agent_sdk.gateway] INFO gateway connected
112
+ 2026-06-18 18:00:05 [miti_agent_sdk.gateway] INFO event received: type=im.message.receive id=evt-xxx
113
+ 2026-06-18 18:00:05 [miti_agent_sdk.message] INFO send_message success: target=user-abc message_id=msg-xxx
114
+ ```
115
+
116
+ ### 日志存储
117
+
118
+ SDK 本身不写日志文件。日志的最终存储取决于宿主进程:
119
+
120
+ | 方式 | 配置 | 适用场景 |
121
+ |------|------|----------|
122
+ | 控制台 | `miti_agent_sdk.enable_logging()` | 本地开发调试 |
123
+ | 文件 | `logging.basicConfig(filename="agent.log", level=logging.INFO)` | 单机部署 |
124
+ | 结构化日志 | 宿主框架的 JSON handler(如 `python-json-logger`) | 生产环境 / 接入 ELK 等 |
125
+ | 容器 stdout | 不做额外配置,`enable_logging()` 输出到 stderr 即可 | Docker / K8s |
126
+
127
+ ### 排查常见问题
128
+
129
+ | 现象 | 排查方向 | 关键日志 |
130
+ |------|----------|----------|
131
+ | 启动后无反应 | Token 获取失败 | `auth token API error: code=... msg=...` |
132
+ | 连接后收不到消息 | WebSocket 断开或无事件推送 | `gateway connection lost` / `no handler for event_type` |
133
+ | 回复消息失败 | 权限不足或目标不存在 | `send_message API error: code=10012 msg=no permission` |
134
+ | Token 过期后断连 | 自动刷新失败 | `auto-refresh failed, will retry in ...s` |
135
+ | WebSocket 频繁重连 | 网络不稳或服务端问题 | `reconnecting in Xs`(观察退避时间是否递增) |
136
+
137
+ ## 安全注意事项
138
+
139
+ ### 日志安全
140
+
141
+ SDK 自身**不会**在日志中输出 `app_secret` 或 `access_token`。但如果你开启了
142
+ `aiohttp` 的 trace/debug 日志(如 `AIOHTTP_TRACE=1`),HTTP 请求体会被底层框架
143
+ 打印出来,其中包含 `app_secret`。**生产环境请勿开启 aiohttp trace 日志。**
144
+
145
+ ### 凭证内存存储
146
+
147
+ `app_secret` 和 `access_token` 在 SDK 运行期间以明文存储在进程内存中。
148
+ 这是 Python SDK 的通用模式(与飞书 SDK、Slack SDK 一致),无法避免。
149
+ 请确保:
150
+
151
+ - 不要在多租户共享环境中运行 agent 进程
152
+ - 不要将进程 memory dump 输出到不安全的位置
153
+ - 进程退出后凭证自动释放,无需手动清理
154
+
155
+ ### TLS 要求
156
+
157
+ SDK 强制要求 `base_url` 使用 `https://`(对应 WebSocket `wss://`)。
158
+ 本地开发调试时 `localhost` / `127.0.0.1` 可使用 `http://`,其他地址会被拒绝。
159
+
160
+ ## Requirements
161
+
162
+ - Python 3.9+
163
+ - `aiohttp >= 3.9`
164
+ - `websockets >= 12.0`
@@ -0,0 +1,151 @@
1
+ # miti-agent-sdk
2
+
3
+ Connect third-party agents (Hermes, OpenClaw, Claude Code, etc.) to the Miti IM platform.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install miti-agent-sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from miti_agent_sdk import MitiAgent
15
+
16
+ agent = MitiAgent(
17
+ app_id="cli_xxx",
18
+ app_secret="secret_xxx",
19
+ )
20
+
21
+ # 单聊消息
22
+ @agent.on_message
23
+ async def handle(event):
24
+ user_text = event.event.message.content.get("text", "")
25
+ await event.reply(f"收到:{user_text}")
26
+
27
+ # 群 @ 消息
28
+ @agent.on_group_at
29
+ async def handle_group(event):
30
+ text = event.event.message.content.get("text", "")
31
+ await event.reply(f"群收到:{text}")
32
+
33
+ agent.run()
34
+ ```
35
+
36
+ SDK 内部自动处理:Token 获取/刷新、WebSocket 保活、指数退避重连。
37
+
38
+ ## 环境配置
39
+
40
+ `base_url` 按以下优先级决定(先找到先用):
41
+
42
+ | 优先级 | 来源 | 示例 |
43
+ |--------|------|------|
44
+ | 1(最高) | 构造函数 `base_url=` 参数 | `MitiAgent(base_url="https://...")` |
45
+ | 2 | 环境变量 `MITI_API_BASE_URL` | `export MITI_API_BASE_URL="https://..."` |
46
+ | 3(默认) | 正式环境 | `https://www.miti.chat/chat` |
47
+
48
+ 大多数场景下**不需要配置任何东西**,SDK 自动连接正式环境。
49
+
50
+ 本地开发调试时,通过环境变量指向本地 appserver 即可,无需改业务代码:
51
+
52
+ ```bash
53
+ export MITI_API_BASE_URL="http://localhost:10006/chat"
54
+ ```
55
+
56
+ 也可以在初始化时显式传入:
57
+
58
+ ```python
59
+ agent = MitiAgent(
60
+ app_id="cli_xxx",
61
+ app_secret="secret_xxx",
62
+ base_url="http://localhost:10006/chat",
63
+ )
64
+ ```
65
+
66
+ ## 拉取历史记录
67
+
68
+ ```python
69
+ @agent.on_message
70
+ async def handle(event):
71
+ history = await agent.get_history(
72
+ event.event.message.conversation_id, limit=10
73
+ )
74
+ # history: list[HistoryMessage] each has .role and .content
75
+ ```
76
+
77
+ ## 日志 & 问题排查
78
+
79
+ SDK 使用 Python 标准 `logging` 模块,logger 层级为 `miti_agent_sdk.*`。
80
+
81
+ ### 快速开启日志
82
+
83
+ ```python
84
+ import miti_agent_sdk
85
+
86
+ # INFO 级别:token 获取/刷新、WebSocket 连接/断开/重连、事件收发、消息发送
87
+ miti_agent_sdk.enable_logging()
88
+
89
+ # DEBUG 级别:额外输出每次 HTTP 请求 URL/参数、自动刷新 sleep 倒计时
90
+ miti_agent_sdk.enable_logging(logging.DEBUG)
91
+ ```
92
+
93
+ 日志输出到 **stderr**(默认),格式示例:
94
+
95
+ ```
96
+ 2026-06-18 18:00:01 [miti_agent_sdk.auth] INFO token acquired, expires_in=7200s
97
+ 2026-06-18 18:00:01 [miti_agent_sdk.gateway] INFO connecting to wss://www.miti.chat/chat/agent/gateway
98
+ 2026-06-18 18:00:02 [miti_agent_sdk.gateway] INFO gateway connected
99
+ 2026-06-18 18:00:05 [miti_agent_sdk.gateway] INFO event received: type=im.message.receive id=evt-xxx
100
+ 2026-06-18 18:00:05 [miti_agent_sdk.message] INFO send_message success: target=user-abc message_id=msg-xxx
101
+ ```
102
+
103
+ ### 日志存储
104
+
105
+ SDK 本身不写日志文件。日志的最终存储取决于宿主进程:
106
+
107
+ | 方式 | 配置 | 适用场景 |
108
+ |------|------|----------|
109
+ | 控制台 | `miti_agent_sdk.enable_logging()` | 本地开发调试 |
110
+ | 文件 | `logging.basicConfig(filename="agent.log", level=logging.INFO)` | 单机部署 |
111
+ | 结构化日志 | 宿主框架的 JSON handler(如 `python-json-logger`) | 生产环境 / 接入 ELK 等 |
112
+ | 容器 stdout | 不做额外配置,`enable_logging()` 输出到 stderr 即可 | Docker / K8s |
113
+
114
+ ### 排查常见问题
115
+
116
+ | 现象 | 排查方向 | 关键日志 |
117
+ |------|----------|----------|
118
+ | 启动后无反应 | Token 获取失败 | `auth token API error: code=... msg=...` |
119
+ | 连接后收不到消息 | WebSocket 断开或无事件推送 | `gateway connection lost` / `no handler for event_type` |
120
+ | 回复消息失败 | 权限不足或目标不存在 | `send_message API error: code=10012 msg=no permission` |
121
+ | Token 过期后断连 | 自动刷新失败 | `auto-refresh failed, will retry in ...s` |
122
+ | WebSocket 频繁重连 | 网络不稳或服务端问题 | `reconnecting in Xs`(观察退避时间是否递增) |
123
+
124
+ ## 安全注意事项
125
+
126
+ ### 日志安全
127
+
128
+ SDK 自身**不会**在日志中输出 `app_secret` 或 `access_token`。但如果你开启了
129
+ `aiohttp` 的 trace/debug 日志(如 `AIOHTTP_TRACE=1`),HTTP 请求体会被底层框架
130
+ 打印出来,其中包含 `app_secret`。**生产环境请勿开启 aiohttp trace 日志。**
131
+
132
+ ### 凭证内存存储
133
+
134
+ `app_secret` 和 `access_token` 在 SDK 运行期间以明文存储在进程内存中。
135
+ 这是 Python SDK 的通用模式(与飞书 SDK、Slack SDK 一致),无法避免。
136
+ 请确保:
137
+
138
+ - 不要在多租户共享环境中运行 agent 进程
139
+ - 不要将进程 memory dump 输出到不安全的位置
140
+ - 进程退出后凭证自动释放,无需手动清理
141
+
142
+ ### TLS 要求
143
+
144
+ SDK 强制要求 `base_url` 使用 `https://`(对应 WebSocket `wss://`)。
145
+ 本地开发调试时 `localhost` / `127.0.0.1` 可使用 `http://`,其他地址会被拒绝。
146
+
147
+ ## Requirements
148
+
149
+ - Python 3.9+
150
+ - `aiohttp >= 3.9`
151
+ - `websockets >= 12.0`
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "miti-agent-sdk"
7
+ version = "0.1.0"
8
+ description = "Miti Agent SDK – connect third-party agents to Miti IM"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ dependencies = [
13
+ "aiohttp>=3.9",
14
+ "websockets>=12.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.0",
20
+ "pytest-asyncio>=0.23",
21
+ ]
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+
26
+ [tool.pytest.ini_options]
27
+ asyncio_mode = "auto"
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,67 @@
1
+ """Miti Agent SDK – connect third-party agents to Miti IM."""
2
+
3
+ import logging
4
+
5
+ from .agent import MitiAgent
6
+ from .auth import AuthClient, AuthError
7
+ from .gateway import GatewayClient
8
+ from .message import ApiError, HistoryClient, MessageClient
9
+ from .models import (
10
+ AgentEvent,
11
+ EventHeader,
12
+ EventMessage,
13
+ EventSender,
14
+ GroupAtEvent,
15
+ HistoryMessage,
16
+ MessageEvent,
17
+ )
18
+ from .stream import build_stream_full_markdown
19
+
20
+ __all__ = [
21
+ "MitiAgent",
22
+ "AuthClient",
23
+ "AuthError",
24
+ "GatewayClient",
25
+ "MessageClient",
26
+ "HistoryClient",
27
+ "ApiError",
28
+ "AgentEvent",
29
+ "EventHeader",
30
+ "EventMessage",
31
+ "EventSender",
32
+ "GroupAtEvent",
33
+ "HistoryMessage",
34
+ "MessageEvent",
35
+ "build_stream_full_markdown",
36
+ "enable_logging",
37
+ ]
38
+
39
+ __version__ = "0.1.0"
40
+
41
+ # Library best practice: NullHandler prevents "No handlers found" warnings.
42
+ # Users must configure logging themselves or call enable_logging() below.
43
+ logging.getLogger("miti_agent_sdk").addHandler(logging.NullHandler())
44
+
45
+
46
+ def enable_logging(
47
+ level: int = logging.INFO,
48
+ fmt: str = "%(asctime)s [%(name)s] %(levelname)s %(message)s",
49
+ ) -> None:
50
+ """Enable SDK logging to stderr with a human-readable format.
51
+
52
+ Call this before ``agent.run()`` to see SDK internal activity::
53
+
54
+ import miti_agent_sdk
55
+ miti_agent_sdk.enable_logging() # INFO level
56
+ miti_agent_sdk.enable_logging(logging.DEBUG) # verbose
57
+
58
+ If you already have ``logging.basicConfig()`` or a framework logger
59
+ configured, you don't need this — SDK logs will flow through the
60
+ ``miti_agent_sdk.*`` logger hierarchy automatically.
61
+ """
62
+ root = logging.getLogger("miti_agent_sdk")
63
+ root.setLevel(level)
64
+ if not any(isinstance(h, logging.StreamHandler) for h in root.handlers):
65
+ handler = logging.StreamHandler()
66
+ handler.setFormatter(logging.Formatter(fmt))
67
+ root.addHandler(handler)
@@ -0,0 +1,272 @@
1
+ """MitiAgent – top-level facade that wires AuthClient, GatewayClient,
2
+ MessageClient, and HistoryClient together."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ import os
9
+ import re
10
+ import signal
11
+ from typing import Any, Callable, Optional
12
+
13
+ import aiohttp
14
+
15
+ from .auth import AuthClient
16
+ from .gateway import EventHandler, GatewayClient
17
+ from .message import HistoryClient, MessageClient
18
+ from .models import AgentEvent, HistoryMessage
19
+
20
+ logger = logging.getLogger("miti_agent_sdk")
21
+
22
+ _BASE_URL_ENV = "MITI_API_BASE_URL"
23
+ _DEFAULT_BASE_URL = "https://www.miti.chat/chat"
24
+
25
+
26
+ class MitiAgent:
27
+ """One-stop entry point for third-party agents connecting to Miti.
28
+
29
+ Usage::
30
+
31
+ agent = MitiAgent(app_id="cli_xxx", app_secret="secret_xxx")
32
+
33
+ @agent.on_message
34
+ async def handle(event: AgentEvent):
35
+ await event.reply("got it!")
36
+
37
+ agent.run()
38
+
39
+ Parameters
40
+ ----------
41
+ app_id : str
42
+ Agent application ID.
43
+ app_secret : str
44
+ Agent application secret.
45
+ base_url : str
46
+ Miti API base URL. Defaults to the ``MITI_API_BASE_URL`` environment
47
+ variable when set, otherwise ``https://www.miti.chat/chat``.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ app_id: str,
53
+ app_secret: str,
54
+ base_url: Optional[str] = None,
55
+ ):
56
+ self._base_url = (
57
+ base_url or os.getenv(_BASE_URL_ENV) or _DEFAULT_BASE_URL
58
+ ).rstrip("/")
59
+
60
+ if not _is_tls_or_localhost(self._base_url):
61
+ raise ValueError(
62
+ f"base_url must use https:// (got {self._base_url!r}). "
63
+ f"Only localhost / 127.0.0.1 is allowed over http:// for local development."
64
+ )
65
+
66
+ self._session: Optional[aiohttp.ClientSession] = None
67
+
68
+ self._auth = AuthClient(
69
+ app_id=app_id,
70
+ app_secret=app_secret,
71
+ base_url=self._base_url,
72
+ )
73
+ self._gateway = GatewayClient(auth=self._auth, base_url=self._base_url)
74
+ self._msg_client: Optional[MessageClient] = None
75
+ self._history_client: Optional[HistoryClient] = None
76
+
77
+ # ------------------------------------------------------------------
78
+ # Decorator API
79
+ # ------------------------------------------------------------------
80
+
81
+ def on_message(self, fn: EventHandler) -> EventHandler:
82
+ """Decorator: register *fn* as the handler for single-chat messages
83
+ (``im.message.receive``).
84
+
85
+ ::
86
+
87
+ @agent.on_message
88
+ async def handle(event):
89
+ await event.reply("hello")
90
+ """
91
+ self._gateway.on("im.message.receive", fn)
92
+ return fn
93
+
94
+ def on_group_at(self, fn: EventHandler) -> EventHandler:
95
+ """Decorator: register *fn* as the handler for group-chat @ messages
96
+ (``im.message.group_at``).
97
+
98
+ ::
99
+
100
+ @agent.on_group_at
101
+ async def handle(event):
102
+ await event.reply("group reply")
103
+ """
104
+ self._gateway.on("im.message.group_at", fn)
105
+ return fn
106
+
107
+ def on(self, event_type: str) -> Callable[[EventHandler], EventHandler]:
108
+ """Generic decorator for any event type::
109
+
110
+ @agent.on("im.message.receive")
111
+ async def handle(event):
112
+ ...
113
+ """
114
+ def decorator(fn: EventHandler) -> EventHandler:
115
+ self._gateway.on(event_type, fn)
116
+ return fn
117
+ return decorator
118
+
119
+ # ------------------------------------------------------------------
120
+ # REST helpers
121
+ # ------------------------------------------------------------------
122
+
123
+ async def send_message(
124
+ self,
125
+ *,
126
+ to_user_id: Optional[str] = None,
127
+ to_group_id: Optional[str] = None,
128
+ msg_type: str = "text",
129
+ content: dict[str, Any] | None = None,
130
+ ) -> dict[str, Any]:
131
+ """Send a message via ``POST /agent/v1/messages/send``.
132
+
133
+ Supported ``msg_type`` values:
134
+
135
+ - ``text`` — plain text (contentType 101)
136
+ - ``stream_full`` — Markdown reply (contentType 125); use
137
+ :func:`miti_agent_sdk.build_stream_full_markdown` or pass
138
+ ``{"markdown": "...", "ask_msg_id": "..."}``
139
+
140
+ Returns the ``data`` dict from the response.
141
+ """
142
+ client = self._ensure_msg_client()
143
+ return await client.send(
144
+ to_user_id=to_user_id,
145
+ to_group_id=to_group_id,
146
+ msg_type=msg_type,
147
+ content=content,
148
+ )
149
+
150
+ async def send_markdown_reply(
151
+ self,
152
+ markdown: str,
153
+ *,
154
+ to_user_id: Optional[str] = None,
155
+ to_group_id: Optional[str] = None,
156
+ ask_msg_id: str = "",
157
+ ) -> dict[str, Any]:
158
+ """Send a Markdown reply as ``stream_full`` (contentType 125)."""
159
+ from .stream import build_stream_full_markdown
160
+
161
+ return await self.send_message(
162
+ to_user_id=to_user_id,
163
+ to_group_id=to_group_id,
164
+ msg_type="stream_full",
165
+ content=build_stream_full_markdown(markdown, ask_msg_id),
166
+ )
167
+
168
+ async def get_history(
169
+ self,
170
+ conversation_id: str,
171
+ limit: int = 10,
172
+ ) -> list[HistoryMessage]:
173
+ """Fetch conversation history via ``GET /agent/v1/messages/history``."""
174
+ client = self._ensure_history_client()
175
+ return await client.get(conversation_id=conversation_id, limit=limit)
176
+
177
+ # ------------------------------------------------------------------
178
+ # Lifecycle
179
+ # ------------------------------------------------------------------
180
+
181
+ def run(self) -> None:
182
+ """Blocking entry point. Starts the event loop, connects the
183
+ WebSocket gateway, and runs until interrupted (Ctrl-C / SIGTERM).
184
+
185
+ Internally calls :meth:`run_async`.
186
+ """
187
+ try:
188
+ asyncio.run(self.run_async())
189
+ except KeyboardInterrupt:
190
+ logger.info("interrupted, shutting down")
191
+
192
+ async def run_async(self, *, register_signals: bool = True) -> None:
193
+ """Async entry point – call this if you already own the event loop.
194
+
195
+ Parameters
196
+ ----------
197
+ register_signals : bool
198
+ When *True* (default), register SIGINT/SIGTERM handlers to
199
+ gracefully stop the gateway. Set to *False* when embedding
200
+ the agent inside a host process (e.g. Hermes) that manages
201
+ its own signal handling.
202
+ """
203
+ if register_signals:
204
+ loop = asyncio.get_running_loop()
205
+ for sig in (signal.SIGINT, signal.SIGTERM):
206
+ try:
207
+ loop.add_signal_handler(sig, self._gateway.stop)
208
+ except NotImplementedError:
209
+ pass # Windows
210
+
211
+ logger.info("MitiAgent starting")
212
+ self._auth.set_shared_session(self._ensure_session())
213
+ await self._auth.start_auto_refresh()
214
+ try:
215
+ await self._gateway.run_forever(inject_agent=self)
216
+ finally:
217
+ await self.close()
218
+ logger.info("MitiAgent stopped")
219
+
220
+ async def close(self) -> None:
221
+ """Release all resources (HTTP sessions, refresh tasks, WebSocket).
222
+
223
+ Safe to call multiple times — subsequent calls are no-ops.
224
+ """
225
+ await self._auth.close()
226
+ if self._msg_client:
227
+ await self._msg_client.close()
228
+ if self._history_client:
229
+ await self._history_client.close()
230
+ if self._session and not self._session.closed:
231
+ await self._session.close()
232
+
233
+ # ------------------------------------------------------------------
234
+ # Internal helpers
235
+ # ------------------------------------------------------------------
236
+
237
+ def _ensure_session(self) -> aiohttp.ClientSession:
238
+ if self._session is None or self._session.closed:
239
+ self._session = aiohttp.ClientSession()
240
+ return self._session
241
+
242
+ def _ensure_msg_client(self) -> MessageClient:
243
+ if self._msg_client is None:
244
+ self._msg_client = MessageClient(
245
+ auth=self._auth,
246
+ base_url=self._base_url,
247
+ session=self._ensure_session(),
248
+ )
249
+ return self._msg_client
250
+
251
+ def _ensure_history_client(self) -> HistoryClient:
252
+ if self._history_client is None:
253
+ self._history_client = HistoryClient(
254
+ auth=self._auth,
255
+ base_url=self._base_url,
256
+ session=self._ensure_session(),
257
+ )
258
+ return self._history_client
259
+
260
+
261
+ _LOCAL_HOST_RE = re.compile(
262
+ r"^https?://(localhost|127\.0\.0\.1)(:\d+)?(/|$)", re.IGNORECASE,
263
+ )
264
+
265
+
266
+ def _is_tls_or_localhost(url: str) -> bool:
267
+ """Return True if *url* uses https:// or targets localhost over http://."""
268
+ if url.startswith("https://"):
269
+ return True
270
+ if url.startswith("http://") and _LOCAL_HOST_RE.match(url):
271
+ return True
272
+ return False