magic-agent 2.6.0 → 2.6.3
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/CLIENT-CONTRACT.md +188 -16
- package/client-ui/pc/app.css +42 -0
- package/client-ui/pc/js/90-glue.js +3 -0
- package/client-ui/shared/conv/03-chat-message.js +25 -19
- package/client-ui/shared/conv/09-stream-apply.js +9 -0
- package/client-ui/shared/conv/29-msg-variants.js +136 -0
- package/client-ui/shared/conv/conv.css +253 -158
- package/client-ui/shared/conversation.js +39 -2
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +2695 -379
- package/client-ui-mobile/reqs.css +11 -4
- package/client-ui-mobile/reqs.js +49 -5
- package/client-ui-mobile/scripts.css +46 -0
- package/client-ui-mobile/scripts.js +66 -6
- package/cmd/magic-client/browsectl.go +1 -1
- package/cmd/magic-client/clientctl.go +37 -2
- package/cmd/magic-client/main.go +159 -12
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +144 -5
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/stream.go +30 -0
- package/internal/agent/streamjson.go +11 -1
- package/internal/cli/ask.go +8 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +11 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/root.go +35 -6
- package/internal/cli/session.go +69 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +209 -18
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +233 -12
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/requirements.go +9 -1
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +167 -13
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +140 -1
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +4 -0
- package/internal/client/scripts_decode_test.go +16 -11
- package/internal/client/scripts_decode_windows.go +0 -24
- package/internal/client/scripts_test.go +14 -1
- package/internal/client/service.go +403 -87
- package/internal/client/service_test.go +166 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +83 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +8 -1
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +8 -3
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// workspace_default_test.go - 默认工作区不能退化成文件系统根(2026-10-26 实测事故)。
|
|
4
|
+
//
|
|
5
|
+
// 事故现场:插件被 `cd /` 起来 → /desk/health 的 defaultWorkspace 报 `/` →
|
|
6
|
+
// 移动端新建会话的工作区就是根目录。后果不只是「文件写在 /」:
|
|
7
|
+
// · 作业文件写 "workspace": "/",而 core 自己登记的是真实 cwd,两边对不上;
|
|
8
|
+
// · mode=auto 的同空间冲突判定、core 的工作空间锁都按这个 key 算 ——
|
|
9
|
+
// `/` 变成「全机所有会话同一个空间」的万能桶,串行/并行全判错。
|
|
10
|
+
//
|
|
11
|
+
// 所以这里钉住三件事:根目录不当默认、有 cwd 就用 cwd、cwd 是根时退回上次用过的工作区。
|
|
12
|
+
|
|
13
|
+
import (
|
|
14
|
+
"encoding/json"
|
|
15
|
+
"os"
|
|
16
|
+
"path/filepath"
|
|
17
|
+
"testing"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// writePrefs 往 prefs 文件写一份配置(只写关心的字段)。
|
|
21
|
+
func writePrefs(t *testing.T, v map[string]any) string {
|
|
22
|
+
t.Helper()
|
|
23
|
+
p := mobilePrefsPath()
|
|
24
|
+
if p == "" {
|
|
25
|
+
t.Fatal("mobilePrefsPath() 为空")
|
|
26
|
+
}
|
|
27
|
+
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
28
|
+
t.Fatal(err)
|
|
29
|
+
}
|
|
30
|
+
b, err := json.Marshal(v)
|
|
31
|
+
if err != nil {
|
|
32
|
+
t.Fatal(err)
|
|
33
|
+
}
|
|
34
|
+
if err := os.WriteFile(p, b, 0o600); err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
return p
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// withTempHome 把 HOME 指向临时目录(prefs 落在 $HOME/.magic-agent 下)。
|
|
41
|
+
func withTempHome(t *testing.T) string {
|
|
42
|
+
t.Helper()
|
|
43
|
+
home := t.TempDir()
|
|
44
|
+
t.Setenv("HOME", home)
|
|
45
|
+
return home
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// TestIsRootDir 根目录的唯一判据:自己和自己的父目录相同。
|
|
49
|
+
func TestIsRootDir(t *testing.T) {
|
|
50
|
+
cases := []struct {
|
|
51
|
+
path string
|
|
52
|
+
want bool
|
|
53
|
+
}{
|
|
54
|
+
{"/", true},
|
|
55
|
+
{"/tmp", false},
|
|
56
|
+
{"/Users/x/project", false},
|
|
57
|
+
{"", false},
|
|
58
|
+
}
|
|
59
|
+
for _, c := range cases {
|
|
60
|
+
if got := isRootDir(c.path); got != c.want {
|
|
61
|
+
t.Errorf("isRootDir(%q) = %v, want %v", c.path, got, c.want)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// TestDefaultWorkspaceUsesCwd cwd 有意义时就用它,不去翻 prefs。
|
|
67
|
+
func TestDefaultWorkspaceUsesCwd(t *testing.T) {
|
|
68
|
+
withTempHome(t)
|
|
69
|
+
proj := t.TempDir()
|
|
70
|
+
if err := os.Chdir(proj); err != nil {
|
|
71
|
+
t.Fatal(err)
|
|
72
|
+
}
|
|
73
|
+
t.Cleanup(func() { _ = os.Chdir("/Users") })
|
|
74
|
+
|
|
75
|
+
// prefs 里放一个别的目录,确认 cwd 优先。
|
|
76
|
+
if err := os.WriteFile(mobilePrefsPath(), []byte(`{"lastWorkspace":"/tmp"}`), 0o600); err != nil {
|
|
77
|
+
// 目录可能不存在,先建
|
|
78
|
+
_ = os.MkdirAll(filepath.Dir(mobilePrefsPath()), 0o700)
|
|
79
|
+
_ = os.WriteFile(mobilePrefsPath(), []byte(`{"lastWorkspace":"/tmp"}`), 0o600)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 注意:真实路径可能带 /private 前缀(macOS 的 /tmp 是软链),所以比较 EvalSymlinks。
|
|
83
|
+
want, _ := filepath.EvalSymlinks(proj)
|
|
84
|
+
got, _ := filepath.EvalSymlinks(defaultWorkspace())
|
|
85
|
+
if got != want {
|
|
86
|
+
t.Errorf("defaultWorkspace() = %q, want cwd %q", got, want)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// TestDefaultWorkspaceFallsBackWhenCwdIsRoot cwd 是 `/` 时退回上次用过的工作区。
|
|
91
|
+
//
|
|
92
|
+
// 这条是本次事故的直接回归测试。
|
|
93
|
+
func TestDefaultWorkspaceFallsBackWhenCwdIsRoot(t *testing.T) {
|
|
94
|
+
withTempHome(t)
|
|
95
|
+
last := t.TempDir()
|
|
96
|
+
writePrefs(t, map[string]any{"lastWorkspace": last})
|
|
97
|
+
|
|
98
|
+
if err := os.Chdir("/"); err != nil {
|
|
99
|
+
t.Fatal(err)
|
|
100
|
+
}
|
|
101
|
+
t.Cleanup(func() { _ = os.Chdir("/Users") })
|
|
102
|
+
|
|
103
|
+
want, _ := filepath.EvalSymlinks(last)
|
|
104
|
+
got, _ := filepath.EvalSymlinks(defaultWorkspace())
|
|
105
|
+
if got != want {
|
|
106
|
+
t.Errorf("cwd 为 / 时 defaultWorkspace() = %q, want 上次工作区 %q", got, want)
|
|
107
|
+
}
|
|
108
|
+
if isRootDir(got) {
|
|
109
|
+
t.Errorf("默认工作区仍然退化成了根目录 %q", got)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// TestDefaultWorkspaceIgnoresBadPrefs prefs 里的值不可用时不能当默认。
|
|
114
|
+
//
|
|
115
|
+
// 三种坏值:不是绝对路径、目录已不存在、字段缺失。任一条被当成默认工作区,
|
|
116
|
+
// 都会让会话归到一个不该归的地方(相对路径还会按插件 cwd 解释,等于又回到根目录)。
|
|
117
|
+
func TestDefaultWorkspaceIgnoresBadPrefs(t *testing.T) {
|
|
118
|
+
withTempHome(t)
|
|
119
|
+
if err := os.Chdir("/"); err != nil {
|
|
120
|
+
t.Fatal(err)
|
|
121
|
+
}
|
|
122
|
+
t.Cleanup(func() { _ = os.Chdir("/Users") })
|
|
123
|
+
|
|
124
|
+
bad := []struct {
|
|
125
|
+
name string
|
|
126
|
+
v map[string]any
|
|
127
|
+
}{
|
|
128
|
+
{"相对路径", map[string]any{"lastWorkspace": "relative/dir"}},
|
|
129
|
+
{"目录不存在", map[string]any{"lastWorkspace": "/definitely/not/here/xyz"}},
|
|
130
|
+
{"字段缺失", map[string]any{"engine": "dsh"}},
|
|
131
|
+
{"空串", map[string]any{"lastWorkspace": ""}},
|
|
132
|
+
}
|
|
133
|
+
for _, c := range bad {
|
|
134
|
+
t.Run(c.name, func(t *testing.T) {
|
|
135
|
+
writePrefs(t, c.v)
|
|
136
|
+
got := defaultWorkspace()
|
|
137
|
+
// 没有可用兜底时只能回到 cwd(/),但绝不能是 prefs 里的坏值。
|
|
138
|
+
if c.v["lastWorkspace"] != "" && c.v["lastWorkspace"] != nil {
|
|
139
|
+
if got == c.v["lastWorkspace"] {
|
|
140
|
+
t.Errorf("采纳了不可用的 lastWorkspace %v", c.v["lastWorkspace"])
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if got != "/" && !filepath.IsAbs(got) {
|
|
144
|
+
t.Errorf("返回值不是绝对路径:%q", got)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// TestLastWorkspacePrefRejectsMissingDir 已删除的目录不算「上次用过的工作区」。
|
|
151
|
+
func TestLastWorkspacePrefRejectsMissingDir(t *testing.T) {
|
|
152
|
+
withTempHome(t)
|
|
153
|
+
gone := filepath.Join(t.TempDir(), "deleted")
|
|
154
|
+
writePrefs(t, map[string]any{"lastWorkspace": gone})
|
|
155
|
+
if got := lastWorkspacePref(); got != "" {
|
|
156
|
+
t.Errorf("目录已不存在却仍返回 %q", got)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/darren/magic-agent/internal/session"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
工作空间锁开关的下发口径(2026-10-26)。
|
|
12
|
+
|
|
13
|
+
为什么单独立一个文件、而不塞进 startAsk 的用例里:这条判据翻转的代价极隐蔽。
|
|
14
|
+
core 的锁从「选入式」改成「默认生效」之后,**不传变量**的含义从「不排」变成了
|
|
15
|
+
「排」。于是原先「串行才传 =1」的写法会在无人察觉时把并行模式串起来 ——
|
|
16
|
+
界面上的表现是「切了并行,任务还是一个一个跑」,没人会先怀疑环境变量。
|
|
17
|
+
|
|
18
|
+
锁的两端(core / 插件)分属两个包,任何一端单独改都会被另一端静默抵消,
|
|
19
|
+
所以这条不变量必须在这里被钉死。
|
|
20
|
+
*/
|
|
21
|
+
func TestWorkspaceLockEnvForSerial(t *testing.T) {
|
|
22
|
+
got := workspaceLockEnv(nil)
|
|
23
|
+
if len(got) != 0 {
|
|
24
|
+
t.Fatalf("serial 未指定应按串行处理、什么都不传,实际 %v", got)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func TestWorkspaceLockEnvSerialLeavesDefault(t *testing.T) {
|
|
29
|
+
serial := true
|
|
30
|
+
got := workspaceLockEnv(&serial)
|
|
31
|
+
/* 不写 `=1` 是刻意的:core 的默认值就是开,显式写一遍只是多一个会和
|
|
32
|
+
workspaceLockEnabled 的解析口径分叉的地方。 */
|
|
33
|
+
if len(got) != 0 {
|
|
34
|
+
t.Fatalf("串行作业不该追加任何变量(core 默认即开),实际 %v", got)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func TestWorkspaceLockEnvParallelDisables(t *testing.T) {
|
|
39
|
+
serial := false
|
|
40
|
+
got := workspaceLockEnv(&serial)
|
|
41
|
+
if len(got) != 1 {
|
|
42
|
+
t.Fatalf("并行作业必须且只能追加一条变量,实际 %v", got)
|
|
43
|
+
}
|
|
44
|
+
want := session.EnvWorkspaceLock + "=0"
|
|
45
|
+
if got[0] != want {
|
|
46
|
+
t.Fatalf("并行作业应下发 %q,实际 %q", want, got[0])
|
|
47
|
+
}
|
|
48
|
+
/* 值必须是 core 认的「关」:workspaceLockEnabled 只把 "0"/"false"/"off"/"no"
|
|
49
|
+
当关闭。写成 "false" 也行,但拼错成 "no " / "OFF " 之类就静默失效了 ——
|
|
50
|
+
这里断言到字面量,改 core 的解析口径时会立刻被这条测试拽住。 */
|
|
51
|
+
if !strings.HasSuffix(got[0], "=0") {
|
|
52
|
+
t.Fatalf("关闭值必须是 core 认得的写法,实际 %q", got[0])
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -71,6 +71,12 @@ const (
|
|
|
71
71
|
EnvCBGWPassword = "MAGIC_AGENT_CBGW_PASSWORD"
|
|
72
72
|
)
|
|
73
73
|
|
|
74
|
+
// feishu 一节支持的环境变量覆盖名(魔法值与其他节一致,都是 MAGIC_AGENT_ 前缀)。
|
|
75
|
+
const (
|
|
76
|
+
EnvFeishuAppID = "MAGIC_AGENT_FEISHU_APP_ID"
|
|
77
|
+
EnvFeishuAppSecret = "MAGIC_AGENT_FEISHU_APP_SECRET"
|
|
78
|
+
)
|
|
79
|
+
|
|
74
80
|
// EnvSystemPrompt 默认系统提示词的环境变量覆盖名。
|
|
75
81
|
const EnvSystemPrompt = "MAGIC_AGENT_SYSTEM_PROMPT"
|
|
76
82
|
|
|
@@ -98,6 +104,40 @@ type Config struct {
|
|
|
98
104
|
* ⚠️ 与 `arkclaw` 节的分工:`arkclaw` 是**历史保留的单实例**(老配置照旧生效,
|
|
99
105
|
* 行为一个字不改);`agents` 是新增的**多实例**通道。两者互不影响。 */
|
|
100
106
|
Agents []AgentConfig `json:"agents"`
|
|
107
|
+
|
|
108
|
+
/* Feishu 飞书推送(2026-10-26)的自建应用凭据:magic-client 用它把「任务完成 /
|
|
109
|
+
* 待确认」推到用户本人的飞书。只有 magic-client 读它,core 引擎不用;
|
|
110
|
+
* 移动端「我的 → 通知 → 飞书」也可以直接录(落 ~/.magic-agent/feishu-notify.json,0600),
|
|
111
|
+
* 那边优先级更高。环境变量 MAGIC_AGENT_FEISHU_APP_ID / _APP_SECRET 也认(见 EnvFeishu*)。 */
|
|
112
|
+
Feishu FeishuConfig `json:"feishu"`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* FeishuConfig 飞书自建应用(机器人)凭据。
|
|
116
|
+
*
|
|
117
|
+
* { "feishu": { "app_id": "cli_xxxx", "app_secret": "xxxx" } }
|
|
118
|
+
*
|
|
119
|
+
* 别名:app_id | appId | appId ;app_secret | appSecret | secret。 */
|
|
120
|
+
type FeishuConfig struct {
|
|
121
|
+
AppID string `json:"app_id"`
|
|
122
|
+
AppSecret string `json:"app_secret"`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// UnmarshalJSON 宽松解析:三个 app id 键名、三个 secret 键名任选其一。
|
|
126
|
+
func (f *FeishuConfig) UnmarshalJSON(b []byte) error {
|
|
127
|
+
var raw struct {
|
|
128
|
+
AppID string `json:"app_id"`
|
|
129
|
+
AppIDAlt string `json:"appId"`
|
|
130
|
+
AppIDAlt2 string `json:"appid"`
|
|
131
|
+
Secret string `json:"app_secret"`
|
|
132
|
+
SecretAlt string `json:"appSecret"`
|
|
133
|
+
SecretA3 string `json:"secret"`
|
|
134
|
+
}
|
|
135
|
+
if err := json.Unmarshal(b, &raw); err != nil {
|
|
136
|
+
return err
|
|
137
|
+
}
|
|
138
|
+
f.AppID = firstNonEmpty(raw.AppID, raw.AppIDAlt, raw.AppIDAlt2)
|
|
139
|
+
f.AppSecret = firstNonEmpty(raw.Secret, raw.SecretAlt, raw.SecretA3)
|
|
140
|
+
return nil
|
|
101
141
|
}
|
|
102
142
|
|
|
103
143
|
// UnmarshalJSON 宽松解析顶层键:systemPrompt / system_prompt / system 任选其一。
|
|
@@ -112,10 +152,12 @@ func (c *Config) UnmarshalJSON(b []byte) error {
|
|
|
112
152
|
SystemPromptAlt string `json:"system_prompt"`
|
|
113
153
|
SystemPromptAlt2 string `json:"system"`
|
|
114
154
|
Agents []AgentConfig `json:"agents"`
|
|
155
|
+
Feishu FeishuConfig `json:"feishu"`
|
|
115
156
|
}
|
|
116
157
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
117
158
|
return err
|
|
118
159
|
}
|
|
160
|
+
c.Feishu = raw.Feishu
|
|
119
161
|
c.ArkClaw = raw.ArkClaw
|
|
120
162
|
// 四个键名任选其一(同一语义的多种写法;都写时按上面的优先级取第一个非空节)。
|
|
121
163
|
c.CodeBuddyGateway = firstNonEmptySection(raw.CBGW, raw.CBGWAlt, raw.CBGWAlt2, raw.CBGWAlt3)
|
|
@@ -407,6 +449,12 @@ func applyEnv(c Config) Config {
|
|
|
407
449
|
if v := strings.TrimSpace(os.Getenv(EnvCBGWPassword)); v != "" {
|
|
408
450
|
c.CodeBuddyGateway.Password = v
|
|
409
451
|
}
|
|
452
|
+
if v := strings.TrimSpace(os.Getenv(EnvFeishuAppID)); v != "" {
|
|
453
|
+
c.Feishu.AppID = v
|
|
454
|
+
}
|
|
455
|
+
if v := strings.TrimSpace(os.Getenv(EnvFeishuAppSecret)); v != "" {
|
|
456
|
+
c.Feishu.AppSecret = v
|
|
457
|
+
}
|
|
410
458
|
return c
|
|
411
459
|
}
|
|
412
460
|
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// Package judge - 判断「本轮任务」与「同一工作空间里正在跑的任务」会不会互相破坏。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它(2026-10-26 用户:「任务模式增加自动 在新会话的时候判断是否可能与其他
|
|
4
|
+
// 运行中会话冲突 如果冲突走串行 否则默认走并行」):锁与进程信号只能回答「有没有人在跑」,
|
|
5
|
+
// 回答不了「在跑什么」—— 同一空间里一个会话在改底部栏、另一个在写这个功能的文档,
|
|
6
|
+
// 两者并行完全安全;而两个都要改 client-ui-mobile/index.html 就必须排队。
|
|
7
|
+
// 这个判断是**语义**的,只能由模型来做(结构化的文件交集在插件侧先用规则筛一遍)。
|
|
8
|
+
//
|
|
9
|
+
// ── 为什么放在 core 而不是各客户端 ──
|
|
10
|
+
// 提示词、输出解析、引擎调用只有一份;客户端(magic-client 的作业队列)exec 这一条即可。
|
|
11
|
+
// 与 `--suggest-followups` / `--summarize-title` 同一套旁路范式:独立进程、ToolsOff、
|
|
12
|
+
// 不带 SessionID,不续接也不污染用户正在聊的上下文,不写会话登记 / 日志。
|
|
13
|
+
//
|
|
14
|
+
// ── 三条纪律 ──
|
|
15
|
+
// 1. **必须快**:调用方只给 2.5s 预算(判定挡在任务开跑前)。所以不思考、不跑工具、
|
|
16
|
+
// 输出限 200 token;超了一律由调用方按「默认并行」放行,绝不拖住用户。
|
|
17
|
+
// 2. **只说结论**:conflict 布尔 + 一句中文理由(必须落到具体文件/功能点,便于界面
|
|
18
|
+
// 解释「为什么排队」)。理由不许编造输入里没有的东西。
|
|
19
|
+
// 3. **判不出来不算冲突**:模型失败/超时返回 error,调用方走 undecided → 并行。
|
|
20
|
+
package judge
|
|
21
|
+
|
|
22
|
+
import (
|
|
23
|
+
"context"
|
|
24
|
+
"encoding/json"
|
|
25
|
+
"fmt"
|
|
26
|
+
"strings"
|
|
27
|
+
"time"
|
|
28
|
+
"unicode/utf8"
|
|
29
|
+
|
|
30
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// 默认参数。判定是「开跑前的闸门」,宁可放行也不能让用户干等。
|
|
34
|
+
const (
|
|
35
|
+
// DefaultEngine 默认 llm:纯文本、无工具、启动最快(CLI 系引擎单是启动就要 2-4s,
|
|
36
|
+
// 塞不进判定预算)。
|
|
37
|
+
DefaultEngine = "llm"
|
|
38
|
+
// DefaultTimeout core 侧兜底;插件侧给 2.5s,到点直接放弃(按并行放行)。
|
|
39
|
+
/* DefaultTimeout 内层上限(core 自己的判定预算)。外层(magic-client 的异步判定)
|
|
40
|
+
给 4.5s,这里留 1s 给「起进程 + 收尾」,别让两层同时到点。 */
|
|
41
|
+
DefaultTimeout = 3500 * time.Millisecond
|
|
42
|
+
// MaxPromptRunes 本轮提示词上限(判定只需要「要动什么」,不需要全文)。
|
|
43
|
+
MaxPromptRunes = 2000
|
|
44
|
+
// MaxCandidates 最多同时比较几个运行中会话:多了提示词放不下,也判不准。
|
|
45
|
+
MaxCandidates = 6
|
|
46
|
+
// MaxFilesPerCandidate 每个候选最多带多少条已改文件(按最近优先)。
|
|
47
|
+
MaxFilesPerCandidate = 60
|
|
48
|
+
// MaxRecentRunes 候选「近况」上限。
|
|
49
|
+
MaxRecentRunes = 800
|
|
50
|
+
// MaxReasonRunes 理由上限(界面一行放得下)。
|
|
51
|
+
MaxReasonRunes = 120
|
|
52
|
+
// maxTokens 只够输出一个短 JSON。
|
|
53
|
+
maxTokens = 200
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Candidate 一个「正在运行、可能冲突」的会话。
|
|
57
|
+
type Candidate struct {
|
|
58
|
+
// ID 会话 id(session_id 优先,没有则 run_id):只用于回传给调用方做归属判断。
|
|
59
|
+
ID string `json:"id"`
|
|
60
|
+
// Title 会话标题(core 归纳过的那份,没有就是首问)。
|
|
61
|
+
Title string `json:"title,omitempty"`
|
|
62
|
+
// Recent 近况:最近几条用户诉求或助手动作的要点,由调用方从会话日志抽取。
|
|
63
|
+
Recent string `json:"recent,omitempty"`
|
|
64
|
+
// Files 这个会话**已经改动过**的文件(日志里 tool_kind=file_change 的参数路径)。
|
|
65
|
+
// 这是最硬的冲突证据:两边文件集合有交集时,调用方可以跳过模型直接判冲突。
|
|
66
|
+
Files []string `json:"files,omitempty"`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// RequestBody `--judge-conflict` 的 stdin/文件形状。
|
|
70
|
+
type RequestBody struct {
|
|
71
|
+
Prompt string `json:"prompt"`
|
|
72
|
+
Candidates []Candidate `json:"candidates"`
|
|
73
|
+
// Engine / Model 用哪个引擎判(空 = DefaultEngine)。由调用方传用户配的「建议引擎」。
|
|
74
|
+
Engine string `json:"engine,omitempty"`
|
|
75
|
+
Model string `json:"model,omitempty"`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Request 一次判定的输入(引擎与超时由 CLI 层填)。
|
|
79
|
+
type Request struct {
|
|
80
|
+
Prompt string
|
|
81
|
+
Candidates []Candidate
|
|
82
|
+
Engine string
|
|
83
|
+
Model string
|
|
84
|
+
Timeout time.Duration
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Result 判定结论(也是 `--judge-conflict` 的 stdout 形状)。
|
|
88
|
+
type Result struct {
|
|
89
|
+
// Conflict 是否冲突。true = 调用方应把本轮改为串行等待。
|
|
90
|
+
Conflict bool `json:"conflict"`
|
|
91
|
+
// Level hard | soft | none:severity 只用于文案(hard=文件/功能直接重叠,
|
|
92
|
+
// soft=相关但不确定,none=不相干)。**是否排队只看 Conflict**,两者不一致时以 Conflict 为准。
|
|
93
|
+
Level string `json:"level"`
|
|
94
|
+
// Reason 一句中文理由,必须提到具体文件或功能点(界面直接显示这句)。
|
|
95
|
+
Reason string `json:"reason"`
|
|
96
|
+
// Overlaps 冲突点(文件名 / 功能名),界面据此高亮。
|
|
97
|
+
Overlaps []string `json:"overlaps,omitempty"`
|
|
98
|
+
// Engine / Model 实际判定的引擎与模型(排查「为什么每次都 undecided」用)。
|
|
99
|
+
Engine string `json:"engine"`
|
|
100
|
+
Model string `json:"model,omitempty"`
|
|
101
|
+
// ElapsedMS 这次判定花了多久。
|
|
102
|
+
ElapsedMS int64 `json:"elapsedMs"`
|
|
103
|
+
// By 判定依据:model = 模型给的结论;rule = 调用方用文件交集直接定的(judge 包不产出);
|
|
104
|
+
// undecided = 没判出来(调用方补的)。这里恒为 "model"。
|
|
105
|
+
By string `json:"by"`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Prompt 拼判定提示词。刻意短:每个候选只给「标题 + 近况 + 文件」,不给整段历史。
|
|
109
|
+
func Prompt(prompt string, cands []Candidate) string {
|
|
110
|
+
var b strings.Builder
|
|
111
|
+
b.WriteString("判断「本轮任务」与「正在运行的任务」并行时会不会互相破坏。\n")
|
|
112
|
+
b.WriteString("冲突 = 动同一批文件 / 同一功能点 / 同一份产物(同一个页面、同一个接口、同一份配置、同一篇文章)。\n")
|
|
113
|
+
b.WriteString("不冲突 = 同仓库里改彼此独立的模块;一边改代码一边写该代码的文档;跑构建与改另一个模块。\n\n")
|
|
114
|
+
b.WriteString("本轮任务:\n")
|
|
115
|
+
b.WriteString(clipRunes(strings.TrimSpace(prompt), MaxPromptRunes))
|
|
116
|
+
b.WriteString("\n\n正在运行:\n")
|
|
117
|
+
for i, c := range cands {
|
|
118
|
+
if i >= MaxCandidates {
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
b.WriteString("- ")
|
|
122
|
+
if t := strings.TrimSpace(c.Title); t != "" {
|
|
123
|
+
b.WriteString(clipRunes(t, 80))
|
|
124
|
+
} else {
|
|
125
|
+
b.WriteString("(未命名会话)")
|
|
126
|
+
}
|
|
127
|
+
if r := strings.TrimSpace(c.Recent); r != "" {
|
|
128
|
+
b.WriteString("|近况:")
|
|
129
|
+
b.WriteString(clipRunes(r, MaxRecentRunes))
|
|
130
|
+
}
|
|
131
|
+
if len(c.Files) > 0 {
|
|
132
|
+
b.WriteString("|已改:")
|
|
133
|
+
b.WriteString(strings.Join(tailStrings(c.Files, MaxFilesPerCandidate), ", "))
|
|
134
|
+
}
|
|
135
|
+
b.WriteString("\n")
|
|
136
|
+
}
|
|
137
|
+
b.WriteString("\n只输出 JSON:{\"conflict\":true|false,\"level\":\"hard|soft|none\",\"reason\":\"中文一句话\",\"overlaps\":[\"…\"]}\n")
|
|
138
|
+
b.WriteString("reason 必须点出具体的文件或功能点(例如「都要改 index.html 的底部栏」),不许笼统;")
|
|
139
|
+
b.WriteString("不冲突就写「互不相干」并说明各自动的模块;不许编造输入里没有的文件或功能。")
|
|
140
|
+
return b.String()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Schema 结构化输出(llm / dsh / arkclaw 等支持 --json-schema 的引擎走它)。
|
|
144
|
+
func Schema() *agent.JSONSchema {
|
|
145
|
+
return &agent.JSONSchema{
|
|
146
|
+
Type: "object",
|
|
147
|
+
Properties: map[string]map[string]any{
|
|
148
|
+
"conflict": {"type": "boolean"},
|
|
149
|
+
"level": {"type": "string", "enum": []string{"hard", "soft", "none"}},
|
|
150
|
+
"reason": {"type": "string"},
|
|
151
|
+
"overlaps": {"type": "array", "items": map[string]any{"type": "string"}},
|
|
152
|
+
},
|
|
153
|
+
Required: []string{"conflict", "level", "reason"},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Judge 跑一次判定。调用方给的预算到了就返回 error(由调用方按并行放行)。
|
|
158
|
+
func Judge(ctx context.Context, in Request) (Result, error) {
|
|
159
|
+
prompt := strings.TrimSpace(in.Prompt)
|
|
160
|
+
if prompt == "" {
|
|
161
|
+
return Result{}, fmt.Errorf("冲突判定:缺少本轮提示词")
|
|
162
|
+
}
|
|
163
|
+
cands := make([]Candidate, 0, len(in.Candidates))
|
|
164
|
+
for _, c := range in.Candidates {
|
|
165
|
+
if strings.TrimSpace(c.Title) == "" && strings.TrimSpace(c.Recent) == "" && len(c.Files) == 0 {
|
|
166
|
+
continue // 三条信息全空:这个候选帮不上任何忙,别占提示词
|
|
167
|
+
}
|
|
168
|
+
cands = append(cands, c)
|
|
169
|
+
if len(cands) >= MaxCandidates {
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if len(cands) == 0 {
|
|
174
|
+
// 没有可比对象 = 不可能冲突。这不是错误,直接给结论,省一次模型调用。
|
|
175
|
+
return Result{Conflict: false, Level: "none", Reason: "没有正在运行的同空间任务", By: "rule"}, nil
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
name := strings.TrimSpace(in.Engine)
|
|
179
|
+
if name == "" {
|
|
180
|
+
name = DefaultEngine
|
|
181
|
+
}
|
|
182
|
+
if agent.Lookup(name) == nil {
|
|
183
|
+
return Result{}, fmt.Errorf("冲突判定:未知引擎 %q", name)
|
|
184
|
+
}
|
|
185
|
+
timeout := in.Timeout
|
|
186
|
+
if timeout <= 0 {
|
|
187
|
+
timeout = DefaultTimeout
|
|
188
|
+
}
|
|
189
|
+
cctx, cancel := context.WithTimeout(ctx, timeout)
|
|
190
|
+
defer cancel()
|
|
191
|
+
start := time.Now()
|
|
192
|
+
|
|
193
|
+
msg := Prompt(prompt, cands)
|
|
194
|
+
res, err := runOnce(cctx, name, strings.TrimSpace(in.Model), msg, timeout, start)
|
|
195
|
+
if err == nil {
|
|
196
|
+
return res, nil
|
|
197
|
+
}
|
|
198
|
+
/* 主引擎失败:预算还够就落一次 llm(1-2s,判定用的小模型足够)。
|
|
199
|
+
不再试用户配的「建议备用引擎」—— 那是给生成建议用的,判定多一跳只会把 2.5s 吃光。 */
|
|
200
|
+
if name == DefaultEngine || cctx.Err() != nil {
|
|
201
|
+
return Result{}, err
|
|
202
|
+
}
|
|
203
|
+
remain := timeout - time.Since(start)
|
|
204
|
+
if remain < 800*time.Millisecond {
|
|
205
|
+
return Result{}, err
|
|
206
|
+
}
|
|
207
|
+
return runOnce(cctx, DefaultEngine, "", msg, remain, start)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// runOnce 用指定引擎跑一次,并解析成 Result。
|
|
211
|
+
func runOnce(ctx context.Context, name, model, msg string, budget time.Duration, start time.Time) (Result, error) {
|
|
212
|
+
engine := agent.Lookup(name)
|
|
213
|
+
if engine == nil {
|
|
214
|
+
return Result{}, fmt.Errorf("冲突判定:未知引擎 %q", name)
|
|
215
|
+
}
|
|
216
|
+
attemptCtx, cancel := context.WithTimeout(ctx, budget)
|
|
217
|
+
defer cancel()
|
|
218
|
+
runner := &agent.Runner{Engine: engine, Timeout: budget}
|
|
219
|
+
resp, err := runner.Run(attemptCtx, agent.Request{
|
|
220
|
+
Model: model,
|
|
221
|
+
// 判定纯文本:不给工具、不思考(与 --suggest-followups 同口径)。
|
|
222
|
+
Tools: agent.ToolsOff,
|
|
223
|
+
NoThinking: true,
|
|
224
|
+
Reasoning: "minimal",
|
|
225
|
+
MaxTokens: maxTokens,
|
|
226
|
+
Messages: []agent.Message{{Role: "user", Content: msg}},
|
|
227
|
+
JSONSchema: Schema(),
|
|
228
|
+
})
|
|
229
|
+
if err != nil {
|
|
230
|
+
return Result{}, err
|
|
231
|
+
}
|
|
232
|
+
out, perr := Parse(resp.Text)
|
|
233
|
+
if perr != nil {
|
|
234
|
+
return Result{}, perr
|
|
235
|
+
}
|
|
236
|
+
if out.Level == "" {
|
|
237
|
+
out.Level = "none"
|
|
238
|
+
}
|
|
239
|
+
if out.Conflict && out.Level == "none" {
|
|
240
|
+
out.Level = "soft" // 模型说冲突但没给档位:按最轻的冲突算,别把「不冲突」写在冲突旁边
|
|
241
|
+
}
|
|
242
|
+
out.Engine = engine.Name()
|
|
243
|
+
out.Model = resp.Model
|
|
244
|
+
out.ElapsedMS = time.Since(start).Milliseconds()
|
|
245
|
+
out.By = "model"
|
|
246
|
+
return out, nil
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Parse 从模型输出里抠出 JSON(容忍 ```json 围栏、前后解释文字)。
|
|
250
|
+
func Parse(text string) (Result, error) {
|
|
251
|
+
raw := strings.TrimSpace(text)
|
|
252
|
+
if raw == "" {
|
|
253
|
+
return Result{}, fmt.Errorf("冲突判定:模型没有输出")
|
|
254
|
+
}
|
|
255
|
+
if i := strings.Index(raw, "{"); i > 0 {
|
|
256
|
+
raw = raw[i:]
|
|
257
|
+
}
|
|
258
|
+
if j := strings.LastIndex(raw, "}"); j >= 0 && j+1 < len(raw) {
|
|
259
|
+
raw = raw[:j+1]
|
|
260
|
+
}
|
|
261
|
+
var out Result
|
|
262
|
+
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
|
263
|
+
return Result{}, fmt.Errorf("冲突判定:输出不是 JSON(%v):%s", err, clipRunes(raw, 120))
|
|
264
|
+
}
|
|
265
|
+
out.Level = normalizeLevel(out.Level)
|
|
266
|
+
out.Reason = clipRunes(strings.TrimSpace(out.Reason), MaxReasonRunes)
|
|
267
|
+
out.Overlaps = cleanList(out.Overlaps)
|
|
268
|
+
return out, nil
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func normalizeLevel(s string) string {
|
|
272
|
+
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
273
|
+
case "hard":
|
|
274
|
+
return "hard"
|
|
275
|
+
case "soft":
|
|
276
|
+
return "soft"
|
|
277
|
+
case "none", "":
|
|
278
|
+
return "none"
|
|
279
|
+
default:
|
|
280
|
+
return "soft" // 认不出的档位按「可能相关」处理,不放大成 hard
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// cleanList 去空、去重、限长(模型常把同一个文件名重复列两遍)。
|
|
285
|
+
func cleanList(in []string) []string {
|
|
286
|
+
out := make([]string, 0, len(in))
|
|
287
|
+
seen := map[string]bool{}
|
|
288
|
+
for _, s := range in {
|
|
289
|
+
v := strings.TrimSpace(s)
|
|
290
|
+
if v == "" || seen[v] {
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
seen[v] = true
|
|
294
|
+
out = append(out, clipRunes(v, 80))
|
|
295
|
+
if len(out) >= 8 {
|
|
296
|
+
break
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func clipRunes(s string, max int) string {
|
|
303
|
+
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
|
304
|
+
return s
|
|
305
|
+
}
|
|
306
|
+
return string([]rune(s)[:max])
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// tailStrings 取最后 n 条(日志按时间追加,越靠后越新)。
|
|
310
|
+
func tailStrings(in []string, n int) []string {
|
|
311
|
+
if n <= 0 || len(in) <= n {
|
|
312
|
+
return in
|
|
313
|
+
}
|
|
314
|
+
return in[len(in)-n:]
|
|
315
|
+
}
|