Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
logger.warning("LLM returned empty assistant message with no tool calls.")
self.run_context.messages.append(Message(role="assistant", content=parts))
self._append_run_messages([Message(role="assistant", content=parts)])

try:
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
Expand Down Expand Up @@ -313,6 +313,7 @@ async def reset(
):
m = await self._assemble_request_context_for_provider(request)
messages.append(Message.model_validate(m))
self._persistent_messages = list(messages)
if request.system_prompt:
messages.insert(
0,
Expand All @@ -323,6 +324,23 @@ async def reset(
self.stats = AgentStats()
self.stats.start_time = time.time()

def _append_run_messages(self, messages: list[Message]) -> None:
"""Append new agent messages to request and persistent histories.

Args:
messages: New messages produced during the current agent run.
"""
self.run_context.messages.extend(messages)
self._persistent_messages.extend(messages)

def get_persistent_messages(self) -> list[Message]:
"""Return the complete conversation history for persistence.

Returns:
A copy of the untrimmed messages accumulated during this agent run.
"""
return list(self._persistent_messages)

def _read_tool_hint(self) -> str:
if self.read_tool is not None:
return f"`{self.read_tool.name}`"
Expand Down Expand Up @@ -926,9 +944,7 @@ async def step(self):
tool_calls_result=tool_call_result_blocks,
)
# record the assistant message with tool calls
self.run_context.messages.extend(
tool_calls_result.to_openai_messages_model()
)
self._append_run_messages(tool_calls_result.to_openai_messages_model())

# If there are cached images and the model supports image input,
# append a user message with images so LLM can see them
Expand Down Expand Up @@ -960,8 +976,8 @@ async def step(self):
)
)
if image_parts:
self.run_context.messages.append(
Message(role="user", content=image_parts)
self._append_run_messages(
[Message(role="user", content=image_parts)]
)
logger.debug(
f"Appended {len(cached_images)} cached image(s) to context for LLM review"
Expand All @@ -988,11 +1004,13 @@ async def step_until_done(
if self.req:
self.req.func_tool = None
# 注入提示词
self.run_context.messages.append(
Message(
role="user",
content=self.MAX_STEPS_REACHED_PROMPT,
)
self._append_run_messages(
[
Message(
role="user",
content=self.MAX_STEPS_REACHED_PROMPT,
)
]
)
# 再执行最后一步
async for resp in self.step():
Expand Down Expand Up @@ -1411,7 +1429,7 @@ async def _finalize_aborted_step(
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if parts:
self.run_context.messages.append(Message(role="assistant", content=parts))
self._append_run_messages([Message(role="assistant", content=parts)])

try:
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ async def process(
event,
req,
agent_runner.get_final_llm_resp(),
agent_runner.run_context.messages,
agent_runner.get_persistent_messages(),
agent_runner.stats,
user_aborted=agent_runner.was_aborted(),
)
Expand Down Expand Up @@ -404,7 +404,7 @@ async def process(
event,
req,
final_resp,
agent_runner.run_context.messages,
agent_runner.get_persistent_messages(),
agent_runner.stats,
user_aborted=agent_runner.was_aborted(),
)
Expand Down
43 changes: 31 additions & 12 deletions astrbot/core/provider/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,19 +172,38 @@ async def text_chat_stream(
raise NotImplementedError()

async def pop_record(self, context: list) -> None:
"""弹出 context 第一条非系统提示词对话记录"""
poped = 0
indexs_to_pop = []
for idx, record in enumerate(context):
if record["role"] == "system":
continue
indexs_to_pop.append(idx)
poped += 1
if poped == 2:
break
"""Remove the oldest complete conversation turn from a request context.

for idx in reversed(indexs_to_pop):
context.pop(idx)
Args:
context: OpenAI-compatible message dictionaries to trim in place.
"""
first_message_index = next(
(
index
for index, record in enumerate(context)
if record.get("role") != "system"
),
None,
)
if first_message_index is None:
return

next_user_index = next(
(
index
for index in range(first_message_index + 1, len(context))
if context[index].get("role") == "user"
),
len(context),
)
context[:] = [
record
for index, record in enumerate(context)
if not (
first_message_index <= index < next_user_index
and record.get("role") != "system"
)
]
Comment on lines +180 to +206

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

pop_record 中,如果 context 列表中包含的是 Message 对象(例如 Pydantic 模型)而不是原始的 dict,直接调用 record.get("role") 会抛出 AttributeError。建议实现一个辅助函数 _get_role,兼容处理 dict 和具有 role 属性的对象,以提高代码的健壮性。

        def _get_role(msg):
            if isinstance(msg, dict):
                return msg.get("role")
            return getattr(msg, "role", None)

        first_message_index = next(
            (
                index
                for index, record in enumerate(context)
                if _get_role(record) != "system"
            ),
            None,
        )
        if first_message_index is None:
            return

        next_user_index = next(
            (
                index
                for index in range(first_message_index + 1, len(context))
                if _get_role(context[index]) == "user"
            ),
            len(context),
        )
        context[:] = [
            record
            for index, record in enumerate(context)
            if not (
                first_message_index < index < next_user_index
                and _get_role(record) != "system"
            )
        ]


def _ensure_message_to_dicts(
self,
Expand Down
21 changes: 20 additions & 1 deletion astrbot/dashboard/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse

from astrbot.core import LogBroker
from astrbot.core import LogBroker, logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.dashboard.responses import ApiError, error
Expand Down Expand Up @@ -162,6 +162,25 @@ async def http_error_handler(_request: Request, exc: HTTPException):
detail = exc.detail if isinstance(exc.detail, str) else "Request failed"
return JSONResponse(error(detail), status_code=exc.status_code)

@app.exception_handler(Exception)
async def unhandled_error_handler(request: Request, exc: Exception):
"""Log unexpected API failures and return a safe JSON response.

Args:
request: Dashboard request that raised the exception.
exc: Unhandled exception raised while serving the request.

Returns:
A generic JSON error response without internal exception details.
"""
logger.exception(
"Unhandled dashboard API exception for %s %s: %s",
request.method,
request.url.path,
exc,
)
return JSONResponse(error("Internal server error"), status_code=500)

# Legacy dashboard routes keep old /api/* callers working without entering OpenAPI.
app.include_router(legacy_api_keys_router)
app.include_router(legacy_auth_router)
Expand Down
2 changes: 1 addition & 1 deletion astrbot/dashboard/services/skills_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ def prepare_skill_archive(self, name: str) -> SkillArchive:
export_dir = Path(get_astrbot_temp_path()) / "skill_exports"
export_dir.mkdir(parents=True, exist_ok=True)
zip_base = export_dir / skill_name
zip_path = zip_base.with_suffix(".zip")
zip_path = Path(f"{zip_base}.zip")
if zip_path.exists():
zip_path.unlink()

Expand Down
19 changes: 17 additions & 2 deletions dashboard/src/composables/useProviderSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
const modelSearch = ref('')

let suppressSourceWatch = false
let persistedProviderSourceIds = new Set<string>()

const providerTypes = computed(() => [
{ value: 'chat_completion', label: tm('providers.tabs.chatCompletion'), icon: 'mdi-message-text' },
Expand Down Expand Up @@ -454,8 +455,14 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
)
if (!confirmed) return

const sourceId = String(source.id || '')
const isPersisted = persistedProviderSourceIds.has(sourceId)

try {
await providerApi.deleteSource(source.id)
if (isPersisted) {
await providerApi.deleteSource(sourceId)
persistedProviderSourceIds.delete(sourceId)
}

providers.value = providers.value.filter((p) => p.provider_source_id !== source.id)
providerSources.value = providerSources.value.filter((s) => s.id !== source.id)
Expand All @@ -464,13 +471,18 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
selectedProviderSource.value = null
selectedProviderSourceOriginalId.value = null
editableProviderSource.value = null
availableModels.value = []
modelMetadata.value = {}
isSourceModified.value = false
}

showMessage(tm('providerSources.deleteSuccess'))
} catch (error: any) {
showMessage(error.message || tm('providerSources.deleteError'), 'error')
} finally {
await loadConfig()
if (isPersisted) {
await loadConfig()
}
}
}

Expand Down Expand Up @@ -691,6 +703,9 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
providerTemplates.value = configSchema.value.provider.config_template
}
providerSources.value = response.data.data.provider_sources || []
persistedProviderSourceIds = new Set(
providerSources.value.map((source: any) => String(source.id || ''))
)
modelMetadata.value = (response.data.data.model_metadata || {}) as Record<string, any>
providers.value = response.data.data.providers || []
}
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/utils/providerMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function contextLimit(
provider: ProviderMetadataSource | null | undefined,
metadata?: ProviderModelMetadata | null
): number {
const context = Number(metadata?.limit?.context || provider?.max_context_tokens || 0)
const context = Number(provider?.max_context_tokens || metadata?.limit?.context || 0)
return Number.isFinite(context) && context > 0 ? context : 0
}

Expand Down
47 changes: 47 additions & 0 deletions tests/agent/test_tool_loop_runner_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import pytest

from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial


@pytest.mark.asyncio
async def test_persistent_history_is_not_replaced_by_trimmed_request_context():
provider = ProviderOpenAIOfficial(
provider_config={
"id": "test-openai",
"type": "openai_chat_completion",
"model": "gpt-4o-mini",
"key": ["test-key"],
},
provider_settings={},
)
request = ProviderRequest(
prompt="current request",
contexts=[
{"role": "user", "content": "old request"},
{"role": "assistant", "content": "old response"},
],
)
try:
runner = ToolLoopAgentRunner()
await runner.reset(
provider=provider,
request=request,
run_context=ContextWrapper(context=None),
tool_executor=BaseFunctionToolExecutor(),
agent_hooks=BaseAgentRunHooks(),
)

runner.run_context.messages = runner.run_context.messages[-1:]

assert [message.role for message in runner.get_persistent_messages()] == [
"user",
"assistant",
"user",
]
finally:
await provider.terminate()
29 changes: 29 additions & 0 deletions tests/test_fastapi_v1_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,35 @@ def _jwt_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}


@pytest.mark.asyncio
async def test_unhandled_dashboard_exception_returns_json_error(
asgi_app: FastAPI,
monkeypatch: pytest.MonkeyPatch,
):
async def raise_unhandled_error():
raise RuntimeError("sensitive internal detail")

monkeypatch.setattr(
asgi_app.state.services.stats,
"get_start_time",
raise_unhandled_error,
)
transport = httpx.ASGITransport(app=asgi_app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
response = await client.get("/api/v1/stats/start-time")

assert response.status_code == 500
assert response.headers["content-type"].startswith("application/json")
assert response.json() == {
"status": "error",
"message": "Internal server error",
}
assert "sensitive internal detail" not in response.text


@pytest.mark.asyncio
async def test_public_versions_route_uses_static_folder(
fake_core_lifecycle,
Expand Down
Loading
Loading