magic-agent 1.0.0
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/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// dsh.go - DeepSeek Harness(DSH)引擎。
|
|
4
|
+
//
|
|
5
|
+
// 后端:`dsh`(DeepSeek Harness,npm 包 @deepseek-ai/dsh,MIT,2026-08 开源;
|
|
6
|
+
// 本文件依据其官方 CLI 行为参考 apps/cli/reference 与 SDK 协议包撰写,非猜测)。
|
|
7
|
+
//
|
|
8
|
+
// **两条通道**,默认走 SDK,不可用时回退 headless:
|
|
9
|
+
//
|
|
10
|
+
// ① SDK(默认) `dsh --profile sdk` + stdio 换行分帧 JSON-RPC 2.0(见 dsh_sdk.go)
|
|
11
|
+
// ② headless(回退)`dsh --profile headless "<任务文本>"`
|
|
12
|
+
//
|
|
13
|
+
// 为什么默认 SDK:headless **没有任何工具调用通道**。它的官方定位就是「推理走 stderr、
|
|
14
|
+
// 最终正文走 stdout、然后退出」—— 实测模型用了 bash/glob/read,CLI 只字未提,工具调用
|
|
15
|
+
// 只写进 $DSH_HOME/sessions/…/session.v3.jsonl.zstd(zstd 压缩)。而 SDK profile 把
|
|
16
|
+
// 会话事件实时推出来(tool/call + tool/result + 逐 step 的正文),且 initialize 可指定
|
|
17
|
+
// provider/model/maxTokens。SDK 通道不可用时(旧版 dsh 没有该 profile)才回退 headless。
|
|
18
|
+
//
|
|
19
|
+
// headless 调用形态与关键事实(官方 reference 明确写出,逐条对应本文件的实现):
|
|
20
|
+
//
|
|
21
|
+
// - 启动器 flags 必须在 app 参数之前,且**在第一个它不认识的 token 处结束**;
|
|
22
|
+
// headless 的 app 参数**只有任务文本本身**(位置参数),没有 --prompt。
|
|
23
|
+
// 故回退路径固定产出 `--profile headless <prompt>`(FlattenPrompt 的正文恒以
|
|
24
|
+
// 「【用户】/【系统指令】」开头,不会撞上启动器 flag 名,无需 `--` 边界符)。
|
|
25
|
+
// - 一次调用 = 一个**全新的持久化 Agent**:提交任务 → 等静默 → flush 会话 →
|
|
26
|
+
// 取该区间最后一条非空助手文本。**没有续接参数**(无 --resume / --continue)。
|
|
27
|
+
// - stdout **只打印最终助手正文**(纯文本,无 JSON、无 --output-format);
|
|
28
|
+
// stderr 是 "dsh: reasoning:" 前缀的推理增量 + 终止性错误原因。
|
|
29
|
+
// - 退出码:turn/end 为 completed → 0,否则 1。
|
|
30
|
+
// - 工作目录:**调用时所在目录就是默认 workspace 根**(base 系 profile 语义)
|
|
31
|
+
// → 与 trae/claude 同路,用子进程 cwd 落地(WorkspaceSupportOf = "cwd")。
|
|
32
|
+
// SDK 通道同理:initialize 的 cwd 就是「每个 SDK 建的会话 header 上记的目录」,
|
|
33
|
+
// 同时也仍用子进程 cwd 兜底(工具执行的相对路径跟着它走)。
|
|
34
|
+
// - 权限:新会话默认 workspace-write 预设(写入限制在工作区 + 平台临时目录),
|
|
35
|
+
// read-only 为另一预设,进程回退模式由环境变量 DSH_PERMISSION_MODE 决定。
|
|
36
|
+
// **没有命令行级 approval flag**,且这两个预设无法表达本项目四档模型里的
|
|
37
|
+
// 「沙箱关闭 / 无审批」(第 4 档)→ 四档模型在 dsh 上**不接线**
|
|
38
|
+
// (PermissionSupportOf = "none"),显式传 --permission 会在 CLI 层 exit 2
|
|
39
|
+
// 报错,而不是静默降级 —— 安全设置不做静默降级。
|
|
40
|
+
// - 工具:DSH 自带 agent 工具循环(base bundle 的 read/write/edit + Bash 等),
|
|
41
|
+
// 增删工具靠 profile bundle / cordis.patch.yml 配置,**无命令行级逐工具开关**
|
|
42
|
+
// → Request.Tools 整体忽略(与 openclaw 的「内嵌 agent」同构,见矩阵注 ③)。
|
|
43
|
+
// - 超时:CLI 无 --timeout flag(超时是 provider 级 settings 的
|
|
44
|
+
// streamIdleTimeoutMs 等);外层超时由 Runner 的 ctx 施加,超时会杀掉
|
|
45
|
+
// 整个进程组(runcmd.go),不留孤儿。
|
|
46
|
+
//
|
|
47
|
+
// 参数映射(统一参数矩阵的 dsh 列):
|
|
48
|
+
//
|
|
49
|
+
// Model SDK:支持(initialize 的 provider/model,取 `route/model` 形态)
|
|
50
|
+
// headless 回退:不支持(静默忽略 + 一行告警:换模型要改配置层)
|
|
51
|
+
// SessionID 两条通道都不支持 → **显式报错**,不静默开新会话
|
|
52
|
+
// (headless 每次全新会话;SDK 的 sessionId 是「未知 id 惰性建会话」,
|
|
53
|
+
// 已存在的 id 会被 -32603 `session already exists` 拒掉,协议也没有
|
|
54
|
+
// resume/load 方法 → 对「每次调用都是新进程」的 magic-agent 一样续不上)
|
|
55
|
+
// Continue 同上(无「最近一次会话」概念)→ 显式报错
|
|
56
|
+
// MaxTokens SDK:支持(initialize 的 maxTokens,官方语义「限制 SDK 创建的 agent
|
|
57
|
+
// 及其进程内后代的每次对话模型输出」);headless 回退:静默忽略
|
|
58
|
+
// Temperature 不支持(无对应键)→ 静默忽略
|
|
59
|
+
// Tools 内嵌 agent(忽略,见上)
|
|
60
|
+
// Permission 未接线(none)→ 显式传则 CLI 层 exit 2
|
|
61
|
+
// SystemPrompt 无独立 system 注入 flag → 展平进 prompt 头部(与 trae/openclaw 同路)
|
|
62
|
+
// Timeout 进程级超时(CLI 无 flag)
|
|
63
|
+
// JSONSchema 无原生约束 → 走与 llm/openclaw/arkclaw 同一条「输出后处理抽 JSON」
|
|
64
|
+
// Workspace 子进程 cwd(SDK 通道另把 cwd 交给 initialize)
|
|
65
|
+
// Attachments 无原生输入通道 → 绝对路径写进提示词(与 trae/openclaw 同路)
|
|
66
|
+
//
|
|
67
|
+
// 流式:实现了 Streamer,两通道能力不同(如实接线,不假装):
|
|
68
|
+
//
|
|
69
|
+
// SDK 通道(默认) 推理 → KindThinking;正文 → 逐 step KindText(真流式);
|
|
70
|
+
// 工具 → KindToolUse / KindToolResult;收尾一条 KindTurnEnd。
|
|
71
|
+
// headless 回退 推理增量走 stderr("dsh: reasoning:" 起头,约 300ms 一批)
|
|
72
|
+
// → 逐条 KindThinking;正文在 turn 结束时一次性 stdout
|
|
73
|
+
// → 收尾一条 KindText。**工具调用在这条路上拿不到**(协议如此)。
|
|
74
|
+
//
|
|
75
|
+
// CLI 路径解析顺序:显式 BinPath → MAGIC_AGENT_DSH_BIN → 常见安装位 → PATH
|
|
76
|
+
//(探测链统一收敛在 engine_base.go 的 cliBase)。
|
|
77
|
+
|
|
78
|
+
import (
|
|
79
|
+
"context"
|
|
80
|
+
"errors"
|
|
81
|
+
"fmt"
|
|
82
|
+
"os"
|
|
83
|
+
"path/filepath"
|
|
84
|
+
"strings"
|
|
85
|
+
"time"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// DshEngine 通过 DeepSeek Harness CLI 实现 Engine(默认 SDK 通道,见 dsh_sdk.go)。
|
|
89
|
+
type DshEngine struct {
|
|
90
|
+
// BinPath 显式指定 CLI 路径(空 = 自动探测)。测试注入用。
|
|
91
|
+
BinPath string
|
|
92
|
+
|
|
93
|
+
// Profile 显式 profile 名(空 = 看 DshProfileEnv,再空 = 默认走 SDK 通道)。
|
|
94
|
+
//
|
|
95
|
+
// 显式给 "headless"(或企业内预置的自定义 profile)即**关闭 SDK 通道**,退回
|
|
96
|
+
// 单 profile 的一次性调用 —— 留这个字段是为了让调用方/测试能指向自定义 profile
|
|
97
|
+
//(例如预置了插件与模型的 profile),语义仍是「一次性任务」入口。
|
|
98
|
+
Profile string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Name 实现 Engine。
|
|
102
|
+
func (e *DshEngine) Name() string { return "dsh" }
|
|
103
|
+
|
|
104
|
+
// DefaultDshTimeout 单次尝试默认超时。
|
|
105
|
+
//
|
|
106
|
+
// dsh 自带 agent 工具循环(读文件 / 跑命令),一轮任务常常是分钟级;
|
|
107
|
+
// 取 10 分钟与 trae/openclaw 对齐。CLI 侧没有 --timeout,超时完全由外层
|
|
108
|
+
// ctx 施加(超时会杀掉 dsh 及其 node worker 的整个进程组)。
|
|
109
|
+
const DefaultDshTimeout = 10 * time.Minute
|
|
110
|
+
|
|
111
|
+
// dshDefaultProfile headless 是一次性(非交互)入口。
|
|
112
|
+
const dshDefaultProfile = "headless"
|
|
113
|
+
|
|
114
|
+
// DshProfileEnv 覆盖 dsh profile 的环境变量(优先级低于 DshEngine.Profile)。
|
|
115
|
+
//
|
|
116
|
+
// 存在的理由:SDK 通道是**默认**,但它是预发布协议(官方自述「无兼容承诺」)。
|
|
117
|
+
// 万一某版 dsh 的 SDK 行为有变,用户要能不改代码就切回 headless:
|
|
118
|
+
//
|
|
119
|
+
// MAGIC_AGENT_DSH_PROFILE=headless magic-agent -e dsh "..."
|
|
120
|
+
//
|
|
121
|
+
// 取值与 Profile 字段同义:"sdk" = SDK 通道,其余(如 "headless"、自定义 profile 名)
|
|
122
|
+
// = 单 profile 的一次性调用。
|
|
123
|
+
const DshProfileEnv = "MAGIC_AGENT_DSH_PROFILE"
|
|
124
|
+
|
|
125
|
+
// effectiveProfile 生效的 profile 名:显式字段 → 环境变量 → 默认(SDK 通道)。
|
|
126
|
+
func (e *DshEngine) effectiveProfile() string {
|
|
127
|
+
if p := strings.TrimSpace(e.Profile); p != "" {
|
|
128
|
+
return p
|
|
129
|
+
}
|
|
130
|
+
if p := strings.TrimSpace(os.Getenv(DshProfileEnv)); p != "" {
|
|
131
|
+
return p
|
|
132
|
+
}
|
|
133
|
+
return dshSDKProfile
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// profile 返回**回退路径**要用的 profile 名(headless / 自定义 profile)。
|
|
137
|
+
//
|
|
138
|
+
// 与 effectiveProfile 分开是必要的:SDK 通道握手失败时我们要退回 `--profile headless`,
|
|
139
|
+
// 而不是把 `--profile sdk` 当 app 参数再喂一遍(那是 SDK 启动器 profile,不是一次性入口)。
|
|
140
|
+
func (e *DshEngine) profile() string {
|
|
141
|
+
if p := e.effectiveProfile(); p != dshSDKProfile {
|
|
142
|
+
return p
|
|
143
|
+
}
|
|
144
|
+
return dshDefaultProfile
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// bin 探测 dsh CLI 路径(委托 cliBase 统一探测链)。
|
|
148
|
+
func (e *DshEngine) bin() string {
|
|
149
|
+
return dshBase.resolve(e.BinPath)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Detect 实现 Engine。
|
|
153
|
+
func (e *DshEngine) Detect() (bool, string) {
|
|
154
|
+
p := dshBase.resolve(e.BinPath)
|
|
155
|
+
if p == "" {
|
|
156
|
+
return false, dshBase.notFound
|
|
157
|
+
}
|
|
158
|
+
return true, p
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ListModels 实现 ModelLister:返回 `$DSH_HOME/settings.yaml` 里**已配置**的模型。
|
|
162
|
+
//
|
|
163
|
+
// 为什么不查 CLI:dsh 启动器只有 profile / plugin / config-dump 三种模式,没有
|
|
164
|
+
// `dsh models` 之类的只读子命令(headless 也没有 --model 参数)。
|
|
165
|
+
//
|
|
166
|
+
// 为什么不查网关的 `GET {baseURL}/models`:官方明确「settings.yaml 才是决定一条路由
|
|
167
|
+
// 服务什么的东西」—— 网关列出的上百个模型并不都能用,只有 `models:` 里声明过的才会被
|
|
168
|
+
// 路由接受。故**配置层才是权威**,也顺带避免了每次探测都发网络请求 + 需要解密凭据。
|
|
169
|
+
//
|
|
170
|
+
// 输出形态 `provider/model`(与 openclaw 的 `provider/model` 同构):dsh 的模型只在
|
|
171
|
+
// 所属路由内有意义,同名模型可能出现在两条路由上,裸 id 会歧义。
|
|
172
|
+
//
|
|
173
|
+
// 这份清单**就是** `-m` 的可取值 —— 但只在 SDK 通道下生效(转成 initialize 的
|
|
174
|
+
// provider/model 两个必填参数,见 dshResolveRoute)。回退 headless 后 `-m` 无作用
|
|
175
|
+
// (无 --model 参数),引擎会打一行告警说明该改配置层。
|
|
176
|
+
func (e *DshEngine) ListModels(_ context.Context) ([]string, error) {
|
|
177
|
+
home := DshHome()
|
|
178
|
+
if home == "" {
|
|
179
|
+
return nil, fmt.Errorf("%w: 无法定位 $DSH_HOME(设置 DSH_HOME 或检查 HOME)", ErrNoModelSource)
|
|
180
|
+
}
|
|
181
|
+
data, err := os.ReadFile(filepath.Join(home, "settings.yaml"))
|
|
182
|
+
if err != nil {
|
|
183
|
+
return nil, fmt.Errorf("%w: 读不到 %s(模型由该文件的 agent-default-model / llm-pi-ai 段决定): %v",
|
|
184
|
+
ErrNoModelSource, filepath.Join(home, "settings.yaml"), err)
|
|
185
|
+
}
|
|
186
|
+
models := parseDshModelRoutes(string(data))
|
|
187
|
+
if len(models) == 0 {
|
|
188
|
+
return nil, fmt.Errorf("%w: %s 里没有已配置的模型(在 llm-pi-ai.providers.<id>.models 下列出,并设 agent-default-model)",
|
|
189
|
+
ErrNoModelSource, filepath.Join(home, "settings.yaml"))
|
|
190
|
+
}
|
|
191
|
+
return models, nil
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// sdkEnabled 是否走 SDK 通道(effectiveProfile == "sdk")。
|
|
195
|
+
//
|
|
196
|
+
// 默认走;显式 Profile 或 MAGIC_AGENT_DSH_PROFILE 给别的值即关闭(见 effectiveProfile)。
|
|
197
|
+
func (e *DshEngine) sdkEnabled() bool {
|
|
198
|
+
return e.effectiveProfile() == dshSDKProfile
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// errDshNoSessionResume 会话续接在 dsh 上不可用(两条通道都不行,理由不同)。
|
|
202
|
+
//
|
|
203
|
+
// 明确报错而不是静默开新会话 —— 后者会让调用方以为上一轮上下文还在。
|
|
204
|
+
var errDshNoSessionResume = errors.New(
|
|
205
|
+
"dsh: 不支持会话续接(-s/--session)—— headless 每次调用都是全新会话;" +
|
|
206
|
+
"SDK 通道的 sessionId 只在**同一个 runtime 进程内**可续(官方 Python SDK 文档:" +
|
|
207
|
+
"reuse a harness, home, and id),换进程拿旧 id 会被 -32603 `session already exists` 拒掉," +
|
|
208
|
+
"协议也没有 resume 方法。要真正的多轮上下文请用常驻会话:" +
|
|
209
|
+
"`magic-agent -e dsh --stream --keep-alive \"首轮\"`,再用 " +
|
|
210
|
+
"`magic-agent --append <run_id|session_id> -p \"追加\"` 接着问")
|
|
211
|
+
|
|
212
|
+
// dshModelLabel 本轮实际用的模型标签(`provider/model`,与 ListModels 同形态)。
|
|
213
|
+
func dshModelLabel(res dshSDKTurnResult) string {
|
|
214
|
+
if res.Provider == "" || res.Model == "" {
|
|
215
|
+
return ""
|
|
216
|
+
}
|
|
217
|
+
return res.Provider + "/" + res.Model
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Complete 实现 Engine:优先 `dsh --profile sdk`(工具调用 + 正文流式 + 可指定模型),
|
|
221
|
+
// 该 profile 不可用时回退 `dsh --profile headless "<prompt>"`。
|
|
222
|
+
//
|
|
223
|
+
// 执行流程:
|
|
224
|
+
// 1. 拒掉续接参数(两条通道都续不上,静默忽略会让调用方误以为上下文接上了)
|
|
225
|
+
// 2. 展平 prompt(无独立 system 通道)+ 附件路径兜底
|
|
226
|
+
// 3. SDK 通道:initialize + session/prompt,收 turn/end 时的正文
|
|
227
|
+
// (通道不可用 → 回退;服务端明确拒绝 → 直接报错,不吞)
|
|
228
|
+
// 4. 回退路径:runCLIIn(workspace 走子进程 cwd),stdout 即最终正文
|
|
229
|
+
// 5. JSONSchema 模式下再做严格抽取替换 Text
|
|
230
|
+
func (e *DshEngine) Complete(ctx context.Context, req Request) (Response, error) {
|
|
231
|
+
start := time.Now()
|
|
232
|
+
bin := e.bin()
|
|
233
|
+
if bin == "" {
|
|
234
|
+
return Response{}, fmt.Errorf("dsh CLI not found; set MAGIC_AGENT_DSH_BIN")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if req.SessionID != "" || req.Continue {
|
|
238
|
+
return Response{}, errDshNoSessionResume
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 无独立 system 注入 flag:与 trae / openclaw 同路,展平进 prompt 头部。
|
|
242
|
+
// 这里不追加 noToolSuffix —— dsh 是自带工具循环的 agent harness,
|
|
243
|
+
// Request.Tools 在它上面没有落地通道(见文件头矩阵),后缀只会是噪音。
|
|
244
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
|
|
245
|
+
if prompt == "" {
|
|
246
|
+
return Response{}, fmt.Errorf("dsh: empty prompt")
|
|
247
|
+
}
|
|
248
|
+
// 附件:dsh 没有附件输入参数,只能把绝对路径写进提示词
|
|
249
|
+
//(与 trae/openclaw 同一兜底;需要 dsh 侧的读文件工具能看到它)。
|
|
250
|
+
prompt = appendAttachmentSection(prompt, req.Attachments)
|
|
251
|
+
|
|
252
|
+
if e.sdkEnabled() {
|
|
253
|
+
res, sdkErr := dshSDKTurn(ctx, bin, req.Workspace, req, prompt, nil)
|
|
254
|
+
if sdkErr == nil {
|
|
255
|
+
resp := Response{
|
|
256
|
+
Engine: e.Name(),
|
|
257
|
+
Text: strings.TrimSpace(res.Text),
|
|
258
|
+
Model: dshModelLabel(res),
|
|
259
|
+
Latency: time.Since(start),
|
|
260
|
+
}
|
|
261
|
+
// JSONSchema 后处理(与 llm / openclaw / arkclaw 同路径)。
|
|
262
|
+
if req.JSONSchema != nil {
|
|
263
|
+
if extracted, ok := extractJSONObjectStrict(resp.Text, req.JSONSchema); ok {
|
|
264
|
+
resp.Text = extracted
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if resp.Text == "" {
|
|
268
|
+
return resp, fmt.Errorf("dsh SDK returned empty result")
|
|
269
|
+
}
|
|
270
|
+
return resp, nil
|
|
271
|
+
}
|
|
272
|
+
// 服务端明确拒绝(模型没配等)→ 真失败,不换通道重试(换了就是静默改模型)。
|
|
273
|
+
if !errors.Is(sdkErr, errDshSDKUnavailable) {
|
|
274
|
+
return Response{}, sdkErr
|
|
275
|
+
}
|
|
276
|
+
// 通道不可用 → 回退 headless,并把代价说清楚。
|
|
277
|
+
// 但常驻会话(req.Append)在 headless 上没有落地通道 → 宁可报错也不静默降级
|
|
278
|
+
//(静默降级 = 追加消息被丢掉,调用方却以为接上了)。
|
|
279
|
+
if req.Append != nil {
|
|
280
|
+
return Response{}, fmt.Errorf(
|
|
281
|
+
"dsh: 常驻会话(--keep-alive/--append)需要 SDK 通道,但它不可用:%v", sdkErr)
|
|
282
|
+
}
|
|
283
|
+
fmt.Fprintf(stderr, " ⚠ dsh: SDK 通道不可用,本轮回退 headless(拿不到工具调用、正文不流式):%v\n", sdkErr)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 走到这里就是要用 headless 执行了 —— 它没有 --model 参数,模型由配置层决定。
|
|
287
|
+
// -m 在 headless 上不是透传是「无作用」:告警一次(与 arkclaw 一致),让用户知道
|
|
288
|
+
// 该去改 dsh 自己的配置。显式 Profile: "headless" 也走这里,同样告警。
|
|
289
|
+
if req.Model != "" {
|
|
290
|
+
fmt.Fprintf(stderr, " ⚠ dsh: headless 通道下 --model %s 不生效(无 --model 参数);"+
|
|
291
|
+
"换模型请改 $DSH_HOME/settings.yaml 的 agent-default-model 段,或用 dsh 的 Settings → Models\n", req.Model)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
args := e.buildArgs(req, prompt)
|
|
295
|
+
|
|
296
|
+
// workspace:dsh 的「调用时所在目录即默认 workspace 根」正是子进程 cwd 的语义。
|
|
297
|
+
stdout, stderrText, runErr := runCLIIn(ctx, req.Workspace, bin, args...)
|
|
298
|
+
|
|
299
|
+
resp := Response{
|
|
300
|
+
Engine: e.Name(),
|
|
301
|
+
// headless 的 stdout 就是最终助手正文(推理增量在 stderr,不混入)。
|
|
302
|
+
Text: strings.TrimSpace(stdout),
|
|
303
|
+
// 模型名尽力而为:读 dsh 配置里的默认模型(读不到给 "config-default")。
|
|
304
|
+
Model: DshConfiguredModel(),
|
|
305
|
+
Latency: time.Since(start),
|
|
306
|
+
}
|
|
307
|
+
if resp.Model == "" {
|
|
308
|
+
resp.Model = "config-default"
|
|
309
|
+
}
|
|
310
|
+
// JSONSchema 后处理(与 llm / openclaw / arkclaw 同路径)。
|
|
311
|
+
if req.JSONSchema != nil {
|
|
312
|
+
if extracted, ok := extractJSONObjectStrict(resp.Text, req.JSONSchema); ok {
|
|
313
|
+
resp.Text = extracted
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if runErr != nil {
|
|
317
|
+
// 非零退出 = turn/end 不是 completed;stderr 末尾通常就是终止原因,
|
|
318
|
+
// wrapCliError 会把它摘成可读摘要。
|
|
319
|
+
return resp, wrapCliError("dsh", stdout, stderrText, runErr)
|
|
320
|
+
}
|
|
321
|
+
if resp.Text == "" {
|
|
322
|
+
return resp, fmt.Errorf("dsh CLI returned empty result")
|
|
323
|
+
}
|
|
324
|
+
return resp, nil
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// buildArgs 构造 dsh 回退路径(headless / 自定义 profile)的参数。
|
|
328
|
+
//
|
|
329
|
+
// 形态(启动器 flags 在前,app 参数在后):
|
|
330
|
+
//
|
|
331
|
+
// dsh --profile headless "<prompt>"
|
|
332
|
+
//
|
|
333
|
+
// prompt 走**位置参数**(headless 唯一的 app 参数,无 --prompt / stdin 通道)。
|
|
334
|
+
// 代价是超长 prompt 会撞 ARG_MAX —— 与 trae 的 -p 同一个已知边界,
|
|
335
|
+
// 需要喂超长文本时请先落成文件、让 dsh 用自己的读文件工具看(配合 -a 路径兜底同理)。
|
|
336
|
+
//
|
|
337
|
+
// ⚠️ 只有回退路径用得到它:SDK 通道的 prompt 走 session/prompt 的 contentBlocks
|
|
338
|
+
// (见 dsh_sdk.go),既不占命令行也不受 ARG_MAX 限制。
|
|
339
|
+
func (e *DshEngine) buildArgs(req Request, prompt string) []string {
|
|
340
|
+
// 统一参数矩阵:MaxTokens / Temperature 在 headless 上无对应 flag(静默忽略);
|
|
341
|
+
// Tools 由 dsh 自己的工具循环决定(忽略);Timeout 由外层 ctx 施加(忽略)。
|
|
342
|
+
_ = req.MaxTokens
|
|
343
|
+
_ = req.Temperature
|
|
344
|
+
_ = req.Tools
|
|
345
|
+
_ = req.Timeout
|
|
346
|
+
return []string{"--profile", e.profile(), prompt}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── 流式(--stream)────────────────────────────────────────────
|
|
350
|
+
//
|
|
351
|
+
// dsh 有两条流式形态,能力不同,如实接线:
|
|
352
|
+
//
|
|
353
|
+
// SDK 通道(默认,见 dsh_sdk.go)
|
|
354
|
+
// session.event 实时推送:
|
|
355
|
+
// assistant/message 的 reasoning 块 → 逐条 KindThinking
|
|
356
|
+
// assistant/message 的 text 块 → 逐 step KindText(真流式,不等 turn 结束)
|
|
357
|
+
// tool/call → KindToolUse(name + arguments + callId)
|
|
358
|
+
// tool/result → KindToolResult(按 callId 关联)
|
|
359
|
+
// turn/end → 收尾一条 KindTurnEnd
|
|
360
|
+
//
|
|
361
|
+
// headless 回退(实测 2026-09-21,dsh 0.1.5-rc.2)
|
|
362
|
+
// stderr:`dsh: reasoning:` 标题行之后,推理增量**逐段到达**(约 300ms 一批)
|
|
363
|
+
// stdout:最终助手正文在 turn 结束时**一次性**到达
|
|
364
|
+
// → 推理 → 逐条 KindThinking;正文 → 一条 KindText(收尾时)
|
|
365
|
+
// ⚠️ 这条路上**没有任何工具调用通道**(官方 headless 的定位就是「stream reasoning
|
|
366
|
+
// to stderr, print the final assistant message, and exit」)—— 不是解析缺陷。
|
|
367
|
+
|
|
368
|
+
// dshReasoningPrefix stderr 上推理增量的起始标记(官方 headless 的固定前缀)。
|
|
369
|
+
const dshReasoningPrefix = "dsh: reasoning:"
|
|
370
|
+
|
|
371
|
+
// Stream 实现 Streamer:默认走 SDK(推理/正文/工具调用全流式),
|
|
372
|
+
// SDK 不可用时回退 headless(只有推理流式 + 收尾正文)。
|
|
373
|
+
func (e *DshEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
374
|
+
start := time.Now()
|
|
375
|
+
bin := e.bin()
|
|
376
|
+
if bin == "" {
|
|
377
|
+
return StreamResult{}, fmt.Errorf("dsh CLI not found; set MAGIC_AGENT_DSH_BIN")
|
|
378
|
+
}
|
|
379
|
+
// 与 Complete 同一条契约:dsh 两条通道都续不上,明确报错而不是静默开新会话。
|
|
380
|
+
if req.SessionID != "" || req.Continue {
|
|
381
|
+
return StreamResult{}, errDshNoSessionResume
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
|
|
385
|
+
if prompt == "" {
|
|
386
|
+
return StreamResult{}, fmt.Errorf("dsh: empty prompt")
|
|
387
|
+
}
|
|
388
|
+
prompt = appendAttachmentSection(prompt, req.Attachments)
|
|
389
|
+
|
|
390
|
+
if e.sdkEnabled() {
|
|
391
|
+
res, sdkErr := dshSDKTurn(ctx, bin, req.Workspace, req, prompt, onEvent)
|
|
392
|
+
if sdkErr == nil {
|
|
393
|
+
resp := Response{
|
|
394
|
+
Engine: e.Name(),
|
|
395
|
+
Text: strings.TrimSpace(res.Text),
|
|
396
|
+
Model: dshModelLabel(res),
|
|
397
|
+
// 报出本轮会话 id:常驻会话里 `--append <session_id>` 能用它定位
|
|
398
|
+
//(会话登记表按 run_id 或 session_id 查找)。注意它**不能**当 -s 用 ——
|
|
399
|
+
// 跨进程续接拿不到(见 errDshNoSessionResume)。
|
|
400
|
+
SessionID: res.SessionID,
|
|
401
|
+
Latency: time.Since(start),
|
|
402
|
+
}
|
|
403
|
+
if req.JSONSchema != nil {
|
|
404
|
+
if extracted, ok := extractJSONObjectStrict(resp.Text, req.JSONSchema); ok {
|
|
405
|
+
resp.Text = extracted
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// ⚠️ 不再在这里补发 KindTurnEnd:SDK 通道**每一轮**都由 dshSDKTurn 在
|
|
409
|
+
// turn/end 时发过(常驻会话里每轮一条,常驻看门狗靠它判断轮次边界)。
|
|
410
|
+
// 再发一条会把「最后一轮」重复上报一次。
|
|
411
|
+
if resp.Text == "" {
|
|
412
|
+
return StreamResult{Response: resp, Thinking: res.Thinking, Tools: res.Tools},
|
|
413
|
+
fmt.Errorf("dsh SDK returned empty result")
|
|
414
|
+
}
|
|
415
|
+
return StreamResult{Response: resp, Thinking: res.Thinking, Tools: res.Tools}, nil
|
|
416
|
+
}
|
|
417
|
+
// 服务端明确拒绝 → 真失败,不换通道(换了就是静默改模型/换语义)。
|
|
418
|
+
if !errors.Is(sdkErr, errDshSDKUnavailable) {
|
|
419
|
+
return StreamResult{}, sdkErr
|
|
420
|
+
}
|
|
421
|
+
// 常驻会话在 headless 上没有落地通道 → 宁可报错也不静默丢追加。
|
|
422
|
+
if req.Append != nil {
|
|
423
|
+
return StreamResult{}, fmt.Errorf(
|
|
424
|
+
"dsh: 常驻会话(--keep-alive/--append)需要 SDK 通道,但它不可用:%v", sdkErr)
|
|
425
|
+
}
|
|
426
|
+
fmt.Fprintf(stderr, " ⚠ dsh: SDK 通道不可用,本轮回退 headless(拿不到工具调用、正文不流式):%v\n", sdkErr)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 同 Complete:headless 通道没有 --model 参数,-m 在这里是「无作用」,告警一次。
|
|
430
|
+
if req.Model != "" {
|
|
431
|
+
fmt.Fprintf(stderr, " ⚠ dsh: headless 通道下 --model %s 不生效(无 --model 参数);"+
|
|
432
|
+
"换模型请改 $DSH_HOME/settings.yaml 的 agent-default-model 段,或用 dsh 的 Settings → Models\n", req.Model)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
args := e.buildArgs(req, prompt)
|
|
436
|
+
|
|
437
|
+
var thinking strings.Builder
|
|
438
|
+
inReasoning := false
|
|
439
|
+
stdout, stderrText, runErr := runStreamStderrIn(ctx, req.Workspace, bin, args, func(line string) error {
|
|
440
|
+
// 标题行本身不是内容;它出现即进入推理区段。
|
|
441
|
+
if strings.HasPrefix(line, dshReasoningPrefix) {
|
|
442
|
+
inReasoning = true
|
|
443
|
+
return nil
|
|
444
|
+
}
|
|
445
|
+
if !inReasoning {
|
|
446
|
+
return nil // 非推理行(例如启动日志)不当作增量
|
|
447
|
+
}
|
|
448
|
+
thinking.WriteString(line)
|
|
449
|
+
thinking.WriteByte('\n')
|
|
450
|
+
if onEvent != nil {
|
|
451
|
+
onEvent(StreamEvent{Kind: KindThinking, Text: line + "\n"})
|
|
452
|
+
}
|
|
453
|
+
return nil
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
resp := Response{
|
|
457
|
+
Engine: e.Name(),
|
|
458
|
+
Text: strings.TrimSpace(stdout),
|
|
459
|
+
Model: DshConfiguredModel(),
|
|
460
|
+
SessionID: "", // dsh 不回可续接的会话 id(见 errDshNoSessionResume)
|
|
461
|
+
Latency: time.Since(start),
|
|
462
|
+
}
|
|
463
|
+
if resp.Model == "" {
|
|
464
|
+
resp.Model = "config-default"
|
|
465
|
+
}
|
|
466
|
+
if req.JSONSchema != nil {
|
|
467
|
+
if extracted, ok := extractJSONObjectStrict(resp.Text, req.JSONSchema); ok {
|
|
468
|
+
resp.Text = extracted
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if runErr != nil {
|
|
472
|
+
return StreamResult{Response: resp, Thinking: thinking.String()},
|
|
473
|
+
wrapCliError("dsh", stdout, stderrText, runErr)
|
|
474
|
+
}
|
|
475
|
+
if resp.Text == "" {
|
|
476
|
+
return StreamResult{Response: resp, Thinking: thinking.String()},
|
|
477
|
+
fmt.Errorf("dsh CLI returned empty result")
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 正文一次性给出(headless 不流式正文,见本节顶部说明)。
|
|
481
|
+
if onEvent != nil {
|
|
482
|
+
onEvent(StreamEvent{Kind: KindText, Text: resp.Text})
|
|
483
|
+
// 同样补一轮结束事件:回退路径下宿主也该知道「本轮做完了」。
|
|
484
|
+
onEvent(StreamEvent{Kind: KindTurnEnd, Text: resp.Text})
|
|
485
|
+
}
|
|
486
|
+
return StreamResult{Response: resp, Thinking: thinking.String()}, nil
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── 配置层:DSH_HOME、模型清单与默认模型(尽力而为)────────────────
|
|
490
|
+
|
|
491
|
+
// DshHome 返回 dsh 的家目录:$DSH_HOME(非空时)→ ~/.dsh。
|
|
492
|
+
// profile / settings.yaml / .credentials.yaml / logs 都在它下面。
|
|
493
|
+
func DshHome() string {
|
|
494
|
+
if v := strings.TrimSpace(os.Getenv("DSH_HOME")); v != "" {
|
|
495
|
+
return expandHome(v)
|
|
496
|
+
}
|
|
497
|
+
home, err := os.UserHomeDir()
|
|
498
|
+
if err != nil {
|
|
499
|
+
return ""
|
|
500
|
+
}
|
|
501
|
+
return filepath.Join(home, ".dsh")
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// DshConfiguredModel 尽力而为地报出 dsh 当前配置的默认模型
|
|
505
|
+
// ($DSH_HOME/settings.yaml 的 agent-default-model 段)。
|
|
506
|
+
//
|
|
507
|
+
// 为什么需要它:headless 不回模型名(stdout 只有正文),而调用方(如观物台)
|
|
508
|
+
// 通常要展示「这一轮是谁答的」。读不到就返回空串(调用方回落 "config-default")——
|
|
509
|
+
// 与 TraeDefaultModel 的「文件缺失 / 格式变化返回空串」同一约定,绝不因此阻断调用。
|
|
510
|
+
func DshConfiguredModel() string {
|
|
511
|
+
home := DshHome()
|
|
512
|
+
if home == "" {
|
|
513
|
+
return ""
|
|
514
|
+
}
|
|
515
|
+
data, err := os.ReadFile(filepath.Join(home, "settings.yaml"))
|
|
516
|
+
if err != nil {
|
|
517
|
+
return ""
|
|
518
|
+
}
|
|
519
|
+
return parseDshDefaultModel(string(data))
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// parseDshDefaultModel 轻量扫描 settings.yaml(不引入 yaml 依赖),取
|
|
523
|
+
// agent-default-model 段里的 model 值。兼容两种写法:
|
|
524
|
+
//
|
|
525
|
+
// agent-default-model: # 块式(与 llm-deepseek: 等段同一风格)
|
|
526
|
+
// provider: deepseek
|
|
527
|
+
// model: deepseek-chat
|
|
528
|
+
//
|
|
529
|
+
// agent-default-model: {provider: deepseek, model: deepseek-chat} # 行内式
|
|
530
|
+
//
|
|
531
|
+
// 段内取**第一个** model: 键;注释行与空行跳过;遇到下一个顶层键即结束。
|
|
532
|
+
// 结构不符(例如键名变化)时返回空串 —— 这是启发式读取,不是契约解析。
|
|
533
|
+
func parseDshDefaultModel(yamlText string) string {
|
|
534
|
+
const key = "agent-default-model:"
|
|
535
|
+
inBlock := false
|
|
536
|
+
for _, ln := range strings.Split(yamlText, "\n") {
|
|
537
|
+
trimmed := strings.TrimSpace(ln)
|
|
538
|
+
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
topLevel := ln == trimmed // 无缩进 = 顶层键
|
|
542
|
+
if !inBlock {
|
|
543
|
+
if !topLevel || !strings.HasPrefix(trimmed, key) {
|
|
544
|
+
continue
|
|
545
|
+
}
|
|
546
|
+
if rest := strings.TrimSpace(strings.TrimPrefix(trimmed, key)); rest != "" {
|
|
547
|
+
// 行内式:只在同一行里找 model 值。
|
|
548
|
+
return yamlScalarAfter(rest, "model:")
|
|
549
|
+
}
|
|
550
|
+
inBlock = true
|
|
551
|
+
continue
|
|
552
|
+
}
|
|
553
|
+
if topLevel {
|
|
554
|
+
return "" // 段结束
|
|
555
|
+
}
|
|
556
|
+
if strings.HasPrefix(trimmed, "model:") {
|
|
557
|
+
if v := yamlScalarAfter(trimmed, "model:"); v != "" {
|
|
558
|
+
return v
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return ""
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// yamlScalarAfter 从一段 yaml 文本里取 `key` 后的标量值(去引号、去行内注释与
|
|
566
|
+
// 行内映射的收尾符号)。找不到返回空串。
|
|
567
|
+
func yamlScalarAfter(s, key string) string {
|
|
568
|
+
i := strings.Index(s, key)
|
|
569
|
+
if i < 0 {
|
|
570
|
+
return ""
|
|
571
|
+
}
|
|
572
|
+
v := strings.TrimSpace(s[i+len(key):])
|
|
573
|
+
if j := strings.IndexAny(v, ",}#"); j >= 0 {
|
|
574
|
+
v = v[:j]
|
|
575
|
+
}
|
|
576
|
+
v = strings.TrimSpace(strings.Trim(v, `"'`))
|
|
577
|
+
return v
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── settings.yaml 里的模型清单(缩进感知的轻量扫描)──────────────────
|
|
581
|
+
//
|
|
582
|
+
// 只认这一种结构(官方文档给出的形状,见 README「配置模型」):
|
|
583
|
+
//
|
|
584
|
+
// agent-default-model:
|
|
585
|
+
// provider: <route>
|
|
586
|
+
// model: <id>
|
|
587
|
+
//
|
|
588
|
+
// llm-pi-ai:
|
|
589
|
+
// providers:
|
|
590
|
+
// <route>:
|
|
591
|
+
// api: openai-completions
|
|
592
|
+
// models:
|
|
593
|
+
// - id: <model-id>
|
|
594
|
+
// - id: <model-id>
|
|
595
|
+
// name: <显示名>
|
|
596
|
+
//
|
|
597
|
+
// 返回去重后的 `route/model` 列表(保持出现顺序)。识别不出结构时返回空切片
|
|
598
|
+
// —— 这是**启发式读取**,不是契约解析:settings.yaml 加新键/换布局时不报错,
|
|
599
|
+
// 只是列表可能变短,由调用方把「为什么空」写进 models_note。
|
|
600
|
+
//
|
|
601
|
+
// 不引入 yaml 依赖的理由与本包既有做法一致(见 TraeDefaultModel / parseDshDefaultModel):
|
|
602
|
+
// 只读几个固定键,为它拉一个 YAML 库不划算。
|
|
603
|
+
|
|
604
|
+
// yamlFrame 一层缩进作用域(key 为不带冒号的键名)。
|
|
605
|
+
type yamlFrame struct {
|
|
606
|
+
indent int
|
|
607
|
+
key string
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// parseDshModelRoutes 从 settings.yaml 文本里抽 `route/model` 列表。
|
|
611
|
+
func parseDshModelRoutes(yamlText string) []string {
|
|
612
|
+
var (
|
|
613
|
+
stack []yamlFrame
|
|
614
|
+
out []string
|
|
615
|
+
seen = map[string]bool{}
|
|
616
|
+
)
|
|
617
|
+
add := func(route, model string) {
|
|
618
|
+
model = strings.TrimSpace(model)
|
|
619
|
+
route = strings.TrimSpace(route)
|
|
620
|
+
if model == "" || route == "" {
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
v := route + "/" + model
|
|
624
|
+
if seen[v] {
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
seen[v] = true
|
|
628
|
+
out = append(out, v)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
for _, raw := range strings.Split(yamlText, "\n") {
|
|
632
|
+
line := stripYAMLComment(raw)
|
|
633
|
+
if strings.TrimSpace(line) == "" {
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
indent := len(line) - len(strings.TrimLeft(line, " \t"))
|
|
637
|
+
trimmed := strings.TrimSpace(line)
|
|
638
|
+
|
|
639
|
+
// 先按缩进退栈:任何 indent >= 当前行的帧都已结束。
|
|
640
|
+
for len(stack) > 0 && stack[len(stack)-1].indent >= indent {
|
|
641
|
+
stack = stack[:len(stack)-1]
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
switch {
|
|
645
|
+
case strings.HasPrefix(trimmed, "- "):
|
|
646
|
+
// 列表项:只在 `llm-pi-ai > providers > <route> > models` 之下才算模型。
|
|
647
|
+
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))
|
|
648
|
+
if route, ok := dshModelsRoute(stack); ok {
|
|
649
|
+
if id, ok := yamlMapScalar(item, "id"); ok {
|
|
650
|
+
add(route, id)
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
case strings.HasSuffix(trimmed, ":"):
|
|
654
|
+
key := strings.TrimSpace(strings.TrimSuffix(trimmed, ":"))
|
|
655
|
+
if key == "" {
|
|
656
|
+
continue
|
|
657
|
+
}
|
|
658
|
+
stack = append(stack, yamlFrame{indent: indent, key: key})
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// 兜底:即使没声明 models,也把 agent-default-model 指的那个报出来,
|
|
663
|
+
// 让 --engines 至少能看到「当前用的是哪个模型」。
|
|
664
|
+
if len(out) == 0 {
|
|
665
|
+
if route, model := parseDshDefaultModelRoute(yamlText); route != "" && model != "" {
|
|
666
|
+
add(route, model)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return out
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// dshModelsRoute 判断当前缩进栈是否正好是
|
|
673
|
+
// `llm-pi-ai > providers > <route> > models`,是则返回 <route>。
|
|
674
|
+
func dshModelsRoute(stack []yamlFrame) (string, bool) {
|
|
675
|
+
if len(stack) < 4 {
|
|
676
|
+
return "", false
|
|
677
|
+
}
|
|
678
|
+
n := len(stack)
|
|
679
|
+
if stack[0].key != "llm-pi-ai" ||
|
|
680
|
+
stack[n-3].key != "providers" ||
|
|
681
|
+
stack[n-1].key != "models" {
|
|
682
|
+
return "", false
|
|
683
|
+
}
|
|
684
|
+
route := stack[n-2].key
|
|
685
|
+
if route == "providers" || route == "models" {
|
|
686
|
+
return "", false
|
|
687
|
+
}
|
|
688
|
+
return route, true
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// yamlMapScalar 从 `key: value` 或行内 `{key: value, ...}` 片段里取 key 的值。
|
|
692
|
+
func yamlMapScalar(s, key string) (string, bool) {
|
|
693
|
+
s = strings.TrimSpace(strings.Trim(s, "{}"))
|
|
694
|
+
for _, part := range strings.Split(s, ",") {
|
|
695
|
+
k, v, ok := strings.Cut(part, ":")
|
|
696
|
+
if !ok {
|
|
697
|
+
continue
|
|
698
|
+
}
|
|
699
|
+
if strings.TrimSpace(k) == key {
|
|
700
|
+
v = strings.TrimSpace(strings.Trim(v, `"'`))
|
|
701
|
+
if v != "" {
|
|
702
|
+
return v, true
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return "", false
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// parseDshDefaultModelRoute 取 agent-default-model 段的 (provider, model)。
|
|
710
|
+
// 与 parseDshDefaultModel 同源,但保留 provider(模型清单要 `route/model` 形态)。
|
|
711
|
+
func parseDshDefaultModelRoute(yamlText string) (string, string) {
|
|
712
|
+
const key = "agent-default-model:"
|
|
713
|
+
inBlock := false
|
|
714
|
+
var provider string
|
|
715
|
+
for _, raw := range strings.Split(yamlText, "\n") {
|
|
716
|
+
line := stripYAMLComment(raw)
|
|
717
|
+
trimmed := strings.TrimSpace(line)
|
|
718
|
+
if trimmed == "" {
|
|
719
|
+
continue
|
|
720
|
+
}
|
|
721
|
+
topLevel := line == trimmed
|
|
722
|
+
if !inBlock {
|
|
723
|
+
if !topLevel || !strings.HasPrefix(trimmed, key) {
|
|
724
|
+
continue
|
|
725
|
+
}
|
|
726
|
+
if rest := strings.TrimSpace(strings.TrimPrefix(trimmed, key)); rest != "" {
|
|
727
|
+
// 行内式:{provider: x, model: y}
|
|
728
|
+
p, _ := yamlMapScalar(rest, "provider")
|
|
729
|
+
m, _ := yamlMapScalar(rest, "model")
|
|
730
|
+
return p, m
|
|
731
|
+
}
|
|
732
|
+
inBlock = true
|
|
733
|
+
continue
|
|
734
|
+
}
|
|
735
|
+
if topLevel {
|
|
736
|
+
break // 段结束
|
|
737
|
+
}
|
|
738
|
+
if p, ok := yamlMapScalar(trimmed, "provider"); ok && provider == "" {
|
|
739
|
+
provider = p
|
|
740
|
+
}
|
|
741
|
+
if m, ok := yamlMapScalar(trimmed, "model"); ok {
|
|
742
|
+
return provider, m
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return provider, ""
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// stripYAMLComment 去掉 yaml 行尾注释(不处理引号内的 #,够本用途)。
|
|
749
|
+
func stripYAMLComment(line string) string {
|
|
750
|
+
if i := strings.Index(line, "#"); i >= 0 {
|
|
751
|
+
return line[:i]
|
|
752
|
+
}
|
|
753
|
+
return line
|
|
754
|
+
}
|