sycommon-python-lib 0.2.4a7__py3-none-any.whl → 0.2.5a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sycommon/agent/sandbox/file_ops.py +24 -4
- sycommon/middleware/sandbox.py +277 -117
- sycommon/middleware/tool_result_truncation.py +92 -11
- sycommon/models/sandbox.py +1 -0
- {sycommon_python_lib-0.2.4a7.dist-info → sycommon_python_lib-0.2.5a0.dist-info}/METADATA +3 -3
- {sycommon_python_lib-0.2.4a7.dist-info → sycommon_python_lib-0.2.5a0.dist-info}/RECORD +9 -9
- {sycommon_python_lib-0.2.4a7.dist-info → sycommon_python_lib-0.2.5a0.dist-info}/WHEEL +0 -0
- {sycommon_python_lib-0.2.4a7.dist-info → sycommon_python_lib-0.2.5a0.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.2.4a7.dist-info → sycommon_python_lib-0.2.5a0.dist-info}/top_level.txt +0 -0
|
@@ -289,15 +289,26 @@ class FileOperationsMixin:
|
|
|
289
289
|
self: "HTTPSandboxBackend",
|
|
290
290
|
file_path: str,
|
|
291
291
|
content: str,
|
|
292
|
+
*,
|
|
293
|
+
overwrite: bool = True, # 默认允许覆盖,兼容 deepagents offload 场景
|
|
292
294
|
) -> WriteResult:
|
|
293
|
-
"""写入文件
|
|
295
|
+
"""写入文件
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
file_path: 文件路径。
|
|
299
|
+
content: 文件内容。
|
|
300
|
+
overwrite: 是否允许覆盖已有文件,默认 True(兼容 deepagents 0.6.3
|
|
301
|
+
SummarizationMiddleware offload 场景,如 /large_tool_results/ 和
|
|
302
|
+
/conversation_history/ 路径)。设为 False 时,文件已存在返回错误。
|
|
303
|
+
"""
|
|
294
304
|
try:
|
|
295
305
|
self._ensure_synced_sync()
|
|
296
306
|
SYLogger.info(f"[Sandbox] 写入文件: {file_path} ({len(content)} 字符)")
|
|
297
307
|
result = self._post_sync(f"{SANDBOX_API_PREFIX}/write", {
|
|
298
308
|
"file_path": file_path,
|
|
299
309
|
"content": base64.b64encode(content.encode()).decode(),
|
|
300
|
-
"user_id": self.user_id
|
|
310
|
+
"user_id": self.user_id,
|
|
311
|
+
"overwrite": overwrite,
|
|
301
312
|
})
|
|
302
313
|
write_result = WriteResult(
|
|
303
314
|
error=result.get("error"),
|
|
@@ -317,16 +328,25 @@ class FileOperationsMixin:
|
|
|
317
328
|
file_path: str,
|
|
318
329
|
content: str,
|
|
319
330
|
*,
|
|
331
|
+
overwrite: bool = True, # 默认允许覆盖,兼容 deepagents offload 场景
|
|
320
332
|
timeout: int = None,
|
|
321
333
|
) -> WriteResult:
|
|
322
|
-
"""异步写入文件
|
|
334
|
+
"""异步写入文件
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
file_path: 文件路径。
|
|
338
|
+
content: 文件内容。
|
|
339
|
+
overwrite: 是否允许覆盖已有文件,默认 True。
|
|
340
|
+
timeout: 请求超时时间。
|
|
341
|
+
"""
|
|
323
342
|
try:
|
|
324
343
|
await self._ensure_synced_async()
|
|
325
344
|
SYLogger.info(f"[Sandbox] 异步写入文件: {file_path} ({len(content)} 字符)")
|
|
326
345
|
result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/write", {
|
|
327
346
|
"file_path": file_path,
|
|
328
347
|
"content": base64.b64encode(content.encode()).decode(),
|
|
329
|
-
"user_id": self.user_id
|
|
348
|
+
"user_id": self.user_id,
|
|
349
|
+
"overwrite": overwrite,
|
|
330
350
|
}, timeout=timeout)
|
|
331
351
|
write_result = WriteResult(
|
|
332
352
|
error=result.get("error"),
|
sycommon/middleware/sandbox.py
CHANGED
|
@@ -133,7 +133,20 @@ WORKSPACE_BASE = os.environ.get("SANDBOX_WORKSPACE", _default_workspace)
|
|
|
133
133
|
# ============== 辅助函数 ==============
|
|
134
134
|
|
|
135
135
|
def _build_init_script(workspace: str) -> str:
|
|
136
|
-
"""构建 shell 初始化脚本(沙箱环境初始化 + 路径映射 + 资源限制)
|
|
136
|
+
"""构建 shell 初始化脚本(沙箱环境初始化 + 路径映射 + 资源限制)
|
|
137
|
+
|
|
138
|
+
Linux/macOS: 使用 bash
|
|
139
|
+
Windows: 使用 PowerShell 兼容模式
|
|
140
|
+
"""
|
|
141
|
+
if platform.system() == "Windows":
|
|
142
|
+
# Windows: 用 PowerShell 简化版(无 bash)
|
|
143
|
+
safe_workspace = workspace.replace("'", "''")
|
|
144
|
+
return f'''
|
|
145
|
+
$env:SANDBOX_ROOT = '{safe_workspace}'
|
|
146
|
+
$env:_SANDBOX_WORKSPACE = '{safe_workspace}'
|
|
147
|
+
Set-Location '{safe_workspace}'
|
|
148
|
+
'''
|
|
149
|
+
|
|
137
150
|
safe_workspace = shlex.quote(workspace)
|
|
138
151
|
return f'''
|
|
139
152
|
# 沙箱环境初始化
|
|
@@ -232,10 +245,38 @@ async def aensure_subdirs(workspace: str, subdirs: list[str] = None):
|
|
|
232
245
|
await asyncio.to_thread(_makedirs)
|
|
233
246
|
|
|
234
247
|
|
|
248
|
+
def _to_virtual_path(abs_path: str, workspace: str) -> str:
|
|
249
|
+
"""将本地绝对路径转换为 POSIX 虚拟路径(用于返回给客户端/LLM)。
|
|
250
|
+
|
|
251
|
+
始终返回 "/" 开头的 POSIX 格式路径,无论当前操作系统。
|
|
252
|
+
例: /Users/.../workspace/skills/foo.md → "/skills/foo.md"
|
|
253
|
+
"""
|
|
254
|
+
from pathlib import PurePosixPath, PurePath
|
|
255
|
+
rel = PurePath(abs_path).relative_to(PurePath(workspace))
|
|
256
|
+
return "/" + str(PurePosixPath(*rel.parts))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _is_path_within(child: str, parent: str) -> bool:
|
|
260
|
+
"""检查 child 路径是否在 parent 目录内,跨平台兼容。
|
|
261
|
+
|
|
262
|
+
使用 os.path.realpath 解析后比较,避免 os.sep 差异(Windows = \\,Linux = /)
|
|
263
|
+
以及 symlink 导致的逃逸。
|
|
264
|
+
"""
|
|
265
|
+
child_resolved = os.path.realpath(child)
|
|
266
|
+
parent_resolved = os.path.realpath(parent)
|
|
267
|
+
return child_resolved.startswith(parent_resolved + os.sep) or child_resolved == parent_resolved
|
|
268
|
+
|
|
269
|
+
|
|
235
270
|
def resolve_sandbox_path(path: str, workspace: str) -> str:
|
|
236
271
|
"""
|
|
237
272
|
将沙箱路径解析到用户工作目录,实现路径隔离
|
|
238
273
|
|
|
274
|
+
跨平台兼容:
|
|
275
|
+
- 输入路径始终为 POSIX 格式(/skills/foo.md),来自客户端/LLM
|
|
276
|
+
- 输出路径为当前 OS 的本地格式(Linux: /data/.../skills/foo.md, Windows: C:/.../skills/foo.md)
|
|
277
|
+
- 使用 pathlib.PurePosixPath 标准化输入,避免 os.path.normpath 在 Windows 上
|
|
278
|
+
不正确处理 POSIX 前导 / 的问题
|
|
279
|
+
|
|
239
280
|
所有路径(包括绝对路径)都会映射到用户工作目录下:
|
|
240
281
|
- /skills/foo.md -> {workspace}/skills/foo.md
|
|
241
282
|
- skills/foo.md -> {workspace}/skills/foo.md
|
|
@@ -244,28 +285,76 @@ def resolve_sandbox_path(path: str, workspace: str) -> str:
|
|
|
244
285
|
|
|
245
286
|
同时防止路径遍历攻击,确保解析后的路径仍在工作目录内
|
|
246
287
|
"""
|
|
247
|
-
|
|
248
|
-
|
|
288
|
+
from pathlib import PurePosixPath, Path
|
|
289
|
+
|
|
249
290
|
workspace = os.path.normpath(workspace)
|
|
250
291
|
|
|
251
|
-
#
|
|
252
|
-
|
|
253
|
-
|
|
292
|
+
# 如果路径已经是本地绝对路径且在 workspace 内,直接返回
|
|
293
|
+
path_normed = os.path.normpath(path)
|
|
294
|
+
if os.path.isabs(path_normed) and _is_path_within(path_normed, workspace):
|
|
295
|
+
return path_normed
|
|
254
296
|
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
297
|
+
# 输入是 POSIX 虚拟路径(如 /skills/foo.md),用 PurePosixPath 解析
|
|
298
|
+
# PurePosixPath 会自动去掉前导 / 得到相对部分
|
|
299
|
+
posix_path = PurePosixPath(path)
|
|
300
|
+
if posix_path.is_absolute():
|
|
301
|
+
# 去掉前导 / 得到相对路径字符串(如 skills/foo.md)
|
|
302
|
+
# parts[0] 是 '/',取 parts[1:] 拼接
|
|
303
|
+
relative_str = str(PurePosixPath(*posix_path.parts[1:])) if len(posix_path.parts) > 1 else ""
|
|
304
|
+
else:
|
|
305
|
+
relative_str = str(posix_path)
|
|
258
306
|
|
|
259
|
-
#
|
|
260
|
-
resolved = os.path.normpath(os.path.join(workspace,
|
|
307
|
+
# 用 os.path.join 拼接到本地 workspace(自动适配 os.sep)
|
|
308
|
+
resolved = os.path.normpath(os.path.join(workspace, relative_str))
|
|
261
309
|
|
|
262
310
|
# 安全检查:确保最终路径在工作目录内
|
|
263
|
-
if not
|
|
311
|
+
if not _is_path_within(resolved, workspace):
|
|
264
312
|
raise ValueError(f"Path traversal detected: {path}")
|
|
265
313
|
|
|
266
314
|
return resolved
|
|
267
315
|
|
|
268
316
|
|
|
317
|
+
# Windows 不支持 start_new_session 和 os.killpg,用条件常量
|
|
318
|
+
_SUBPROCESS_NEW_SESSION = platform.system() != "Windows"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _kill_process_tree(process):
|
|
322
|
+
"""杀掉进程及其子进程,跨平台兼容。
|
|
323
|
+
|
|
324
|
+
Linux/macOS: os.killpg 杀进程组(包括孙进程)
|
|
325
|
+
Windows: process.kill() 杀主进程
|
|
326
|
+
"""
|
|
327
|
+
if platform.system() == "Windows":
|
|
328
|
+
try:
|
|
329
|
+
process.kill()
|
|
330
|
+
except (ProcessLookupError, PermissionError):
|
|
331
|
+
pass
|
|
332
|
+
else:
|
|
333
|
+
_kill_process_tree(process)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _build_execute_command(init_script: str, user_command: str) -> tuple[str, dict]:
|
|
337
|
+
"""构建跨平台的执行命令。
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
(full_command, kwargs) - 完整命令字符串和 subprocess 额外参数。
|
|
341
|
+
"""
|
|
342
|
+
if platform.system() == "Windows":
|
|
343
|
+
# Windows: PowerShell 执行
|
|
344
|
+
full_command = f'''{init_script}
|
|
345
|
+
{user_command}
|
|
346
|
+
'''
|
|
347
|
+
return full_command, {"shell": True}
|
|
348
|
+
else:
|
|
349
|
+
# Linux/macOS: bash heredoc
|
|
350
|
+
full_command = f'''bash <<'COMMAND_EOF'
|
|
351
|
+
{init_script}
|
|
352
|
+
{user_command}
|
|
353
|
+
COMMAND_EOF
|
|
354
|
+
'''
|
|
355
|
+
return full_command, {}
|
|
356
|
+
|
|
357
|
+
|
|
269
358
|
def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
270
359
|
"""
|
|
271
360
|
沙箱服务初始化
|
|
@@ -337,13 +426,8 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
337
426
|
# 使用统一初始化脚本
|
|
338
427
|
init_script = _build_init_script(workspace)
|
|
339
428
|
|
|
340
|
-
#
|
|
341
|
-
|
|
342
|
-
full_command = f'''bash <<'COMMAND_EOF'
|
|
343
|
-
{init_script}
|
|
344
|
-
{req.command}
|
|
345
|
-
COMMAND_EOF
|
|
346
|
-
'''
|
|
429
|
+
# 构建跨平台执行命令
|
|
430
|
+
full_command, _ = _build_execute_command(init_script, req.command)
|
|
347
431
|
|
|
348
432
|
# 设置环境变量
|
|
349
433
|
env = os.environ.copy()
|
|
@@ -360,7 +444,7 @@ COMMAND_EOF
|
|
|
360
444
|
stderr=asyncio.subprocess.PIPE,
|
|
361
445
|
cwd=workspace,
|
|
362
446
|
env=env,
|
|
363
|
-
start_new_session=
|
|
447
|
+
start_new_session=_SUBPROCESS_NEW_SESSION,
|
|
364
448
|
)
|
|
365
449
|
|
|
366
450
|
try:
|
|
@@ -373,10 +457,7 @@ COMMAND_EOF
|
|
|
373
457
|
stderr = stderr.decode('utf-8') if stderr else ''
|
|
374
458
|
except asyncio.TimeoutError:
|
|
375
459
|
# 杀掉整个进程组(包括孙进程)
|
|
376
|
-
|
|
377
|
-
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
378
|
-
except (ProcessLookupError, PermissionError):
|
|
379
|
-
process.kill()
|
|
460
|
+
_kill_process_tree(process)
|
|
380
461
|
stdout, stderr = await process.communicate()
|
|
381
462
|
stdout = stdout.decode('utf-8') if stdout else ''
|
|
382
463
|
stderr = stderr.decode('utf-8') if stderr else ''
|
|
@@ -437,11 +518,7 @@ COMMAND_EOF
|
|
|
437
518
|
await aensure_subdirs(workspace)
|
|
438
519
|
|
|
439
520
|
init_script = _build_init_script(workspace)
|
|
440
|
-
full_command =
|
|
441
|
-
{init_script}
|
|
442
|
-
{req.command}
|
|
443
|
-
COMMAND_EOF
|
|
444
|
-
'''
|
|
521
|
+
full_command, _ = _build_execute_command(init_script, req.command)
|
|
445
522
|
|
|
446
523
|
env = os.environ.copy()
|
|
447
524
|
env["TMPDIR"] = os.path.join(workspace, "tmp")
|
|
@@ -457,7 +534,7 @@ COMMAND_EOF
|
|
|
457
534
|
stderr=asyncio.subprocess.PIPE,
|
|
458
535
|
cwd=workspace,
|
|
459
536
|
env=env,
|
|
460
|
-
start_new_session=
|
|
537
|
+
start_new_session=_SUBPROCESS_NEW_SESSION,
|
|
461
538
|
)
|
|
462
539
|
|
|
463
540
|
queue = asyncio.Queue()
|
|
@@ -492,10 +569,7 @@ COMMAND_EOF
|
|
|
492
569
|
try:
|
|
493
570
|
exit_code = await asyncio.wait_for(process.wait(), timeout=5)
|
|
494
571
|
except asyncio.TimeoutError:
|
|
495
|
-
|
|
496
|
-
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
497
|
-
except (ProcessLookupError, PermissionError):
|
|
498
|
-
process.kill()
|
|
572
|
+
_kill_process_tree(process)
|
|
499
573
|
exit_code = -9
|
|
500
574
|
|
|
501
575
|
yield f"data: {json.dumps({'type': 'done', 'exit_code': exit_code})}\n\n"
|
|
@@ -523,11 +597,7 @@ COMMAND_EOF
|
|
|
523
597
|
# 使用统一初始化脚本
|
|
524
598
|
init_script = _build_init_script(workspace)
|
|
525
599
|
|
|
526
|
-
full_command =
|
|
527
|
-
{init_script}
|
|
528
|
-
{req.command}
|
|
529
|
-
COMMAND_EOF
|
|
530
|
-
'''
|
|
600
|
+
full_command, _ = _build_execute_command(init_script, req.command)
|
|
531
601
|
|
|
532
602
|
env = os.environ.copy()
|
|
533
603
|
env["TMPDIR"] = os.path.join(workspace, "tmp")
|
|
@@ -543,7 +613,7 @@ COMMAND_EOF
|
|
|
543
613
|
stderr=asyncio.subprocess.PIPE,
|
|
544
614
|
cwd=workspace,
|
|
545
615
|
env=env,
|
|
546
|
-
start_new_session=
|
|
616
|
+
start_new_session=_SUBPROCESS_NEW_SESSION,
|
|
547
617
|
)
|
|
548
618
|
|
|
549
619
|
async with lock:
|
|
@@ -571,10 +641,7 @@ COMMAND_EOF
|
|
|
571
641
|
stdout = stdout.decode('utf-8') if stdout else ''
|
|
572
642
|
stderr = stderr.decode('utf-8') if stderr else ''
|
|
573
643
|
except asyncio.TimeoutError:
|
|
574
|
-
|
|
575
|
-
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
576
|
-
except (ProcessLookupError, PermissionError):
|
|
577
|
-
process.kill()
|
|
644
|
+
_kill_process_tree(process)
|
|
578
645
|
stdout, stderr = await process.communicate()
|
|
579
646
|
stdout = stdout.decode('utf-8') if stdout else ''
|
|
580
647
|
stderr = stderr.decode('utf-8') if stderr else ''
|
|
@@ -711,10 +778,7 @@ COMMAND_EOF
|
|
|
711
778
|
|
|
712
779
|
# 在线程锁外执行 kill 操作(可能阻塞)
|
|
713
780
|
try:
|
|
714
|
-
|
|
715
|
-
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
716
|
-
except (ProcessLookupError, PermissionError):
|
|
717
|
-
process.kill()
|
|
781
|
+
_kill_process_tree(process)
|
|
718
782
|
try:
|
|
719
783
|
await asyncio.wait_for(process.wait(), timeout=5)
|
|
720
784
|
except asyncio.TimeoutError:
|
|
@@ -770,10 +834,7 @@ COMMAND_EOF
|
|
|
770
834
|
for pid, info in to_kill:
|
|
771
835
|
process = info["process"]
|
|
772
836
|
try:
|
|
773
|
-
|
|
774
|
-
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
775
|
-
except (ProcessLookupError, PermissionError):
|
|
776
|
-
process.kill()
|
|
837
|
+
_kill_process_tree(process)
|
|
777
838
|
try:
|
|
778
839
|
await asyncio.wait_for(process.wait(), timeout=5)
|
|
779
840
|
except asyncio.TimeoutError:
|
|
@@ -808,7 +869,11 @@ COMMAND_EOF
|
|
|
808
869
|
|
|
809
870
|
@sandbox_router.post("/ls", response_model=list[FileInfo])
|
|
810
871
|
async def ls_info(req: LsRequest):
|
|
811
|
-
"""列出目录内容
|
|
872
|
+
"""列出目录内容
|
|
873
|
+
|
|
874
|
+
安全增强(对齐 deepagents 0.6.3):
|
|
875
|
+
- symlink 逃逸防护:结果路径 resolve 后必须在 workspace 内
|
|
876
|
+
"""
|
|
812
877
|
workspace = await aget_user_workspace(req.user_id)
|
|
813
878
|
try:
|
|
814
879
|
target_path = resolve_sandbox_path(req.path, workspace)
|
|
@@ -817,6 +882,7 @@ COMMAND_EOF
|
|
|
817
882
|
return []
|
|
818
883
|
|
|
819
884
|
def _ls():
|
|
885
|
+
workspace_resolved = os.path.realpath(workspace)
|
|
820
886
|
if not os.path.isdir(target_path):
|
|
821
887
|
return None
|
|
822
888
|
results = []
|
|
@@ -824,7 +890,14 @@ COMMAND_EOF
|
|
|
824
890
|
with os.scandir(target_path) as it:
|
|
825
891
|
for entry in it:
|
|
826
892
|
abs_path = os.path.join(target_path, entry.name)
|
|
827
|
-
|
|
893
|
+
# symlink 逃逸防护
|
|
894
|
+
try:
|
|
895
|
+
resolved = os.path.realpath(abs_path)
|
|
896
|
+
if not _is_path_within(resolved, workspace_resolved):
|
|
897
|
+
continue
|
|
898
|
+
except OSError:
|
|
899
|
+
continue
|
|
900
|
+
virtual_path = _to_virtual_path(abs_path, workspace)
|
|
828
901
|
info = FileInfo(path=virtual_path)
|
|
829
902
|
try:
|
|
830
903
|
info.is_dir = entry.is_dir(follow_symlinks=False)
|
|
@@ -922,7 +995,12 @@ COMMAND_EOF
|
|
|
922
995
|
|
|
923
996
|
@sandbox_router.post("/write", response_model=WriteResponse)
|
|
924
997
|
async def write_file(req: WriteRequest):
|
|
925
|
-
"""
|
|
998
|
+
"""写入文件
|
|
999
|
+
|
|
1000
|
+
默认行为:文件不存在时创建新文件。
|
|
1001
|
+
当 req.overwrite=True 时允许覆盖已有文件(用于 deepagents 内部 offload 场景,
|
|
1002
|
+
如 /large_tool_results/ 和 /conversation_history/ 路径)。
|
|
1003
|
+
"""
|
|
926
1004
|
workspace = await aget_user_workspace(req.user_id)
|
|
927
1005
|
try:
|
|
928
1006
|
file_path = resolve_sandbox_path(req.file_path, workspace)
|
|
@@ -933,7 +1011,9 @@ COMMAND_EOF
|
|
|
933
1011
|
SYLogger.info(f"[Sandbox Server] 写入文件: {file_path}")
|
|
934
1012
|
|
|
935
1013
|
def _write():
|
|
936
|
-
|
|
1014
|
+
# 允许覆盖模式(用于 deepagents offload 场景)
|
|
1015
|
+
overwrite = getattr(req, 'overwrite', False)
|
|
1016
|
+
if os.path.exists(file_path) and not overwrite:
|
|
937
1017
|
SYLogger.warning(f"[Sandbox Server] 文件已存在: {file_path}")
|
|
938
1018
|
return WriteResponse(error=f"Error: File already exists at {req.file_path}. Use edit_file to modify existing files.", path=None)
|
|
939
1019
|
|
|
@@ -946,9 +1026,10 @@ COMMAND_EOF
|
|
|
946
1026
|
with open(file_path, 'w') as f:
|
|
947
1027
|
f.write(content)
|
|
948
1028
|
|
|
1029
|
+
action = "覆盖" if (os.path.exists(file_path) and overwrite) else "新建"
|
|
949
1030
|
SYLogger.info(
|
|
950
|
-
f"[Sandbox Server] 写入成功(
|
|
951
|
-
virtual_path =
|
|
1031
|
+
f"[Sandbox Server] 写入成功({action}): {file_path} ({len(content)} 字符)")
|
|
1032
|
+
virtual_path = _to_virtual_path(file_path, workspace)
|
|
952
1033
|
return WriteResponse(error=None, path=virtual_path)
|
|
953
1034
|
|
|
954
1035
|
try:
|
|
@@ -959,7 +1040,15 @@ COMMAND_EOF
|
|
|
959
1040
|
|
|
960
1041
|
@sandbox_router.post("/edit", response_model=EditResponse)
|
|
961
1042
|
async def edit_file(req: EditRequest):
|
|
962
|
-
"""编辑文件
|
|
1043
|
+
"""编辑文件
|
|
1044
|
+
|
|
1045
|
+
增强错误处理(对齐 deepagents 0.6.3 BaseSandbox):
|
|
1046
|
+
- os.stat + S_ISREG 精确判断文件类型
|
|
1047
|
+
- try-except 包裹整个操作,捕获 FileNotFoundError/PermissionError
|
|
1048
|
+
- CRLF 自动适配:匹配文件实际换行风格,保留原始行尾
|
|
1049
|
+
"""
|
|
1050
|
+
import stat as stat_mod
|
|
1051
|
+
|
|
963
1052
|
workspace = await aget_user_workspace(req.user_id)
|
|
964
1053
|
try:
|
|
965
1054
|
file_path = resolve_sandbox_path(req.file_path, workspace)
|
|
@@ -973,18 +1062,52 @@ COMMAND_EOF
|
|
|
973
1062
|
old_string = base64.b64decode(req.old_string).decode('utf-8')
|
|
974
1063
|
new_string = base64.b64decode(req.new_string).decode('utf-8')
|
|
975
1064
|
|
|
976
|
-
|
|
1065
|
+
try:
|
|
1066
|
+
st = os.stat(file_path)
|
|
1067
|
+
if not stat_mod.S_ISREG(st.st_mode):
|
|
1068
|
+
SYLogger.error(f"[Sandbox Server] 编辑失败: 不是普通文件 {file_path}")
|
|
1069
|
+
return EditResponse(error="not_a_file")
|
|
1070
|
+
except FileNotFoundError:
|
|
977
1071
|
SYLogger.error(f"[Sandbox Server] 编辑失败: 文件不存在 {file_path}")
|
|
978
|
-
return EditResponse(error=
|
|
1072
|
+
return EditResponse(error="file_not_found")
|
|
1073
|
+
except PermissionError:
|
|
1074
|
+
SYLogger.error(f"[Sandbox Server] 编辑失败: 权限不足 {file_path}")
|
|
1075
|
+
return EditResponse(error="permission_denied")
|
|
979
1076
|
|
|
980
|
-
with open(file_path, '
|
|
981
|
-
|
|
1077
|
+
with open(file_path, 'rb') as f:
|
|
1078
|
+
raw = f.read()
|
|
982
1079
|
|
|
983
|
-
|
|
1080
|
+
try:
|
|
1081
|
+
content = raw.decode('utf-8')
|
|
1082
|
+
except UnicodeDecodeError:
|
|
1083
|
+
SYLogger.error(f"[Sandbox Server] 编辑失败: 非文本文件 {file_path}")
|
|
1084
|
+
return EditResponse(error="not_a_text_file")
|
|
1085
|
+
|
|
1086
|
+
# CRLF 自适应匹配(对齐 deepagents 0.6.3):
|
|
1087
|
+
# read endpoint 读取时会将 CRLF 转为 LF 给 LLM,
|
|
1088
|
+
# 所以 old_string 到达时是 LF-only,但文件实际可能是 CRLF。
|
|
1089
|
+
# 尝试多种变体,第一个匹配的揭示了文件在该区域的换行风格。
|
|
1090
|
+
old_crlf = old_string.replace('\r\n', '\n').replace('\n', '\r\n')
|
|
1091
|
+
old_lf = old_string.replace('\r\n', '\n')
|
|
1092
|
+
new_crlf = new_string.replace('\r\n', '\n').replace('\n', '\r\n')
|
|
1093
|
+
new_lf = new_string.replace('\r\n', '\n')
|
|
1094
|
+
|
|
1095
|
+
matched_old, matched_new, count = old_string, new_string, 0
|
|
1096
|
+
for cand_old, cand_new in (
|
|
1097
|
+
(old_string, new_string),
|
|
1098
|
+
(old_crlf, new_crlf),
|
|
1099
|
+
(old_lf, new_lf),
|
|
1100
|
+
):
|
|
1101
|
+
c = content.count(cand_old)
|
|
1102
|
+
if c >= 1:
|
|
1103
|
+
matched_old, matched_new, count = cand_old, cand_new, c
|
|
1104
|
+
break
|
|
984
1105
|
|
|
985
1106
|
if count == 0:
|
|
986
|
-
SYLogger.error(
|
|
987
|
-
return EditResponse(
|
|
1107
|
+
SYLogger.error("[Sandbox Server] 编辑失败: 未找到匹配字符串")
|
|
1108
|
+
return EditResponse(
|
|
1109
|
+
error=f"String not found: '{old_string[:50]}...'"
|
|
1110
|
+
)
|
|
988
1111
|
if count > 1 and not req.replace_all:
|
|
989
1112
|
SYLogger.error(f"[Sandbox Server] 编辑失败: 多处匹配 ({count}次)")
|
|
990
1113
|
return EditResponse(
|
|
@@ -992,15 +1115,15 @@ COMMAND_EOF
|
|
|
992
1115
|
)
|
|
993
1116
|
|
|
994
1117
|
if req.replace_all:
|
|
995
|
-
new_content = content.replace(
|
|
1118
|
+
new_content = content.replace(matched_old, matched_new)
|
|
996
1119
|
else:
|
|
997
|
-
new_content = content.replace(
|
|
1120
|
+
new_content = content.replace(matched_old, matched_new, 1)
|
|
998
1121
|
|
|
999
|
-
with open(file_path, '
|
|
1000
|
-
f.write(new_content)
|
|
1122
|
+
with open(file_path, 'wb') as f:
|
|
1123
|
+
f.write(new_content.encode('utf-8'))
|
|
1001
1124
|
|
|
1002
1125
|
SYLogger.info(f"[Sandbox Server] 编辑成功: {file_path} (替换 {count} 处)")
|
|
1003
|
-
virtual_path =
|
|
1126
|
+
virtual_path = _to_virtual_path(file_path, workspace)
|
|
1004
1127
|
return EditResponse(error=None, path=virtual_path, occurrences=count)
|
|
1005
1128
|
|
|
1006
1129
|
try:
|
|
@@ -1159,7 +1282,7 @@ COMMAND_EOF
|
|
|
1159
1282
|
for entry in entries:
|
|
1160
1283
|
|
|
1161
1284
|
abs_path = os.path.join(dir_path, entry.name)
|
|
1162
|
-
virtual_path =
|
|
1285
|
+
virtual_path = _to_virtual_path(abs_path, workspace)
|
|
1163
1286
|
file_count += 1
|
|
1164
1287
|
|
|
1165
1288
|
try:
|
|
@@ -1181,7 +1304,7 @@ COMMAND_EOF
|
|
|
1181
1304
|
|
|
1182
1305
|
return node
|
|
1183
1306
|
|
|
1184
|
-
root_virtual =
|
|
1307
|
+
root_virtual = _to_virtual_path(target_path, workspace)
|
|
1185
1308
|
return build_tree(target_path, root_virtual, 0)
|
|
1186
1309
|
|
|
1187
1310
|
try:
|
|
@@ -1197,7 +1320,12 @@ COMMAND_EOF
|
|
|
1197
1320
|
|
|
1198
1321
|
@sandbox_router.post("/glob", response_model=list[FileInfo])
|
|
1199
1322
|
async def glob_files(req: GlobRequest):
|
|
1200
|
-
"""glob 搜索文件
|
|
1323
|
+
"""glob 搜索文件
|
|
1324
|
+
|
|
1325
|
+
增强错误处理(对齐 deepagents 0.6.3 BaseSandbox):
|
|
1326
|
+
- FileNotFoundError / NotADirectoryError / PermissionError 捕获
|
|
1327
|
+
- os.stat 异常时跳过单个条目
|
|
1328
|
+
"""
|
|
1201
1329
|
import glob as glob_module
|
|
1202
1330
|
|
|
1203
1331
|
workspace = await aget_user_workspace(req.user_id)
|
|
@@ -1210,17 +1338,32 @@ COMMAND_EOF
|
|
|
1210
1338
|
full_pattern = os.path.join(search_path, req.pattern)
|
|
1211
1339
|
|
|
1212
1340
|
def _glob():
|
|
1213
|
-
|
|
1341
|
+
workspace_resolved = os.path.realpath(workspace)
|
|
1342
|
+
try:
|
|
1343
|
+
matches = sorted(glob_module.glob(full_pattern, recursive=True))
|
|
1344
|
+
except (FileNotFoundError, NotADirectoryError, PermissionError) as e:
|
|
1345
|
+
SYLogger.warning(f"[Sandbox Server] glob 异常: {e}")
|
|
1346
|
+
return []
|
|
1347
|
+
|
|
1214
1348
|
results = []
|
|
1215
1349
|
for m in matches:
|
|
1216
|
-
|
|
1350
|
+
# 路径安全检查:跳过逃逸出 workspace 的 symlink
|
|
1351
|
+
try:
|
|
1352
|
+
if not _is_path_within(m, workspace_resolved):
|
|
1353
|
+
SYLogger.warning(f"[Sandbox Server] glob 跳过逃逸路径: {m}")
|
|
1354
|
+
continue
|
|
1355
|
+
except OSError:
|
|
1356
|
+
continue
|
|
1357
|
+
|
|
1358
|
+
virtual_path = _to_virtual_path(m, workspace)
|
|
1217
1359
|
info = FileInfo(path=virtual_path)
|
|
1218
1360
|
try:
|
|
1361
|
+
st = os.stat(m)
|
|
1219
1362
|
info.is_dir = os.path.isdir(m)
|
|
1220
1363
|
if not info.is_dir:
|
|
1221
|
-
info.size =
|
|
1222
|
-
except
|
|
1223
|
-
|
|
1364
|
+
info.size = st.st_size
|
|
1365
|
+
except OSError:
|
|
1366
|
+
continue
|
|
1224
1367
|
results.append(info)
|
|
1225
1368
|
return results
|
|
1226
1369
|
|
|
@@ -1228,7 +1371,13 @@ COMMAND_EOF
|
|
|
1228
1371
|
|
|
1229
1372
|
@sandbox_router.post("/grep", response_model=list[GrepMatch])
|
|
1230
1373
|
async def grep_search(req: GrepRequest):
|
|
1231
|
-
"""grep 搜索内容
|
|
1374
|
+
"""grep 搜索内容
|
|
1375
|
+
|
|
1376
|
+
安全增强(对齐 deepagents 0.6.3 backends/filesystem.py):
|
|
1377
|
+
- symlink 逃逸防护:结果路径必须 resolve 到 workspace 内
|
|
1378
|
+
- 路径规范化:统一转换为虚拟路径,防止泄露宿主绝对路径
|
|
1379
|
+
- 结果解析全量异步:os.path.realpath/relpath 通过 asyncio.to_thread 执行
|
|
1380
|
+
"""
|
|
1232
1381
|
workspace = await aget_user_workspace(req.user_id)
|
|
1233
1382
|
try:
|
|
1234
1383
|
search_path = resolve_sandbox_path(req.path or "/", workspace)
|
|
@@ -1250,44 +1399,56 @@ COMMAND_EOF
|
|
|
1250
1399
|
cmd,
|
|
1251
1400
|
stdout=asyncio.subprocess.PIPE,
|
|
1252
1401
|
stderr=asyncio.subprocess.PIPE,
|
|
1253
|
-
start_new_session=
|
|
1402
|
+
start_new_session=_SUBPROCESS_NEW_SESSION,
|
|
1254
1403
|
)
|
|
1255
1404
|
stdout, _ = await proc.communicate()
|
|
1256
1405
|
result_text = stdout.decode('utf-8') if stdout else ''
|
|
1257
1406
|
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1407
|
+
# 结果解析(含 os.path.realpath / relpath 同步 IO)放入线程池
|
|
1408
|
+
def _parse_grep_results():
|
|
1409
|
+
workspace_resolved = os.path.realpath(workspace)
|
|
1410
|
+
matches = []
|
|
1411
|
+
for line in result_text.strip().split("\n"):
|
|
1412
|
+
if not line:
|
|
1413
|
+
continue
|
|
1414
|
+
first_colon = line.find(":")
|
|
1415
|
+
if first_colon == -1:
|
|
1416
|
+
continue
|
|
1417
|
+
second_colon = line.find(":", first_colon + 1)
|
|
1418
|
+
if second_colon == -1:
|
|
1419
|
+
continue
|
|
1420
|
+
|
|
1421
|
+
path_part = line[:first_colon]
|
|
1422
|
+
|
|
1423
|
+
# 路径安全检查(对齐 deepagents 0.6.3):
|
|
1424
|
+
# resolve 后必须在 workspace 内,跳过 symlink 逃逸的结果
|
|
1425
|
+
try:
|
|
1426
|
+
resolved = os.path.realpath(path_part)
|
|
1427
|
+
if not _is_path_within(resolved, workspace_resolved):
|
|
1428
|
+
SYLogger.warning(f"[Sandbox Server] grep 跳过逃逸路径: {path_part}")
|
|
1429
|
+
continue
|
|
1430
|
+
except OSError:
|
|
1431
|
+
continue
|
|
1432
|
+
|
|
1433
|
+
# 将绝对路径转换为虚拟路径
|
|
1434
|
+
try:
|
|
1435
|
+
virtual_path_part = _to_virtual_path(path_part, workspace)
|
|
1436
|
+
except ValueError:
|
|
1437
|
+
virtual_path_part = path_part
|
|
1438
|
+
try:
|
|
1439
|
+
line_num = int(line[first_colon + 1:second_colon])
|
|
1440
|
+
except ValueError:
|
|
1441
|
+
continue
|
|
1442
|
+
text_part = line[second_colon + 1:]
|
|
1283
1443
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1444
|
+
matches.append(GrepMatch(
|
|
1445
|
+
path=virtual_path_part,
|
|
1446
|
+
line=line_num,
|
|
1447
|
+
text=text_part
|
|
1448
|
+
))
|
|
1449
|
+
return matches
|
|
1289
1450
|
|
|
1290
|
-
return
|
|
1451
|
+
return await asyncio.to_thread(_parse_grep_results)
|
|
1291
1452
|
|
|
1292
1453
|
@sandbox_router.post("/stat", response_model=StatResponse)
|
|
1293
1454
|
async def stat_file(req: StatRequest):
|
|
@@ -1298,7 +1459,7 @@ COMMAND_EOF
|
|
|
1298
1459
|
except ValueError as e:
|
|
1299
1460
|
return StatResponse(error=str(e))
|
|
1300
1461
|
|
|
1301
|
-
virtual_path =
|
|
1462
|
+
virtual_path = _to_virtual_path(target_path, workspace)
|
|
1302
1463
|
|
|
1303
1464
|
def _stat():
|
|
1304
1465
|
if not os.path.exists(target_path):
|
|
@@ -1337,7 +1498,7 @@ COMMAND_EOF
|
|
|
1337
1498
|
try:
|
|
1338
1499
|
for item in os.listdir(target_path):
|
|
1339
1500
|
item_path = os.path.join(target_path, item)
|
|
1340
|
-
virtual_path =
|
|
1501
|
+
virtual_path = _to_virtual_path(item_path, workspace)
|
|
1341
1502
|
info = FileInfo(path=virtual_path)
|
|
1342
1503
|
try:
|
|
1343
1504
|
info.is_dir = os.path.isdir(item_path)
|
|
@@ -1366,8 +1527,7 @@ COMMAND_EOF
|
|
|
1366
1527
|
return LsResponse(files=files, warning=f"Results truncated at {MAX_FILES} files")
|
|
1367
1528
|
|
|
1368
1529
|
filepath = os.path.join(root, filename)
|
|
1369
|
-
virtual_filepath =
|
|
1370
|
-
os.path.relpath(filepath, workspace)
|
|
1530
|
+
virtual_filepath = _to_virtual_path(filepath, workspace)
|
|
1371
1531
|
file_info = FileInfo(path=virtual_filepath)
|
|
1372
1532
|
try:
|
|
1373
1533
|
file_info.is_dir = False
|
|
@@ -1537,7 +1697,7 @@ COMMAND_EOF
|
|
|
1537
1697
|
continue
|
|
1538
1698
|
|
|
1539
1699
|
is_dir = new[2] if new else (old[2] if old else False)
|
|
1540
|
-
virtual =
|
|
1700
|
+
virtual = _to_virtual_path(os.path.join(target_path, p), workspace)
|
|
1541
1701
|
events.append(WatchEvent(event_type=evt, path=virtual, is_dir=is_dir))
|
|
1542
1702
|
|
|
1543
1703
|
with open(snapshot_file, 'w') as f:
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
同时处理 list 类型 content(如 read_file 读取图片返回的 base64 数据),
|
|
10
10
|
将其转换为字符串描述,防止上游 API 拒绝 list content 的 400 错误。
|
|
11
|
+
|
|
12
|
+
head+tail 预览模式(对齐 deepagents 0.6.3 _message_eviction):
|
|
13
|
+
截断时保留内容头部和尾部若干行,中间用行数提示替代,
|
|
14
|
+
帮助模型了解整体结构而不必 read_file 恢复全部内容。
|
|
11
15
|
"""
|
|
12
16
|
|
|
13
17
|
from collections.abc import Awaitable, Callable
|
|
@@ -36,18 +40,68 @@ DEFAULT_PASSTHROUGH_TOOLS: FrozenSet[str] = frozenset({
|
|
|
36
40
|
"web_search", # web_search 已在工具内部自行截断
|
|
37
41
|
})
|
|
38
42
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
"
|
|
43
|
+
# head+tail 预览截断模板(对齐 deepagents 0.6.3 TOO_LARGE_TOOL_MSG 风格)
|
|
44
|
+
HEAD_TAIL_TRUNCATION_TEMPLATE = (
|
|
45
|
+
"[输出已截断,原始长度 {original} 字符 / {original_lines} 行。]"
|
|
46
|
+
"\n\n以下是输出的头部和尾部预览(中间省略的行用 `... [N lines truncated] ...` 标记):\n\n"
|
|
47
|
+
"{preview}\n\n"
|
|
42
48
|
"如需查看完整输出,可以:\n"
|
|
43
49
|
"1. 重新执行命令并添加过滤条件(如 grep、head、tail)\n"
|
|
44
|
-
"2. 将输出重定向到文件后用 read_file
|
|
50
|
+
"2. 将输出重定向到文件后用 read_file 分段读取(使用 offset 和 limit 参数)"
|
|
45
51
|
)
|
|
46
52
|
|
|
47
53
|
# list 类型 content 中 base64 图片的最大字符数
|
|
48
54
|
MAX_IMAGE_BASE64_CHARS = 500
|
|
49
55
|
|
|
50
56
|
|
|
57
|
+
def _create_head_tail_preview(
|
|
58
|
+
content: str,
|
|
59
|
+
*,
|
|
60
|
+
head_lines: int = 10,
|
|
61
|
+
tail_lines: int = 10,
|
|
62
|
+
max_line_chars: int = 200,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""创建 head+tail 预览,中间用行数提示替代。
|
|
65
|
+
|
|
66
|
+
对齐 deepagents 0.6.3 的 _create_content_preview 逻辑:
|
|
67
|
+
- 保留头部和尾部若干行
|
|
68
|
+
- 每行截断到 max_line_chars 防止单行爆炸
|
|
69
|
+
- 中间用 `... [N lines truncated] ...` 标记
|
|
70
|
+
- 附带行号帮助模型定位
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
content: 原始内容。
|
|
74
|
+
head_lines: 保留头部行数。
|
|
75
|
+
tail_lines: 保留尾部行数。
|
|
76
|
+
max_line_chars: 每行最大字符数。
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
格式化后的预览字符串。
|
|
80
|
+
"""
|
|
81
|
+
lines = content.splitlines()
|
|
82
|
+
total = len(lines)
|
|
83
|
+
|
|
84
|
+
if total <= head_lines + tail_lines + 5:
|
|
85
|
+
# 内容足够短,直接返回(每行截断)
|
|
86
|
+
return "\n".join(line[:max_line_chars] for line in lines)
|
|
87
|
+
|
|
88
|
+
head = [line[:max_line_chars] for line in lines[:head_lines]]
|
|
89
|
+
tail = [line[:max_line_chars] for line in lines[-tail_lines:]]
|
|
90
|
+
omitted = total - head_lines - tail_lines
|
|
91
|
+
|
|
92
|
+
head_with_nums = []
|
|
93
|
+
for i, line in enumerate(head, start=1):
|
|
94
|
+
head_with_nums.append(f"{i:6d} | {line}")
|
|
95
|
+
|
|
96
|
+
truncation_marker = f"\n... [{omitted} lines truncated] ...\n"
|
|
97
|
+
|
|
98
|
+
tail_with_nums = []
|
|
99
|
+
for i, line in enumerate(tail, start=total - tail_lines + 1):
|
|
100
|
+
tail_with_nums.append(f"{i:6d} | {line}")
|
|
101
|
+
|
|
102
|
+
return "\n".join(head_with_nums) + truncation_marker + "\n".join(tail_with_nums)
|
|
103
|
+
|
|
104
|
+
|
|
51
105
|
def _convert_list_content_to_str(content: list, tool_name: str) -> str:
|
|
52
106
|
"""将 list 类型的 ToolMessage.content 转换为字符串。
|
|
53
107
|
|
|
@@ -80,12 +134,18 @@ def _convert_list_content_to_str(content: list, tool_name: str) -> str:
|
|
|
80
134
|
class ToolResultTruncationMiddleware(AgentMiddleware):
|
|
81
135
|
"""截断过长的工具结果,防止超出模型上下文窗口。
|
|
82
136
|
|
|
137
|
+
采用 head+tail 预览模式(对齐 deepagents 0.6.3 _message_eviction):
|
|
138
|
+
- 保留内容头部和尾部若干行
|
|
139
|
+
- 中间省略部分用行数标记替代
|
|
140
|
+
- 附带行号帮助模型定位和恢复
|
|
141
|
+
|
|
83
142
|
Args:
|
|
84
143
|
max_content_length: 工具结果内容的最大字符长度,默认 15000(约 3750 token)。
|
|
85
144
|
模型上下文 200K token,70% 触发压缩 = 140K token,给单条工具结果留 3750 token
|
|
86
145
|
约占剩余空间的 2.6%,多条工具调用并行时也不会逼近上限。
|
|
87
146
|
passthrough_tools: 不截断的工具名集合。
|
|
88
|
-
|
|
147
|
+
head_lines: 截断时保留的头部行数,默认 10。
|
|
148
|
+
tail_lines: 截断时保留的尾部行数,默认 10。
|
|
89
149
|
"""
|
|
90
150
|
|
|
91
151
|
def __init__(
|
|
@@ -93,11 +153,13 @@ class ToolResultTruncationMiddleware(AgentMiddleware):
|
|
|
93
153
|
*,
|
|
94
154
|
max_content_length: int = 15000,
|
|
95
155
|
passthrough_tools: FrozenSet[str] | None = None,
|
|
96
|
-
|
|
156
|
+
head_lines: int = 10,
|
|
157
|
+
tail_lines: int = 10,
|
|
97
158
|
) -> None:
|
|
98
159
|
self._max_content_length = max_content_length
|
|
99
160
|
self._passthrough_tools = passthrough_tools or DEFAULT_PASSTHROUGH_TOOLS
|
|
100
|
-
self.
|
|
161
|
+
self._head_lines = head_lines
|
|
162
|
+
self._tail_lines = tail_lines
|
|
101
163
|
|
|
102
164
|
@property
|
|
103
165
|
def name(self) -> str:
|
|
@@ -114,13 +176,32 @@ class ToolResultTruncationMiddleware(AgentMiddleware):
|
|
|
114
176
|
if original_len <= self._max_content_length:
|
|
115
177
|
return content
|
|
116
178
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
179
|
+
# head+tail 预览模式
|
|
180
|
+
preview = _create_head_tail_preview(
|
|
181
|
+
content,
|
|
182
|
+
head_lines=self._head_lines,
|
|
183
|
+
tail_lines=self._tail_lines,
|
|
184
|
+
)
|
|
185
|
+
original_lines = len(content.splitlines())
|
|
186
|
+
truncated = HEAD_TAIL_TRUNCATION_TEMPLATE.format(
|
|
187
|
+
original=original_len,
|
|
188
|
+
original_lines=original_lines,
|
|
189
|
+
preview=preview,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# 最终安全兜底:如果 head+tail 拼出来还是超长,硬截断
|
|
193
|
+
if len(truncated) > self._max_content_length * 2:
|
|
194
|
+
kept = self._max_content_length
|
|
195
|
+
truncated = content[:kept] + (
|
|
196
|
+
f"\n\n[输出已截断至前{kept}字符,原始长度{original_len}字符。"
|
|
197
|
+
"如需查看完整输出,可以:\n"
|
|
198
|
+
"1. 重新执行命令并添加过滤条件(如 grep、head、tail)\n"
|
|
199
|
+
"2. 将输出重定向到文件后用 read_file 分段读取]"
|
|
200
|
+
)
|
|
120
201
|
|
|
121
202
|
SYLogger.info(
|
|
122
203
|
f"[ToolResultTruncation] tool='{tool_name}' truncated: "
|
|
123
|
-
f"{original_len} -> {len(truncated)} chars"
|
|
204
|
+
f"{original_len} -> {len(truncated)} chars (head+tail preview)"
|
|
124
205
|
)
|
|
125
206
|
return truncated
|
|
126
207
|
|
sycommon/models/sandbox.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sycommon-python-lib
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5a0
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,8 +8,8 @@ Requires-Dist: aio-pika>=9.6.2
|
|
|
8
8
|
Requires-Dist: aiohttp>=3.13.5
|
|
9
9
|
Requires-Dist: aiomysql>=0.3.2
|
|
10
10
|
Requires-Dist: anyio>=4.12.1
|
|
11
|
-
Requires-Dist: decorator>=5.3.
|
|
12
|
-
Requires-Dist: deepagents>=0.6.
|
|
11
|
+
Requires-Dist: decorator>=5.3.1
|
|
12
|
+
Requires-Dist: deepagents>=0.6.3
|
|
13
13
|
Requires-Dist: elasticsearch>=9.4.0
|
|
14
14
|
Requires-Dist: fastapi>=0.136.1
|
|
15
15
|
Requires-Dist: jinja2>=3.1.6
|
|
@@ -136,7 +136,7 @@ sycommon/agent/mcp/__init__.py,sha256=iKrdDhIrFsNIkqG_kgcwNe-nOiM6uVfolKv44LfQ-F
|
|
|
136
136
|
sycommon/agent/mcp/models.py,sha256=RBAIbGETNXkqD3wQZT7eKS4ozkgE9DQEneF1WKZf1C0,1355
|
|
137
137
|
sycommon/agent/mcp/tool_loader.py,sha256=SEny14f7Bm9I17pT-9PJWMbhi9Ki77wvCR0KRNEJmyM,6428
|
|
138
138
|
sycommon/agent/sandbox/__init__.py,sha256=jR7LlkD4J4Y6QYyRXQClkwmqDBCCPmycV_hQV9p9YHw,4621
|
|
139
|
-
sycommon/agent/sandbox/file_ops.py,sha256=
|
|
139
|
+
sycommon/agent/sandbox/file_ops.py,sha256=0PEhmK1OcMq1Qe33JmQwphj670Tih7HQLNuAb1snIjI,24028
|
|
140
140
|
sycommon/agent/sandbox/http_sandbox_backend.py,sha256=kwuPEmrOMyxfrRu20AEGqWD9t38L-DrtKSFp6CWt44o,56877
|
|
141
141
|
sycommon/agent/sandbox/minio_sync.py,sha256=d1kuWllvyAvAMsFZCP0OdHEQtXN9BEIgHbupC31BjSk,20000
|
|
142
142
|
sycommon/agent/sandbox/sandbox_pool.py,sha256=eMn8sLakCWf90l6ni2-333QM8oBdX1CflV-WzneFp_k,9133
|
|
@@ -203,10 +203,10 @@ sycommon/middleware/exception.py,sha256=UAy0tKijI_2JoKjwT3h62aL-tybftP3IETvcr26N
|
|
|
203
203
|
sycommon/middleware/middleware.py,sha256=z3-6KHdBt93TOSNn0LY579WReSCo0o4f9d9-dq7c-ic,1600
|
|
204
204
|
sycommon/middleware/monitor_memory.py,sha256=pYRK-wRuDd6enSg9Pf8tQxPdYQS6S0AyjyXeKFRLKEs,628
|
|
205
205
|
sycommon/middleware/mq.py,sha256=9X6KKtadFjBXKS5L3kEKujYio9wwGfWgXwWOAHO-HDg,254
|
|
206
|
-
sycommon/middleware/sandbox.py,sha256=
|
|
206
|
+
sycommon/middleware/sandbox.py,sha256=DPvG1PhR57O4azvekS1C3XZ-lsS6yvOI5BKDDza9deI,68216
|
|
207
207
|
sycommon/middleware/timeout.py,sha256=KlxOPa8xl2dg6yuRi_EzkVJG8bX4stb5ueYxctzzGM8,1433
|
|
208
208
|
sycommon/middleware/token_tracking.py,sha256=rEbgV1bgWMdzAERx4aq5XAvOIT6jTY_tK1P0xHJnL3o,6609
|
|
209
|
-
sycommon/middleware/tool_result_truncation.py,sha256=
|
|
209
|
+
sycommon/middleware/tool_result_truncation.py,sha256=C5739a3HkpiEPLplTnBTGcVOUvkd-lQBl3oYsM0_7rI,10129
|
|
210
210
|
sycommon/middleware/traceid.py,sha256=HX4zg7Tp_mPNr2eWDDvK3f7GFJ6eOSeW062Xcc-xshc,14340
|
|
211
211
|
sycommon/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
212
212
|
sycommon/models/base_http.py,sha256=EICAAibx3xhjBsLqm35Mi3DCqxp0FME4rD_3iQVjT_E,3051
|
|
@@ -214,7 +214,7 @@ sycommon/models/log.py,sha256=rZpj6VkDRxK3B6H7XSeWdYZshU8F0Sks8bq1p6pPlDw,500
|
|
|
214
214
|
sycommon/models/mqlistener_config.py,sha256=japihb4kmP5ZqvqQEs6C7CmcrPlE1UmhAtCimoL61xI,1704
|
|
215
215
|
sycommon/models/mqmsg_model.py,sha256=Zo-LsDMFuF1Vkx9ZmwBC9E7TrCw-7nAQD2TE4v9-6F4,291
|
|
216
216
|
sycommon/models/mqsend_config.py,sha256=NQX9dc8PpuquMG36GCVhJe8omAW1KVXXqr6lSRU6D7I,268
|
|
217
|
-
sycommon/models/sandbox.py,sha256=
|
|
217
|
+
sycommon/models/sandbox.py,sha256=bW273k59UndtAgUTQEIrZ4oL_ZaaO0pco1THtU_dj2M,7382
|
|
218
218
|
sycommon/models/sso_user.py,sha256=uqLlZevVaDQQche5eBq8m1b8lu-45Pe4vHY3kd3NdxA,2896
|
|
219
219
|
sycommon/models/token_usage.py,sha256=w3Aash7jUVSUIgKxBUzI6SqJ9VV5Pk7rLBbMUlLv6nA,1242
|
|
220
220
|
sycommon/models/token_usage_mysql.py,sha256=eWnCvI2FDwyTA_fr4wWQtAmUnFJgLwrt0WXMXHLAESQ,2633
|
|
@@ -265,8 +265,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
|
|
|
265
265
|
sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
|
|
266
266
|
sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
|
|
267
267
|
sycommon/xxljob/xxljob_service.py,sha256=JIEJaGXhqrTLcyxlyynSrsHg9bBnDNzX-D4qIWLRPUE,6815
|
|
268
|
-
sycommon_python_lib-0.2.
|
|
269
|
-
sycommon_python_lib-0.2.
|
|
270
|
-
sycommon_python_lib-0.2.
|
|
271
|
-
sycommon_python_lib-0.2.
|
|
272
|
-
sycommon_python_lib-0.2.
|
|
268
|
+
sycommon_python_lib-0.2.5a0.dist-info/METADATA,sha256=Nr9thCmAZmPdc2M2phRRYCQ6rOCzvak_fQd42DuWaRM,7879
|
|
269
|
+
sycommon_python_lib-0.2.5a0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
270
|
+
sycommon_python_lib-0.2.5a0.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
|
|
271
|
+
sycommon_python_lib-0.2.5a0.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
|
|
272
|
+
sycommon_python_lib-0.2.5a0.dist-info/RECORD,,
|
|
File without changes
|
{sycommon_python_lib-0.2.4a7.dist-info → sycommon_python_lib-0.2.5a0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{sycommon_python_lib-0.2.4a7.dist-info → sycommon_python_lib-0.2.5a0.dist-info}/top_level.txt
RENAMED
|
File without changes
|