Files
zk-data-agent/src/jupyter_runtime.py
T
wangsenhao 85ce8c0e69 fix: session resume, config_set schema, metrics chart, subagent logging
- Fix session resume: remove messages.length>1 guard so sessionStorage
  ID is used as resumeSessionId on first message (prevents new session
  creation when user sends "继续")
- Fix config_set tool schema: add missing `items: {}` to array type in
  oneOf (Azure OpenAI strict validation rejects it otherwise)
- Metrics chart: use actual target set filename from trigger text
  instead of generic "target_subset" key
- Add sub-agent skill call logging to agent_runtime.py (full
  prompt/response, no truncation)
- Various metric enrichment and dedup fixes in server.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 17:02:24 +08:00

1373 lines
51 KiB
Python

from __future__ import annotations
import base64
import hashlib
import io
import json
import re
import shlex
import ssl
import tarfile
import threading
import time
import uuid
from dataclasses import dataclass
from html import unescape
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import quote, urlparse, urlunparse
try: # 依赖在部署环境安装;这里延迟报错,方便无网络环境做静态检查。
import requests
except ImportError: # pragma: no cover
requests = None # type: ignore[assignment]
try:
import websocket
except ImportError: # pragma: no cover
websocket = None # type: ignore[assignment]
DEFAULT_JUPYTER_WORKSPACE_ROOT = '/root/zk_agent_workspaces'
REMOTE_PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple'
REMOTE_PIP_TRUSTED_HOST = 'pypi.tuna.tsinghua.edu.cn'
class JupyterRuntimeError(RuntimeError):
"""Raised when a remote Jupyter runtime cannot complete a request."""
@dataclass(frozen=True)
class JupyterWorkspaceBinding:
account_id: str
session_id: str
base_url: str
workspace_root: str
workspace_cwd: str
workspace_api_path: str
jupyter_tree_url: str
created_at: float
def to_dict(self) -> dict[str, Any]:
return {
'account_id': self.account_id,
'session_id': self.session_id,
'base_url': self.base_url,
'workspace_root': self.workspace_root,
'workspace_cwd': self.workspace_cwd,
'workspace_api_path': self.workspace_api_path,
'jupyter_tree_url': self.jupyter_tree_url,
'created_at': self.created_at,
}
@dataclass(frozen=True)
class RemoteCommandResult:
exit_code: int
stdout: str
stderr: str = ''
timed_out: bool = False
class JupyterRuntimeSession:
"""A small terminal-backed execution bridge for one account/session pair."""
def __init__(
self,
*,
binding: JupyterWorkspaceBinding,
http_session: Any,
xsrf_token: str,
) -> None:
self.binding = binding
self._session = http_session
self._xsrf_token = xsrf_token
self._terminal_name: str | None = None
self._lock = threading.RLock()
account_part = sanitize_remote_path_part(binding.account_id)
self.account_runtime_root = (
f'{binding.workspace_root}/.runtime/accounts/{account_part}'
)
self.python_env_path = f'{self.account_runtime_root}/python/.venv'
self.platform_root = ''
self.skills_root = ''
self.bundle_digest = ''
self.synced_at: float | None = None
@classmethod
def from_persisted(cls, payload: dict[str, Any]) -> 'JupyterRuntimeSession':
"""从持久化的 Jupyter cookie 和绑定信息恢复 runtime。"""
if requests is None:
raise JupyterRuntimeError(
'Missing dependency: requests. Please install project dependencies.'
)
binding_payload = payload.get('binding')
if not isinstance(binding_payload, dict):
raise JupyterRuntimeError('Invalid persisted Jupyter binding.')
binding = JupyterWorkspaceBinding(
account_id=str(binding_payload['account_id']),
session_id=str(binding_payload['session_id']),
base_url=normalize_jupyter_base_url(str(binding_payload['base_url'])),
workspace_root=normalize_posix_path(str(binding_payload['workspace_root'])),
workspace_cwd=normalize_posix_path(str(binding_payload['workspace_cwd'])),
workspace_api_path=str(binding_payload['workspace_api_path']),
jupyter_tree_url=str(binding_payload['jupyter_tree_url']),
created_at=float(binding_payload.get('created_at') or time.time()),
)
http_session = requests.Session()
cookies = payload.get('cookies')
if isinstance(cookies, list):
for cookie in cookies:
if not isinstance(cookie, dict):
continue
name = cookie.get('name')
value = cookie.get('value')
if not isinstance(name, str) or not isinstance(value, str):
continue
http_session.cookies.set(
name,
value,
domain=(
cookie.get('domain')
if isinstance(cookie.get('domain'), str)
else None
),
path=(
cookie.get('path')
if isinstance(cookie.get('path'), str)
else '/'
),
)
xsrf_token = str(
payload.get('xsrf_token') or http_session.cookies.get('_xsrf') or ''
)
if not xsrf_token:
raise JupyterRuntimeError('Invalid persisted Jupyter _xsrf token.')
runtime = cls(
binding=binding,
http_session=http_session,
xsrf_token=xsrf_token,
)
if isinstance(payload.get('platform_root'), str):
runtime.platform_root = str(payload['platform_root'])
if isinstance(payload.get('skills_root'), str):
runtime.skills_root = str(payload['skills_root'])
if isinstance(payload.get('bundle_digest'), str):
runtime.bundle_digest = str(payload['bundle_digest'])
if isinstance(payload.get('synced_at'), (int, float)):
runtime.synced_at = float(payload['synced_at'])
return runtime
@classmethod
def connect(
cls,
*,
account_id: str,
session_id: str,
base_url: str,
password: str,
workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT,
timeout_seconds: float = 20.0,
project_root: Path | None = None,
) -> 'JupyterRuntimeSession':
if requests is None:
raise JupyterRuntimeError(
'Missing dependency: requests. Please install project dependencies.'
)
normalized_base_url = normalize_jupyter_base_url(base_url)
http_session = requests.Session()
xsrf_token = _login_jupyter(
http_session,
normalized_base_url,
password,
timeout_seconds=timeout_seconds,
)
workspace_root = normalize_posix_path(workspace_root)
workspace_cwd = f'{workspace_root}/{sanitize_remote_path_part(session_id)}'
workspace_api_path = api_path_for_absolute_path(workspace_cwd)
binding = JupyterWorkspaceBinding(
account_id=account_id,
session_id=session_id,
base_url=normalized_base_url,
workspace_root=workspace_root,
workspace_cwd=workspace_cwd,
workspace_api_path=workspace_api_path,
jupyter_tree_url=(
f'{normalized_base_url}/lab/tree/{quote(workspace_api_path)}'
),
created_at=time.time(),
)
runtime = cls(
binding=binding,
http_session=http_session,
xsrf_token=xsrf_token,
)
runtime.bootstrap_workspace(
timeout_seconds=timeout_seconds,
project_root=project_root,
)
return runtime
def to_persisted_dict(self) -> dict[str, Any]:
"""导出可持久化的连接信息,用于页面刷新或服务重启后恢复。"""
return {
'binding': self.binding.to_dict(),
'xsrf_token': self._xsrf_token,
'cookies': [
{
'name': cookie.name,
'value': cookie.value,
'domain': cookie.domain,
'path': cookie.path,
'secure': bool(cookie.secure),
'expires': cookie.expires,
}
for cookie in self._session.cookies
],
'platform_root': self.platform_root,
'skills_root': self.skills_root,
'bundle_digest': self.bundle_digest,
'synced_at': self.synced_at,
'persisted_at': time.time(),
}
@property
def chat_workspace_root(self) -> str:
"""Per-user-per-chat root on shared NFS so the SFT training pod
(which doesn't mount the jupyter pod's private workspace) can read
and write the same artifacts. Layout:
/mnt/wangsenhao/autoresearch-zk-users/<account_id>/<session_id>/"""
account_part = sanitize_remote_path_part(self.binding.account_id)
session_part = sanitize_remote_path_part(self.binding.session_id)
return (
f'/mnt/wangsenhao/autoresearch-zk-users/'
f'{account_part}/{session_part}'
)
def bootstrap_workspace(
self,
*,
timeout_seconds: float = 20.0,
project_root: Path | None = None,
) -> None:
chat_root = self.chat_workspace_root
nfs_users_root = '/mnt/wangsenhao/autoresearch-zk-users'
probe = self.run_command(
(
f'mkdir -p {shlex.quote(nfs_users_root)} && '
f'touch {shlex.quote(nfs_users_root)}/.write_probe && '
f'rm -f {shlex.quote(nfs_users_root)}/.write_probe'
),
timeout_seconds=timeout_seconds,
max_output_chars=2000,
)
if probe.exit_code != 0:
raise JupyterRuntimeError(
'远端 jupyter pod 未挂载 /mnt/wangsenhao 或不可写:'
+ (probe.stdout.strip() or probe.stderr.strip() or 'unknown error')
)
ws_output = f'{shlex.quote(self.binding.workspace_cwd)}/output'
chat_output_link = f'{shlex.quote(chat_root)}/output'
result = self.run_command(
(
'mkdir -p '
f'{ws_output} '
f'{shlex.quote(self.binding.workspace_cwd)}/input '
f'{shlex.quote(self.binding.workspace_cwd)}/scratchpad '
f'{shlex.quote(self.binding.workspace_root)}/.runtime/uploads '
f'{shlex.quote(self.binding.workspace_root)}/runtime_uploads '
f'{shlex.quote(self.account_runtime_root)}/python '
f'{shlex.quote(chat_root)}/results '
f'{shlex.quote(chat_root)}/sft_output '
f'{shlex.quote(chat_root)}/scripts && '
f'if [ -d {chat_output_link} ] && [ ! -L {chat_output_link} ]; then '
f'cp -a {chat_output_link}/* {ws_output}/ 2>/dev/null; '
f'rm -rf {chat_output_link}; '
f'fi && '
f'ln -sfnT {ws_output} {chat_output_link}'
),
timeout_seconds=timeout_seconds,
max_output_chars=4000,
)
if result.exit_code != 0:
raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to bootstrap Jupyter workspace.'
)
self.ensure_python_environment(timeout_seconds=max(timeout_seconds, 300.0))
if project_root is not None:
self.sync_runtime_bundle(project_root, timeout_seconds=max(timeout_seconds, 180.0))
self._sync_chat_workspace_scripts(timeout_seconds=timeout_seconds)
def _sync_chat_workspace_scripts(self, *, timeout_seconds: float = 20.0) -> None:
if not self.skills_root:
return
chat_root = self.chat_workspace_root
# SKILL/program §5 让 agent 直接 `bash scripts/<x>` 调下面这些脚本,
# 不同步过去 → agent 当 "脚本不存在" 处理后会肉手写 yaml,常把
# imageCommand 里的 `/scripts/prepare_and_train_sft.py` 写丢前缀,
# 导致训练 pod 报 "No such file or directory"。每次 bind 全量覆盖
# 一份,跟 prepare_and_train_sft.py 同样 "刷最新版"。
script_names = (
'prepare_and_train_sft.py',
'submit_sft.sh',
'sft_train_job.yaml.tpl',
'submit_cml_eval.sh',
'resolve_run_ids.sh',
)
src_dir = f'{self.skills_root}/model-iteration/scripts'
dst_dir = f'{chat_root}/scripts'
copy_cmds = []
for name in script_names:
src = f'{src_dir}/{name}'
dst = f'{dst_dir}/{name}'
copy_cmds.append(
f'if [ -f {shlex.quote(src)} ]; then '
f'cp {shlex.quote(src)} {shlex.quote(dst)} && '
f'chmod +x {shlex.quote(dst)} 2>/dev/null || true; '
f'fi'
)
result = self.run_command(
' && '.join(copy_cmds),
timeout_seconds=timeout_seconds,
max_output_chars=2000,
)
if result.exit_code != 0:
raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to sync chat workspace SFT script.'
)
def to_dict(self) -> dict[str, Any]:
payload = self.binding.to_dict()
payload['connected'] = True
payload['account_runtime_root'] = self.account_runtime_root
payload['python_env_path'] = self.python_env_path
payload['python_interpreter'] = self.python_interpreter
payload['platform_root'] = self.platform_root
payload['skills_root'] = self.skills_root
payload['bundle_digest'] = self.bundle_digest
payload['synced_at'] = self.synced_at
return payload
def render_context(self) -> str:
lines = [
'[远端 Jupyter 工作区]',
'当前 session 已切换到远端 Jupyter 运行时。',
f'- 默认工作目录:{self.binding.workspace_cwd}',
f'- 输出文件优先写入:{self.binding.workspace_cwd}/output',
f'- Python 环境:{self.python_env_path}',
'- Python 环境默认只初始化 venv 和 pip 清华源;缺包时使用 python_package 按需安装。',
'- 线上挂载目录(例如 /mnt/...)可以通过绝对路径读取和分析。',
]
if self.platform_root:
lines.extend(
[
f'- 平台运行包:{self.platform_root}',
f'- Skill 运行包:{self.skills_root}',
'- 读取 skills/...、src/...、标签定义/... 时会使用远端同步后的运行包。',
]
)
lines.extend(
[
'- 除非用户明确要求,不要修改平台代码目录;数据产物应留在当前 session 工作区。',
f'- Jupyter 工作区入口:{self.binding.jupyter_tree_url}',
]
)
return '\n'.join(lines)
@property
def python_interpreter(self) -> str:
return f'{self.python_env_path}/bin/python'
def ensure_python_environment(self, *, timeout_seconds: float = 300.0) -> None:
python_env = shlex.quote(self.python_env_path)
pip_conf = shlex.quote(f'{self.python_env_path}/pip.conf')
marker = shlex.quote(f'{self.python_env_path}/.zk-agent-python-env-v2')
command = f'''
set -e
if [ ! -x {python_env}/bin/python ]; then
python3 -m venv {python_env}
fi
cat > {pip_conf} <<'EOF'
[global]
index-url = {REMOTE_PIP_INDEX_URL}
trusted-host = {REMOTE_PIP_TRUSTED_HOST}
disable-pip-version-check = true
EOF
touch {marker}
{python_env}/bin/python -m pip --version
{python_env}/bin/python - <<'PY'
import sys
print(sys.executable)
PY
'''
result = self.run_command(
command,
timeout_seconds=timeout_seconds,
max_output_chars=12000,
)
if result.exit_code != 0:
raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to initialize remote Python env.'
)
def install_python_packages(
self,
packages: list[str],
*,
timeout_seconds: float,
max_output_chars: int,
cancel_event: Any | None = None,
) -> RemoteCommandResult:
package_args = ' '.join(shlex.quote(package) for package in packages)
return self.run_command(
f'{shlex.quote(self.python_interpreter)} -m pip install {package_args}',
timeout_seconds=timeout_seconds,
max_output_chars=max_output_chars,
cancel_event=cancel_event,
)
def sync_runtime_bundle(
self,
project_root: Path,
*,
timeout_seconds: float = 180.0,
) -> dict[str, Any]:
bundle_bytes, digest = build_runtime_bundle(project_root)
platform_root = f'{self.binding.workspace_root}/.runtime/platform/{digest[:16]}'
upload_path = f'{self.binding.workspace_root}/runtime_uploads/platform-{digest}.tar.gz'
digest_file = f'{platform_root}/.bundle_digest'
probe = self.run_command(
(
f'test -f {shlex.quote(digest_file)} '
f'&& cat {shlex.quote(digest_file)} || true'
),
timeout_seconds=20.0,
max_output_chars=2000,
)
if digest not in probe.stdout:
self.put_file_bytes(
api_path_for_absolute_path(upload_path),
bundle_bytes,
)
tmp_root = f'{platform_root}.tmp-{uuid.uuid4().hex[:8]}'
result = self.run_command(
(
f'rm -rf {shlex.quote(tmp_root)} && '
f'mkdir -p {shlex.quote(tmp_root)} && '
f'tar -xzf {shlex.quote(upload_path)} -C {shlex.quote(tmp_root)} && '
f'printf %s {shlex.quote(digest)} > {shlex.quote(tmp_root)}/.bundle_digest && '
f'rm -rf {shlex.quote(platform_root)} && '
f'mv {shlex.quote(tmp_root)} {shlex.quote(platform_root)}'
),
timeout_seconds=timeout_seconds,
max_output_chars=12000,
)
if result.exit_code != 0:
raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to extract remote runtime bundle.'
)
self.platform_root = platform_root
self.skills_root = f'{platform_root}/skills'
self.bundle_digest = digest
self.synced_at = time.time()
self.link_platform_resources()
return {
'bundle_digest': digest,
'platform_root': self.platform_root,
'skills_root': self.skills_root,
'uploaded_bytes': len(bundle_bytes),
}
def link_platform_resources(self) -> None:
if not self.platform_root:
return
workspace = shlex.quote(self.binding.workspace_cwd)
platform = shlex.quote(self.platform_root)
command = (
f'ln -sfn {platform}/skills {workspace}/skills && '
f'ln -sfn {platform}/src {workspace}/src && '
f'ln -sfn {platform}/标签定义 {workspace}/标签定义 && '
f'ln -sfn {platform}/pyproject.toml {workspace}/pyproject.toml'
)
result = self.run_command(
command,
timeout_seconds=20.0,
max_output_chars=4000,
)
if result.exit_code != 0:
raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to link runtime resources into workspace.'
)
def put_file_bytes(self, api_path: str, data: bytes) -> None:
encoded = base64.b64encode(data).decode('ascii')
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
response = self._session.put(
url,
headers={'X-XSRFToken': self._xsrf_token},
json={
'type': 'file',
'format': 'base64',
'content': encoded,
},
timeout=60,
)
if response.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to upload file to Jupyter contents API: HTTP {response.status_code} {response.text[:500]}'
)
def run_command(
self,
command: str,
*,
timeout_seconds: float,
max_output_chars: int,
cancel_event: Any | None = None,
) -> RemoteCommandResult:
if websocket is None:
raise JupyterRuntimeError(
'Missing dependency: websocket-client. Please install project dependencies.'
)
if not command.strip():
raise JupyterRuntimeError('Remote command is empty.')
marker = f'__ZK_AGENT_EXIT_{uuid.uuid4().hex}__'
workspace = shlex.quote(self.binding.workspace_cwd)
env_prefix = self._remote_env_prefix()
inner_command = f'{env_prefix}{command}'
wrapped = (
f'mkdir -p {workspace} && '
f'cd {workspace} && '
f'bash -lc {shlex.quote(inner_command)}; '
f'printf "\\n{marker}:%s\\n" "$?"'
)
with self._lock:
name = self._ensure_terminal()
ws_url = self._terminal_websocket_url(name)
ws = websocket.create_connection(
ws_url,
timeout=min(timeout_seconds, 30.0),
cookie=self._cookie_header(),
origin=self.binding.base_url,
sslopt={'cert_reqs': ssl.CERT_NONE},
header=[
f'X-XSRFToken: {self._xsrf_token}',
f'Referer: {self.binding.base_url}/lab',
],
)
try:
self._drain_initial_messages(ws, max_wait_seconds=1.0)
ws.send(json.dumps(['stdin', 'stty -echo\n']))
self._drain_initial_messages(ws, max_wait_seconds=0.3)
ws.send(json.dumps(['stdin', f'{wrapped}\n']))
return self._collect_until_marker(
ws,
marker=marker,
timeout_seconds=timeout_seconds,
max_output_chars=max_output_chars,
cancel_event=cancel_event,
)
finally:
try:
ws.close()
except Exception:
pass
def run_python(
self,
*,
code: str | None,
script_path: str | None,
args: list[str],
stdin: str | None,
timeout_seconds: float,
max_output_chars: int,
cancel_event: Any | None = None,
) -> RemoteCommandResult:
if bool(code) == bool(script_path):
raise JupyterRuntimeError('code and script_path must specify exactly one value')
temp_dir = f'{self.binding.workspace_cwd}/scratchpad/.python_exec'
command_parts = [f'mkdir -p {shlex.quote(temp_dir)}']
if code is not None:
script_path = f'{temp_dir}/snippet_{uuid.uuid4().hex}.py'
command_parts.append(
self._remote_write_text_command(script_path, code)
)
assert script_path is not None
quoted_args = ' '.join(shlex.quote(arg) for arg in args)
python_command = f'{shlex.quote(self.python_interpreter)} {shlex.quote(script_path)}'
if quoted_args:
python_command = f'{python_command} {quoted_args}'
if stdin is not None:
stdin_path = f'{temp_dir}/stdin_{uuid.uuid4().hex}.txt'
command_parts.append(
self._remote_write_text_command(stdin_path, stdin)
)
python_command = f'{python_command} < {shlex.quote(stdin_path)}'
command_parts.append(python_command)
return self.run_command(
' && '.join(command_parts),
timeout_seconds=timeout_seconds,
max_output_chars=max_output_chars,
cancel_event=cancel_event,
)
def list_dir(self, path: str, *, max_entries: int, max_output_chars: int) -> str:
target = self.resolve_workspace_path(path)
code = r'''
import json
from pathlib import Path
target = Path(PATH)
if not target.exists():
raise SystemExit(f"Path not found: {target}")
if not target.is_dir():
raise SystemExit(f"Path is not a directory: {target}")
entries = []
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))[:MAX_ENTRIES]:
entries.append(("dir" if item.is_dir() else "file") + "\t" + str(item))
print("\n".join(entries) if entries else "(empty directory)")
'''
script = (
code.replace('PATH', python_literal(target))
.replace('MAX_ENTRIES', str(max_entries))
)
result = self.run_python(
code=script,
script_path=None,
args=[],
stdin=None,
timeout_seconds=20.0,
max_output_chars=max_output_chars,
)
if result.exit_code != 0:
raise JupyterRuntimeError(result.stdout.strip() or 'remote list_dir failed')
return result.stdout.strip()
def list_files(
self,
path: str,
*,
kind: str,
max_entries: int = 200,
max_output_chars: int = 20000,
) -> list[dict[str, Any]]:
target = self.resolve_workspace_path(path)
code = r'''
import json
from datetime import datetime, timezone
from pathlib import Path
target = Path(PATH)
entries = []
if target.exists() and target.is_dir():
for item in sorted(target.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)[:MAX_ENTRIES]:
if not item.is_file():
continue
stat = item.stat()
entries.append({
"name": item.name,
"path": str(item),
"kind": KIND,
"size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat().replace("+00:00", "Z"),
})
print(json.dumps(entries, ensure_ascii=False))
'''
script = (
code.replace('PATH', python_literal(target))
.replace('MAX_ENTRIES', str(max_entries))
.replace('KIND', python_literal(kind))
)
result = self.run_python(
code=script,
script_path=None,
args=[],
stdin=None,
timeout_seconds=20.0,
max_output_chars=max_output_chars,
)
if result.exit_code != 0:
raise JupyterRuntimeError(result.stdout.strip() or 'remote list_files failed')
try:
payload = json.loads(result.stdout.strip() or '[]')
except json.JSONDecodeError as exc:
raise JupyterRuntimeError('remote list_files returned invalid JSON') from exc
if not isinstance(payload, list):
raise JupyterRuntimeError('remote list_files returned invalid payload')
return [item for item in payload if isinstance(item, dict)]
def read_file_bytes(self, path: str) -> tuple[bytes, str]:
target = self.resolve_workspace_path(path)
api_path = api_path_for_absolute_path(target)
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
response = self._session.get(
url,
params={'content': 1},
headers={'X-XSRFToken': self._xsrf_token},
timeout=60,
)
if response.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to read remote file: HTTP {response.status_code} {response.text[:500]}'
)
payload = response.json()
if payload.get('type') != 'file':
raise JupyterRuntimeError('Remote path is not a file.')
content = payload.get('content')
file_format = payload.get('format')
if not isinstance(content, str):
raise JupyterRuntimeError('Remote file response did not contain content.')
if file_format == 'base64':
data = base64.b64decode(content)
else:
data = content.encode('utf-8')
name = str(payload.get('name') or PurePosixPath(target).name or 'remote-file')
return data, name
def file_info(self, path: str) -> dict[str, Any]:
target = self.resolve_workspace_path(path)
api_path = api_path_for_absolute_path(target)
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
response = self._session.get(
url,
params={'content': 0},
headers={'X-XSRFToken': self._xsrf_token},
timeout=30,
)
if response.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to inspect remote file: HTTP {response.status_code} {response.text[:500]}'
)
payload = response.json()
if payload.get('type') != 'file':
raise JupyterRuntimeError('Remote path is not a file.')
name = str(payload.get('name') or PurePosixPath(target).name or 'remote-file')
size = payload.get('size')
return {
'name': name,
'path': target,
'size': int(size) if isinstance(size, int) and size >= 0 else None,
}
def open_file_stream(self, path: str) -> tuple[Any, str]:
"""打开远端文件流,由调用方负责关闭 response。"""
target = self.resolve_workspace_path(path)
api_path = api_path_for_absolute_path(target)
url = f'{self.binding.base_url}/files/{quote(api_path, safe="/")}'
response = self._session.get(
url,
headers={'X-XSRFToken': self._xsrf_token},
stream=True,
timeout=(10, 300),
)
if response.status_code >= 400:
response.close()
raise JupyterRuntimeError(
f'Unable to stream remote file: HTTP {response.status_code} {response.text[:500]}'
)
return response, str(PurePosixPath(target).name or 'remote-file')
def read_text(
self,
path: str,
*,
start_line: int | None,
end_line: int | None,
max_output_chars: int,
) -> str:
target = self.resolve_workspace_path(path)
code = r'''
from pathlib import Path
target = Path(PATH)
if not target.is_file():
raise SystemExit(f"Path is not a file: {target}")
text = target.read_text(encoding="utf-8", errors="replace")
start_line = START_LINE
end_line = END_LINE
if start_line is None and end_line is None:
print(text, end="")
else:
lines = text.splitlines()
start_idx = max((start_line or 1) - 1, 0)
end_idx = end_line or len(lines)
print("\n".join(f"{start_idx + idx + 1}: {line}" for idx, line in enumerate(lines[start_idx:end_idx])), end="")
'''
script = (
code.replace('PATH', python_literal(target))
.replace('START_LINE', python_literal(start_line))
.replace('END_LINE', python_literal(end_line))
)
result = self.run_python(
code=script,
script_path=None,
args=[],
stdin=None,
timeout_seconds=20.0,
max_output_chars=max_output_chars,
)
if result.exit_code != 0:
raise JupyterRuntimeError(result.stdout.strip() or 'remote read_file failed')
return result.stdout[:max_output_chars]
def write_text(
self,
path: str,
content: str,
*,
append: bool,
newline_at_end: bool,
max_output_chars: int,
) -> str:
target = self.resolve_workspace_path(path)
api_path = api_path_for_absolute_path(target)
if not api_path:
raise JupyterRuntimeError(f'Invalid remote path: {target}')
parent_api = '/'.join(api_path.split('/')[:-1])
if parent_api:
self._ensure_remote_dir(parent_api)
final_content = content
if append:
final_content = self._read_remote_text_for_append(api_path) + content
if newline_at_end and final_content and not final_content.endswith('\n'):
final_content += '\n'
encoded = base64.b64encode(final_content.encode('utf-8')).decode('ascii')
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
response = self._session.put(
url,
json={
'type': 'file',
'format': 'base64',
'content': encoded,
},
headers={
'X-XSRFToken': self._xsrf_token,
'Content-Type': 'application/json',
},
timeout=60,
)
if response.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to write remote file: HTTP {response.status_code} {response.text[:500]}'
)
verb = 'appended' if append else 'wrote'
return f'{verb} {target} ({len(final_content)} chars)'
def _ensure_remote_dir(self, api_path: str) -> None:
parts = [segment for segment in api_path.split('/') if segment]
cumulative = ''
for segment in parts:
cumulative = f'{cumulative}/{segment}' if cumulative else segment
url = f'{self.binding.base_url}/api/contents/{quote(cumulative, safe="/")}'
resp = self._session.get(
url,
params={'content': 0},
headers={'X-XSRFToken': self._xsrf_token},
timeout=30,
)
if resp.status_code == 200:
kind = resp.json().get('type')
if kind == 'directory':
continue
raise JupyterRuntimeError(
f'Cannot create directory at {cumulative}: path exists as {kind}'
)
if resp.status_code != 404:
raise JupyterRuntimeError(
f'Unable to inspect remote path {cumulative}: HTTP {resp.status_code} {resp.text[:300]}'
)
create = self._session.put(
url,
json={'type': 'directory'},
headers={
'X-XSRFToken': self._xsrf_token,
'Content-Type': 'application/json',
},
timeout=30,
)
if create.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to create remote directory {cumulative}: HTTP {create.status_code} {create.text[:300]}'
)
def _read_remote_text_for_append(self, api_path: str) -> str:
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
resp = self._session.get(
url,
params={'content': 1},
headers={'X-XSRFToken': self._xsrf_token},
timeout=60,
)
if resp.status_code == 404:
return ''
if resp.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to read existing remote file for append: HTTP {resp.status_code} {resp.text[:500]}'
)
payload = resp.json()
if payload.get('type') != 'file':
raise JupyterRuntimeError(
f'Cannot append: remote path {api_path} is not a file (type={payload.get("type")})'
)
existing = payload.get('content', '')
if not isinstance(existing, str):
return ''
if payload.get('format') == 'base64':
return base64.b64decode(existing).decode('utf-8', errors='replace')
return existing
def resolve_workspace_path(self, raw_path: str) -> str:
value = raw_path.strip() or '.'
# 系统提示里给的"当前会话目录"通常是本地后端的绝对/相对路径,例如
# `.port_sessions/accounts/<acc>/sessions/<sid>/input` 或
# `/home/mi/.../.port_sessions/accounts/<acc>/sessions/<sid>/output/foo.csv`。
# 远端 Jupyter 没这套目录结构。识别这种 pattern 后,把 input/output/
# scratchpad bucket 映射到远端 workspace 的同名目录。
bucket_match = self._match_local_session_bucket(value)
if bucket_match is not None:
bucket, rest = bucket_match
tail = f'/{rest}' if rest else ''
return normalize_posix_path(
f'{self.binding.workspace_cwd}/{bucket}{tail}'
)
if value.startswith('/'):
return normalize_posix_path(value)
path = PurePosixPath(value)
if self.platform_root and path.parts:
platform_heads = {'skills', 'src', '标签定义'}
if path.parts[0] in platform_heads or value == 'pyproject.toml':
return normalize_posix_path(f'{self.platform_root}/{value}')
if path.parts and path.parts[0] in {'output', 'outputs'}:
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
return normalize_posix_path(f'{self.binding.workspace_cwd}/output/{tail}')
if path.parts and path.parts[0] in {'scratchpad', 'scratch'}:
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
return normalize_posix_path(f'{self.binding.workspace_cwd}/scratchpad/{tail}')
if path.parts and path.parts[0] in {'input', 'inputs'}:
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
return normalize_posix_path(f'{self.binding.workspace_cwd}/input/{tail}')
return normalize_posix_path(f'{self.binding.workspace_cwd}/{value}')
_LOCAL_SESSION_BUCKET_RE = re.compile(
r'(?:^|/)\.port_sessions/accounts/[^/]+/sessions/[^/]+/'
r'(?P<bucket>input|inputs|output|outputs|scratchpad|scratch)'
r'(?:/(?P<rest>.*))?$'
)
@classmethod
def _match_local_session_bucket(cls, raw: str) -> tuple[str, str] | None:
m = cls._LOCAL_SESSION_BUCKET_RE.search(raw)
if not m:
return None
bucket = m.group('bucket')
normalized = {
'input': 'input',
'inputs': 'input',
'output': 'output',
'outputs': 'output',
'scratchpad': 'scratchpad',
'scratch': 'scratchpad',
}[bucket]
rest = m.group('rest') or ''
return normalized, rest
def _remote_env_prefix(self) -> str:
exports = {
'ZK_AGENT_WORKSPACE': self.binding.workspace_cwd,
'ZK_AGENT_OUTPUT': f'{self.binding.workspace_cwd}/output',
'ZK_AGENT_SCRATCHPAD': f'{self.binding.workspace_cwd}/scratchpad',
'ZK_AGENT_PYTHON_ENV': self.python_env_path,
'AUTORESEARCH_CHAT_ROOT': self.chat_workspace_root,
}
if self.platform_root:
exports['ZK_AGENT_PLATFORM_ROOT'] = self.platform_root
exports['ZK_AGENT_SKILLS_ROOT'] = self.skills_root
lines = [f'export {key}={shlex.quote(value)}' for key, value in exports.items()]
lines.append(f'export PATH={shlex.quote(self.python_env_path + "/bin")}:$PATH')
# CloudML 的 Jupyter Python 可能来自 Nix,pip 安装的科学计算轮子会依赖系统库。
# 只在库文件存在时预加载,避免 pandas/numpy/pyarrow 因 libz/libstdc++ 找不到而失败。
lines.extend(
[
'if [ -f /lib/x86_64-linux-gnu/libz.so.1 ] && '
'[ -f /lib/x86_64-linux-gnu/libstdc++.so.6 ]; then '
'export LD_PRELOAD=/lib/x86_64-linux-gnu/libz.so.1:'
'/lib/x86_64-linux-gnu/libstdc++.so.6${LD_PRELOAD:+:$LD_PRELOAD}; '
'fi'
]
)
if self.platform_root:
lines.append(f'export PYTHONPATH={shlex.quote(self.platform_root)}:${{PYTHONPATH:-}}')
return '; '.join(lines) + '; '
def _ensure_terminal(self) -> str:
if self._terminal_name:
return self._terminal_name
response = self._session.post(
f'{self.binding.base_url}/api/terminals',
headers={'X-XSRFToken': self._xsrf_token},
timeout=20,
)
if response.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to create Jupyter terminal: HTTP {response.status_code} {response.text[:500]}'
)
payload = response.json()
name = str(payload.get('name') or '').strip()
if not name:
raise JupyterRuntimeError('Jupyter terminal response did not contain a terminal name.')
self._terminal_name = name
return name
def _terminal_websocket_url(self, name: str) -> str:
parsed = urlparse(self.binding.base_url)
scheme = 'wss' if parsed.scheme == 'https' else 'ws'
base_path = parsed.path.rstrip('/')
path = f'{base_path}/terminals/websocket/{quote(name)}'
return urlunparse((scheme, parsed.netloc, path, '', '', ''))
def _cookie_header(self) -> str:
return '; '.join(
f'{cookie.name}={cookie.value}'
for cookie in self._session.cookies
)
def _drain_initial_messages(self, ws: Any, *, max_wait_seconds: float) -> None:
deadline = time.monotonic() + max_wait_seconds
old_timeout = ws.gettimeout()
try:
while time.monotonic() < deadline:
ws.settimeout(max(0.05, deadline - time.monotonic()))
try:
ws.recv()
except Exception:
break
finally:
ws.settimeout(old_timeout)
def _collect_until_marker(
self,
ws: Any,
*,
marker: str,
timeout_seconds: float,
max_output_chars: int,
cancel_event: Any | None,
) -> RemoteCommandResult:
deadline = time.monotonic() + timeout_seconds
transcript: list[str] = []
exit_code: int | None = None
marker_pattern = re.compile(rf'{re.escape(marker)}:(\d+)')
while time.monotonic() < deadline:
if cancel_event is not None and cancel_event.is_set():
try:
ws.send(json.dumps(['stdin', '\x03']))
except Exception:
pass
raise JupyterRuntimeError('Run cancelled by user')
ws.settimeout(max(0.1, min(1.0, deadline - time.monotonic())))
try:
raw = ws.recv()
except Exception:
continue
for chunk in parse_terminal_payload(raw):
match = marker_pattern.search(chunk)
if match:
exit_code = int(match.group(1))
chunk = marker_pattern.sub('', chunk)
if chunk:
transcript.append(chunk)
break
transcript.append(chunk)
if exit_code is not None:
break
if exit_code is None:
try:
ws.send(json.dumps(['stdin', '\x03']))
except Exception:
pass
return RemoteCommandResult(
exit_code=124,
stdout=truncate_text(''.join(transcript), max_output_chars),
timed_out=True,
)
return RemoteCommandResult(
exit_code=exit_code,
stdout=truncate_text(clean_terminal_output(''.join(transcript)), max_output_chars),
)
def _remote_write_text_command(self, path: str, content: str) -> str:
encoded = base64.b64encode(content.encode('utf-8')).decode('ascii')
script = '; '.join(
[
'import base64',
'from pathlib import Path',
f'path = Path({json.dumps(path)})',
'path.parent.mkdir(parents=True, exist_ok=True)',
f'path.write_bytes(base64.b64decode({json.dumps(encoded)}))',
]
)
return f'python3 -c {shlex.quote(script)}'
class JupyterRuntimeManager:
def __init__(self) -> None:
self._lock = threading.RLock()
self._sessions: dict[tuple[str, str], JupyterRuntimeSession] = {}
def bind_session(
self,
*,
account_id: str,
session_id: str,
base_url: str,
password: str,
workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT,
project_root: Path | None = None,
) -> JupyterRuntimeSession:
runtime = JupyterRuntimeSession.connect(
account_id=account_id,
session_id=session_id,
base_url=base_url,
password=password,
workspace_root=workspace_root,
project_root=project_root,
)
with self._lock:
self._sessions[(account_id, session_id)] = runtime
return runtime
def get(self, account_id: str, session_id: str) -> JupyterRuntimeSession | None:
with self._lock:
return self._sessions.get((account_id, session_id))
def restore_session(
self,
*,
account_id: str,
session_id: str,
payload: dict[str, Any],
) -> JupyterRuntimeSession:
runtime = JupyterRuntimeSession.from_persisted(payload)
if (
runtime.binding.account_id != account_id
or runtime.binding.session_id != session_id
):
raise JupyterRuntimeError('Persisted Jupyter binding does not match session.')
with self._lock:
self._sessions[(account_id, session_id)] = runtime
return runtime
def session_payload(self, account_id: str, session_id: str) -> dict[str, Any]:
runtime = self.get(account_id, session_id)
if runtime is None:
return {
'connected': False,
'account_id': account_id,
'session_id': session_id,
}
return runtime.to_dict()
def sync_account(self, account_id: str, project_root: Path) -> list[dict[str, Any]]:
with self._lock:
runtimes = [
runtime
for (runtime_account, _), runtime in self._sessions.items()
if runtime_account == account_id
]
results: list[dict[str, Any]] = []
for runtime in runtimes:
payload = runtime.sync_runtime_bundle(project_root)
results.append(
{
'session_id': runtime.binding.session_id,
**payload,
}
)
return results
def normalize_jupyter_base_url(value: str) -> str:
raw = value.strip()
if not raw:
raise JupyterRuntimeError('Jupyter URL is empty.')
parsed = urlparse(raw)
if not parsed.scheme:
parsed = urlparse(f'https://{raw}')
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
raise JupyterRuntimeError(f'Invalid Jupyter URL: {value}')
path = parsed.path.rstrip('/')
for marker in ('/lab', '/tree', '/notebooks', '/login'):
index = path.find(marker)
if index >= 0:
path = path[:index]
break
return urlunparse((parsed.scheme, parsed.netloc, path.rstrip('/'), '', '', '')).rstrip('/')
def normalize_posix_path(value: str) -> str:
path = PurePosixPath(value)
if not str(path).startswith('/'):
path = PurePosixPath('/') / path
normalized = str(path)
normalized = re.sub(r'/+', '/', normalized)
return normalized.rstrip('/') or '/'
def sanitize_remote_path_part(value: str) -> str:
return re.sub(r'[^A-Za-z0-9_.-]+', '_', value).strip('._') or uuid.uuid4().hex
def api_path_for_absolute_path(path: str) -> str:
normalized = normalize_posix_path(path)
if normalized == '/root':
return ''
if normalized.startswith('/root/'):
return normalized[len('/root/') :]
return normalized.lstrip('/')
def parse_terminal_payload(raw: str) -> list[str]:
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return [raw]
if isinstance(payload, list) and len(payload) >= 2:
kind, content = payload[0], payload[1]
if kind in {'stdout', 'stderr'} and isinstance(content, str):
return [content]
return []
def clean_terminal_output(text: str) -> str:
# 去掉少量 ANSI 控制符,避免输出里带终端颜色和光标控制。
cleaned = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
lines = [
line.rstrip('\r')
for line in cleaned.splitlines()
if line.strip() not in {'#', '>'} and not re.fullmatch(r'(>\s*)+', line.strip())
]
return '\n'.join(lines).strip('\r\n')
def truncate_text(text: str, limit: int) -> str:
if limit <= 0 or len(text) <= limit:
return text
omitted = len(text) - limit
return f'{text[:limit]}\n... [truncated, {omitted} chars omitted]'
def python_literal(value: object) -> str:
return repr(value)
def build_runtime_bundle(project_root: Path) -> tuple[bytes, str]:
root = project_root.resolve()
digest = calculate_runtime_bundle_digest(root)
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode='w:gz') as archive:
for name in ('src', 'skills', '标签定义', 'pyproject.toml'):
source = root / name
if not source.exists():
continue
archive.add(source, arcname=name, filter=_runtime_bundle_filter)
data = buffer.getvalue()
return data, digest
def calculate_runtime_bundle_digest(project_root: Path) -> str:
digest = hashlib.sha256()
for name in ('src', 'skills', '标签定义', 'pyproject.toml'):
source = project_root / name
if not source.exists():
continue
if source.is_file():
_update_digest_for_file(digest, source, Path(name))
continue
for path in sorted(source.rglob('*')):
rel = Path(name) / path.relative_to(source)
if _should_skip_bundle_path(rel) or not path.is_file():
continue
_update_digest_for_file(digest, path, rel)
return digest.hexdigest()
def _update_digest_for_file(digest: 'hashlib._Hash', path: Path, rel: Path) -> None:
digest.update(rel.as_posix().encode('utf-8'))
digest.update(b'\0')
digest.update(path.read_bytes())
digest.update(b'\0')
def _should_skip_bundle_path(path: Path | PurePosixPath) -> bool:
parts = path.parts
skipped_dirs = {
'.git',
'.venv',
'.port_sessions',
'.next',
'node_modules',
'__pycache__',
}
if any(part in skipped_dirs for part in parts):
return True
return str(path).endswith(('.pyc', '.pyo', '.DS_Store'))
def _runtime_bundle_filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
if _should_skip_bundle_path(PurePosixPath(info.name)):
return None
return info
def _login_jupyter(
http_session: Any,
base_url: str,
password: str,
*,
timeout_seconds: float,
) -> str:
login_url = f'{base_url}/login?next=%2Flab'
response = http_session.get(login_url, timeout=timeout_seconds)
if response.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to open Jupyter login page: HTTP {response.status_code}'
)
xsrf_token = _extract_xsrf_token(response.text) or http_session.cookies.get('_xsrf')
if not xsrf_token:
raise JupyterRuntimeError('Unable to find Jupyter _xsrf token on login page.')
post = http_session.post(
f'{base_url}/login',
data={'_xsrf': xsrf_token, 'password': password},
headers={'Referer': login_url},
timeout=timeout_seconds,
allow_redirects=True,
)
if post.status_code >= 400:
raise JupyterRuntimeError(
f'Jupyter login failed: HTTP {post.status_code} {post.text[:500]}'
)
probe = http_session.get(f'{base_url}/api/contents', timeout=timeout_seconds)
if probe.status_code >= 400:
raise JupyterRuntimeError(
f'Jupyter contents API probe failed after login: HTTP {probe.status_code} {probe.text[:500]}'
)
return str(xsrf_token)
def _extract_xsrf_token(html: str) -> str | None:
patterns = [
r'name=["\']_xsrf["\']\s+value=["\']([^"\']+)["\']',
r'value=["\']([^"\']+)["\']\s+name=["\']_xsrf["\']',
]
for pattern in patterns:
match = re.search(pattern, html)
if match:
return unescape(match.group(1))
return None