Add Jupyter workspace runtime

This commit is contained in:
wuyang6
2026-05-12 21:27:43 +08:00
parent 126f35ae46
commit 295d5d1ea1
7 changed files with 1207 additions and 5 deletions
+73 -2
View File
@@ -40,6 +40,11 @@ from src.agent_types import (
) )
from src.bundled_skills import ALWAYS_ENABLED_HIDDEN_SKILL_NAMES, get_bundled_skills 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.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.mcp_runtime import MCPRuntime, MCPServerProfile
from src.openai_compat import OpenAICompatClient, OpenAICompatError from src.openai_compat import OpenAICompatClient, OpenAICompatError
from src.session_store import ( from src.session_store import (
@@ -498,6 +503,7 @@ class AgentState:
self._run_locks: dict[str, Lock] = {} self._run_locks: dict[str, Lock] = {}
self._account_configs: dict[str, AgentInstanceConfig] = {} self._account_configs: dict[str, AgentInstanceConfig] = {}
self.run_manager = RunManager() self.run_manager = RunManager()
self.jupyter_runtime_manager = JupyterRuntimeManager()
self._default_config = AgentInstanceConfig( self._default_config = AgentInstanceConfig(
cwd=cwd.resolve(), cwd=cwd.resolve(),
model=model, model=model,
@@ -914,6 +920,14 @@ class ModelListRequest(BaseModel):
account_id: str | None = None 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): class SkillPreferenceUpdate(BaseModel):
skill: str | None = None skill: str | None = None
enabled: bool enabled: bool
@@ -940,6 +954,11 @@ class FeishuOnlineDocRequest(BaseModel):
account_id: str | None = None 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 # App factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -971,6 +990,46 @@ def create_app(state: AgentState) -> FastAPI:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
return state.snapshot(payload.account_id) 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') @app.get('/api/slash-commands')
async def list_slash_commands() -> list[dict[str, Any]]: async def list_slash_commands() -> list[dict[str, Any]]:
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) agent = state.agent_for(request.account_id, requested_session_id)
config = state.config_for(request.account_id) config = state.config_for(request.account_id)
session_directory = state.account_paths(request.account_id)['sessions'] 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: def emit_agent_event(event: dict[str, object]) -> None:
stage = _runtime_event_stage(event) stage = _runtime_event_stage(event)
@@ -1371,6 +1440,7 @@ def create_app(state: AgentState) -> FastAPI:
agent.tool_context, agent.tool_context,
cancel_event=run_record.cancel_event, cancel_event=run_record.cancel_event,
process_registry=run_record.process_registry, process_registry=run_record.process_registry,
jupyter_runtime=jupyter_runtime,
) )
started_at = time.perf_counter() started_at = time.perf_counter()
try: try:
@@ -1390,14 +1460,14 @@ def create_app(state: AgentState) -> FastAPI:
result = agent.resume( result = agent.resume(
prompt, prompt,
stored, stored,
runtime_context=request.runtime_context, runtime_context=runtime_context,
event_sink=emit_agent_event, event_sink=emit_agent_event,
) )
else: else:
result = agent.run( result = agent.run(
prompt, prompt,
session_id=requested_session_id, session_id=requested_session_id,
runtime_context=request.runtime_context, runtime_context=runtime_context,
event_sink=emit_agent_event, event_sink=emit_agent_event,
) )
except Exception as exc: except Exception as exc:
@@ -1426,6 +1496,7 @@ def create_app(state: AgentState) -> FastAPI:
agent.tool_context, agent.tool_context,
cancel_event=None, cancel_event=None,
process_registry=None, process_registry=None,
jupyter_runtime=None,
) )
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000)) elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
payload = _serialize_run_result(result) payload = _serialize_run_result(result)
@@ -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 },
);
}
}
+196 -1
View File
@@ -33,7 +33,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui"; import { Popover as PopoverPrimitive } from "radix-ui";
import type { ComponentProps, FC, KeyboardEvent, ReactNode } from "react"; 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 TextareaAutosize from "react-textarea-autosize";
import { useActivityPanel } from "@/components/assistant-ui/activity-panel"; import { useActivityPanel } from "@/components/assistant-ui/activity-panel";
import { import {
@@ -53,10 +60,13 @@ import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -279,9 +289,13 @@ async function cancelLatestRun(sessionId: string, runId?: string | null) {
const ChatTopActions: FC = () => { const ChatTopActions: FC = () => {
const { openFiles } = useActivityPanel(); const { openFiles } = useActivityPanel();
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const latestSessionId = useAuiState((s) => const latestSessionId = useAuiState((s) =>
findLatestMessageMetadataValue(s.thread.messages, "sessionId"), findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
); );
const normalizedSessionId = normalizeSessionId(
typeof latestSessionId === "string" ? latestSessionId : null,
);
return ( return (
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end"> <div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
@@ -316,13 +330,194 @@ const ChatTopActions: FC = () => {
<FileTextIcon className="size-4 text-muted-foreground" /> <FileTextIcon className="size-4 text-muted-foreground" />
</button> </button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
onClick={() => setWorkspaceOpen(true)}
>
<WrenchIcon className="size-4 text-muted-foreground" />
</button>
</PopoverPrimitive.Content> </PopoverPrimitive.Content>
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
</PopoverPrimitive.Root> </PopoverPrimitive.Root>
<WorkspaceSwitchDialog
open={workspaceOpen}
onOpenChange={setWorkspaceOpen}
sessionId={normalizedSessionId}
/>
</div> </div>
); );
}; };
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<JupyterWorkspaceStatus | null>(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<string, never>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="pointer-events-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
session Jupyter
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 text-sm">
{status?.connected ? (
<div className="rounded-md border bg-muted/40 p-3 text-muted-foreground">
<div className="font-medium text-foreground"></div>
<div className="mt-1 break-all">
{status.workspace_cwd}
</div>
{status.jupyter_tree_url ? (
<a
className="mt-1 block break-all text-primary underline-offset-4 hover:underline"
href={status.jupyter_tree_url}
target="_blank"
rel="noreferrer"
>
Jupyter
</a>
) : null}
</div>
) : null}
<label className="grid gap-1.5" htmlFor={`${fieldId}-url`}>
<span className="text-muted-foreground">Jupyter </span>
<Input
id={`${fieldId}-url`}
value={jupyterUrl}
onChange={(event) => setJupyterUrl(event.target.value)}
placeholder="https://...-jupyter.../lab"
/>
</label>
<label className="grid gap-1.5" htmlFor={`${fieldId}-password`}>
<span className="text-muted-foreground"></span>
<Input
id={`${fieldId}-password`}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Jupyter 登录密码"
/>
</label>
<label className="grid gap-1.5" htmlFor={`${fieldId}-root`}>
<span className="text-muted-foreground"></span>
<Input
id={`${fieldId}-root`}
value={workspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
/>
</label>
{message ? (
<div className="rounded-md bg-muted px-3 py-2 text-muted-foreground">
{message}
</div>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
<Button type="button" disabled={submitting} onClick={submit}>
{submitting ? "连接中..." : "切换"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const ThreadMessage: FC = () => { const ThreadMessage: FC = () => {
const role = useAuiState((s) => s.message.role); const role = useAuiState((s) => s.message.role);
const isEditing = useAuiState((s) => s.message.composer.isEditing); const isEditing = useAuiState((s) => s.message.composer.isEditing);
+2
View File
@@ -38,6 +38,8 @@ dependencies = [
"pydantic>=2.5", "pydantic>=2.5",
"openpyxl>=3.1", "openpyxl>=3.1",
"pyarrow>=15", "pyarrow>=15",
"requests>=2.31",
"websocket-client>=1.7",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+4
View File
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
from .config_runtime import ConfigRuntime from .config_runtime import ConfigRuntime
from .lsp_runtime import LSPRuntime from .lsp_runtime import LSPRuntime
from .mcp_runtime import MCPRuntime from .mcp_runtime import MCPRuntime
from .jupyter_runtime import JupyterRuntimeSession
from .plan_runtime import PlanRuntime from .plan_runtime import PlanRuntime
from .remote_runtime import RemoteRuntime from .remote_runtime import RemoteRuntime
from .remote_trigger_runtime import RemoteTriggerRuntime from .remote_trigger_runtime import RemoteTriggerRuntime
@@ -49,6 +50,7 @@ class ToolExecutionContext:
config_runtime: 'ConfigRuntime | None' = None config_runtime: 'ConfigRuntime | None' = None
lsp_runtime: 'LSPRuntime | None' = None lsp_runtime: 'LSPRuntime | None' = None
mcp_runtime: 'MCPRuntime | None' = None mcp_runtime: 'MCPRuntime | None' = None
jupyter_runtime: 'JupyterRuntimeSession | None' = None
remote_runtime: 'RemoteRuntime | None' = None remote_runtime: 'RemoteRuntime | None' = None
remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None
plan_runtime: 'PlanRuntime | None' = None plan_runtime: 'PlanRuntime | None' = None
@@ -138,6 +140,7 @@ def build_tool_context(
config_runtime: 'ConfigRuntime | None' = None, config_runtime: 'ConfigRuntime | None' = None,
lsp_runtime: 'LSPRuntime | None' = None, lsp_runtime: 'LSPRuntime | None' = None,
mcp_runtime: 'MCPRuntime | None' = None, mcp_runtime: 'MCPRuntime | None' = None,
jupyter_runtime: 'JupyterRuntimeSession | None' = None,
remote_runtime: 'RemoteRuntime | None' = None, remote_runtime: 'RemoteRuntime | None' = None,
remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None, remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None,
plan_runtime: 'PlanRuntime | None' = None, plan_runtime: 'PlanRuntime | None' = None,
@@ -169,6 +172,7 @@ def build_tool_context(
config_runtime=config_runtime, config_runtime=config_runtime,
lsp_runtime=lsp_runtime, lsp_runtime=lsp_runtime,
mcp_runtime=mcp_runtime, mcp_runtime=mcp_runtime,
jupyter_runtime=jupyter_runtime,
remote_runtime=remote_runtime, remote_runtime=remote_runtime,
remote_trigger_runtime=remote_trigger_runtime, remote_trigger_runtime=remote_trigger_runtime,
plan_runtime=plan_runtime, plan_runtime=plan_runtime,
+201 -2
View File
@@ -1683,6 +1683,8 @@ def _display_path(path: Path, context: ToolExecutionContext) -> str:
def _execution_cwd(context: ToolExecutionContext) -> Path: 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: if context.scratchpad_directory is not None:
context.scratchpad_directory.mkdir(parents=True, exist_ok=True) context.scratchpad_directory.mkdir(parents=True, exist_ok=True)
return context.scratchpad_directory return context.scratchpad_directory
@@ -1781,6 +1783,12 @@ def _list_dir(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
if not isinstance(raw_path, str): if not isinstance(raw_path, str):
raise ToolExecutionError('path must be a string') raise ToolExecutionError('path must be a string')
max_entries = _coerce_int(arguments, 'max_entries', 200) 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) target = _resolve_path(raw_path, context, allow_outside_root=True)
if not target.exists(): if not target.exists():
raise ToolExecutionError(f'Path not found: {raw_path}') 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: 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( target = _resolve_path(
_require_string(arguments, 'path'), _require_string(arguments, 'path'),
context, 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: def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context) _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) content = _coerce_write_file_content(arguments)
append = arguments.get('append', False) append = arguments.get('append', False)
if not isinstance(append, bool): 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) newline_at_end = arguments.get('newline_at_end', False)
if not isinstance(newline_at_end, bool): if not isinstance(newline_at_end, bool):
raise ToolExecutionError('newline_at_end must be a boolean') 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'): if newline_at_end and content and not content.endswith('\n'):
content += '\n' content += '\n'
previous_text: str | None = None 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: def _glob_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
pattern = _require_string(arguments, 'pattern') 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)) matches = sorted(context.root.glob(pattern))
if not matches: if not matches:
return '(no matches)' return '(no matches)'
@@ -2093,6 +2163,53 @@ def _grep_search(arguments: dict[str, Any], context: ToolExecutionContext) -> st
if not isinstance(literal, bool): if not isinstance(literal, bool):
raise ToolExecutionError('literal must be a boolean') raise ToolExecutionError('literal must be a boolean')
max_matches = _coerce_int(arguments, 'max_matches', 100) 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) root = _resolve_path(raw_path, context, allow_outside_root=True)
if not root.exists(): if not root.exists():
raise ToolExecutionError(f'Path not found: {raw_path}') 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, context.command_timeout_seconds,
) )
max_output_chars = _coerce_int(arguments, 'max_output_chars', context.max_output_chars) 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) interpreter = _resolve_python_interpreter(context)
if code: if code:
command = [interpreter, '-c', 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: def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
command = _require_string(arguments, 'command') command = _require_string(arguments, 'command')
_ensure_shell_allowed(command, context) _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( completed = subprocess.run(
command, command,
shell=True, shell=True,
@@ -3784,6 +3969,20 @@ def _stream_bash(
arguments: dict[str, Any], arguments: dict[str, Any],
context: ToolExecutionContext, context: ToolExecutionContext,
) -> Iterator[ToolStreamUpdate]: ) -> 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: try:
command = _require_string(arguments, 'command') command = _require_string(arguments, 'command')
_ensure_shell_allowed(command, context) _ensure_shell_allowed(command, context)
+648
View File
@@ -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