diff --git a/backend/api/server.py b/backend/api/server.py
index 3c49609..644f8dd 100644
--- a/backend/api/server.py
+++ b/backend/api/server.py
@@ -40,6 +40,11 @@ from src.agent_types import (
)
from src.bundled_skills import ALWAYS_ENABLED_HIDDEN_SKILL_NAMES, get_bundled_skills
from src.data_agent_inputs import DataAgentInputError, load_input_sources
+from src.jupyter_runtime import (
+ DEFAULT_JUPYTER_WORKSPACE_ROOT,
+ JupyterRuntimeError,
+ JupyterRuntimeManager,
+)
from src.mcp_runtime import MCPRuntime, MCPServerProfile
from src.openai_compat import OpenAICompatClient, OpenAICompatError
from src.session_store import (
@@ -498,6 +503,7 @@ class AgentState:
self._run_locks: dict[str, Lock] = {}
self._account_configs: dict[str, AgentInstanceConfig] = {}
self.run_manager = RunManager()
+ self.jupyter_runtime_manager = JupyterRuntimeManager()
self._default_config = AgentInstanceConfig(
cwd=cwd.resolve(),
model=model,
@@ -914,6 +920,14 @@ class ModelListRequest(BaseModel):
account_id: str | None = None
+class JupyterWorkspaceBindRequest(BaseModel):
+ session_id: str = Field(min_length=1)
+ base_url: str = Field(min_length=1)
+ password: str = Field(min_length=1)
+ workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT
+ account_id: str | None = None
+
+
class SkillPreferenceUpdate(BaseModel):
skill: str | None = None
enabled: bool
@@ -940,6 +954,11 @@ class FeishuOnlineDocRequest(BaseModel):
account_id: str | None = None
+def _append_runtime_context(current: str | None, addition: str) -> str:
+ parts = [part.strip() for part in (current, addition) if part and part.strip()]
+ return '\n\n'.join(parts)
+
+
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
@@ -971,6 +990,46 @@ def create_app(state: AgentState) -> FastAPI:
raise HTTPException(status_code=400, detail=str(exc))
return state.snapshot(payload.account_id)
+ @app.get('/api/jupyter/session')
+ async def get_jupyter_session(
+ session_id: str,
+ account_id: str | None = None,
+ ) -> dict[str, Any]:
+ safe_session_id = _safe_session_id(session_id)
+ if not safe_session_id:
+ raise HTTPException(status_code=400, detail='session_id is required')
+ account_key = state._account_key(account_id)
+ return state.jupyter_runtime_manager.session_payload(
+ account_key,
+ safe_session_id,
+ )
+
+ @app.post('/api/jupyter/bind-session')
+ async def bind_jupyter_session(
+ payload: JupyterWorkspaceBindRequest,
+ ) -> dict[str, Any]:
+ safe_session_id = _safe_session_id(payload.session_id)
+ if not safe_session_id:
+ raise HTTPException(status_code=400, detail='session_id is required')
+ account_key = state._account_key(payload.account_id)
+ try:
+ runtime = await asyncio.to_thread(
+ state.jupyter_runtime_manager.bind_session,
+ account_id=account_key,
+ session_id=safe_session_id,
+ base_url=payload.base_url,
+ password=payload.password,
+ workspace_root=payload.workspace_root,
+ )
+ except JupyterRuntimeError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(
+ status_code=502,
+ detail=f'Unable to connect Jupyter runtime: {exc}',
+ ) from exc
+ return runtime.to_dict()
+
@app.get('/api/slash-commands')
async def list_slash_commands() -> list[dict[str, Any]]:
commands: list[dict[str, Any]] = []
@@ -1312,6 +1371,16 @@ def create_app(state: AgentState) -> FastAPI:
agent = state.agent_for(request.account_id, requested_session_id)
config = state.config_for(request.account_id)
session_directory = state.account_paths(request.account_id)['sessions']
+ jupyter_runtime = state.jupyter_runtime_manager.get(
+ account_key,
+ requested_session_id,
+ )
+ runtime_context = request.runtime_context
+ if jupyter_runtime is not None:
+ runtime_context = _append_runtime_context(
+ runtime_context,
+ jupyter_runtime.render_context(),
+ )
def emit_agent_event(event: dict[str, object]) -> None:
stage = _runtime_event_stage(event)
@@ -1371,6 +1440,7 @@ def create_app(state: AgentState) -> FastAPI:
agent.tool_context,
cancel_event=run_record.cancel_event,
process_registry=run_record.process_registry,
+ jupyter_runtime=jupyter_runtime,
)
started_at = time.perf_counter()
try:
@@ -1390,14 +1460,14 @@ def create_app(state: AgentState) -> FastAPI:
result = agent.resume(
prompt,
stored,
- runtime_context=request.runtime_context,
+ runtime_context=runtime_context,
event_sink=emit_agent_event,
)
else:
result = agent.run(
prompt,
session_id=requested_session_id,
- runtime_context=request.runtime_context,
+ runtime_context=runtime_context,
event_sink=emit_agent_event,
)
except Exception as exc:
@@ -1426,6 +1496,7 @@ def create_app(state: AgentState) -> FastAPI:
agent.tool_context,
cancel_event=None,
process_registry=None,
+ jupyter_runtime=None,
)
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
payload = _serialize_run_result(result)
diff --git a/frontend/app/app/api/claw/jupyter/route.ts b/frontend/app/app/api/claw/jupyter/route.ts
new file mode 100644
index 0000000..aea48a4
--- /dev/null
+++ b/frontend/app/app/api/claw/jupyter/route.ts
@@ -0,0 +1,83 @@
+import { getCurrentAccount } from "@/lib/claw-auth";
+
+const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
+
+type JupyterBindPayload = {
+ session_id?: string;
+ base_url?: string;
+ password?: string;
+ workspace_root?: string;
+};
+
+export async function GET(req: Request) {
+ const account = await getCurrentAccount();
+ if (!account)
+ return Response.json({ error: "请先登录账号" }, { status: 401 });
+
+ const url = new URL(req.url);
+ const sessionId = url.searchParams.get("session_id");
+ if (!sessionId)
+ return Response.json({ error: "缺少 session_id" }, { status: 400 });
+
+ try {
+ const target = new URL(`${CLAW_API_URL}/api/jupyter/session`);
+ target.searchParams.set("account_id", account.id);
+ target.searchParams.set("session_id", sessionId);
+ const response = await fetch(target, { cache: "no-store" });
+ const body = await response.text();
+ return new Response(body, {
+ status: response.status,
+ headers: {
+ "content-type":
+ response.headers.get("content-type") ?? "application/json",
+ },
+ });
+ } catch (err) {
+ return Response.json(
+ {
+ error:
+ err instanceof Error
+ ? `Unable to reach Claw backend: ${err.message}`
+ : "Unable to reach Claw backend",
+ },
+ { status: 502 },
+ );
+ }
+}
+
+export async function POST(req: Request) {
+ const account = await getCurrentAccount();
+ if (!account)
+ return Response.json({ error: "请先登录账号" }, { status: 401 });
+
+ const payload = (await req.json()) as JupyterBindPayload;
+ try {
+ const response = await fetch(`${CLAW_API_URL}/api/jupyter/bind-session`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ...payload,
+ account_id: account.id,
+ }),
+ cache: "no-store",
+ });
+ const body = await response.text();
+ return new Response(body, {
+ status: response.status,
+ headers: {
+ "content-type":
+ response.headers.get("content-type") ?? "application/json",
+ },
+ });
+ } catch (err) {
+ return Response.json(
+ {
+ error:
+ err instanceof Error
+ ? `Unable to reach Claw backend: ${err.message}`
+ : "Unable to reach Claw backend",
+ },
+ { status: 502 },
+ );
+ }
+}
diff --git a/frontend/app/components/assistant-ui/thread.tsx b/frontend/app/components/assistant-ui/thread.tsx
index d7bb69a..0d77551 100644
--- a/frontend/app/components/assistant-ui/thread.tsx
+++ b/frontend/app/components/assistant-ui/thread.tsx
@@ -33,7 +33,14 @@ import {
} from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type { ComponentProps, FC, KeyboardEvent, ReactNode } from "react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import TextareaAutosize from "react-textarea-autosize";
import { useActivityPanel } from "@/components/assistant-ui/activity-panel";
import {
@@ -53,10 +60,13 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
+ DialogDescription,
+ DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
@@ -279,9 +289,13 @@ async function cancelLatestRun(sessionId: string, runId?: string | null) {
const ChatTopActions: FC = () => {
const { openFiles } = useActivityPanel();
+ const [workspaceOpen, setWorkspaceOpen] = useState(false);
const latestSessionId = useAuiState((s) =>
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
);
+ const normalizedSessionId = normalizeSessionId(
+ typeof latestSessionId === "string" ? latestSessionId : null,
+ );
return (
@@ -316,13 +330,194 @@ const ChatTopActions: FC = () => {
聊天中的文件
+
+
);
};
+type JupyterWorkspaceStatus = {
+ connected?: boolean;
+ session_id?: string;
+ workspace_cwd?: string;
+ jupyter_tree_url?: string;
+ detail?: string;
+ error?: string;
+};
+
+function WorkspaceSwitchDialog({
+ open,
+ onOpenChange,
+ sessionId,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ sessionId: string | null;
+}) {
+ const [jupyterUrl, setJupyterUrl] = useState("");
+ const [password, setPassword] = useState("");
+ const [workspaceRoot, setWorkspaceRoot] = useState(
+ "/root/zk_agent_workspaces",
+ );
+ const [status, setStatus] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [message, setMessage] = useState("");
+ const fieldId = useId();
+ const effectiveSessionId =
+ sessionId ??
+ (typeof window !== "undefined"
+ ? normalizeSessionId(readActiveSessionId())
+ : null);
+
+ useEffect(() => {
+ if (!open || !effectiveSessionId) return;
+ const sessionIdForRequest = effectiveSessionId;
+ let cancelled = false;
+ async function refreshStatus() {
+ const response = await fetch(
+ `/api/claw/jupyter?session_id=${encodeURIComponent(sessionIdForRequest)}`,
+ { cache: "no-store" },
+ );
+ const payload = (await response.json().catch(() => ({}))) as
+ | JupyterWorkspaceStatus
+ | Record;
+ if (!cancelled) setStatus(payload as JupyterWorkspaceStatus);
+ }
+ void refreshStatus().catch(() => undefined);
+ return () => {
+ cancelled = true;
+ };
+ }, [open, effectiveSessionId]);
+
+ async function submit() {
+ setMessage("");
+ if (!effectiveSessionId) {
+ setMessage("请先发送一条消息创建 session,再切换工作区。");
+ return;
+ }
+ if (!jupyterUrl.trim() || !password) {
+ setMessage("请填写 Jupyter 地址和密码。");
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const response = await fetch("/api/claw/jupyter", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ session_id: effectiveSessionId,
+ base_url: jupyterUrl.trim(),
+ password,
+ workspace_root: workspaceRoot.trim() || "/root/zk_agent_workspaces",
+ }),
+ });
+ const payload = (await response.json().catch(() => ({}))) as
+ | JupyterWorkspaceStatus
+ | { detail?: string; error?: string };
+ if (!response.ok) {
+ setMessage(payload.detail ?? payload.error ?? "切换工作区失败");
+ return;
+ }
+ setStatus(payload as JupyterWorkspaceStatus);
+ setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
+ setPassword("");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
const ThreadMessage: FC = () => {
const role = useAuiState((s) => s.message.role);
const isEditing = useAuiState((s) => s.message.composer.isEditing);
diff --git a/pyproject.toml b/pyproject.toml
index 00b7df7..896a0ac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,6 +38,8 @@ dependencies = [
"pydantic>=2.5",
"openpyxl>=3.1",
"pyarrow>=15",
+ "requests>=2.31",
+ "websocket-client>=1.7",
]
[project.optional-dependencies]
diff --git a/src/agent_tool_core.py b/src/agent_tool_core.py
index 8ab9ecd..0ec8e0f 100644
--- a/src/agent_tool_core.py
+++ b/src/agent_tool_core.py
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
from .config_runtime import ConfigRuntime
from .lsp_runtime import LSPRuntime
from .mcp_runtime import MCPRuntime
+ from .jupyter_runtime import JupyterRuntimeSession
from .plan_runtime import PlanRuntime
from .remote_runtime import RemoteRuntime
from .remote_trigger_runtime import RemoteTriggerRuntime
@@ -49,6 +50,7 @@ class ToolExecutionContext:
config_runtime: 'ConfigRuntime | None' = None
lsp_runtime: 'LSPRuntime | None' = None
mcp_runtime: 'MCPRuntime | None' = None
+ jupyter_runtime: 'JupyterRuntimeSession | None' = None
remote_runtime: 'RemoteRuntime | None' = None
remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None
plan_runtime: 'PlanRuntime | None' = None
@@ -138,6 +140,7 @@ def build_tool_context(
config_runtime: 'ConfigRuntime | None' = None,
lsp_runtime: 'LSPRuntime | None' = None,
mcp_runtime: 'MCPRuntime | None' = None,
+ jupyter_runtime: 'JupyterRuntimeSession | None' = None,
remote_runtime: 'RemoteRuntime | None' = None,
remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None,
plan_runtime: 'PlanRuntime | None' = None,
@@ -169,6 +172,7 @@ def build_tool_context(
config_runtime=config_runtime,
lsp_runtime=lsp_runtime,
mcp_runtime=mcp_runtime,
+ jupyter_runtime=jupyter_runtime,
remote_runtime=remote_runtime,
remote_trigger_runtime=remote_trigger_runtime,
plan_runtime=plan_runtime,
diff --git a/src/agent_tools.py b/src/agent_tools.py
index fa752ad..753be9d 100644
--- a/src/agent_tools.py
+++ b/src/agent_tools.py
@@ -1683,6 +1683,8 @@ def _display_path(path: Path, context: ToolExecutionContext) -> str:
def _execution_cwd(context: ToolExecutionContext) -> Path:
+ if context.jupyter_runtime is not None:
+ return Path(context.jupyter_runtime.binding.workspace_cwd)
if context.scratchpad_directory is not None:
context.scratchpad_directory.mkdir(parents=True, exist_ok=True)
return context.scratchpad_directory
@@ -1781,6 +1783,12 @@ def _list_dir(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
if not isinstance(raw_path, str):
raise ToolExecutionError('path must be a string')
max_entries = _coerce_int(arguments, 'max_entries', 200)
+ if context.jupyter_runtime is not None:
+ return context.jupyter_runtime.list_dir(
+ raw_path,
+ max_entries=max_entries,
+ max_output_chars=context.max_output_chars,
+ )
target = _resolve_path(raw_path, context, allow_outside_root=True)
if not target.exists():
raise ToolExecutionError(f'Path not found: {raw_path}')
@@ -1798,6 +1806,23 @@ def _list_dir(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
def _read_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
+ if context.jupyter_runtime is not None:
+ start_line = arguments.get('start_line')
+ end_line = arguments.get('end_line')
+ if start_line is not None and (
+ isinstance(start_line, bool) or not isinstance(start_line, int) or start_line < 1
+ ):
+ raise ToolExecutionError('start_line must be an integer >= 1')
+ if end_line is not None and (
+ isinstance(end_line, bool) or not isinstance(end_line, int) or end_line < 1
+ ):
+ raise ToolExecutionError('end_line must be an integer >= 1')
+ return context.jupyter_runtime.read_text(
+ _require_string(arguments, 'path'),
+ start_line=start_line,
+ end_line=end_line,
+ max_output_chars=context.max_output_chars,
+ )
target = _resolve_path(
_require_string(arguments, 'path'),
context,
@@ -1825,8 +1850,6 @@ def _read_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
- target = _resolve_path(_require_string(arguments, 'path'), context)
- _ensure_not_platform_code_write(target, context)
content = _coerce_write_file_content(arguments)
append = arguments.get('append', False)
if not isinstance(append, bool):
@@ -1834,6 +1857,25 @@ def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str
newline_at_end = arguments.get('newline_at_end', False)
if not isinstance(newline_at_end, bool):
raise ToolExecutionError('newline_at_end must be a boolean')
+ if context.jupyter_runtime is not None:
+ message = context.jupyter_runtime.write_text(
+ _require_string(arguments, 'path'),
+ content,
+ append=append,
+ newline_at_end=newline_at_end,
+ max_output_chars=context.max_output_chars,
+ )
+ return (
+ message,
+ {
+ 'action': 'remote_append_file' if append else 'remote_write_file',
+ 'path': _require_string(arguments, 'path'),
+ 'remote_workspace': context.jupyter_runtime.binding.workspace_cwd,
+ 'content_length': len(content),
+ },
+ )
+ target = _resolve_path(_require_string(arguments, 'path'), context)
+ _ensure_not_platform_code_write(target, context)
if newline_at_end and content and not content.endswith('\n'):
content += '\n'
previous_text: str | None = None
@@ -2068,6 +2110,34 @@ def _notebook_edit(arguments: dict[str, Any], context: ToolExecutionContext) ->
def _glob_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
pattern = _require_string(arguments, 'pattern')
+ if context.jupyter_runtime is not None:
+ code = r'''
+from pathlib import Path
+import glob
+pattern = PATTERN
+workspace = WORKSPACE
+if pattern.startswith("/"):
+ matches = sorted(glob.glob(pattern, recursive=True))
+else:
+ matches = sorted(glob.glob(str(Path(workspace) / pattern), recursive=True))
+print("\n".join(matches) if matches else "(no matches)")
+'''
+ script = (
+ code.replace('PATTERN', repr(pattern))
+ .replace('WORKSPACE', repr(context.jupyter_runtime.binding.workspace_cwd))
+ )
+ result = context.jupyter_runtime.run_python(
+ code=script,
+ script_path=None,
+ args=[],
+ stdin=None,
+ timeout_seconds=20.0,
+ max_output_chars=context.max_output_chars,
+ cancel_event=context.cancel_event,
+ )
+ if result.exit_code != 0:
+ raise ToolExecutionError(result.stdout.strip() or 'remote glob_search failed')
+ return result.stdout.strip() or '(no matches)'
matches = sorted(context.root.glob(pattern))
if not matches:
return '(no matches)'
@@ -2093,6 +2163,53 @@ def _grep_search(arguments: dict[str, Any], context: ToolExecutionContext) -> st
if not isinstance(literal, bool):
raise ToolExecutionError('literal must be a boolean')
max_matches = _coerce_int(arguments, 'max_matches', 100)
+ if context.jupyter_runtime is not None:
+ code = r'''
+from pathlib import Path
+import re
+pattern = PATTERN
+literal = LITERAL
+max_matches = MAX_MATCHES
+root = Path(ROOT)
+if not root.exists():
+ raise SystemExit(f"Path not found: {root}")
+regex = re.compile(re.escape(pattern) if literal else pattern)
+hits = []
+files = root.rglob("*") if root.is_dir() else [root]
+for file_path in files:
+ if not file_path.is_file():
+ continue
+ try:
+ text = file_path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ continue
+ for line_no, line in enumerate(text.splitlines(), start=1):
+ if regex.search(line):
+ hits.append(f"{file_path}:{line_no}: {line[:500]}")
+ if len(hits) >= max_matches:
+ print("\n".join(hits + [f"... truncated at {max_matches} matches ..."]))
+ raise SystemExit(0)
+print("\n".join(hits) if hits else "(no matches)")
+'''
+ root = context.jupyter_runtime.resolve_workspace_path(raw_path)
+ script = (
+ code.replace('PATTERN', repr(pattern))
+ .replace('LITERAL', repr(literal))
+ .replace('MAX_MATCHES', str(max_matches))
+ .replace('ROOT', repr(root))
+ )
+ result = context.jupyter_runtime.run_python(
+ code=script,
+ script_path=None,
+ args=[],
+ stdin=None,
+ timeout_seconds=30.0,
+ max_output_chars=context.max_output_chars,
+ cancel_event=context.cancel_event,
+ )
+ if result.exit_code != 0:
+ raise ToolExecutionError(result.stdout.strip() or 'remote grep_search failed')
+ return result.stdout.strip() or '(no matches)'
root = _resolve_path(raw_path, context, allow_outside_root=True)
if not root.exists():
raise ToolExecutionError(f'Path not found: {raw_path}')
@@ -2180,6 +2297,46 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
context.command_timeout_seconds,
)
max_output_chars = _coerce_int(arguments, 'max_output_chars', context.max_output_chars)
+ if context.jupyter_runtime is not None:
+ result = context.jupyter_runtime.run_python(
+ code=code,
+ script_path=(
+ context.jupyter_runtime.resolve_workspace_path(script_path)
+ if script_path
+ else None
+ ),
+ args=raw_args,
+ stdin=stdin,
+ timeout_seconds=timeout_seconds,
+ max_output_chars=max_output_chars,
+ cancel_event=context.cancel_event,
+ )
+ payload = [
+ f'exit_code={result.exit_code}',
+ 'interpreter=remote:python3',
+ f'remote_workspace={context.jupyter_runtime.binding.workspace_cwd}',
+ '[stdout]',
+ result.stdout.rstrip(),
+ '[stderr]',
+ result.stderr.rstrip(),
+ ]
+ if result.timed_out:
+ payload.insert(0, f'timed_out=true\ntimeout_seconds={timeout_seconds:g}')
+ return (
+ _truncate_output('\n'.join(payload).strip(), max_output_chars),
+ {
+ 'action': 'remote_python_exec',
+ 'mode': 'code' if code else 'script',
+ 'interpreter': 'remote:python3',
+ 'remote_workspace': context.jupyter_runtime.binding.workspace_cwd,
+ 'timed_out': result.timed_out,
+ 'timeout_seconds': timeout_seconds,
+ 'exit_code': result.exit_code,
+ 'stdout_preview': _snapshot_text(result.stdout),
+ 'stderr_preview': _snapshot_text(result.stderr),
+ 'output_preview': _snapshot_text('\n'.join(payload).strip()),
+ },
+ )
interpreter = _resolve_python_interpreter(context)
if code:
command = [interpreter, '-c', code]
@@ -2484,6 +2641,34 @@ def _resolve_python_interpreter(context: ToolExecutionContext) -> str:
def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
command = _require_string(arguments, 'command')
_ensure_shell_allowed(command, context)
+ if context.jupyter_runtime is not None:
+ result = context.jupyter_runtime.run_command(
+ command,
+ timeout_seconds=context.command_timeout_seconds,
+ max_output_chars=context.max_output_chars,
+ cancel_event=context.cancel_event,
+ )
+ payload = [
+ f'exit_code={result.exit_code}',
+ f'remote_workspace={context.jupyter_runtime.binding.workspace_cwd}',
+ '[stdout]',
+ result.stdout.rstrip(),
+ '[stderr]',
+ result.stderr.rstrip(),
+ ]
+ return (
+ _truncate_output('\n'.join(payload).strip(), context.max_output_chars),
+ {
+ 'action': 'remote_bash',
+ 'command': command,
+ 'exit_code': result.exit_code,
+ 'remote_workspace': context.jupyter_runtime.binding.workspace_cwd,
+ 'timed_out': result.timed_out,
+ 'stdout_preview': _snapshot_text(result.stdout),
+ 'stderr_preview': _snapshot_text(result.stderr),
+ 'output_preview': _snapshot_text('\n'.join(payload).strip()),
+ },
+ )
completed = subprocess.run(
command,
shell=True,
@@ -3784,6 +3969,20 @@ def _stream_bash(
arguments: dict[str, Any],
context: ToolExecutionContext,
) -> Iterator[ToolStreamUpdate]:
+ if context.jupyter_runtime is not None:
+ result = _run_bash(arguments, context)
+ content, metadata = result if isinstance(result, tuple) else (result, {})
+ tool_result = ToolExecutionResult(
+ name='bash',
+ ok=True,
+ content=content,
+ metadata=metadata,
+ )
+ if content:
+ yield from _stream_static_text_result(tool_result)
+ else:
+ yield ToolStreamUpdate(kind='result', result=tool_result)
+ return
try:
command = _require_string(arguments, 'command')
_ensure_shell_allowed(command, context)
diff --git a/src/jupyter_runtime.py b/src/jupyter_runtime.py
new file mode 100644
index 0000000..af9ceaf
--- /dev/null
+++ b/src/jupyter_runtime.py
@@ -0,0 +1,648 @@
+from __future__ import annotations
+
+import base64
+import json
+import re
+import shlex
+import ssl
+import threading
+import time
+import uuid
+from dataclasses import dataclass
+from html import unescape
+from pathlib import 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'
+
+
+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()
+
+ @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,
+ ) -> '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)
+ return runtime
+
+ def bootstrap_workspace(self, *, timeout_seconds: float = 20.0) -> None:
+ result = self.run_command(
+ (
+ 'mkdir -p '
+ f'{shlex.quote(self.binding.workspace_cwd)}/output '
+ f'{shlex.quote(self.binding.workspace_cwd)}/scratchpad'
+ ),
+ timeout_seconds=timeout_seconds,
+ max_output_chars=4000,
+ )
+ if result.exit_code != 0:
+ raise JupyterRuntimeError(
+ result.stdout.strip() or 'Unable to bootstrap Jupyter workspace.'
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ payload = self.binding.to_dict()
+ payload['connected'] = True
+ return payload
+
+ def render_context(self) -> str:
+ return '\n'.join(
+ [
+ '[远端 Jupyter 工作区]',
+ '当前 session 已切换到远端 Jupyter 运行时。',
+ f'- 默认工作目录:{self.binding.workspace_cwd}',
+ f'- 输出文件优先写入:{self.binding.workspace_cwd}/output',
+ '- 线上挂载目录(例如 /mnt/...)可以通过绝对路径读取和分析。',
+ '- 除非用户明确要求,不要修改平台代码目录;数据产物应留在当前 session 工作区。',
+ f'- Jupyter 工作区入口:{self.binding.jupyter_tree_url}',
+ ]
+ )
+
+ 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)
+ wrapped = (
+ f'mkdir -p {workspace} && '
+ f'cd {workspace} && '
+ f'bash -lc {shlex.quote(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'python3 {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 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)
+ if newline_at_end and content and not content.endswith('\n'):
+ content += '\n'
+ mode = 'a' if append else 'w'
+ code = r'''
+from pathlib import Path
+target = Path(PATH)
+target.parent.mkdir(parents=True, exist_ok=True)
+content = CONTENT
+with target.open(MODE, encoding="utf-8") as fh:
+ fh.write(content)
+print(f"{'appended' if MODE == 'a' else 'wrote'} {target} ({len(content)} chars)")
+'''
+ script = (
+ code.replace('PATH', python_literal(target))
+ .replace('CONTENT', python_literal(content))
+ .replace('MODE', python_literal(mode))
+ )
+ 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 write_file failed')
+ return result.stdout.strip()
+
+ def resolve_workspace_path(self, raw_path: str) -> str:
+ value = raw_path.strip() or '.'
+ if value.startswith('/'):
+ return normalize_posix_path(value)
+ path = PurePosixPath(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}')
+ return normalize_posix_path(f'{self.binding.workspace_cwd}/{value}')
+
+ 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,
+ ) -> JupyterRuntimeSession:
+ runtime = JupyterRuntimeSession.connect(
+ account_id=account_id,
+ session_id=session_id,
+ base_url=base_url,
+ password=password,
+ workspace_root=workspace_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 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 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 _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