diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 775ad37c7b..f29595c49f 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -520,9 +520,10 @@ async def _wake_main_agent_for_background_result( extra_result_fields: dict[str, T.Any] | None = None, ) -> None: from astrbot.core.astr_main_agent import ( - MainAgentBuildConfig, _get_session_conv, + append_proactive_history, build_main_agent, + build_proactive_agent_config, ) event = run_context.context.event @@ -549,24 +550,17 @@ async def _wake_main_agent_for_background_result( cron_event.role = event.role cfg = ctx.get_config(umo=event.unified_msg_origin) or {} provider_settings = cfg.get("provider_settings") or {} - config = MainAgentBuildConfig( - tool_call_timeout=run_context.tool_call_timeout, - streaming_response=provider_settings.get("stream", False), + config = build_proactive_agent_config( + plugin_context=ctx, + app_config=cfg, provider_settings=provider_settings, + tool_call_timeout=run_context.tool_call_timeout, ) req = ProviderRequest() conv = await _get_session_conv(event=cron_event, plugin_context=ctx) req.conversation = conv - context = json.loads(conv.history) - if context: - req.contexts = context - context_dump = req._print_friendly_context() - req.contexts = [] - req.system_prompt += ( - "\n\nBellow is you and user previous conversation history:\n" - f"{context_dump}" - ) + append_proactive_history(req, conv, config) bg = json.dumps(extras["background_task_result"], ensure_ascii=False) req.system_prompt += BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT.format( @@ -590,14 +584,23 @@ async def _wake_main_agent_for_background_result( event=cron_event, plugin_context=ctx, config=config, req=req ) if not result: - logger.error(f"Failed to build main agent for background task {tool_name}.") - return + raise RuntimeError( + f"Failed to build main agent for background task {tool_name}" + ) runner = result.agent_runner - async for _ in runner.step_until_done(30): + async for _ in runner.step_until_done(config.max_agent_step): # agent will send message to user via using tools pass llm_resp = runner.get_final_llm_resp() + if not llm_resp or llm_resp.role == "err": + error_text = ( + llm_resp.completion_text + if llm_resp and llm_resp.completion_text + else "Background task agent returned no usable response" + ) + raise RuntimeError(error_text) + task_meta = extras.get("background_task_result", {}) summary_note = ( f"[BackgroundTask] {summary_name} " @@ -614,9 +617,6 @@ async def _wake_main_agent_for_background_result( req=req, summary_note=summary_note, ) - if not llm_resp: - logger.warning("background task agent got no response") - return @classmethod async def _execute_local( diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 7f63a1f844..c075972bcf 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -12,9 +12,11 @@ from pathlib import Path from astrbot.core import logger +from astrbot.core.agent.context.token_counter import EstimateTokenCounter +from astrbot.core.agent.context.truncator import ContextTruncator from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool -from astrbot.core.agent.message import TextPart +from astrbot.core.agent.message import Message, TextPart from astrbot.core.agent.tool import ToolSet from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContext from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS @@ -212,6 +214,8 @@ class MainAgentBuildConfig: timezone: str | None = None max_quoted_fallback_images: int = 20 """Maximum number of images injected from quoted-message fallback extraction.""" + max_agent_step: int = 30 + """Maximum number of agent steps for callers that drive the runner directly.""" @dataclass(slots=True) @@ -222,6 +226,159 @@ class MainAgentBuildResult: reset_coro: Coroutine | None = None +PROACTIVE_HISTORY_MAX_TOKENS = 8192 + + +def build_proactive_agent_config( + *, + plugin_context: Context, + app_config: dict, + provider_settings: dict, + tool_call_timeout: int | None = None, + streaming_response: bool | None = None, + llm_safety_mode: bool | None = None, + add_cron_tools: bool | None = None, +) -> MainAgentBuildConfig: + """Build a main-agent config for proactive entry points. + + Proactive jobs must use the same provider settings as normal messages. The + explicit overrides are limited to behavior that is inherent to a wake-up + path, such as disabling streaming for cron delivery. + + Args: + plugin_context: AstrBot context used for global configuration lookup. + app_config: Global AstrBot configuration. + provider_settings: Provider and agent settings. + tool_call_timeout: Optional timeout override for tool calls. + streaming_response: Optional streaming override for the caller. + llm_safety_mode: Optional safety-mode override for the caller. + add_cron_tools: Optional cron-tool availability override for the caller. + + Returns: + Configuration shared by proactive and normal agent entry points. + """ + settings = provider_settings or {} + file_extract_conf = settings.get("file_extract", {}) or {} + global_config = app_config or {} + proactive_cfg = settings.get("proactive_capability", {}) or {} + if streaming_response is None: + streaming_response = settings.get( + "streaming_response", settings.get("stream", False) + ) + if add_cron_tools is None: + add_cron_tools = proactive_cfg.get("add_cron_tools", True) + + return MainAgentBuildConfig( + tool_call_timeout=int( + settings.get("tool_call_timeout", 120) + if tool_call_timeout is None + else tool_call_timeout + ), + tool_schema_mode=settings.get("tool_schema_mode", "full"), + streaming_response=bool(streaming_response), + sanitize_context_by_modalities=bool( + settings.get("sanitize_context_by_modalities", False) + ), + kb_agentic_mode=bool(global_config.get("kb_agentic_mode", False)), + file_extract_enabled=bool(file_extract_conf.get("enable", False)), + file_extract_prov=file_extract_conf.get("provider", "moonshotai"), + file_extract_msh_api_key=file_extract_conf.get("moonshotai_api_key", ""), + context_limit_reached_strategy=settings.get( + "context_limit_reached_strategy", "truncate_by_turns" + ), + llm_compress_instruction=settings.get("llm_compress_instruction", ""), + llm_compress_keep_recent_ratio=float( + settings.get("llm_compress_keep_recent_ratio", 0.15) + ), + llm_compress_provider_id=settings.get("llm_compress_provider_id", ""), + max_context_length=int(settings.get("max_context_length", -1)), + dequeue_context_length=int(settings.get("dequeue_context_length", 1)), + fallback_max_context_tokens=int( + settings.get("fallback_max_context_tokens", 128000) + ), + llm_safety_mode=bool( + settings.get("llm_safety_mode", True) + if llm_safety_mode is None + else llm_safety_mode + ), + safety_mode_strategy=settings.get("safety_mode_strategy", "system_prompt"), + computer_use_runtime=settings.get("computer_use_runtime", "none"), + sandbox_cfg=settings.get("sandbox", {}) or {}, + add_cron_tools=bool(add_cron_tools), + provider_settings=settings, + subagent_orchestrator=global_config.get("subagent_orchestrator", {}) or {}, + timezone=global_config.get("timezone") + or plugin_context.get_config().get("timezone"), + max_quoted_fallback_images=int(settings.get("max_quoted_fallback_images", 20)), + max_agent_step=int(settings.get("max_agent_step", 30)), + ) + + +def append_proactive_history( + req: ProviderRequest, + conversation: Conversation, + config: MainAgentBuildConfig, +) -> None: + """Add bounded conversation history to a proactive system prompt. + + Wake-up prompts intentionally describe history as reference material, but + they still need a hard bound. Otherwise they bypass the runner's normal + context manager because proactive callers flatten history into the system + prompt. + + Args: + req: Provider request receiving the formatted history. + conversation: Conversation whose history should be included. + config: Agent configuration controlling history truncation. + """ + try: + raw_context = json.loads(conversation.history or "[]") + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to parse proactive conversation history: %s", exc) + return + + if not raw_context: + return + + messages: list[Message] = [] + for item in raw_context: + try: + messages.append(Message.model_validate(item)) + except Exception: # noqa: BLE001 + logger.debug("Skip malformed proactive history item: %r", item) + + if not messages: + return + + truncator = ContextTruncator() + if config.max_context_length != -1: + messages = truncator.truncate_by_turns( + messages, + keep_most_recent_turns=config.max_context_length, + drop_turns=max(config.dequeue_context_length, 1), + ) + + token_counter = EstimateTokenCounter() + while ( + len(messages) > 2 + and token_counter.count_tokens(messages) > PROACTIVE_HISTORY_MAX_TOKENS + ): + next_messages = truncator.truncate_by_dropping_oldest_turns( + messages, drop_turns=max(config.dequeue_context_length, 1) + ) + if len(next_messages) >= len(messages): + break + messages = next_messages + + req.contexts = [message.model_dump(exclude_none=True) for message in messages] + context_dump = req._print_friendly_context() + req.contexts = [] + req.system_prompt += ( + "\n\nBelow is bounded previous conversation history for reference only:\n" + f"---\n{context_dump}\n---\n" + ) + + def _set_llm_error_message(event: AstrMessageEvent, message: str) -> None: event.set_extra(LLM_ERROR_MESSAGE_EXTRA_KEY, message) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index eac4bafc01..2207363a3c 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -159,6 +159,7 @@ "reachability_check": False, "max_agent_step": 30, "tool_call_timeout": 120, + "cron_job_timeout": 3600, "tool_schema_mode": "full", "llm_safety_mode": True, "safety_mode_strategy": "system_prompt", # TODO: llm judge @@ -2931,6 +2932,9 @@ "tool_call_timeout": { "type": "int", }, + "cron_job_timeout": { + "type": "int", + }, "tool_schema_mode": { "type": "string", }, diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index b5a0e7c3e4..74c2529f1c 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -26,6 +26,7 @@ _CRONTAB_WEEKDAY_NAMES = ("sun", "mon", "tue", "wed", "thu", "fri", "sat") _CRONTAB_WEEKDAY_PATTERN = re.compile(r"^(?:(\*)|(\d+)(?:-(\d+))?)(?:/(\d+))?$") +DEFAULT_CRON_JOB_TIMEOUT = 3600 def _normalize_crontab_day_of_week(day_of_week: str) -> str: @@ -129,6 +130,21 @@ async def shutdown(self) -> None: async def sync_from_db(self) -> None: jobs = await self.db.list_cron_jobs() for job in jobs: + if job.status == "running": + interrupted_error = ( + "Cron job was interrupted before the process restarted." + ) + logger.warning( + "Resetting interrupted cron job %s from running to failed.", + job.job_id, + ) + await self.db.update_cron_job( + job.job_id, + status="failed", + last_error=interrupted_error, + ) + job.status = "failed" + job.last_error = interrupted_error if not job.enabled or not job.persistent: continue if job.job_type == "basic" and job.job_id not in self._basic_handlers: @@ -269,6 +285,7 @@ def _schedule_job(self, job: CronJob) -> None: trigger=trigger, args=[job.job_id], replace_existing=True, + max_instances=1, misfire_grace_time=30, ) asyncio.create_task( @@ -343,8 +360,8 @@ async def _run_job( last_error=last_error, next_run_time=next_run, ) - if job.run_once and delete_run_once: - # one-shot: remove after execution regardless of success + if job.run_once and delete_run_once and status == "completed": + # Keep failed one-shot jobs available for inspection and retry. await self.delete_job(job_id) async def _run_basic_job(self, job: CronJob) -> None: @@ -385,12 +402,24 @@ async def _run_active_agent_job(self, job: CronJob, start_time: datetime) -> Non "cron_payload": payload, } - await self._woke_main_agent( - message=note, - session_str=session_str, - extras=extras, - delivery_session_str=delivery_session_str, + provider_settings = ( + self.ctx.get_config(umo=session_str).get("provider_settings", {}) or {} + ) + job_timeout = int( + provider_settings.get("cron_job_timeout", DEFAULT_CRON_JOB_TIMEOUT) ) + try: + async with asyncio.timeout(job_timeout): + await self._woke_main_agent( + message=note, + session_str=session_str, + extras=extras, + delivery_session_str=delivery_session_str, + ) + except asyncio.TimeoutError as exc: + raise RuntimeError( + f"Cron job agent exceeded the {job_timeout}s execution timeout" + ) from exc async def _woke_main_agent( self, @@ -402,9 +431,10 @@ async def _woke_main_agent( ) -> None: """Woke the main agent to handle the cron job message.""" from astrbot.core.astr_main_agent import ( - MainAgentBuildConfig, _get_session_conv, + append_proactive_history, build_main_agent, + build_proactive_agent_config, ) from astrbot.core.astr_main_agent_resources import ( PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT, @@ -418,8 +448,7 @@ async def _woke_main_agent( else MessageSession.from_str(session_str) ) except Exception as e: # noqa: BLE001 - logger.error(f"Invalid session for cron job: {e}") - return + raise RuntimeError(f"Invalid session for cron job: {e}") from e cron_event = CronMessageEvent( context=self.ctx, @@ -441,28 +470,18 @@ async def _woke_main_agent( cron_event.role = "admin" provider_settings = cfg.get("provider_settings", {}) or {} - tool_call_timeout = provider_settings.get("tool_call_timeout", 120) - config = MainAgentBuildConfig( - tool_call_timeout=tool_call_timeout, - llm_safety_mode=False, - streaming_response=False, + config = build_proactive_agent_config( + plugin_context=self.ctx, + app_config=cfg, provider_settings=provider_settings, + streaming_response=False, + llm_safety_mode=False, ) req = ProviderRequest() conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx) req.conversation = conv # finetine the messages - context = json.loads(conv.history) - if context: - req.contexts = context - context_dump = req._print_friendly_context() - req.contexts = [] - req.system_prompt += ( - "\n\nBellow is you and user previous conversation history:\n" - f"---\n" - f"{context_dump}\n" - f"---\n" - ) + append_proactive_history(req, conv, config) cron_job_str = json.dumps(extras.get("cron_job", {}), ensure_ascii=False) req.system_prompt += PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT.format( cron_job=cron_job_str @@ -484,14 +503,21 @@ async def _woke_main_agent( event=cron_event, plugin_context=self.ctx, config=config, req=req ) if not result: - logger.error("Failed to build main agent for cron job.") - return + raise RuntimeError("Failed to build main agent for cron job") runner = result.agent_runner - async for _ in runner.step_until_done(30): + async for _ in runner.step_until_done(config.max_agent_step): # agent will send message to user via using tools pass llm_resp = runner.get_final_llm_resp() + if not llm_resp or llm_resp.role == "err": + error_text = ( + llm_resp.completion_text + if llm_resp and llm_resp.completion_text + else "Cron agent returned no usable response" + ) + raise RuntimeError(error_text) + cron_meta = extras.get("cron_job", {}) if extras else {} summary_note = ( f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: {cron_meta.get('description', '')} " @@ -508,9 +534,6 @@ async def _woke_main_agent( req=req, summary_note=summary_note, ) - if not llm_resp: - logger.warning("Cron job agent got no response") - return __all__ = ["CronJobManager"] diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index 18256f65d9..aeb1228585 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -15,9 +15,9 @@ from astrbot.core.agent.response import AgentStats from astrbot.core.astr_main_agent import ( LLM_ERROR_MESSAGE_EXTRA_KEY, - MainAgentBuildConfig, MainAgentBuildResult, build_main_agent, + build_proactive_agent_config, ) from astrbot.core.message.components import File, Image, Record, Reply, Video from astrbot.core.message.message_event_result import ( @@ -128,30 +128,14 @@ async def initialize(self, ctx: PipelineContext) -> None: self.conv_manager = ctx.plugin_manager.context.conversation_manager - self.main_agent_cfg = MainAgentBuildConfig( + self.main_agent_cfg = build_proactive_agent_config( + plugin_context=ctx.plugin_manager.context, + app_config=conf, + provider_settings=settings, tool_call_timeout=self.tool_call_timeout, - tool_schema_mode=self.tool_schema_mode, - sanitize_context_by_modalities=self.sanitize_context_by_modalities, - kb_agentic_mode=self.kb_agentic_mode, - file_extract_enabled=self.file_extract_enabled, - file_extract_prov=self.file_extract_prov, - file_extract_msh_api_key=self.file_extract_msh_api_key, - context_limit_reached_strategy=self.context_limit_reached_strategy, - llm_compress_instruction=self.llm_compress_instruction, - llm_compress_keep_recent_ratio=self.llm_compress_keep_recent_ratio, - llm_compress_provider_id=self.llm_compress_provider_id, - max_context_length=self.max_context_length, - dequeue_context_length=self.dequeue_context_length, - fallback_max_context_tokens=self.fallback_max_context_tokens, + streaming_response=self.streaming_response, llm_safety_mode=self.llm_safety_mode, - safety_mode_strategy=self.safety_mode_strategy, - computer_use_runtime=self.computer_use_runtime, - sandbox_cfg=self.sandbox_cfg, add_cron_tools=self.add_cron_tools, - provider_settings=settings, - subagent_orchestrator=conf.get("subagent_orchestrator", {}), - timezone=self.ctx.plugin_manager.context.get_config().get("timezone"), - max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20), ) async def _send_llm_error_message( diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index c6ed77e500..aad8e8b8ed 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -151,9 +151,16 @@ def _print_friendly_context(self): continue role = ctx.get("role", "unknown") content = ctx.get("content", "") + tool_call_names = [] + for tool_call in ctx.get("tool_calls") or []: + if isinstance(tool_call, dict): + function = tool_call.get("function") or {} + name = function.get("name") if isinstance(function, dict) else None + if name: + tool_call_names.append(str(name)) if isinstance(content, str): - result_parts.append(f"{role}: {content}") + content_text = content elif isinstance(content, list): msg_parts = [] image_count = 0 @@ -180,7 +187,18 @@ def _print_friendly_context(self): else: msg_parts.append(f"[{audio_count} audios]") - result_parts.append(f"{role}: {''.join(msg_parts)}") + content_text = "".join(msg_parts) + else: + content_text = str(content or "") + + if tool_call_names: + tool_calls_text = f"[tool_calls: {', '.join(tool_call_names)}]" + content_text = ( + f"{content_text}\n{tool_calls_text}" + if content_text + else tool_calls_text + ) + result_parts.append(f"{role}: {content_text}") return "\n".join(result_parts) diff --git a/astrbot/dashboard/services/cron_service.py b/astrbot/dashboard/services/cron_service.py index 2c21551ce9..6412a4d222 100644 --- a/astrbot/dashboard/services/cron_service.py +++ b/astrbot/dashboard/services/cron_service.py @@ -37,7 +37,6 @@ def serialize_job(job) -> dict: data["note"] = payload.get("note") or data.get("description") or "" data["run_at"] = payload.get("run_at") data["run_once"] = data.get("run_once", False) - data.pop("status", None) return data async def list_jobs(self, job_type: str | None = None) -> list[dict]: diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 73aa4419dd..dc01ee2586 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -226,6 +226,75 @@ def test_config_with_custom_values(self): assert config.add_cron_tools is False +def test_build_proactive_agent_config_preserves_provider_settings(mock_context): + provider_settings = { + "tool_call_timeout": 77, + "tool_schema_mode": "skills-like", + "streaming_response": True, + "max_context_length": 12, + "dequeue_context_length": 2, + "max_agent_step": 18, + "proactive_capability": {"add_cron_tools": False}, + } + + config = ama.build_proactive_agent_config( + plugin_context=mock_context, + app_config={"kb_agentic_mode": True}, + provider_settings=provider_settings, + streaming_response=False, + ) + + assert config.tool_call_timeout == 77 + assert config.tool_schema_mode == "skills-like" + assert config.streaming_response is False + assert config.max_context_length == 12 + assert config.dequeue_context_length == 2 + assert config.max_agent_step == 18 + assert config.kb_agentic_mode is True + assert config.add_cron_tools is False + + +def test_append_proactive_history_is_bounded_and_keeps_tool_call_summary( + mock_conversation, +): + history = [] + for index in range(80): + history.extend( + [ + {"role": "user", "content": f"request {index} " + "x" * 500}, + { + "role": "assistant", + "content": f"answer {index} " + "y" * 500, + "tool_calls": [ + { + "id": f"call-{index}", + "type": "function", + "function": {"name": "write_diary", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "content": f"tool result {index}", + "tool_call_id": f"call-{index}", + }, + ] + ) + mock_conversation.history = ama.json.dumps(history) + req = ProviderRequest() + config = ama.MainAgentBuildConfig( + tool_call_timeout=60, + max_context_length=-1, + dequeue_context_length=1, + ) + + ama.append_proactive_history(req, mock_conversation, config) + + assert "[tool_calls: write_diary]" in req.system_prompt + assert "request 0" not in req.system_prompt + assert len(req.system_prompt) < 50000 + + class TestSelectProvider: """Tests for _select_provider function.""" diff --git a/tests/unit/test_cron_manager.py b/tests/unit/test_cron_manager.py index 47f97ef445..1f85be119f 100644 --- a/tests/unit/test_cron_manager.py +++ b/tests/unit/test_cron_manager.py @@ -610,7 +610,7 @@ async def gen(): return gen() def get_final_llm_resp(self): - return None + return MagicMock(role="assistant", completion_text="done") captured = {} @@ -646,6 +646,26 @@ async def fake_persist_agent_history(*args, **kwargs): assert config.provider_settings is provider_settings assert config.provider_settings["fallback_chat_models"] == ["fallback-provider"] + @pytest.mark.asyncio + async def test_run_job_marks_agent_error_as_failed_and_keeps_one_shot_job( + self, cron_manager, mock_db, sample_cron_job + ): + sample_cron_job.job_type = "active_agent" + sample_cron_job.run_once = True + mock_db.get_cron_job.return_value = sample_cron_job + cron_manager._run_active_agent_job = AsyncMock( + side_effect=RuntimeError("agent failed") + ) + cron_manager.delete_job = AsyncMock() + + await cron_manager._run_job(sample_cron_job.job_id) + + assert mock_db.update_cron_job.call_args_list[-1].kwargs["status"] == "failed" + assert mock_db.update_cron_job.call_args_list[-1].kwargs["last_error"] == ( + "agent failed" + ) + cron_manager.delete_job.assert_not_awaited() + class TestGetNextRunTime: """Tests for _get_next_run_time method."""