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
+201 -2
View File
@@ -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)