Skip to content

Commit ba3f393

Browse files
committed
merge: resolve upstream master conflicts
2 parents c175649 + 49cd4d2 commit ba3f393

6 files changed

Lines changed: 502 additions & 11 deletions

File tree

astrbot/core/astr_main_agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ async def _apply_kb(
229229
config: MainAgentBuildConfig,
230230
) -> None:
231231
if not config.kb_agentic_mode:
232-
if req.prompt is None:
232+
if req.prompt is None or not req.prompt.strip():
233233
return
234234
try:
235235
kb_result = await retrieve_knowledge_base(

astrbot/core/computer/booters/cua.py

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,43 @@ def _has_component_method(root: Any, component_name: str, method_name: str) -> b
232232
return getattr(component, method_name, None) is not None
233233

234234

235+
def _resolve_files_components(sandbox: Any) -> tuple[Any, ...]:
236+
components: list[Any] = []
237+
seen_ids: set[int] = set()
238+
for name in ("files", "filesystem"):
239+
component = getattr(sandbox, name, None)
240+
if component is None:
241+
continue
242+
component_id = id(component)
243+
if component_id in seen_ids:
244+
continue
245+
seen_ids.add(component_id)
246+
components.append(component)
247+
return tuple(components)
248+
249+
250+
def _resolve_files_method(
251+
components: tuple[Any, ...],
252+
method_names: str | tuple[str, ...],
253+
) -> Any | None:
254+
for component in components:
255+
method = _resolve_component_method(component, method_names)
256+
if method is not None:
257+
return method
258+
return None
259+
260+
261+
def _normalize_native_upload_result(raw: Any, file_name: str) -> dict[str, Any]:
262+
payload = _maybe_model_dump(raw)
263+
if not payload:
264+
return {"success": True, "file_path": file_name}
265+
if "file_path" not in payload and "path" not in payload:
266+
payload["file_path"] = file_name
267+
if "success" not in payload:
268+
payload["success"] = not bool(payload.get("error") or payload.get("stderr"))
269+
return payload
270+
271+
235272
class CuaShellComponent(ShellComponent):
236273
def __init__(self, sandbox: Any, os_type: str = "linux") -> None:
237274
self._sandbox = sandbox
@@ -360,7 +397,7 @@ def __init__(
360397
self, sandbox: Any, os_type: str = CUA_DEFAULT_CONFIG["os_type"]
361398
) -> None:
362399
self._shell = CuaShellComponent(sandbox, os_type=os_type)
363-
self._fs = getattr(sandbox, "filesystem", None)
400+
self._fs_components = _resolve_files_components(sandbox)
364401
self._os_type = os_type.lower()
365402
self._fallback = _PosixShellFileSystem(self._shell, self._os_type)
366403

@@ -382,7 +419,9 @@ async def read_file(
382419
offset: int | None = None,
383420
limit: int | None = None,
384421
) -> dict[str, Any]:
385-
read_file = None if self._fs is None else getattr(self._fs, "read_file", None)
422+
read_file = _resolve_files_method(
423+
self._fs_components, ("read_file", "read_text")
424+
)
386425
if read_file is None:
387426
return await self._fallback.read_file(path, encoding, offset, limit)
388427
else:
@@ -405,19 +444,19 @@ async def write_file(
405444
encoding: str = "utf-8",
406445
) -> dict[str, Any]:
407446
_ = mode
408-
write_file = None if self._fs is None else getattr(self._fs, "write_file", None)
447+
write_file = _resolve_files_method(
448+
self._fs_components, ("write_file", "write_text")
449+
)
409450
if write_file is None:
410451
return await self._fallback.write_file(path, content, mode, encoding)
411452
else:
412453
await _maybe_await(write_file(path, content))
413454
return {"success": True, "path": path}
414455

415456
async def delete_file(self, path: str) -> dict[str, Any]:
416-
delete = None
417-
if self._fs is not None:
418-
delete = getattr(self._fs, "delete", None) or getattr(
419-
self._fs, "delete_file", None
420-
)
457+
delete = _resolve_files_method(
458+
self._fs_components, ("delete", "delete_file", "remove")
459+
)
421460
if delete is None:
422461
return await self._fallback.delete_file(path)
423462
else:
@@ -429,7 +468,7 @@ async def list_dir(
429468
path: str = ".",
430469
show_hidden: bool = False,
431470
) -> dict[str, Any]:
432-
list_dir = None if self._fs is None else getattr(self._fs, "list_dir", None)
471+
list_dir = _resolve_files_method(self._fs_components, ("list_dir", "list"))
433472
if list_dir is not None:
434473
entries = await _maybe_await(list_dir(path))
435474
return {"success": True, "path": path, "entries": entries}
@@ -802,6 +841,15 @@ async def upload_file(self, path: str, file_name: str) -> dict:
802841
return _maybe_model_dump(
803842
await sandbox.upload_file(str(local_path), file_name)
804843
)
844+
files_components = () if sandbox is None else _resolve_files_components(sandbox)
845+
upload = _resolve_files_method(files_components, "upload")
846+
if upload is not None:
847+
result = await _maybe_await(upload(str(local_path), file_name))
848+
return _normalize_native_upload_result(result, file_name)
849+
write_bytes = _resolve_files_method(files_components, "write_bytes")
850+
if write_bytes is not None:
851+
result = await _maybe_await(write_bytes(file_name, local_path.read_bytes()))
852+
return _normalize_native_upload_result(result, file_name)
805853
if not _is_posix_os_type(self.os_type):
806854
return _non_posix_filesystem_result(file_name, self.os_type)
807855
result = await _write_base64_via_shell(

astrbot/core/computer/computer_client.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import asyncio
12
import json
23
import os
34
import shutil
5+
import time
46
import uuid
7+
from dataclasses import dataclass
58
from pathlib import Path
69

710
from astrbot.api import logger
@@ -20,6 +23,70 @@
2023
_MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json"
2124

2225

26+
@dataclass(slots=True)
27+
class _CUAIdleState:
28+
expires_at: float
29+
task: asyncio.Task
30+
31+
32+
cua_idle_state: dict[str, _CUAIdleState] = {}
33+
34+
35+
def _get_cua_idle_timeout(config: dict) -> float:
36+
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
37+
value = sandbox_cfg.get("cua_idle_timeout", 0)
38+
try:
39+
timeout = float(value)
40+
except (TypeError, ValueError):
41+
return 0.0
42+
return max(timeout, 0.0)
43+
44+
45+
def _clear_cua_idle_state(session_id: str) -> None:
46+
state = cua_idle_state.pop(session_id, None)
47+
if state is not None and not state.task.done():
48+
state.task.cancel()
49+
50+
51+
def _schedule_cua_idle_cleanup(session_id: str, timeout: float) -> None:
52+
_clear_cua_idle_state(session_id)
53+
if timeout <= 0:
54+
return
55+
expires_at = time.monotonic() + timeout
56+
57+
async def _expire_when_idle() -> None:
58+
try:
59+
remaining = expires_at - time.monotonic()
60+
if remaining > 0:
61+
await asyncio.sleep(remaining)
62+
63+
state = cua_idle_state.get(session_id)
64+
if state is None or state.expires_at != expires_at:
65+
return
66+
67+
booter = session_booter.get(session_id)
68+
if booter is not None:
69+
try:
70+
await booter.shutdown()
71+
except Exception as shutdown_err:
72+
logger.warning(
73+
"[Computer] Failed to shutdown idle CUA sandbox for session %s: %s",
74+
session_id,
75+
shutdown_err,
76+
)
77+
finally:
78+
session_booter.pop(session_id, None)
79+
except asyncio.CancelledError:
80+
raise
81+
finally:
82+
state = cua_idle_state.get(session_id)
83+
if state is not None and state.expires_at == expires_at:
84+
cua_idle_state.pop(session_id, None)
85+
86+
task = asyncio.create_task(_expire_when_idle())
87+
cua_idle_state[session_id] = _CUAIdleState(expires_at=expires_at, task=task)
88+
89+
2390
def _list_local_skill_dirs(skills_root: Path) -> list[Path]:
2491
skills: list[Path] = []
2592
for entry in sorted(skills_root.iterdir()):
@@ -486,6 +553,7 @@ async def get_booter(
486553

487554
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
488555
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
556+
cua_idle_timeout = _get_cua_idle_timeout(config) if booter_type == "cua" else 0.0
489557

490558
if session_id in session_booter:
491559
booter = session_booter[session_id]
@@ -506,6 +574,7 @@ async def get_booter(
506574
session_id,
507575
shutdown_err,
508576
)
577+
_clear_cua_idle_state(session_id)
509578
session_booter.pop(session_id, None)
510579
if session_id not in session_booter:
511580
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
@@ -579,9 +648,12 @@ async def get_booter(
579648
session_id,
580649
shutdown_error,
581650
)
651+
_clear_cua_idle_state(session_id)
582652
raise e
583653

584654
session_booter[session_id] = client
655+
if booter_type == "cua":
656+
_schedule_cua_idle_cleanup(session_id, cua_idle_timeout)
585657
return session_booter[session_id]
586658

587659

astrbot/core/pipeline/result_decorate/stage.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,9 @@ async def process(
289289
),
290290
)
291291
else:
292-
result.chain.insert(0, Plain(f"🤔 思考: {reasoning_content}\n"))
292+
result.chain.insert(
293+
0, Plain(f"🤔 思考: {reasoning_content}\n\n────\n")
294+
)
293295

294296
if should_tts and tts_provider:
295297
new_chain = []

tests/unit/test_astr_main_agent.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,23 @@ async def test_apply_kb_no_prompt(self, mock_event, mock_context):
342342

343343
assert req.system_prompt == "System"
344344

345+
@pytest.mark.asyncio
346+
@pytest.mark.parametrize("prompt", ["", " \n\t"])
347+
async def test_apply_kb_blank_prompt(self, prompt, mock_event, mock_context):
348+
"""Test applying knowledge base when prompt is blank."""
349+
module = ama
350+
req = ProviderRequest(prompt=prompt, system_prompt="System")
351+
config = module.MainAgentBuildConfig(
352+
tool_call_timeout=60, kb_agentic_mode=False
353+
)
354+
retrieve = AsyncMock(return_value="KB result")
355+
356+
with patch("astrbot.core.astr_main_agent.retrieve_knowledge_base", retrieve):
357+
await module._apply_kb(mock_event, req, mock_context, config)
358+
359+
retrieve.assert_not_awaited()
360+
assert req.system_prompt == "System"
361+
345362
@pytest.mark.asyncio
346363
async def test_apply_kb_no_result(self, mock_event, mock_context):
347364
"""Test applying knowledge base when no result is returned."""

0 commit comments

Comments
 (0)