axi-cli 0.0.3__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.
axi/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ """axi — Agent eXecution Interface."""
2
+
3
+ import logging
4
+ from typing import Any, Callable
5
+
6
+ from axi.cli import get_executor, get_registry
7
+ from axi.providers.native import register_tool
8
+
9
+ __all__ = ["tool"]
10
+
11
+ # 包级 logger:各子模块通过 logging.getLogger(__name__) 自动继承
12
+ _logger = logging.getLogger("axi")
13
+ _logger.addHandler(logging.NullHandler())
14
+
15
+
16
+ def tool(
17
+ name: str | None = None,
18
+ description: str | None = None,
19
+ output_example: Any | None = None,
20
+ ) -> Callable:
21
+ """装饰器:注册一个 Python 函数为 axi 工具。
22
+
23
+ 用法:
24
+ @tool(name="query_orders", description="按区域查询订单")
25
+ def query_orders(region: str, limit: int = 10) -> dict:
26
+ ...
27
+
28
+ 也可以作为函数调用获取 PTC 可调用对象:
29
+ query = tool("query_orders")
30
+ result = query(region="cn")
31
+ """
32
+ # 当作为 PTC 调用时:tool("tool_name") 返回可调用对象
33
+ if name is not None and description is None and output_example is None:
34
+ # 先查本地原生工具
35
+ registry = get_registry()
36
+ meta = registry.get(name)
37
+ if meta is not None:
38
+ executor = get_executor()
39
+
40
+ def _native_caller(**kwargs: Any) -> Any:
41
+ result = executor.run(name, kwargs) # type: ignore[arg-type]
42
+ if result.status == "error":
43
+ raise RuntimeError(result.error)
44
+ return result.data
45
+
46
+ return _native_caller
47
+
48
+ # 再查 daemon(MCP 工具)
49
+ return _make_daemon_caller(name)
50
+
51
+ # 当作为装饰器时
52
+ def decorator(func: Callable) -> Callable:
53
+ meta = register_tool(
54
+ func,
55
+ name=name,
56
+ description=description,
57
+ output_example=output_example,
58
+ )
59
+ get_registry().register(meta)
60
+ return func
61
+
62
+ return decorator
63
+
64
+
65
+ def _make_daemon_caller(tool_name: str) -> Callable:
66
+ """创建通过 daemon 调用 MCP 工具的 PTC 函数。"""
67
+ from axi.daemon.client import ensure_daemon, send_request
68
+ from axi.daemon.protocol import DaemonRequest
69
+
70
+ def _daemon_caller(**kwargs: Any) -> Any:
71
+ if not ensure_daemon():
72
+ raise RuntimeError("Daemon is not running. Start it with: axi daemon start")
73
+
74
+ resp = send_request(
75
+ DaemonRequest(method="call_tool", tool_name=tool_name, params=kwargs)
76
+ )
77
+ if resp.status == "error":
78
+ raise RuntimeError(resp.error)
79
+ return resp.data
80
+
81
+ return _daemon_caller
axi/cli.py ADDED
@@ -0,0 +1,378 @@
1
+ """axi CLI 入口:Typer app。"""
2
+
3
+ import json
4
+ import logging
5
+ from collections import Counter
6
+
7
+ import typer
8
+ from pydantic import BaseModel
9
+
10
+ from axi.config import app_config
11
+ from axi.daemon.client import ensure_daemon, is_daemon_running, send_request
12
+ from axi.daemon.protocol import DaemonRequest, DaemonResponse
13
+ from axi.executor import Executor
14
+ from axi.models import RunResult, SearchResult
15
+ from axi.registry import AmbiguousToolError, Registry, ToolNotFoundError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ app = typer.Typer(
20
+ name="axi",
21
+ help="Agent eXecution Interface - unified tool layer for AI Agents",
22
+ no_args_is_help=True,
23
+ rich_markup_mode="rich" if app_config.cli.rich else None,
24
+ )
25
+
26
+ daemon_app = typer.Typer(help="管理 axi daemon")
27
+ app.add_typer(daemon_app, name="daemon")
28
+
29
+ # 全局实例(原生工具用)
30
+ _registry = Registry()
31
+ _executor = Executor(_registry)
32
+
33
+
34
+ @app.callback(invoke_without_command=True)
35
+ def main_callback(ctx: typer.Context) -> None:
36
+ """CLI 启动时加载 axi.json 中配置的原生工具模块。"""
37
+ from axi.providers.mcp import load_native_tool_modules
38
+
39
+ load_native_tool_modules()
40
+ if ctx.invoked_subcommand is None:
41
+ raise typer.Exit()
42
+
43
+
44
+ def get_registry() -> Registry:
45
+ return _registry
46
+
47
+
48
+ def get_executor() -> Executor:
49
+ return _executor
50
+
51
+
52
+ def _output_json(data: object) -> None:
53
+ """统一 JSON 输出。"""
54
+ if isinstance(data, BaseModel):
55
+ d = data.model_dump(exclude_none=True)
56
+ elif isinstance(data, list):
57
+ d = [
58
+ item.model_dump(exclude_none=True) if isinstance(item, BaseModel) else item
59
+ for item in data
60
+ ]
61
+ else:
62
+ d = data
63
+ typer.echo(json.dumps(d, ensure_ascii=False))
64
+
65
+
66
+ def _daemon_request(req: DaemonRequest) -> DaemonResponse:
67
+ """向 daemon 发送请求。如果 daemon 未运行则自动启动。"""
68
+ if not ensure_daemon():
69
+ return DaemonResponse.fail(
70
+ "Daemon is not running. Start it with: axi daemon start"
71
+ )
72
+ return send_request(req)
73
+
74
+
75
+ # ── daemon 管理命令 ──────────────────────────────────────────────
76
+
77
+
78
+ @daemon_app.command("start")
79
+ def daemon_start() -> None:
80
+ """启动 daemon。"""
81
+ if is_daemon_running():
82
+ typer.echo("Daemon is already running.")
83
+ return
84
+
85
+ if ensure_daemon():
86
+ typer.echo("Daemon started.")
87
+ else:
88
+ typer.echo("Failed to start daemon.")
89
+ raise typer.Exit(code=1)
90
+
91
+
92
+ @daemon_app.command("stop")
93
+ def daemon_stop() -> None:
94
+ """停止 daemon。"""
95
+ if not is_daemon_running():
96
+ typer.echo("Daemon is not running.")
97
+ return
98
+
99
+ try:
100
+ resp = send_request(DaemonRequest(method="shutdown"))
101
+ if resp.status == "error":
102
+ typer.echo(f"Failed to stop daemon: {resp.error}", err=True)
103
+ raise typer.Exit(code=1)
104
+ except OSError as e:
105
+ typer.echo(f"Failed to connect to daemon: {e}", err=True)
106
+ raise typer.Exit(code=1)
107
+ typer.echo("Daemon stopped.")
108
+
109
+
110
+ @daemon_app.command("status")
111
+ def daemon_status() -> None:
112
+ """查看 daemon 状态。"""
113
+ if not is_daemon_running():
114
+ _output_json({"status": "stopped"})
115
+ return
116
+
117
+ resp = send_request(DaemonRequest(method="status"))
118
+ if resp.status == "error":
119
+ _output_json({"status": "error", "error": resp.error})
120
+ raise typer.Exit(code=1)
121
+
122
+ data = resp.data
123
+ # 原生工具按 server 统计
124
+ native_server_tools = dict(
125
+ Counter(meta.server or "unknown" for meta in _registry.list_all())
126
+ )
127
+
128
+ result = {
129
+ "status": "running",
130
+ **data,
131
+ "native_tools": native_server_tools,
132
+ }
133
+ _output_json(result)
134
+
135
+
136
+ # ── 核心命令 ──────────────────────────────────────────────
137
+
138
+
139
+ def _search_and_merge(
140
+ local_results: list[SearchResult],
141
+ daemon_method: str,
142
+ query: str,
143
+ top_k: int,
144
+ ) -> None:
145
+ """执行 daemon 搜索,合并本地和远程结果后输出 JSON。"""
146
+ mcp_results: list[dict] = []
147
+ resp = _daemon_request(
148
+ DaemonRequest(method=daemon_method, query=query, top_k=top_k)
149
+ )
150
+ if resp.status == "success" and resp.data:
151
+ mcp_results = resp.data
152
+ elif resp.status == "error":
153
+ logger.warning("Daemon search failed: %s", resp.error)
154
+
155
+ combined = [r.model_dump(exclude_none=True) for r in local_results] + (
156
+ mcp_results or []
157
+ )
158
+ typer.echo(json.dumps(combined, ensure_ascii=False))
159
+
160
+
161
+ @app.command()
162
+ def search(
163
+ query: str = typer.Argument(help="搜索关键词"),
164
+ top_k: int = typer.Option(5, "--top-k", "-k", help="返回结果数量"),
165
+ ) -> None:
166
+ """混合搜索工具(BM25 + Embedding)。"""
167
+ local_results = _registry.search(query, top_k=top_k)
168
+ _search_and_merge(local_results, "search", query, top_k)
169
+
170
+
171
+ @app.command()
172
+ def grep(
173
+ pattern: str = typer.Argument(help="正则表达式"),
174
+ limit: int = typer.Option(10, "--limit", "-l", help="返回结果数量"),
175
+ ) -> None:
176
+ """正则表达式搜索工具。"""
177
+ try:
178
+ local_results = _registry.grep(pattern, top_k=limit)
179
+ except ValueError as e:
180
+ _output_json({"error": str(e)})
181
+ raise typer.Exit(code=1)
182
+ _search_and_merge(local_results, "grep", pattern, limit)
183
+
184
+
185
+ def _collect_tool_groups() -> dict[str | None, list[dict]]:
186
+ """收集所有工具并按 server 分组。"""
187
+ from axi.providers.mcp import MCPProvider
188
+
189
+ groups: dict[str | None, list[dict]] = {}
190
+
191
+ # 本地原生工具
192
+ for meta in _registry.list_all():
193
+ groups.setdefault(meta.server, []).append(
194
+ {"name": meta.name, "description": meta.description}
195
+ )
196
+
197
+ # MCP 工具(通过 daemon)
198
+ mcp_tools: list[dict] = []
199
+ resp = _daemon_request(DaemonRequest(method="list_tools"))
200
+ if resp.status == "success" and resp.data:
201
+ mcp_tools = resp.data
202
+ for t in mcp_tools:
203
+ groups.setdefault(t.get("server"), []).append(
204
+ {"name": t["name"], "description": t.get("description", "")}
205
+ )
206
+
207
+ # daemon 未返回工具时,至少从配置列出 server
208
+ if not mcp_tools:
209
+ provider = MCPProvider()
210
+ for cfg in provider.load_config():
211
+ if cfg.server not in groups:
212
+ groups[cfg.server] = []
213
+
214
+ return groups
215
+
216
+
217
+ def _filter_groups(
218
+ groups: dict[str | None, list[dict]], server_name: str
219
+ ) -> dict[str, list[dict]]:
220
+ """按逗号分隔的 server 名过滤分组。未找到时抛 typer.Exit。"""
221
+ names = [n.strip() for n in server_name.split(",") if n.strip()]
222
+ filtered = {k: v for k, v in groups.items() if k in names}
223
+ if not filtered:
224
+ missing = [n for n in names if n not in groups]
225
+ _output_json({"error": f"Server not found: {', '.join(missing)}"})
226
+ raise typer.Exit(code=1)
227
+ return filtered
228
+
229
+
230
+ @app.command("list")
231
+ def list_tools(
232
+ server_name: str | None = typer.Argument(
233
+ None, help="只列出指定 server 的工具(逗号分隔多个)"
234
+ ),
235
+ ) -> None:
236
+ """列出所有 server 及其工具。"""
237
+ groups = _collect_tool_groups()
238
+
239
+ if server_name is not None:
240
+ filtered = _filter_groups(groups, server_name)
241
+ if len(filtered) == 1:
242
+ key = next(iter(filtered))
243
+ typer.echo(
244
+ json.dumps({"server": key, "tools": filtered[key]}, ensure_ascii=False)
245
+ )
246
+ else:
247
+ typer.echo(
248
+ json.dumps(
249
+ [{"server": k, "tools": v} for k, v in filtered.items()],
250
+ ensure_ascii=False,
251
+ )
252
+ )
253
+ return
254
+
255
+ # 全部列出(只显示工具名)
256
+ result = [
257
+ {"server": key, "tools": [t["name"] for t in tools]}
258
+ for key, tools in groups.items()
259
+ ]
260
+ typer.echo(json.dumps(result, ensure_ascii=False))
261
+
262
+
263
+ def _resolve_tool(name: str) -> dict:
264
+ """解析单个工具,返回工具详情 dict 或 error dict。"""
265
+ try:
266
+ meta = _registry.resolve(name)
267
+ return meta.model_dump(exclude_none=True)
268
+ except AmbiguousToolError as e:
269
+ return {"error": str(e)}
270
+ except ToolNotFoundError:
271
+ pass # 本地未找到,继续尝试 daemon
272
+
273
+ resp = _daemon_request(DaemonRequest(method="describe", tool_name=name))
274
+ if resp.status == "success":
275
+ return resp.data
276
+ return {"error": resp.error or f"Tool not found: {name}"}
277
+
278
+
279
+ @app.command()
280
+ def describe(
281
+ tool_name: str = typer.Argument(help="工具完整名称(逗号分隔多个)"),
282
+ ) -> None:
283
+ """查看工具详情。"""
284
+ names = [n.strip() for n in tool_name.split(",") if n.strip()]
285
+ if len(names) == 1:
286
+ result = _resolve_tool(names[0])
287
+ if "error" in result:
288
+ _output_json(result)
289
+ raise typer.Exit(code=1)
290
+ _output_json(result)
291
+ return
292
+ results = [_resolve_tool(n) for n in names]
293
+ typer.echo(json.dumps(results, ensure_ascii=False))
294
+
295
+
296
+ @app.command(
297
+ context_settings={"allow_extra_args": True, "allow_interspersed_args": False},
298
+ )
299
+ def run(
300
+ ctx: typer.Context,
301
+ tool_name: str = typer.Argument(help="工具完整名称"),
302
+ ) -> None:
303
+ """执行工具。参数支持 --key value 或 --json '{...}' 格式。"""
304
+ args = ctx.args
305
+
306
+ if "--help" in args or "-h" in args:
307
+ typer.echo(ctx.get_help())
308
+ raise typer.Exit()
309
+
310
+ json_str, args = _extract_option(args, "--json", "-j")
311
+
312
+ if json_str:
313
+ try:
314
+ parsed = json.loads(json_str)
315
+ except json.JSONDecodeError as e:
316
+ _output_json(RunResult.fail(f"Invalid JSON argument: {e}"))
317
+ raise typer.Exit(code=1)
318
+ else:
319
+ parsed = _parse_params(args)
320
+
321
+ try:
322
+ meta = _registry.resolve(tool_name)
323
+ result = _executor.run(meta.full_name, parsed)
324
+ _output_json(result)
325
+ return
326
+ except AmbiguousToolError as e:
327
+ _output_json({"error": str(e)})
328
+ raise typer.Exit(code=1)
329
+ except ToolNotFoundError:
330
+ pass # 本地未找到,继续尝试 daemon
331
+
332
+ resp = _daemon_request(
333
+ DaemonRequest(method="call_tool", tool_name=tool_name, params=parsed)
334
+ )
335
+ if resp.status == "success":
336
+ _output_json(RunResult.success(resp.data))
337
+ else:
338
+ _output_json(RunResult.fail(resp.error or "Unknown error"))
339
+
340
+
341
+ # ── 参数解析辅助函数 ──────────────────────────────────────────────
342
+
343
+
344
+ def _extract_option(args: list[str], *names: str) -> tuple[str | None, list[str]]:
345
+ remaining = []
346
+ value = None
347
+ i = 0
348
+ while i < len(args):
349
+ if args[i] in names and i + 1 < len(args):
350
+ value = args[i + 1]
351
+ i += 2
352
+ else:
353
+ remaining.append(args[i])
354
+ i += 1
355
+ return value, remaining
356
+
357
+
358
+ def _parse_params(params: list[str]) -> dict:
359
+ parsed: dict = {}
360
+ i = 0
361
+ while i < len(params):
362
+ arg = params[i]
363
+ if arg.startswith("--"):
364
+ key = arg[2:]
365
+ if i + 1 < len(params) and not params[i + 1].startswith("--"):
366
+ value = params[i + 1]
367
+ try:
368
+ parsed[key] = json.loads(value)
369
+ except (json.JSONDecodeError, ValueError):
370
+ parsed[key] = value
371
+ i += 2
372
+ else:
373
+ parsed[key] = True
374
+ i += 1
375
+ else:
376
+ logger.warning("Ignoring unrecognized argument: %s", arg)
377
+ i += 1
378
+ return parsed
axi/config.py ADDED
@@ -0,0 +1,140 @@
1
+ """axi 配置中心:Pydantic 模型化,统一加载 axi.json,全局共享。"""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, Field, model_validator
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ CONFIG_PATH = Path(os.environ.get("AXI_CONFIG", "axi.json"))
13
+
14
+
15
+ # ── 子配置模型 ──────────────────────────────────────────────
16
+
17
+
18
+ class CliConfig(BaseModel):
19
+ """CLI 显示配置。"""
20
+
21
+ rich: bool = Field(default=False, description="启用 Rich 格式化输出")
22
+
23
+ @model_validator(mode="before")
24
+ @classmethod
25
+ def override_with_env(cls, values: dict) -> dict:
26
+ if isinstance(values, dict):
27
+ env = os.environ.get("AXI_RICH", "").lower()
28
+ if env in ("1", "true"):
29
+ values["rich"] = True
30
+ elif env in ("0", "false"):
31
+ values["rich"] = False
32
+ return values
33
+
34
+
35
+ class EmbeddingConfig(BaseModel):
36
+ """Embedding 搜索配置。"""
37
+
38
+ provider: str | None = Field(default=None, description="jina 或 openai")
39
+ api_key: str | None = Field(default=None, alias="apiKey", description="API 密钥")
40
+ model: str | None = Field(default=None, description="模型名称")
41
+ base_url: str | None = Field(
42
+ default=None, alias="baseUrl", description="自定义端点"
43
+ )
44
+
45
+ @model_validator(mode="before")
46
+ @classmethod
47
+ def override_with_env(cls, values: dict) -> dict:
48
+ if isinstance(values, dict) and not values.get("apiKey"):
49
+ provider = values.get("provider", "")
50
+ if provider == "jina":
51
+ values["apiKey"] = os.environ.get("JINA_API_KEY")
52
+ elif provider == "openai":
53
+ values["apiKey"] = os.environ.get("OPENAI_API_KEY")
54
+ return values
55
+
56
+
57
+ class SearchWeightsConfig(BaseModel):
58
+ """混合搜索 RRF 融合权重。"""
59
+
60
+ bm25: float = Field(default=0.3, description="BM25 权重")
61
+ embedding: float = Field(default=0.7, description="Embedding 权重")
62
+
63
+
64
+ class SearchConfig(BaseModel):
65
+ """搜索引擎配置。"""
66
+
67
+ embedding: EmbeddingConfig = Field(default_factory=EmbeddingConfig)
68
+ weights: SearchWeightsConfig = Field(default_factory=SearchWeightsConfig)
69
+
70
+
71
+ class DaemonConfig(BaseModel):
72
+ """Daemon 进程配置。"""
73
+
74
+ idle_timeout_minutes: int = Field(
75
+ default=30, alias="idleTimeoutMinutes", description="空闲自动关闭(分钟)"
76
+ )
77
+
78
+
79
+ class MCPServerConfig(BaseModel):
80
+ """单个 MCP server 的配置。"""
81
+
82
+ command: str | None = None
83
+ args: list[str] = Field(default_factory=list)
84
+ env: dict[str, str] | None = None
85
+ url: str | None = None
86
+
87
+
88
+ class NativeToolEntry(BaseModel):
89
+ """原生工具模块声明。"""
90
+
91
+ module: str = Field(description="Python 模块路径或文件路径")
92
+ name: str | None = Field(default=None, description="server 名,省略时自动推导")
93
+
94
+
95
+ # ── 主配置 ──────────────────────────────────────────────
96
+
97
+
98
+ class AxiConfig(BaseModel):
99
+ """axi 主配置。"""
100
+
101
+ cli: CliConfig = Field(default_factory=CliConfig)
102
+ mcp_servers: dict[str, MCPServerConfig] = Field(
103
+ default_factory=dict, alias="mcpServers"
104
+ )
105
+ native_tools: list[NativeToolEntry] = Field(
106
+ default_factory=list, alias="nativeTools"
107
+ )
108
+ search: SearchConfig = Field(default_factory=SearchConfig)
109
+ daemon: DaemonConfig = Field(default_factory=DaemonConfig)
110
+
111
+
112
+ # ── 加载 ──────────────────────────────────────────────
113
+
114
+
115
+ def load_config(path: Path) -> AxiConfig:
116
+ """读取并解析配置文件。找不到文件则返回默认配置,格式错误则报错退出。"""
117
+ if not path.exists():
118
+ return AxiConfig()
119
+ with open(path) as f:
120
+ try:
121
+ raw = json.load(f)
122
+ except json.JSONDecodeError as e:
123
+ raise SystemExit(f"Error: Malformed config file {path}: {e}")
124
+ try:
125
+ return AxiConfig.model_validate(raw)
126
+ except Exception as e:
127
+ raise SystemExit(f"Error: Invalid config in {path}: {e}")
128
+
129
+
130
+ def _load_app_config() -> AxiConfig:
131
+ """延迟加载配置,捕获异常并输出友好信息。"""
132
+ try:
133
+ return load_config(CONFIG_PATH)
134
+ except SystemExit:
135
+ raise
136
+ except Exception as e:
137
+ raise SystemExit(f"Error: Failed to load config: {e}")
138
+
139
+
140
+ app_config: AxiConfig = _load_app_config()
axi/daemon/client.py ADDED
@@ -0,0 +1,94 @@
1
+ """daemon 客户端:CLI 侧通过 Unix socket 与 daemon 通信。"""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import time
9
+
10
+ from axi.daemon.protocol import (
11
+ SOCKET_DIR,
12
+ SOCKET_PATH,
13
+ PID_PATH,
14
+ DaemonRequest,
15
+ DaemonResponse,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ DAEMON_LOG_PATH = os.path.join(SOCKET_DIR, "daemon.log")
21
+
22
+ _DAEMON_START_POLL_RETRIES = 30
23
+ _DAEMON_START_POLL_INTERVAL = 0.1 # seconds
24
+ _DAEMON_REQUEST_TIMEOUT = 30 # seconds
25
+
26
+
27
+ def is_daemon_running() -> bool:
28
+ """检查 daemon 是否在运行。"""
29
+ if not os.path.exists(PID_PATH):
30
+ return False
31
+
32
+ try:
33
+ with open(PID_PATH) as f:
34
+ pid = int(f.read().strip())
35
+ os.kill(pid, 0)
36
+ return os.path.exists(SOCKET_PATH)
37
+ except (OSError, ValueError):
38
+ return False
39
+
40
+
41
+ def ensure_daemon() -> bool:
42
+ """确保 daemon 已启动。未运行时自动启动,返回是否就绪。"""
43
+ if is_daemon_running():
44
+ return True
45
+
46
+ os.makedirs(SOCKET_DIR, exist_ok=True)
47
+
48
+ with open(DAEMON_LOG_PATH, "a") as log_file:
49
+ subprocess.Popen(
50
+ [sys.executable, "-m", "axi.daemon.server"],
51
+ stdout=log_file,
52
+ stderr=log_file,
53
+ start_new_session=True,
54
+ )
55
+
56
+ for _ in range(_DAEMON_START_POLL_RETRIES):
57
+ time.sleep(_DAEMON_START_POLL_INTERVAL)
58
+ if is_daemon_running():
59
+ return True
60
+ logger.error("Daemon failed to start. Check log: %s", DAEMON_LOG_PATH)
61
+ return False
62
+
63
+
64
+ def send_request(req: DaemonRequest) -> DaemonResponse:
65
+ """向 daemon 发送请求并获取响应。"""
66
+ return asyncio.run(_send(req))
67
+
68
+
69
+ async def _send(req: DaemonRequest) -> DaemonResponse:
70
+ try:
71
+ reader, writer = await asyncio.open_unix_connection(SOCKET_PATH)
72
+ except OSError as e:
73
+ return DaemonResponse.fail(
74
+ f"Cannot connect to daemon: {e}. Try: axi daemon stop && axi daemon start"
75
+ )
76
+
77
+ try:
78
+ writer.write(req.model_dump_json().encode() + b"\n")
79
+ await writer.drain()
80
+
81
+ line = await asyncio.wait_for(
82
+ reader.readline(), timeout=_DAEMON_REQUEST_TIMEOUT
83
+ )
84
+ if not line:
85
+ return DaemonResponse.fail("Daemon connection closed unexpectedly")
86
+
87
+ return DaemonResponse.model_validate_json(line)
88
+ except asyncio.TimeoutError:
89
+ return DaemonResponse.fail(
90
+ f"Daemon request timed out after {_DAEMON_REQUEST_TIMEOUT}s"
91
+ )
92
+ finally:
93
+ writer.close()
94
+ await writer.wait_closed()