dsh-miloco-bridge 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/NOTICE +37 -0
- package/README.md +122 -2
- package/SECURITY.md +33 -0
- package/adapter/dsh_adapter.py +399 -0
- package/cordis.patch.yml +85 -0
- package/docs/ARCHITECTURE.md +212 -0
- package/docs/INSTALL.md +303 -0
- package/docs/SKILL-REWRITES.md +168 -0
- package/docs/TOOLS.md +89 -0
- package/docs/VERIFICATION.md +245 -0
- package/docs/images/agent-presets.png +0 -0
- package/docs/images/miloco-settings.png +0 -0
- package/install.sh +261 -0
- package/lib/client.js +1195 -0
- package/lib/deliver-tool.js +132 -0
- package/lib/deploy.js +375 -0
- package/lib/index.js +164 -0
- package/lib/miloco-client.js +228 -0
- package/lib/overlay.js +132 -0
- package/lib/paths.js +33 -0
- package/lib/perception.js +103 -0
- package/lib/profile-config.js +116 -0
- package/lib/push.js +569 -0
- package/lib/settings-api.js +699 -0
- package/lib/skills.js +97 -0
- package/lib/state.js +187 -0
- package/lib/tools-entry.js +218 -0
- package/lib/tools.js +1441 -0
- package/lib/uninstall.js +240 -0
- package/package.json +64 -4
- package/scripts/build-bundle.sh +41 -0
- package/skills/miloco-create-task/SKILL.md +96 -0
- package/skills/miloco-devices/SKILL.md +50 -0
- package/skills/miloco-habit-suggest/SKILL.md +41 -0
- package/skills/miloco-home-observe/SKILL.md +39 -0
- package/skills/miloco-home-patrol/SKILL.md +43 -0
- package/skills/miloco-home-profile/SKILL.md +46 -0
- package/skills/miloco-home-promote/SKILL.md +33 -0
- package/skills/miloco-home-prune/SKILL.md +30 -0
- package/skills/miloco-miot-admin/SKILL.md +44 -0
- package/skills/miloco-miot-identity/SKILL.md +35 -0
- package/skills/miloco-miot-identity-register/SKILL.md +38 -0
- package/skills/miloco-miot-pet-register/SKILL.md +43 -0
- package/skills/miloco-miot-scope/SKILL.md +42 -0
- package/skills/miloco-notify/SKILL.md +38 -0
- package/skills/miloco-onboarding/SKILL.md +41 -0
- package/skills/miloco-perception/SKILL.md +42 -0
- package/skills/miloco-perception-digest/SKILL.md +40 -0
- package/skills/miloco-terminate-task/SKILL.md +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-miloco-bridge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
dsh-miloco-bridge
|
|
2
|
+
=================
|
|
3
|
+
|
|
4
|
+
This repository is an independent, unofficial integration between
|
|
5
|
+
DeepSeek Harness (DSH) and Xiaomi Miloco.
|
|
6
|
+
|
|
7
|
+
What this repository contains
|
|
8
|
+
-----------------------------
|
|
9
|
+
- Original Node.js/JavaScript code for a native DSH plugin.
|
|
10
|
+
- Original Chinese Skill documents written for this plugin. They describe how
|
|
11
|
+
to use this plugin's own tools; they are not copies of Xiaomi Miloco's
|
|
12
|
+
Skill documents.
|
|
13
|
+
- An original, duck-typed `agent_platform` adapter that talks to DSH's HTTP
|
|
14
|
+
webhook. It does not import or vendor Xiaomi Miloco backend code.
|
|
15
|
+
|
|
16
|
+
What this repository does NOT contain
|
|
17
|
+
-------------------------------------
|
|
18
|
+
- No Xiaomi Miloco source code, binaries, model files, dashboard assets, or
|
|
19
|
+
documentation is redistributed here.
|
|
20
|
+
- No Xiaomi logo or other trademark is used to endorse this project.
|
|
21
|
+
|
|
22
|
+
Third-party project
|
|
23
|
+
-------------------
|
|
24
|
+
Xiaomi Miloco is a separate project owned by Xiaomi Corporation and governed
|
|
25
|
+
by its own license:
|
|
26
|
+
https://github.com/XiaoMi/xiaomi-miloco
|
|
27
|
+
|
|
28
|
+
The optional "one-click deploy" feature clones and installs Xiaomi Miloco at
|
|
29
|
+
runtime from its official repository, on the user's own machine, after the
|
|
30
|
+
user explicitly clicks the button. The user is responsible for reviewing and
|
|
31
|
+
complying with Xiaomi Miloco's license and with the terms applicable to any
|
|
32
|
+
Xiaomi account, device, or API used with it.
|
|
33
|
+
|
|
34
|
+
"Xiaomi", "Mi Home", "Mijia", and "Miloco" are trademarks or product names of
|
|
35
|
+
Xiaomi Corporation and/or its affiliates. They are used here only to describe
|
|
36
|
+
interoperability. This project is not affiliated with, endorsed by, or
|
|
37
|
+
sponsored by Xiaomi.
|
package/README.md
CHANGED
|
@@ -1,3 +1,123 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-miloco-bridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**一个把 Xiaomi Miloco 接入 DeepSeek Harness (DSH) 的桥接插件:用专用 Agent 预设隔离 Miloco 产生的工具,避免干扰其他对话/项目,同时兼容 Miloco 的主动推送能力。**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-miloco-bridge)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/topics/dsh-plugin)
|
|
8
|
+
|
|
9
|
+
> 非官方项目,与小米无隶属关系;非网页版,是运行在 DSH Web profile 里的原生插件。
|
|
10
|
+
> Miloco 是独立项目,受其自身许可证约束,见 [NOTICE](NOTICE)。
|
|
11
|
+
|
|
12
|
+
## 截图
|
|
13
|
+
|
|
14
|
+
| Agent 预设 | Miloco 配置页 |
|
|
15
|
+
|---|---|
|
|
16
|
+
|  |  |
|
|
17
|
+
|
|
18
|
+
> 截图使用演示数据;真实账号、内网地址与 token 用量已脱敏。
|
|
19
|
+
|
|
20
|
+
## 它解决什么问题
|
|
21
|
+
|
|
22
|
+
- **Agent 隔离**:56 个 Miloco 工具和 18 个 Skill 只挂在专用预设「Miloco 家庭助手」上。
|
|
23
|
+
普通对话看不到 `miloco_*` 工具,也不会被注入 Miloco 技能。
|
|
24
|
+
- **完整能力**:设备、场景、感知、规则、任务、身份、宠物、家庭档案、通知、token 用量。
|
|
25
|
+
- **推送闭环**:Miloco 的感知事件、规则触发、任务到期可以主动推送到 DSH 会话。
|
|
26
|
+
- **感知过滤**:按 `low / medium / high` 事件优先级过滤,低于阈值的感知建议不产生 DSH turn。
|
|
27
|
+
- **可视化配置**:账号/设备/token 概览、推送目标、感知等级、运行模式、连接与卸载都在
|
|
28
|
+
「设置 → 插件 → Miloco」里完成。
|
|
29
|
+
- **可独立安装**:没有 Miloco 也能装插件;之后可在 UI 一键部署或连接已有实例。
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
### 从 npm 安装(推荐)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
dsh plugin --profile web add dsh-miloco-bridge
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> npm 新包可能处于审核期;若上述命令暂时 404,请使用下面的源码安装。
|
|
40
|
+
|
|
41
|
+
### 源码安装(npm 审核期 / 未发布时)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
dsh plugin --profile web add github:exa160/dsh-miloco-bridge
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 从 bundle / 本地安装
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 发布包
|
|
51
|
+
dsh plugin --profile web add file:dist/dsh-miloco-bridge-0.1.1.tgz
|
|
52
|
+
|
|
53
|
+
# 本地开发(link)
|
|
54
|
+
./install.sh --profile web
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Miloco 侧 adapter
|
|
58
|
+
|
|
59
|
+
如果已安装 Miloco,`install.sh` 会自动安装 adapter、写入连接与推送令牌。
|
|
60
|
+
手动步骤、systemd、升级与卸载见 [docs/INSTALL.md](docs/INSTALL.md)。
|
|
61
|
+
|
|
62
|
+
安装后重启 DSH:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
systemctl --user restart dsh-web.service
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 快速开始
|
|
69
|
+
|
|
70
|
+
1. 打开 DSH WebUI →「设置 → 插件 → Miloco」。
|
|
71
|
+
2. 没有 Miloco:点「一键部署 Miloco」;已有实例:点「连接已有的 Miloco」。
|
|
72
|
+
3. 新建会话时选择 **「Miloco 家庭助手」**。
|
|
73
|
+
4. 在设置页选择运行模式(只读 / 管家)、推送会话、可选模型与感知等级。
|
|
74
|
+
5. 想收 Miloco 主动推送时,确认 Miloco 的 `agent.platform=dsh`,并使用同一枚 Bearer token。
|
|
75
|
+
|
|
76
|
+
## 权限与风险
|
|
77
|
+
|
|
78
|
+
- 写入 `$DSH_HOME`(配置、状态、可选部署源码)和 `$MILOCO_HOME`(adapter)。
|
|
79
|
+
- 访问 Miloco HTTP API(默认 `127.0.0.1:1810`,也可配置局域网地址)。
|
|
80
|
+
- 「一键部署」会运行上游 Miloco 安装器,可能安装 Python 依赖并占用磁盘。
|
|
81
|
+
- **默认只读**:写工具不注册;切换到管家模式后才会暴露设备控制、规则/任务创建等能力。
|
|
82
|
+
- 插件不包含遥测,不内置任何账号或 token;推送入口无令牌时 fail closed。
|
|
83
|
+
|
|
84
|
+
## 兼容性
|
|
85
|
+
|
|
86
|
+
- DSH:`0.2.1-rc.4` 实测通过;建议 `>= 0.2.1-rc.4`。
|
|
87
|
+
- Node.js:`>= 22`。
|
|
88
|
+
- Miloco:2.0 HTTP API(`/api/*`,`/api/admin/status`、`/api/perception/*` 等)。
|
|
89
|
+
- 平台:Linux / macOS;Web profile(`dsh --profile web`)。
|
|
90
|
+
|
|
91
|
+
## 文档索引
|
|
92
|
+
|
|
93
|
+
| 文档 | 内容 |
|
|
94
|
+
|---|---|
|
|
95
|
+
| [docs/INSTALL.md](docs/INSTALL.md) | 安装、连接、adapter、systemd、升级/卸载、排错 |
|
|
96
|
+
| [docs/VERIFICATION.md](docs/VERIFICATION.md) | 离线测试、真实 DSH/Miloco 验证、推送与过滤验证 |
|
|
97
|
+
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 双半架构、路由、配置层、状态文件、推送协议 |
|
|
98
|
+
| [docs/SKILL-REWRITES.md](docs/SKILL-REWRITES.md) | 为什么重写 18 个 Skill、改了什么、合规边界 |
|
|
99
|
+
| [docs/TOOLS.md](docs/TOOLS.md) | 56 个工具的参数与语义 |
|
|
100
|
+
| [SECURITY.md](SECURITY.md) | 安全边界与漏洞报告 |
|
|
101
|
+
|
|
102
|
+
## 开发
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm test # 65 项测试
|
|
106
|
+
npm run selftest # 离线自检
|
|
107
|
+
npm run verify:readonly # 真实 Miloco 只读接口
|
|
108
|
+
npm run pack:bundle # 生成 dist/*.tgz
|
|
109
|
+
./install.sh --check # 安装环境检查
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## DSH Plugin 市场信息
|
|
113
|
+
|
|
114
|
+
- 能力分类:**集成与连接**
|
|
115
|
+
- 安装命令:`dsh plugin --profile web add dsh-miloco-bridge`
|
|
116
|
+
- GitHub topic:`dsh-plugin`
|
|
117
|
+
- 许可证:MIT
|
|
118
|
+
- 插件导出:`apply(ctx)`;根目录包含 `package.json`
|
|
119
|
+
|
|
120
|
+
## 许可证
|
|
121
|
+
|
|
122
|
+
MIT。Xiaomi Miloco 的名称、接口与商标归小米所有;本仓库不包含上游源码、Skill 原文、
|
|
123
|
+
模型或面板资源。详见 [LICENSE](LICENSE) 与 [NOTICE](NOTICE)。
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting
|
|
4
|
+
|
|
5
|
+
Open a private security advisory on the repository host, or contact the
|
|
6
|
+
maintainers directly. Do not file a public issue for a vulnerability that can
|
|
7
|
+
control devices, leak tokens, or reach a private camera stream.
|
|
8
|
+
|
|
9
|
+
## Design boundaries
|
|
10
|
+
|
|
11
|
+
- **Default read-only.** Write tools are not registered while the plugin runs
|
|
12
|
+
in `readonly` mode. There is no runtime "hidden tool" that the model could
|
|
13
|
+
still call.
|
|
14
|
+
- **Webhook fail-closed.** `/miloco/webhook` rejects every request when no
|
|
15
|
+
push token is configured.
|
|
16
|
+
- **Token storage.** Connection and push tokens live in
|
|
17
|
+
`$DSH_HOME/miloco-plugin.json` with mode `0600`; they are never returned to
|
|
18
|
+
the browser.
|
|
19
|
+
- **Same-origin writes.** Settings mutations reject cross-site `Origin`
|
|
20
|
+
headers.
|
|
21
|
+
- **No secrets in the repository.** The project ships no credentials, device
|
|
22
|
+
ids, or Xiaomi account data.
|
|
23
|
+
- **No shell interpolation in deployment.** The deployer uses
|
|
24
|
+
`spawn(command, args)`; the repository URL must be `https://`.
|
|
25
|
+
|
|
26
|
+
## Operational guidance
|
|
27
|
+
|
|
28
|
+
- Prefer a dedicated Miloco backend token, not your Xiaomi account password.
|
|
29
|
+
- Do not expose the DSH Web UI or `/miloco/webhook` directly to the public
|
|
30
|
+
internet; put them behind a trusted reverse proxy/VPN and TLS.
|
|
31
|
+
- Review Miloco's own security model before enabling camera perception.
|
|
32
|
+
- Treat model-generated device actions as untrusted: confirm high-risk actions
|
|
33
|
+
(locks, gas, cameras, member deletion) in the UI.
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""DSH agent_platform adapter for Xiaomi Miloco.
|
|
2
|
+
|
|
3
|
+
Miloco loads this file from ``$MILOCO_HOME/agent_platform/dsh/adapter.py`` and
|
|
4
|
+
calls it with duck-typed turn contexts. It does not import Miloco internals:
|
|
5
|
+
the only optional import is the public ``AdapterTransportError`` class when
|
|
6
|
+
running inside the backend process, so the dispatcher can classify transport
|
|
7
|
+
failures. Standalone use falls back to a local class with the same name.
|
|
8
|
+
|
|
9
|
+
Configuration is read from ``$MILOCO_HOME/agent_platform/dsh/config.yaml``
|
|
10
|
+
(flat ``key: value`` lines) or environment variables:
|
|
11
|
+
|
|
12
|
+
webhook_url: "http://127.0.0.1:3080/miloco/webhook"
|
|
13
|
+
auth_bearer: "same token as the DSH plugin push token"
|
|
14
|
+
include_home_profile: true
|
|
15
|
+
home_profile_max_chars: 4000
|
|
16
|
+
# optional explicit backend endpoint for reading the rendered profile
|
|
17
|
+
backend_url: "http://127.0.0.1:1810"
|
|
18
|
+
backend_token: "..."
|
|
19
|
+
|
|
20
|
+
Environment overrides:
|
|
21
|
+
|
|
22
|
+
DSH_MILOCO_WEBHOOK_URL
|
|
23
|
+
DSH_MILOCO_WEBHOOK_TOKEN
|
|
24
|
+
DSH_MILOCO_INCLUDE_HOME_PROFILE
|
|
25
|
+
DSH_MILOCO_BACKEND_URL
|
|
26
|
+
DSH_MILOCO_BACKEND_TOKEN
|
|
27
|
+
|
|
28
|
+
The protocol is the one Miloco already uses for webhook agents::
|
|
29
|
+
|
|
30
|
+
POST {webhook_url}
|
|
31
|
+
{"action": "agent", "payload": {
|
|
32
|
+
"message": "...", "sessionKey": "...", "lane": "...",
|
|
33
|
+
"traceId": "...", "idempotencyKey": "...", "timeoutMs": 120000,
|
|
34
|
+
"urgency": "high|medium|low"
|
|
35
|
+
}}
|
|
36
|
+
|
|
37
|
+
DSH replies ``{"code": 0, "data": {runId, status, sessionId, reply}}``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json
|
|
43
|
+
import logging
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import time
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from types import SimpleNamespace
|
|
49
|
+
from typing import Any, Optional
|
|
50
|
+
|
|
51
|
+
try: # Available when this adapter runs inside the Miloco backend.
|
|
52
|
+
from miloco.agent_platform.base import AdapterTransportError
|
|
53
|
+
except Exception: # pragma: no cover - standalone / tests
|
|
54
|
+
class AdapterTransportError(Exception):
|
|
55
|
+
"""Transport-level failure (connect, timeout, 5xx)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
import httpx
|
|
60
|
+
except ImportError: # pragma: no cover - dependency is provided by backend
|
|
61
|
+
httpx = None # type: ignore[assignment]
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
_HTTP_BUFFER_S = 20.0
|
|
66
|
+
_DEFAULT_WAIT_MS = 120_000
|
|
67
|
+
_URGENCY_RE = re.compile(r"(?:事件优先级|urgency)\s*[::=]\s*(high|medium|low|ignore)", re.I)
|
|
68
|
+
_URGENCY_RANK = {"low": 1, "medium": 2, "high": 3}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_urgency(text: str) -> str:
|
|
72
|
+
"""Return the highest urgency mentioned by a perception event batch.
|
|
73
|
+
|
|
74
|
+
Miloco's event text uses ``事件优先级:high|medium|low``. A batch may
|
|
75
|
+
contain several suggestions; the highest one decides the batch priority.
|
|
76
|
+
Returns an empty string for non-perception events (rules/tasks/etc.).
|
|
77
|
+
"""
|
|
78
|
+
best = ""
|
|
79
|
+
best_rank = 0
|
|
80
|
+
saw_ignore = False
|
|
81
|
+
for match in _URGENCY_RE.finditer(text or ""):
|
|
82
|
+
value = match.group(1).lower()
|
|
83
|
+
if value == "ignore":
|
|
84
|
+
saw_ignore = True
|
|
85
|
+
continue
|
|
86
|
+
rank = _URGENCY_RANK.get(value, 0)
|
|
87
|
+
if rank > best_rank:
|
|
88
|
+
best = value
|
|
89
|
+
best_rank = rank
|
|
90
|
+
return best or ("ignore" if saw_ignore else "")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _miloco_home() -> Path:
|
|
94
|
+
raw = os.environ.get("MILOCO_HOME", "").strip()
|
|
95
|
+
return Path(raw).expanduser() if raw else Path.home() / ".miloco"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_flat_yaml(path: Path) -> dict[str, str]:
|
|
99
|
+
"""Read simple ``key: value`` YAML without adding a dependency."""
|
|
100
|
+
data: dict[str, str] = {}
|
|
101
|
+
try:
|
|
102
|
+
text = path.read_text(encoding="utf-8")
|
|
103
|
+
except OSError:
|
|
104
|
+
return data
|
|
105
|
+
for raw_line in text.splitlines():
|
|
106
|
+
line = raw_line.strip()
|
|
107
|
+
if not line or line.startswith("#") or ":" not in line:
|
|
108
|
+
continue
|
|
109
|
+
key, _, value = line.partition(":")
|
|
110
|
+
value = value.strip()
|
|
111
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
112
|
+
value = value[1:-1]
|
|
113
|
+
data[key.strip()] = value
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _truthy(value: Any, default: bool = True) -> bool:
|
|
118
|
+
if value is None or value == "":
|
|
119
|
+
return default
|
|
120
|
+
return str(value).strip().lower() not in ("0", "false", "no", "off")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _read_backend_connection() -> tuple[str, str]:
|
|
124
|
+
"""Read Miloco's own server URL/token from ``$MILOCO_HOME/config.json``."""
|
|
125
|
+
try:
|
|
126
|
+
payload = json.loads((_miloco_home() / "config.json").read_text(encoding="utf-8"))
|
|
127
|
+
except (OSError, ValueError):
|
|
128
|
+
return "", ""
|
|
129
|
+
server = payload.get("server") if isinstance(payload, dict) else {}
|
|
130
|
+
if not isinstance(server, dict):
|
|
131
|
+
return "", ""
|
|
132
|
+
url = str(server.get("url") or "").strip()
|
|
133
|
+
if not url:
|
|
134
|
+
host = str(server.get("host") or "127.0.0.1")
|
|
135
|
+
port = str(server.get("port") or "1810")
|
|
136
|
+
url = f"http://{host}:{port}"
|
|
137
|
+
return url.rstrip("/"), str(server.get("token") or "").strip()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Adapter:
|
|
141
|
+
"""Deliver Miloco turns into DeepSeek Harness."""
|
|
142
|
+
|
|
143
|
+
name = "dsh"
|
|
144
|
+
|
|
145
|
+
def __init__(self) -> None:
|
|
146
|
+
config = _read_flat_yaml(_miloco_home() / "agent_platform" / "dsh" / "config.yaml")
|
|
147
|
+
self.webhook_url = str(
|
|
148
|
+
os.environ.get("DSH_MILOCO_WEBHOOK_URL")
|
|
149
|
+
or config.get("webhook_url")
|
|
150
|
+
or "http://127.0.0.1:3080/miloco/webhook"
|
|
151
|
+
).strip()
|
|
152
|
+
self.auth_bearer = str(
|
|
153
|
+
os.environ.get("DSH_MILOCO_WEBHOOK_TOKEN")
|
|
154
|
+
or config.get("auth_bearer")
|
|
155
|
+
or ""
|
|
156
|
+
).strip()
|
|
157
|
+
self.include_home_profile = _truthy(
|
|
158
|
+
os.environ.get("DSH_MILOCO_INCLUDE_HOME_PROFILE", config.get("include_home_profile")),
|
|
159
|
+
default=True,
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
self.home_profile_max_chars = int(config.get("home_profile_max_chars", "4000"))
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
self.home_profile_max_chars = 4000
|
|
165
|
+
|
|
166
|
+
default_backend_url, default_backend_token = _read_backend_connection()
|
|
167
|
+
self.backend_url = str(
|
|
168
|
+
os.environ.get("DSH_MILOCO_BACKEND_URL")
|
|
169
|
+
or config.get("backend_url")
|
|
170
|
+
or default_backend_url
|
|
171
|
+
).strip().rstrip("/")
|
|
172
|
+
self.backend_token = str(
|
|
173
|
+
os.environ.get("DSH_MILOCO_BACKEND_TOKEN")
|
|
174
|
+
or config.get("backend_token")
|
|
175
|
+
or default_backend_token
|
|
176
|
+
).strip()
|
|
177
|
+
|
|
178
|
+
self._client: Optional[Any] = None
|
|
179
|
+
self._profile_cache: Optional[tuple[float, str]] = None
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------ HTTP
|
|
182
|
+
|
|
183
|
+
def _http(self) -> Any:
|
|
184
|
+
if httpx is None:
|
|
185
|
+
raise AdapterTransportError("httpx is not available; cannot reach DSH")
|
|
186
|
+
if self._client is None:
|
|
187
|
+
self._client = httpx.AsyncClient(timeout=180.0)
|
|
188
|
+
return self._client
|
|
189
|
+
|
|
190
|
+
async def aclose(self) -> None:
|
|
191
|
+
if self._client is not None:
|
|
192
|
+
try:
|
|
193
|
+
await self._client.aclose()
|
|
194
|
+
finally:
|
|
195
|
+
self._client = None
|
|
196
|
+
|
|
197
|
+
# --------------------------------------------------------------- profile
|
|
198
|
+
|
|
199
|
+
def build_system(self, profile: str, extra: dict[str, Any]) -> str:
|
|
200
|
+
"""Return the optional home-profile block prepended to the message.
|
|
201
|
+
|
|
202
|
+
DSH has no public "replace system prompt per turn" hook, so the block
|
|
203
|
+
travels as part of the message text and is clearly delimited. It is
|
|
204
|
+
omitted for lightweight profiles (``minimal`` / ``rule`` /
|
|
205
|
+
``suggestion``) to save tokens.
|
|
206
|
+
"""
|
|
207
|
+
if not self.include_home_profile or profile in ("minimal", "rule", "suggestion"):
|
|
208
|
+
return ""
|
|
209
|
+
rendered = self._load_home_profile()
|
|
210
|
+
if not rendered:
|
|
211
|
+
return ""
|
|
212
|
+
return (
|
|
213
|
+
"【家庭档案 · 由 Miloco 注入】\n"
|
|
214
|
+
f"{rendered}\n"
|
|
215
|
+
"【以上为家庭档案】\n\n"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _load_home_profile(self) -> str:
|
|
219
|
+
now = time.monotonic()
|
|
220
|
+
if self._profile_cache is not None and now - self._profile_cache[0] < 300:
|
|
221
|
+
return self._profile_cache[1]
|
|
222
|
+
text = self._fetch_home_profile()
|
|
223
|
+
self._profile_cache = (now, text)
|
|
224
|
+
return text
|
|
225
|
+
|
|
226
|
+
def _fetch_home_profile(self) -> str:
|
|
227
|
+
if httpx is None or not self.backend_url:
|
|
228
|
+
return ""
|
|
229
|
+
headers = {"accept": "application/json"}
|
|
230
|
+
if self.backend_token:
|
|
231
|
+
headers["authorization"] = f"Bearer {self.backend_token}"
|
|
232
|
+
try:
|
|
233
|
+
response = httpx.get(
|
|
234
|
+
f"{self.backend_url}/api/home-profile/rendered",
|
|
235
|
+
headers=headers,
|
|
236
|
+
timeout=10.0,
|
|
237
|
+
)
|
|
238
|
+
if response.status_code >= 400:
|
|
239
|
+
return ""
|
|
240
|
+
payload = response.json()
|
|
241
|
+
except Exception as exc: # noqa: BLE001 - profile injection is best effort
|
|
242
|
+
logger.debug("dsh adapter: home profile unavailable: %s", exc)
|
|
243
|
+
return ""
|
|
244
|
+
|
|
245
|
+
data = payload.get("data") if isinstance(payload, dict) else payload
|
|
246
|
+
if isinstance(data, str):
|
|
247
|
+
rendered = data
|
|
248
|
+
elif isinstance(data, dict):
|
|
249
|
+
rendered = (
|
|
250
|
+
data.get("markdown")
|
|
251
|
+
or data.get("rendered")
|
|
252
|
+
or data.get("text")
|
|
253
|
+
or ""
|
|
254
|
+
)
|
|
255
|
+
if not rendered:
|
|
256
|
+
rendered = json.dumps(data, ensure_ascii=False)
|
|
257
|
+
else:
|
|
258
|
+
rendered = ""
|
|
259
|
+
rendered = str(rendered).strip()
|
|
260
|
+
if len(rendered) > self.home_profile_max_chars:
|
|
261
|
+
rendered = rendered[: self.home_profile_max_chars] + "\n…(已截断)"
|
|
262
|
+
return rendered
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------ turn
|
|
265
|
+
|
|
266
|
+
async def send_turn(self, ctx: Any) -> Any:
|
|
267
|
+
"""Deliver one turn to DSH and wait for its completion."""
|
|
268
|
+
client = self._http()
|
|
269
|
+
|
|
270
|
+
text = getattr(ctx, "text", "") or ""
|
|
271
|
+
session_key = getattr(ctx, "session_key", "") or ""
|
|
272
|
+
lane = getattr(ctx, "lane", "") or ""
|
|
273
|
+
trace_id = getattr(ctx, "trace_id", "") or ""
|
|
274
|
+
wait_timeout_ms = int(getattr(ctx, "wait_timeout_ms", _DEFAULT_WAIT_MS) or _DEFAULT_WAIT_MS)
|
|
275
|
+
profile = getattr(ctx, "profile", "full") or "full"
|
|
276
|
+
extra = getattr(ctx, "extra", {}) or {}
|
|
277
|
+
|
|
278
|
+
system_block = ""
|
|
279
|
+
try:
|
|
280
|
+
system_block = self.build_system(profile, extra)
|
|
281
|
+
except Exception as exc: # noqa: BLE001
|
|
282
|
+
logger.warning("dsh adapter: build_system failed: %s", exc)
|
|
283
|
+
|
|
284
|
+
payload: dict[str, Any] = {
|
|
285
|
+
"message": f"{system_block}{text}" if system_block else text,
|
|
286
|
+
"sessionKey": session_key,
|
|
287
|
+
"lane": lane,
|
|
288
|
+
"traceId": trace_id,
|
|
289
|
+
"idempotencyKey": trace_id,
|
|
290
|
+
"timeoutMs": wait_timeout_ms,
|
|
291
|
+
}
|
|
292
|
+
urgency = _extract_urgency(text)
|
|
293
|
+
if urgency:
|
|
294
|
+
payload["urgency"] = urgency
|
|
295
|
+
delivery = extra.get("delivery") or {}
|
|
296
|
+
if isinstance(delivery, dict):
|
|
297
|
+
for key in ("deliver", "resolveTarget", "lightContext"):
|
|
298
|
+
if delivery.get(key) is not None:
|
|
299
|
+
payload[key] = delivery[key]
|
|
300
|
+
|
|
301
|
+
headers = {"content-type": "application/json"}
|
|
302
|
+
if self.auth_bearer:
|
|
303
|
+
headers["authorization"] = f"Bearer {self.auth_bearer}"
|
|
304
|
+
|
|
305
|
+
started = time.monotonic()
|
|
306
|
+
timeout_s = wait_timeout_ms / 1000 + _HTTP_BUFFER_S
|
|
307
|
+
try:
|
|
308
|
+
response = await client.post(
|
|
309
|
+
self.webhook_url,
|
|
310
|
+
json={"action": "agent", "payload": payload},
|
|
311
|
+
headers=headers,
|
|
312
|
+
timeout=timeout_s,
|
|
313
|
+
)
|
|
314
|
+
except Exception as exc: # noqa: BLE001 - classified as transport
|
|
315
|
+
raise AdapterTransportError(f"DSH webhook request failed: {exc}") from exc
|
|
316
|
+
|
|
317
|
+
rtt_ms = (time.monotonic() - started) * 1000
|
|
318
|
+
if response.status_code >= 400:
|
|
319
|
+
body = response.text[:300]
|
|
320
|
+
raise AdapterTransportError(f"DSH webhook HTTP {response.status_code}: {body}")
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
body = response.json()
|
|
324
|
+
except ValueError as exc:
|
|
325
|
+
raise AdapterTransportError(f"DSH webhook returned non-JSON: {response.text[:200]}") from exc
|
|
326
|
+
|
|
327
|
+
code = body.get("code", -1) if isinstance(body, dict) else -1
|
|
328
|
+
if code != 0:
|
|
329
|
+
message = body.get("message", "unknown error") if isinstance(body, dict) else "unknown error"
|
|
330
|
+
logger.warning("dsh adapter: webhook error code=%s message=%s", code, message)
|
|
331
|
+
return SimpleNamespace(
|
|
332
|
+
run_id=None,
|
|
333
|
+
status="error",
|
|
334
|
+
rtt_ms=rtt_ms,
|
|
335
|
+
recovered=None,
|
|
336
|
+
error=str(message),
|
|
337
|
+
reply=None,
|
|
338
|
+
session_id=None,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
342
|
+
data = data if isinstance(data, dict) else {}
|
|
343
|
+
status = data.get("status", "ok")
|
|
344
|
+
if status not in ("ok", "timeout", "error"):
|
|
345
|
+
status = "ok"
|
|
346
|
+
return SimpleNamespace(
|
|
347
|
+
run_id=data.get("runId"),
|
|
348
|
+
status=status,
|
|
349
|
+
rtt_ms=rtt_ms,
|
|
350
|
+
recovered=data.get("recovered"),
|
|
351
|
+
error=data.get("error"),
|
|
352
|
+
reply=data.get("reply"),
|
|
353
|
+
session_id=data.get("sessionId"),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# ----------------------------------------------------------------- trace
|
|
357
|
+
|
|
358
|
+
async def read_trace_meta(self, run_id: str) -> Optional[Any]:
|
|
359
|
+
"""DSH keeps turn metadata in memory, not as a trace file."""
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
# ----------------------------------------------------------------- reset
|
|
363
|
+
|
|
364
|
+
async def reset_sessions(self, routes: list[Any], *, timeout: float = 10.0) -> Optional[Any]:
|
|
365
|
+
"""Ask DSH to forget the sessions tied to Miloco session keys."""
|
|
366
|
+
if not routes or not self.webhook_url:
|
|
367
|
+
return None
|
|
368
|
+
keys = []
|
|
369
|
+
for item in routes:
|
|
370
|
+
if isinstance(item, str):
|
|
371
|
+
keys.append(item)
|
|
372
|
+
elif isinstance(item, (tuple, list)) and item:
|
|
373
|
+
keys.append(str(item[0]))
|
|
374
|
+
else:
|
|
375
|
+
value = getattr(item, "session_key", None)
|
|
376
|
+
if value:
|
|
377
|
+
keys.append(str(value))
|
|
378
|
+
keys = sorted(set(keys))
|
|
379
|
+
if not keys:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
headers = {"content-type": "application/json"}
|
|
383
|
+
if self.auth_bearer:
|
|
384
|
+
headers["authorization"] = f"Bearer {self.auth_bearer}"
|
|
385
|
+
try:
|
|
386
|
+
client = self._http()
|
|
387
|
+
response = await client.post(
|
|
388
|
+
self.webhook_url,
|
|
389
|
+
json={
|
|
390
|
+
"action": "reset_sessions",
|
|
391
|
+
"payload": {"sessionKeys": keys, "deleteTranscript": True},
|
|
392
|
+
},
|
|
393
|
+
headers=headers,
|
|
394
|
+
timeout=timeout,
|
|
395
|
+
)
|
|
396
|
+
return response.json()
|
|
397
|
+
except Exception as exc: # noqa: BLE001 - cleanup is best effort
|
|
398
|
+
logger.warning("dsh adapter: reset_sessions failed: %s", exc)
|
|
399
|
+
return None
|