202 lines
7.8 KiB
Python
202 lines
7.8 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from deepagents.backends.composite import (
|
|
CompositeBackend,
|
|
_remap_file_info_path,
|
|
_route_for_path,
|
|
_strip_route_from_pattern,
|
|
)
|
|
from deepagents.backends.protocol import FileInfo, GlobResult
|
|
from deepagents.middleware.filesystem import FilesystemMiddleware
|
|
|
|
from yuxi.agents.skills.service import normalize_string_list
|
|
from yuxi.utils.paths import VIRTUAL_PATH_CONVERSATION_HISTORY, VIRTUAL_PATH_LARGE_TOOL_RESULTS, VIRTUAL_PATH_OUTPUTS
|
|
|
|
from .sandbox import ProvisionerSandboxBackend
|
|
from .skills_backend import SelectedSkillsReadonlyBackend
|
|
|
|
_TOOL_RESULT_EVICTION_EXEMPT_TOOLS = frozenset({"read_file", "open_kb_document"})
|
|
|
|
|
|
def _coerce_glob_result(result) -> GlobResult:
|
|
if isinstance(result, GlobResult):
|
|
return result
|
|
return GlobResult(matches=result or [])
|
|
|
|
|
|
class CustomCompositeBackend(CompositeBackend):
|
|
"""修复 glob 路由逻辑的 CompositeBackend。"""
|
|
|
|
def glob(self, pattern: str, path: str = "/") -> GlobResult:
|
|
backend, backend_path, route_prefix = _route_for_path(
|
|
default=self.default,
|
|
sorted_routes=self.sorted_routes,
|
|
path=path,
|
|
)
|
|
if route_prefix is not None:
|
|
result = _coerce_glob_result(backend.glob(pattern, backend_path))
|
|
if result.error:
|
|
return result
|
|
return GlobResult(matches=[_remap_file_info_path(fi, route_prefix) for fi in (result.matches or [])])
|
|
|
|
if path is None or path == "/":
|
|
results: list[FileInfo] = []
|
|
default_result = _coerce_glob_result(self.default.glob(pattern, path))
|
|
if default_result.error:
|
|
return default_result
|
|
results.extend(default_result.matches or [])
|
|
for route_prefix, backend in self.routes.items():
|
|
route_pattern = _strip_route_from_pattern(pattern, route_prefix)
|
|
result = _coerce_glob_result(backend.glob(route_pattern, "/"))
|
|
if result.error:
|
|
return result
|
|
results.extend(_remap_file_info_path(fi, route_prefix) for fi in (result.matches or []))
|
|
results.sort(key=lambda x: x.get("path", ""))
|
|
return GlobResult(matches=results)
|
|
|
|
return _coerce_glob_result(self.default.glob(pattern, path))
|
|
|
|
async def aglob(self, pattern: str, path: str = "/") -> GlobResult:
|
|
backend, backend_path, route_prefix = _route_for_path(
|
|
default=self.default,
|
|
sorted_routes=self.sorted_routes,
|
|
path=path,
|
|
)
|
|
if route_prefix is not None:
|
|
result = _coerce_glob_result(await backend.aglob(pattern, backend_path))
|
|
if result.error:
|
|
return result
|
|
return GlobResult(matches=[_remap_file_info_path(fi, route_prefix) for fi in (result.matches or [])])
|
|
|
|
if path is None or path == "/":
|
|
results: list[FileInfo] = []
|
|
default_result = _coerce_glob_result(await self.default.aglob(pattern, path))
|
|
if default_result.error:
|
|
return default_result
|
|
results.extend(default_result.matches or [])
|
|
for route_prefix, backend in self.routes.items():
|
|
route_pattern = _strip_route_from_pattern(pattern, route_prefix)
|
|
result = _coerce_glob_result(await backend.aglob(route_pattern, "/"))
|
|
if result.error:
|
|
return result
|
|
results.extend(_remap_file_info_path(fi, route_prefix) for fi in (result.matches or []))
|
|
results.sort(key=lambda x: x.get("path", ""))
|
|
return GlobResult(matches=results)
|
|
|
|
return _coerce_glob_result(await self.default.aglob(pattern, path))
|
|
|
|
|
|
class YuxiFilesystemMiddleware(FilesystemMiddleware):
|
|
"""Filesystem middleware that budgets large tool outputs before they hit model context."""
|
|
|
|
def wrap_tool_call(self, request, handler):
|
|
tool_result = handler(request)
|
|
|
|
if self._tool_token_limit_before_evict is None:
|
|
return tool_result
|
|
if request.tool_call["name"] in _TOOL_RESULT_EVICTION_EXEMPT_TOOLS:
|
|
return tool_result
|
|
|
|
return self._intercept_large_tool_result(tool_result, request.runtime)
|
|
|
|
async def awrap_tool_call(self, request, handler):
|
|
tool_result = await handler(request)
|
|
|
|
if self._tool_token_limit_before_evict is None:
|
|
return tool_result
|
|
if request.tool_call["name"] in _TOOL_RESULT_EVICTION_EXEMPT_TOOLS:
|
|
return tool_result
|
|
|
|
return await self._aintercept_large_tool_result(tool_result, request.runtime)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _BackendScope:
|
|
thread_id: str
|
|
uid: str
|
|
readable_skills: list[str]
|
|
file_thread_id: str
|
|
skills_thread_id: str
|
|
|
|
@classmethod
|
|
def from_runtime(cls, runtime) -> _BackendScope:
|
|
config = getattr(runtime, "config", None)
|
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
|
context = getattr(runtime, "context", None)
|
|
state = getattr(runtime, "state", None)
|
|
return cls.from_sources(
|
|
configurable if isinstance(configurable, dict) else {},
|
|
context,
|
|
state if isinstance(state, dict) else {},
|
|
readable_skills_source=context,
|
|
error_context="runtime configurable context",
|
|
)
|
|
|
|
@classmethod
|
|
def from_sources(cls, *sources, readable_skills_source, error_context: str) -> _BackendScope:
|
|
def string_value(key: str) -> str | None:
|
|
for source in sources:
|
|
value = source.get(key) if isinstance(source, dict) else getattr(source, key, None)
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return None
|
|
|
|
thread_id = string_value("thread_id")
|
|
if not thread_id:
|
|
raise ValueError(f"thread_id is required in {error_context}")
|
|
|
|
uid = string_value("uid")
|
|
if not uid:
|
|
raise ValueError(f"uid is required in {error_context}")
|
|
|
|
selected = getattr(readable_skills_source, "_readable_skills", [])
|
|
return cls(
|
|
thread_id=thread_id,
|
|
uid=uid,
|
|
readable_skills=normalize_string_list(selected if isinstance(selected, list) else []),
|
|
file_thread_id=string_value("file_thread_id") or thread_id,
|
|
skills_thread_id=string_value("skills_thread_id") or thread_id,
|
|
)
|
|
|
|
def create_backend(self) -> CompositeBackend:
|
|
return CustomCompositeBackend(
|
|
default=ProvisionerSandboxBackend(
|
|
thread_id=self.thread_id,
|
|
uid=self.uid,
|
|
readable_skills=self.readable_skills,
|
|
file_thread_id=self.file_thread_id,
|
|
skills_thread_id=self.skills_thread_id,
|
|
),
|
|
routes={
|
|
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=self.readable_skills),
|
|
},
|
|
artifacts_root=VIRTUAL_PATH_OUTPUTS,
|
|
)
|
|
|
|
|
|
def create_agent_composite_backend(runtime) -> CompositeBackend:
|
|
return _BackendScope.from_runtime(runtime).create_backend()
|
|
|
|
|
|
def create_agent_filesystem_middleware(
|
|
tool_token_limit_before_evict: int | None = None,
|
|
*,
|
|
context=None,
|
|
) -> FilesystemMiddleware:
|
|
backend = create_agent_composite_backend
|
|
if context is not None:
|
|
backend = _BackendScope.from_sources(
|
|
context,
|
|
readable_skills_source=context,
|
|
error_context="runtime context",
|
|
).create_backend()
|
|
middleware = YuxiFilesystemMiddleware(
|
|
backend=backend,
|
|
tool_token_limit_before_evict=tool_token_limit_before_evict,
|
|
)
|
|
middleware._large_tool_results_prefix = VIRTUAL_PATH_LARGE_TOOL_RESULTS
|
|
middleware._conversation_history_prefix = VIRTUAL_PATH_CONVERSATION_HISTORY
|
|
return middleware
|