ymai-codeflow 0.0.0-stage → 0.3.30
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/bin/cli.js +40 -0
- package/package.json +17 -4
- package/runtime/__init__.py +0 -0
- package/runtime/chain.py +162 -0
- package/runtime/config.py +94 -0
- package/runtime/conversation.py +220 -0
- package/runtime/daemon.py +557 -0
- package/runtime/frames.py +90 -0
- package/runtime/logging_util.py +34 -0
- package/runtime/packager.py +146 -0
- package/runtime/powercontext_client.py +131 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/base.py +81 -0
- package/runtime/providers/claude_code.py +226 -0
- package/runtime/providers/mock.py +42 -0
- package/runtime/providers/text_cli.py +106 -0
- package/runtime/store.py +126 -0
- package/runtime/wsclient.py +174 -0
- package/README.md +0 -3
package/bin/cli.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Launcher: delegates to the bundled Python executor runtime (stdlib-only, Python 3.11+).
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const packageRoot = path.join(__dirname, '..');
|
|
8
|
+
const runtimeDir = path.join(packageRoot, 'runtime');
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const known = new Set(['connect', '--once-smoke', '--check-config', '--help']);
|
|
11
|
+
if (args.length === 0 || !known.has(args[0])) {
|
|
12
|
+
console.error('Usage: ymai-codeflow connect --ws-url <url> --token <token> --executor-id <id> [--provider claude] [--memory-mode platform] [--max-tasks N]');
|
|
13
|
+
console.error(' ymai-codeflow --check-config | --once-smoke | --help');
|
|
14
|
+
console.error('Requires Python 3.11+ on the target machine.');
|
|
15
|
+
process.exit(args.length === 0 ? 2 : 1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const pythonCandidates = process.env.AINATIVE_PYTHON
|
|
19
|
+
? [process.env.AINATIVE_PYTHON]
|
|
20
|
+
: ['python3', 'python3.11', 'python'];
|
|
21
|
+
|
|
22
|
+
let child;
|
|
23
|
+
(function tryNext(i) {
|
|
24
|
+
if (i >= pythonCandidates.length) {
|
|
25
|
+
console.error('Python 3.11+ not found. Install Python 3.11+ or set AINATIVE_PYTHON=/path/to/python3.');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const py = pythonCandidates[i];
|
|
29
|
+
child = spawn(py, ['-m', 'runtime.daemon', ...args], {
|
|
30
|
+
cwd: packageRoot,
|
|
31
|
+
stdio: 'inherit',
|
|
32
|
+
env: process.env,
|
|
33
|
+
});
|
|
34
|
+
child.on('error', (err) => {
|
|
35
|
+
if (err.code === 'ENOENT') { tryNext(i + 1); return; }
|
|
36
|
+
console.error(err.message);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
child.on('exit', (code2) => process.exit(code2 ?? 0));
|
|
40
|
+
})(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ymai-codeflow",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.30",
|
|
4
|
+
"description": "CodeFlow executor runtime launcher — connects AI worker daemons to the CodeFlow platform (Python 3.11+ required).",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ymai-codeflow": "./bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"runtime/"
|
|
11
|
+
],
|
|
12
|
+
"keywords": ["codeflow", "executor", "agent", "ai", "delivery"],
|
|
13
|
+
"license": "Apache-2.0",
|
|
14
|
+
"engines": { "node": ">=16" },
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/qsumm/CodeFlow.git"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
File without changes
|
package/runtime/chain.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""跨角色接棒:经平台 REST API(架构许可通道,见 SdlcDriver javadoc / docs/10 v2)。
|
|
2
|
+
|
|
3
|
+
配置(runtime.env):
|
|
4
|
+
RUNTIME_HANDOFF_AGENT_ID 下一角色 agentId(必填才启用接棒)
|
|
5
|
+
AINATIVE_API_BASE_URL 默认 http://localhost:7001
|
|
6
|
+
AINATIVE_WORKSPACE_ID 工作空间 ID
|
|
7
|
+
AINATIVE_REFRESH_TOKEN 平台 refresh token(401 时自动换取新 access token)
|
|
8
|
+
AINATIVE_ACCESS_TOKEN 当前 access token(可选,建议给 refresh token 即可)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
_state = {"access": None, "refresh": None, "base": None, "ws": None}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _put(path: str, body: dict, token: str | None = None) -> dict:
|
|
20
|
+
req = urllib.request.Request(_state["base"] + path, method="PUT",
|
|
21
|
+
data=json.dumps(body).encode())
|
|
22
|
+
req.add_header("Content-Type", "application/json")
|
|
23
|
+
if token:
|
|
24
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
25
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
26
|
+
return json.loads(r.read())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _post(path: str, body: dict, token: str | None = None) -> dict:
|
|
30
|
+
req = urllib.request.Request(_state["base"] + path, method="POST",
|
|
31
|
+
data=json.dumps(body).encode())
|
|
32
|
+
req.add_header("Content-Type", "application/json")
|
|
33
|
+
if token:
|
|
34
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
35
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
36
|
+
return json.loads(r.read())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ensure_token(cfg) -> str:
|
|
40
|
+
if _state["access"] is None:
|
|
41
|
+
_state["access"] = cfg.extra_env.get("AINATIVE_ACCESS_TOKEN") or None
|
|
42
|
+
_state["refresh"] = cfg.extra_env.get("AINATIVE_REFRESH_TOKEN")
|
|
43
|
+
_state["base"] = cfg.extra_env.get("AINATIVE_API_BASE_URL", "http://localhost:7001")
|
|
44
|
+
if _state["access"] is None and _state["refresh"]:
|
|
45
|
+
d = _post("/api/auth/refresh", {"refreshToken": _state["refresh"],
|
|
46
|
+
"workspaceId": int(cfg.extra_env.get("AINATIVE_WORKSPACE_ID", 0))})["data"]
|
|
47
|
+
_state["access"] = d["accessToken"]
|
|
48
|
+
if _state["access"] is None:
|
|
49
|
+
raise RuntimeError("no API token: set AINATIVE_ACCESS_TOKEN/REFRESH_TOKEN")
|
|
50
|
+
return _state["access"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _switch_workspace_token(cfg) -> str | None:
|
|
54
|
+
"""把平台级令牌换成工作区级令牌(DEF-028)。
|
|
55
|
+
|
|
56
|
+
平台登录返回的 access token 是"平台级"的,直接调 /api/workitems/{id} 会被拒
|
|
57
|
+
(HTTP 403,code 11001「非该工作空间成员」)。工作区级令牌需先调用
|
|
58
|
+
POST /api/workspaces/{id}/switch 换取。
|
|
59
|
+
"""
|
|
60
|
+
ws = cfg.extra_env.get("AINATIVE_WORKSPACE_ID")
|
|
61
|
+
if not ws:
|
|
62
|
+
return None
|
|
63
|
+
base = _state["base"]
|
|
64
|
+
current = _state["access"] or _state["refresh"]
|
|
65
|
+
req = urllib.request.Request(f"{base}/api/workspaces/{int(ws)}/switch", method="POST",
|
|
66
|
+
data=b"{}")
|
|
67
|
+
req.add_header("Content-Type", "application/json")
|
|
68
|
+
if current:
|
|
69
|
+
req.add_header("Authorization", f"Bearer {current}")
|
|
70
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
71
|
+
data = json.loads(r.read()).get("data") or {}
|
|
72
|
+
token = data.get("accessToken")
|
|
73
|
+
if token:
|
|
74
|
+
_state["access"] = token
|
|
75
|
+
return token
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _with_refresh_retry(cfg, fn):
|
|
79
|
+
try:
|
|
80
|
+
return fn(_ensure_token(cfg))
|
|
81
|
+
except urllib.error.HTTPError as e:
|
|
82
|
+
if e.code == 403 and _state["ws"] is None:
|
|
83
|
+
# 平台级令牌 → 先换工作区级令牌再试一次(DEF-028)
|
|
84
|
+
ws = cfg.extra_env.get("AINATIVE_WORKSPACE_ID")
|
|
85
|
+
if ws:
|
|
86
|
+
_state["ws"] = ws
|
|
87
|
+
try:
|
|
88
|
+
_switch_workspace_token(cfg)
|
|
89
|
+
return fn(_ensure_token(cfg))
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
raise
|
|
93
|
+
if e.code != 401 or not _state["refresh"]:
|
|
94
|
+
raise
|
|
95
|
+
_state["access"] = None
|
|
96
|
+
return fn(_ensure_token(cfg))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get(path: str, token: str) -> dict:
|
|
100
|
+
req = urllib.request.Request(_state["base"] + path, method="GET")
|
|
101
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
102
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
103
|
+
return json.loads(r.read())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def finalize_to_done(cfg, workitem_id: int) -> None:
|
|
107
|
+
"""把工单流转到其状态模板的 DONE 节点(链路终点收敛,等价于页面上的完工流转)。"""
|
|
108
|
+
def call(tok):
|
|
109
|
+
w = _get(f"/api/workitems/{workitem_id}", tok)["data"]
|
|
110
|
+
nodes = _get(f"/api/status-templates/{w['templateId']}/nodes", tok)["data"]
|
|
111
|
+
done = next((n for n in nodes if str(n.get("category", "")).upper() == "DONE"), None)
|
|
112
|
+
if done is None:
|
|
113
|
+
raise RuntimeError("no DONE node in status template")
|
|
114
|
+
if done["id"] == w.get("statusNodeId"):
|
|
115
|
+
return {"success": True, "message": "already at DONE"}
|
|
116
|
+
body = {"toNodeId": done["id"], "fromNodeId": w.get("statusNodeId"),
|
|
117
|
+
"expectedVersion": w.get("version")}
|
|
118
|
+
return _post(f"/api/workitems/{workitem_id}/transition", body, tok)
|
|
119
|
+
resp = _with_refresh_retry(cfg, call)
|
|
120
|
+
if not resp.get("success"):
|
|
121
|
+
raise RuntimeError(f"finalize failed: {resp.get('message')}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def handoff_to_human(cfg, workitem_id: int, user_id: int | None = None) -> None:
|
|
125
|
+
"""把工单交还真人(平台合法操作:assigneeType=HUMAN + 真人 userId)。"""
|
|
126
|
+
uid = user_id if user_id is not None else cfg.extra_env.get("AINATIVE_HUMAN_USER_ID")
|
|
127
|
+
if uid is None:
|
|
128
|
+
raise RuntimeError("no human user id for handoff (roster/ AINATIVE_HUMAN_USER_ID)")
|
|
129
|
+
def call(tok):
|
|
130
|
+
return _put(f"/api/workitems/{workitem_id}/assignee",
|
|
131
|
+
{"assigneeType": "HUMAN", "assigneeRef": int(uid)}, tok)
|
|
132
|
+
resp = _with_refresh_retry(cfg, call)
|
|
133
|
+
if not resp.get("success"):
|
|
134
|
+
raise RuntimeError(f"handoff_to_human failed: {resp.get('message')}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def reassign_to_agent(cfg, workitem_id: int, agent_id: int) -> None:
|
|
138
|
+
"""把工单重新指派给下一角色的数字员工(平台合法操作,等价于项目经理在页面点指派)。"""
|
|
139
|
+
cfg.extra_env.setdefault("AINATIVE_API_BASE_URL", "http://localhost:7001")
|
|
140
|
+
_state["base"] = cfg.extra_env["AINATIVE_API_BASE_URL"]
|
|
141
|
+
body = {"assigneeType": "AGENT", "assigneeRef": agent_id}
|
|
142
|
+
def call(tok):
|
|
143
|
+
return _put(f"/api/workitems/{workitem_id}/assignee", body, tok)
|
|
144
|
+
resp = _with_refresh_retry(cfg, call)
|
|
145
|
+
if not resp.get("success"):
|
|
146
|
+
raise RuntimeError(f"reassign failed: {resp.get('message')}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def agent_comment(cfg, dispatch_id: int, content_md: str) -> dict:
|
|
150
|
+
"""数字员工备注:走平台 daemon 备注通道(executor token 认证,免 Bearer)。
|
|
151
|
+
|
|
152
|
+
平台侧为该备注触发 WorkitemCommentCreatedEvent → outbox COMMENT_CREATE →
|
|
153
|
+
外部工单平台(禅道等)备注回写。这是"交付结论回到源头平台"的正式通道。
|
|
154
|
+
"""
|
|
155
|
+
base = cfg.extra_env.get("AINATIVE_API_BASE_URL", "http://localhost:7001")
|
|
156
|
+
_state["base"] = base
|
|
157
|
+
path = f"/api/daemon/dispatches/{int(dispatch_id)}/comments?token={cfg.executor_token}"
|
|
158
|
+
req = urllib.request.Request(base + path, method="POST",
|
|
159
|
+
data=json.dumps({"contentMd": content_md}).encode())
|
|
160
|
+
req.add_header("Content-Type", "application/json")
|
|
161
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
162
|
+
return json.loads(r.read())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""CodeFlow runtime - configuration (env only, stdlib)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Config:
|
|
15
|
+
server_ws_url: str
|
|
16
|
+
executor_id: int
|
|
17
|
+
executor_token: str
|
|
18
|
+
provider: str = "mock"
|
|
19
|
+
agent_cmd: str = "claude"
|
|
20
|
+
agent_timeout_sec: int = 1800
|
|
21
|
+
agent_max_turns: int = 40
|
|
22
|
+
agent_allowed_tools: str = ""
|
|
23
|
+
handoff_agent_id: int | None = None
|
|
24
|
+
chain_finalize: bool = False
|
|
25
|
+
handoff_human_on_fail: bool = False
|
|
26
|
+
data_dir: str = "./runtime-data"
|
|
27
|
+
heartbeat_sec: int = 30
|
|
28
|
+
max_concurrent: int = 1
|
|
29
|
+
log_level: str = "info"
|
|
30
|
+
powercontext_base_url: str = ""
|
|
31
|
+
powercontext_timeout_sec: float = 8.0
|
|
32
|
+
powercontext_remember: bool = True
|
|
33
|
+
extra_env: dict = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def ws_path(self) -> str:
|
|
37
|
+
# ws://host:port/ws/executor -> path with query appended later
|
|
38
|
+
parts = self.server_ws_url.split("://", 1)
|
|
39
|
+
if len(parts) != 2 or parts[0] not in ("ws", "wss"):
|
|
40
|
+
raise ConfigError(f"AINATIVE_SERVER_WS_URL must start with ws:// or wss://: {self.server_ws_url}")
|
|
41
|
+
hostpath = parts[1]
|
|
42
|
+
path = "/" + hostpath.split("/", 1)[1] if "/" in hostpath else "/ws/executor"
|
|
43
|
+
if path.rstrip("/") != "/ws/executor":
|
|
44
|
+
raise ConfigError(f"WS path must be /ws/executor, got: {path}")
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def ws_hostport(self) -> str:
|
|
49
|
+
return self.server_ws_url.split("://", 1)[1].split("/", 1)[0]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def use_tls(self) -> bool:
|
|
53
|
+
return self.server_ws_url.startswith("wss://")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config(env: dict | None = None) -> Config:
|
|
57
|
+
env = dict(os.environ if env is None else env)
|
|
58
|
+
missing = [k for k in ("AINATIVE_SERVER_WS_URL", "AINATIVE_EXECUTOR_ID", "AINATIVE_EXECUTOR_TOKEN") if not env.get(k)]
|
|
59
|
+
if missing:
|
|
60
|
+
raise ConfigError("missing required env: " + ", ".join(missing))
|
|
61
|
+
provider = env.get("AINATIVE_PROVIDER", "mock")
|
|
62
|
+
if provider not in ("mock", "claude", "gemini", "codex", "cursor"):
|
|
63
|
+
raise ConfigError(f"AINATIVE_PROVIDER must be mock|claude|gemini|codex|cursor, got: {provider}")
|
|
64
|
+
# 各 provider 的默认 CLI 命令;可用 AINATIVE_AGENT_CMD 覆盖(绝对路径/自定义名)。
|
|
65
|
+
cfg = Config(
|
|
66
|
+
server_ws_url=env["AINATIVE_SERVER_WS_URL"],
|
|
67
|
+
executor_id=int(env["AINATIVE_EXECUTOR_ID"]),
|
|
68
|
+
executor_token=env["AINATIVE_EXECUTOR_TOKEN"],
|
|
69
|
+
provider=provider,
|
|
70
|
+
agent_cmd=env.get("AINATIVE_AGENT_CMD",
|
|
71
|
+
{"claude": "claude", "gemini": "gemini", "codex": "codex",
|
|
72
|
+
"cursor": "cursor-agent"}.get(provider, "claude")),
|
|
73
|
+
agent_timeout_sec=int(env.get("AINATIVE_AGENT_TIMEOUT_SEC", "1800")),
|
|
74
|
+
agent_max_turns=int(env.get("AINATIVE_AGENT_MAX_TURNS", "40")),
|
|
75
|
+
agent_allowed_tools=env.get("AINATIVE_AGENT_ALLOWED_TOOLS", ""),
|
|
76
|
+
handoff_agent_id=int(env["RUNTIME_HANDOFF_AGENT_ID"]) if env.get("RUNTIME_HANDOFF_AGENT_ID") else None,
|
|
77
|
+
chain_finalize=env.get("RUNTIME_CHAIN_FINALIZE", "0") in ("1", "true", "yes"),
|
|
78
|
+
handoff_human_on_fail=env.get("RUNTIME_HANDOFF_HUMAN_ON_FAIL", "0") in ("1", "true", "yes"),
|
|
79
|
+
# 绝对化:工作目录会作为 claude CLI 的 cwd,相对路径会让 --settings 解析失败(DEF-026)
|
|
80
|
+
data_dir=str(Path(env.get("RUNTIME_DATA_DIR", "./runtime-data")).expanduser().resolve()),
|
|
81
|
+
heartbeat_sec=int(env.get("RUNTIME_HEARTBEAT_SEC", "30")),
|
|
82
|
+
max_concurrent=int(env.get("RUNTIME_MAX_CONCURRENT", "1")),
|
|
83
|
+
log_level=env.get("RUNTIME_LOG_LEVEL", "info"),
|
|
84
|
+
# PowerContext 记忆外挂(可选):不配 POWERCONTEXT_BASE_URL 即关闭
|
|
85
|
+
powercontext_base_url=(env.get("POWERCONTEXT_BASE_URL") or "").strip().rstrip("/"),
|
|
86
|
+
powercontext_timeout_sec=float(env.get("POWERCONTEXT_TIMEOUT_SEC") or "8.0"),
|
|
87
|
+
powercontext_remember=env.get("POWERCONTEXT_REMEMBER", "1") in ("1", "true", "yes"),
|
|
88
|
+
extra_env={k: v for k, v in env.items()
|
|
89
|
+
if k.startswith(("ANTHROPIC_", "AINATIVE_AGENT_"))
|
|
90
|
+
or k in ("AINATIVE_ACCESS_TOKEN", "AINATIVE_REFRESH_TOKEN",
|
|
91
|
+
"AINATIVE_API_BASE_URL", "AINATIVE_WORKSPACE_ID")},
|
|
92
|
+
)
|
|
93
|
+
cfg.ws_path # validate URL shape eagerly
|
|
94
|
+
return cfg
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Clarification-conversation turn execution (CONVERSATION_TURN frames).
|
|
2
|
+
|
|
3
|
+
协议(平台侧 WsConversationTransport/InboundFrameRouter):
|
|
4
|
+
平台 → runtime: CONVERSATION_TURN{conversationId,turnId,agentId,dispatchAttempt?,
|
|
5
|
+
content,requestId,cliSessionRef?,systemPrompt?,capabilityDownloadUrl,
|
|
6
|
+
capabilitySha256,capabilityHash,mcpToken}
|
|
7
|
+
runtime → 平台: CONVERSATION_TURN_ACK{conversationId,turnId,dispatchAttempt?,
|
|
8
|
+
status:SUCCESS|FAILED,error?,replyMarkdown?,sessionId?,usage?}
|
|
9
|
+
|
|
10
|
+
心跳声明 CONVERSATION_DELIVERY_ACK_V1 后,ACK 必须回显 dispatchAttempt,
|
|
11
|
+
平台据此做陈旧投递判定与投递确认。
|
|
12
|
+
|
|
13
|
+
claude 执行形态:每会话一个工作区;每回合下载 capability 包(sha256 校验)解包,
|
|
14
|
+
--resume 上一回合 sessionId 保持上下文;MCP(ainative) 以 Bearer mcpToken 注入,
|
|
15
|
+
数字员工由此读取工单与平台数据。
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
import urllib.request
|
|
24
|
+
import zipfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .logging_util import log
|
|
28
|
+
from .providers.claude_code import write_settings_file
|
|
29
|
+
|
|
30
|
+
CAPABILITY_TIMEOUT_SEC = 120
|
|
31
|
+
TURN_TIMEOUT_SEC = 30 * 60 # 平台 ~11min 会重投;dispatchAttempt 去重保证幂等
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def download_capability(url: str, expected_sha256: str, dest_dir: Path) -> Path:
|
|
35
|
+
"""下载 capability 包并做 sha256 完整性校验(与平台 TaskPackager 计算口径一致)。"""
|
|
36
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
zip_path = dest_dir / "capability.zip"
|
|
38
|
+
tmp = zip_path.with_suffix(".part")
|
|
39
|
+
req = urllib.request.Request(url)
|
|
40
|
+
with urllib.request.urlopen(req, timeout=CAPABILITY_TIMEOUT_SEC) as resp, open(tmp, "wb") as f:
|
|
41
|
+
while True:
|
|
42
|
+
chunk = resp.read(256 * 1024)
|
|
43
|
+
if not chunk:
|
|
44
|
+
break
|
|
45
|
+
f.write(chunk)
|
|
46
|
+
digest = hashlib.sha256(tmp.read_bytes()).hexdigest()
|
|
47
|
+
if expected_sha256 and digest.lower() != expected_sha256.lower():
|
|
48
|
+
tmp.unlink(missing_ok=True)
|
|
49
|
+
raise ValueError(f"capability sha256 mismatch: got {digest}, want {expected_sha256}")
|
|
50
|
+
tmp.replace(zip_path)
|
|
51
|
+
return zip_path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract_capability(zip_path: Path, dest_dir: Path) -> None:
|
|
55
|
+
"""解包 capability 包(覆盖式;包内为 skills.json/mcp 配置/仓库上下文等)。"""
|
|
56
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
58
|
+
zf.extractall(dest_dir)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ainative_mcp_server(capsule_dir: Path, mcp_token: str) -> dict | None:
|
|
62
|
+
"""从 capability 的 skills.json 取内置 ainative MCP 描述,注入 Bearer mcpToken。
|
|
63
|
+
|
|
64
|
+
平台 authType=ainative-dispatch 的鉴权即 MCP 端点的 Authorization 头。
|
|
65
|
+
"""
|
|
66
|
+
skills_path = capsule_dir / "skills.json"
|
|
67
|
+
if not skills_path.exists():
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
doc = json.loads(skills_path.read_text(encoding="utf-8"))
|
|
71
|
+
except (ValueError, OSError) as e:
|
|
72
|
+
log.warn("conversation.skills_unreadable", error=str(e))
|
|
73
|
+
return None
|
|
74
|
+
for server in doc.get("mcpServers") or []:
|
|
75
|
+
name = str(server.get("name", "")).lower()
|
|
76
|
+
if name != "ainative":
|
|
77
|
+
continue
|
|
78
|
+
url = server.get("url")
|
|
79
|
+
if not url:
|
|
80
|
+
return None
|
|
81
|
+
return {"type": "http" if server.get("transport", "http") == "http" else "sse",
|
|
82
|
+
"url": url, "headers": {"Authorization": f"Bearer {mcp_token}"}}
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_mcp_config(workdir: Path, server: dict | None) -> None:
|
|
87
|
+
if not server:
|
|
88
|
+
return
|
|
89
|
+
(workdir / ".mcp.json").write_text(
|
|
90
|
+
json.dumps({"mcpServers": {"ainative": server}}, ensure_ascii=False), encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def write_conversation_settings(workdir: Path, env: dict, mcp_token: str | None) -> str | None:
|
|
94
|
+
"""claude --settings:钉模型端点环境变量 + 预授权 ainative MCP 工具。
|
|
95
|
+
|
|
96
|
+
-p 模式无权限弹窗,部分 CLI 版本对 --allowedTools 的服务器级简写不生效,
|
|
97
|
+
settings 的 permissions.allow(服务器名 + 工具通配两种形式)是权威路径。
|
|
98
|
+
"""
|
|
99
|
+
picked = {k: v for k, v in env.items()
|
|
100
|
+
if k.startswith("ANTHROPIC_") or k == "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"}
|
|
101
|
+
settings = {"env": picked}
|
|
102
|
+
if mcp_token:
|
|
103
|
+
settings["permissions"] = {"allow": ["mcp__ainative", "mcp__ainative__*"]}
|
|
104
|
+
path = workdir / "claude-settings.json"
|
|
105
|
+
path.write_text(json.dumps(settings, ensure_ascii=False), encoding="utf-8")
|
|
106
|
+
return str(path)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_command(cli_binary: str, content: str, *, session_ref: str | None,
|
|
110
|
+
system_prompt: str | None, settings_file: str | None,
|
|
111
|
+
max_turns: int, allowed_tools: str | None = None) -> list[str]:
|
|
112
|
+
argv = [cli_binary, "-p", content, "--output-format", "json"]
|
|
113
|
+
if session_ref:
|
|
114
|
+
argv += ["--resume", session_ref]
|
|
115
|
+
if max_turns:
|
|
116
|
+
argv += ["--max-turns", str(max_turns)]
|
|
117
|
+
if settings_file:
|
|
118
|
+
argv += ["--settings", settings_file]
|
|
119
|
+
if system_prompt:
|
|
120
|
+
argv += ["--append-system-prompt", system_prompt]
|
|
121
|
+
if allowed_tools:
|
|
122
|
+
argv += ["--allowedTools", allowed_tools]
|
|
123
|
+
return argv
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_result(raw: str) -> dict:
|
|
127
|
+
"""解析 claude --output-format json 单行结果 → {text, session_id, is_error, usage}。"""
|
|
128
|
+
out = {"text": "", "session_id": None, "is_error": True, "error": "empty output",
|
|
129
|
+
"usage": None, "model": None}
|
|
130
|
+
for line in (raw or "").splitlines():
|
|
131
|
+
line = line.strip()
|
|
132
|
+
if not line or not line.startswith("{"):
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads(line)
|
|
136
|
+
except ValueError:
|
|
137
|
+
continue
|
|
138
|
+
if data.get("type") != "result":
|
|
139
|
+
continue
|
|
140
|
+
out["text"] = data.get("result") or ""
|
|
141
|
+
out["session_id"] = data.get("session_id")
|
|
142
|
+
out["is_error"] = bool(data.get("is_error"))
|
|
143
|
+
out["error"] = None if not out["is_error"] else (out["text"] or "claude reported error")
|
|
144
|
+
usage = data.get("usage") or {}
|
|
145
|
+
out["usage"] = {
|
|
146
|
+
"inputTokens": int(usage.get("input_tokens") or 0),
|
|
147
|
+
"outputTokens": int(usage.get("output_tokens") or 0),
|
|
148
|
+
"cacheReadTokens": int(usage.get("cache_read_input_tokens") or 0),
|
|
149
|
+
"cacheWriteTokens": int(usage.get("cache_creation_input_tokens") or 0),
|
|
150
|
+
}
|
|
151
|
+
model_usage = data.get("modelUsage") or {}
|
|
152
|
+
if isinstance(model_usage, dict) and model_usage:
|
|
153
|
+
out["model"] = next(iter(model_usage))
|
|
154
|
+
break
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run_claude_turn(cli_binary: str, workdir: Path, content: str, *, session_ref: str | None,
|
|
159
|
+
system_prompt: str | None, settings_path: str | None, max_turns: int,
|
|
160
|
+
env: dict, timeout_sec: int = TURN_TIMEOUT_SEC,
|
|
161
|
+
allowed_tools: str | None = None) -> dict:
|
|
162
|
+
"""同步执行一回合对话;返回 parse_result 形态,附 exit_code/stderr 供失败诊断。"""
|
|
163
|
+
argv = build_command(cli_binary, content, session_ref=session_ref,
|
|
164
|
+
system_prompt=system_prompt, settings_file=settings_path,
|
|
165
|
+
max_turns=max_turns, allowed_tools=allowed_tools)
|
|
166
|
+
started = time.monotonic()
|
|
167
|
+
try:
|
|
168
|
+
proc = subprocess.run(argv, cwd=str(workdir), env=env, capture_output=True,
|
|
169
|
+
text=True, timeout=timeout_sec)
|
|
170
|
+
result = parse_result(proc.stdout or "")
|
|
171
|
+
result["exit_code"] = proc.returncode
|
|
172
|
+
if proc.returncode != 0 and not result["text"]:
|
|
173
|
+
result["is_error"] = True
|
|
174
|
+
result["error"] = (proc.stderr or "")[-800:] or f"claude exited {proc.returncode}"
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
result = {"text": "", "session_id": None, "is_error": True,
|
|
177
|
+
"error": f"conversation turn timeout after {timeout_sec}s",
|
|
178
|
+
"usage": None, "model": None, "exit_code": None}
|
|
179
|
+
result["duration_ms"] = int((time.monotonic() - started) * 1000)
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def turn_ack(conversation_id: int, turn_id: int, dispatch_attempt: int | None,
|
|
184
|
+
status: str, *, reply_markdown: str = "", error: str = "",
|
|
185
|
+
session_id: str | None = None, usage: dict | None = None,
|
|
186
|
+
model: str | None = None) -> dict:
|
|
187
|
+
frame = {"type": "CONVERSATION_TURN_ACK", "conversationId": int(conversation_id),
|
|
188
|
+
"turnId": int(turn_id), "status": status,
|
|
189
|
+
"replyMarkdown": reply_markdown or "", "error": error or ""}
|
|
190
|
+
if dispatch_attempt:
|
|
191
|
+
frame["dispatchAttempt"] = int(dispatch_attempt)
|
|
192
|
+
if session_id:
|
|
193
|
+
frame["sessionId"] = session_id
|
|
194
|
+
if usage:
|
|
195
|
+
frame["usage"] = {"provider": "claude", "model": model or "", **usage}
|
|
196
|
+
return frame
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def register_mcp_server(cli_binary: str, workdir: Path, env: dict, url: str, mcp_token: str) -> bool:
|
|
200
|
+
"""以 local scope 注册 ainative MCP(用户添加即受信,headless 免批准)。
|
|
201
|
+
|
|
202
|
+
token 每回合轮换(turn-bound),-f 覆盖旧注册。失败仅告警:工具不可用时数字员工
|
|
203
|
+
仍可凭 capability 包内上下文作答。
|
|
204
|
+
"""
|
|
205
|
+
# 该 CLI 版本无 force 旗标:先 remove(不存在则报错,忽略)再 add 实现每回合轮换覆盖
|
|
206
|
+
subprocess.run([cli_binary, "mcp", "remove", "--scope", "local", "ainative"],
|
|
207
|
+
cwd=str(workdir), env=env, capture_output=True, text=True, timeout=60)
|
|
208
|
+
argv = [cli_binary, "mcp", "add", "--transport", "http", "--scope", "local",
|
|
209
|
+
"ainative", url, "--header", f"Authorization: Bearer {mcp_token}"]
|
|
210
|
+
try:
|
|
211
|
+
proc = subprocess.run(argv, cwd=str(workdir), env=env, capture_output=True,
|
|
212
|
+
text=True, timeout=60)
|
|
213
|
+
if proc.returncode != 0:
|
|
214
|
+
log.warn("conversation.mcp_add_failed", code=proc.returncode,
|
|
215
|
+
stderr=(proc.stderr or "")[-200:])
|
|
216
|
+
return False
|
|
217
|
+
return True
|
|
218
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
219
|
+
log.warn("conversation.mcp_add_error", error=str(e))
|
|
220
|
+
return False
|