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
99 changes: 82 additions & 17 deletions agent_core/core/impl/llm/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ def __init__(
self._initialized = False
self._deferred = deferred

# Store for reinitialization
# Last-applied api_key/base_url, used by reinitialize() to detect
# whether a Settings save actually changed anything.
self._init_api_key = api_key
self._init_base_url = base_url

Expand Down Expand Up @@ -180,7 +181,13 @@ def __init__(
deferred=deferred,
)

logger.info(f"[LLM FACTORY] {ctx}")
_safe_ctx = {k: v for k, v in ctx.items() if k != "byteplus"}
if ctx.get("byteplus"):
_safe_ctx["byteplus"] = {
"api_key": "<redacted>",
"base_url": ctx["byteplus"].get("base_url"),
}
logger.info(f"[LLM FACTORY] {_safe_ctx}")

self.provider = ctx["provider"]
self.model = ctx["model"]
Expand Down Expand Up @@ -297,6 +304,33 @@ def reinitialize(
None # app context not available (e.g. agent_core standalone)
)

# Diff against the currently-live config so a Settings save that
# didn't actually change anything (or only changed the model within
# the same provider) doesn't have to nuke every active task's
# accumulated session state. `target_model is not None` guards the
# no-op fast path off in the app-context-unavailable edge case above,
# where we can't tell what "unchanged" means — fall through to the
# full-reset path there, matching prior behavior.
old_provider = self.provider
old_model = self.model
old_api_key = self._init_api_key
old_base_url = self._init_base_url
provider_unchanged = target_provider == old_provider
nothing_changed = (
provider_unchanged
and target_model is not None
and target_model == old_model
and target_api_key == old_api_key
and target_base_url == old_base_url
)

if nothing_changed:
logger.info(
"[LLM] Reinitialize no-op — provider/model/credentials "
"unchanged, skipping reset"
)
return self._initialized

try:
logger.info(
f"[LLM] Reinitializing with provider: {target_provider}, model: {target_model or 'registry default'}"
Expand Down Expand Up @@ -329,15 +363,21 @@ def reinitialize(
base_url=self.byteplus_base_url,
model=self.model,
)
# Reset session system prompts and multi-turn message histories
self._session_system_prompts = {}
self._anthropic_session_messages = {}
self._bedrock_session_messages = {}
self._openrouter_anthropic_session_messages = {}
self._gemini_session_messages = {}
self._openai_compat_session_messages = {}
else:
self._byteplus_cache_manager = None

if provider_unchanged:
# Model/credentials-only change: the message-history buffers
# are provider-agnostic message lists the new model can
# consume as-is, so keep them — the prefix cache takes one
# miss and re-warms instead of rebuilding from scratch.
logger.info(
f"[LLM] Model-only reinit within provider {self.provider}: "
f"preserving session histories"
)
else:
# Real provider change: message formats differ across
# providers, so the accumulated histories aren't reusable.
self._session_system_prompts = {}
self._anthropic_session_messages = {}
self._bedrock_session_messages = {}
Expand All @@ -364,6 +404,11 @@ def reinitialize(
)
self._consecutive_failures = 0

# Track last-applied credentials so the next reinitialize() call
# can tell whether anything actually changed.
self._init_api_key = target_api_key
self._init_base_url = target_base_url

logger.info(
f"[LLM] Reinitialized successfully with provider: {self.provider}, model: {self.model}"
)
Expand Down Expand Up @@ -1570,15 +1615,35 @@ def _generate_byteplus_with_session(

try:
if not self._byteplus_cache_manager.has_session(task_id, call_type):
raise ValueError(f"No session cache found for {session_key}")
# The cache manager was rebuilt (e.g. a model-only Settings
# change recreates it since BytePlus sessions are server-side
# and model-bound), emptying its session registry — but the
# system prompt survives a model-only reinit, so reseed a
# fresh session instead of failing this turn outright.
system_prompt = self._session_system_prompts.get(session_key)
if not system_prompt:
raise ValueError(f"No session cache found for {session_key}")

result = self._byteplus_cache_manager.chat_with_session(
task_id=task_id,
call_type=call_type,
user_prompt=user_prompt,
temperature=self.temperature,
max_tokens=self.max_tokens,
)
logger.info(
f"[BYTEPLUS] No session cache for {session_key} — "
f"reseeding a fresh session from the stored system prompt"
)
result = self._byteplus_cache_manager.create_session_cache(
task_id=task_id,
call_type=call_type,
system_prompt=system_prompt,
user_prompt=user_prompt,
temperature=self.temperature,
max_tokens=self.max_tokens,
)
else:
result = self._byteplus_cache_manager.chat_with_session(
task_id=task_id,
call_type=call_type,
user_prompt=user_prompt,
temperature=self.temperature,
max_tokens=self.max_tokens,
)

logger.info(f"BYTEPLUS SESSION RESPONSE: {result}")

Expand Down
99 changes: 56 additions & 43 deletions app/agent_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3130,6 +3130,7 @@ def reinitialize_llm(self, provider: str | None = None) -> bool:

llm_provider = provider or get_llm_provider()
vlm_provider = get_vlm_provider()
old_llm_provider = self.llm.provider
llm_ok = self.llm.reinitialize(llm_provider)
vlm_ok = self.vlm.reinitialize(vlm_provider)

Expand All @@ -3138,51 +3139,63 @@ def reinitialize_llm(self, provider: str | None = None) -> bool:
f"[AGENT] LLM and VLM reinitialized with provider: {self.llm.provider}"
)

# Rebuild session caches for any task that was mid-flight when
# the provider switched. `LLMInterface.reinitialize()` wipes
# `_session_system_prompts` and all per-provider message-history
# buffers — without this rebuild step, `has_session_cache()`
# would return False for the rest of every active task and the
# router would fall back to the single-turn path, defeating
# session caching for the remainder of the task.
#
# Re-deriving the system prompt via `context_engine.make_prompt()`
# (inside `_create_session_caches`) means the new provider sees
# the *current* compiled prompt — so any todos / action-set
# changes since the original registration are picked up too.
#
# We also reset the event-stream sync point so the next call
# under the new provider hits the router's "first call" branch
# and resends the FULL prompt + accumulated event stream,
# establishing a fresh session-cache prefix instead of sending
# a tiny delta against an empty history.
try:
active_task_ids = (
self.task_manager.get_active_task_ids() if self.task_manager else []
# Only rebuild session caches when the LLM provider actually
# changed. `LLMInterface.reinitialize()` only wipes
# `_session_system_prompts` and the per-provider message-history
# buffers on a real provider change — a model-only (or no-op)
# Settings save preserves them, so `has_session_cache()` still
# returns True and no rebuild is needed. Rebuilding anyway would
# force every active task's next call to resend the FULL event
# stream on top of the already-preserved history, duplicating
# context instead of protecting it.
if self.llm.provider == old_llm_provider:
logger.info(
"[AGENT] Skipping session-cache rebuild — provider "
"unchanged, session state preserved"
)
if active_task_ids:
for task_id in active_task_ids:
self.task_manager.rebuild_session_caches(task_id)
if self.context_engine:
for call_type in (
LLMCallType.REASONING,
LLMCallType.ACTION_SELECTION,
LLMCallType.GUI_REASONING,
LLMCallType.GUI_ACTION_SELECTION,
):
self.context_engine.reset_event_stream_sync(
call_type, session_id=task_id
)
logger.info(
f"[AGENT] Rebuilt session caches for "
f"{len(active_task_ids)} active task(s) under new "
f"provider {self.llm.provider}"
else:
# Rebuild session caches for any task that was mid-flight when
# the provider switched.
#
# Re-deriving the system prompt via `context_engine.make_prompt()`
# (inside `_create_session_caches`) means the new provider sees
# the *current* compiled prompt — so any todos / action-set
# changes since the original registration are picked up too.
#
# We also reset the event-stream sync point so the next call
# under the new provider hits the router's "first call" branch
# and resends the FULL prompt + accumulated event stream,
# establishing a fresh session-cache prefix instead of sending
# a tiny delta against an empty history.
try:
active_task_ids = (
self.task_manager.get_active_task_ids()
if self.task_manager
else []
)
if active_task_ids:
for task_id in active_task_ids:
self.task_manager.rebuild_session_caches(task_id)
if self.context_engine:
for call_type in (
LLMCallType.REASONING,
LLMCallType.ACTION_SELECTION,
LLMCallType.GUI_REASONING,
LLMCallType.GUI_ACTION_SELECTION,
):
self.context_engine.reset_event_stream_sync(
call_type, session_id=task_id
)
logger.info(
f"[AGENT] Rebuilt session caches for "
f"{len(active_task_ids)} active task(s) under new "
f"provider {self.llm.provider}"
)
except Exception as e:
logger.warning(
f"[AGENT] Failed to rebuild session caches after "
f"provider switch: {e}"
)
except Exception as e:
logger.warning(
f"[AGENT] Failed to rebuild session caches after "
f"provider switch: {e}"
)

# Update GUI module provider if needed (only if GUI mode is enabled)
gui_globally_enabled = os.getenv("GUI_MODE_ENABLED", "True") == "True"
Expand Down
16 changes: 14 additions & 2 deletions app/data/action/send_message_with_attachment.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from agent_core import action
from agent_core.utils.logger import logger


@action(
Expand Down Expand Up @@ -81,10 +82,13 @@ async def send_message_with_attachment(input_data: dict) -> dict:
errors.append(f"Cannot attach directory: {fp}")

if errors:
logger.warning(
f"[send_message_with_attachment] Attachment(s) not ready, message not sent: {errors}"
)
return {
"status": "error",
"fire_at_delay": 0,
"wait_for_user_reply": wait_for_user_reply,
"wait_for_user_reply": False,
"files_sent": 0,
"errors": errors,
}
Expand All @@ -105,15 +109,23 @@ async def send_message_with_attachment(input_data: dict) -> dict:
message, file_paths, session_id=session_id
)

fire_at_delay = 10800 if wait_for_user_reply else 0
files_sent = result.get("files_sent", 0)
errors = result.get("errors")

# Determine status based on whether all files were sent successfully
if result.get("success", False):
status = "ok"
fire_at_delay = 10800 if wait_for_user_reply else 0
else:
status = "error"
# Nothing was actually sent, so there is nothing for the user to
# reply to - don't let the agent go dark waiting on a message that
# never arrived.
fire_at_delay = 0
wait_for_user_reply = False
logger.warning(
f"[send_message_with_attachment] Failed to send attachment(s): {errors}"
)

response = {
"status": status,
Expand Down
3 changes: 3 additions & 0 deletions app/internal_action_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,9 @@ async def do_chat_with_attachments(
errors.append(f"Cannot attach directory: {fp}")

if errors:
logger.warning(
f"[do_chat_with_attachments] Attachment(s) not ready: {errors}"
)
return {"success": False, "files_sent": 0, "errors": errors}

ui_adapter = InternalActionInterface.ui_adapter
Expand Down
8 changes: 7 additions & 1 deletion app/llm_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,7 +854,13 @@ def __init__(
deferred=deferred,
)

logger.info(f"[LLM FACTORY] {ctx}")
_safe_ctx = {k: v for k, v in ctx.items() if k != "byteplus"}
if ctx.get("byteplus"):
_safe_ctx["byteplus"] = {
"api_key": "<redacted>",
"base_url": ctx["byteplus"].get("base_url"),
}
logger.info(f"[LLM FACTORY] {_safe_ctx}")

self.provider = ctx["provider"]
self.model = ctx["model"]
Expand Down
25 changes: 22 additions & 3 deletions app/ui_layer/adapters/browser_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,12 +427,13 @@ def __init__(self, adapter: "BrowserAdapter") -> None:
self._adapter = adapter
self._items: List[ActionItem] = []
self._storage = None
self._initial_has_more_ended = False
self._init_storage()

def _init_storage(self) -> None:
"""Initialize storage and load persisted actions."""
try:
from app.usage.action_storage import get_action_storage
from app.usage.action_storage import TERMINAL_STATUSES, get_action_storage

self._storage = get_action_storage()

Expand All @@ -442,8 +443,21 @@ def _init_storage(self) -> None:
)
self._storage.mark_running_as_cancelled(exclude=restored_ids)

# Load recent tasks (and their child actions) from storage
stored_items = self._storage.get_recent_tasks_with_actions(task_limit=15)
# Load every active task plus the most recent ended tasks (and
# their child actions) from storage. Active tasks are loaded in
# full here rather than paginated, so they're never discovered
# later via scroll pagination and don't "pop in" above already
# rendered rows.
ended_limit = 15
stored_items = self._storage.get_active_and_recent_ended_tasks(
ended_limit=ended_limit
)
ended_count = sum(
1
for s in stored_items
if s.item_type == "task" and s.status in TERMINAL_STATUSES
)
self._initial_has_more_ended = ended_count == ended_limit
for stored in stored_items:
self._items.append(
ActionItem(
Expand Down Expand Up @@ -878,6 +892,10 @@ def get_items(self) -> List[ActionItem]:
"""Get all loaded items."""
return self._items.copy()

def get_initial_has_more(self) -> bool:
"""Whether there are more ended tasks beyond the initial load."""
return self._initial_has_more_ended

def get_tasks_before(
self, before_timestamp: float, task_limit: int = 15
) -> List[ActionItem]:
Expand Down Expand Up @@ -8805,6 +8823,7 @@ def _get_initial_state(self) -> Dict[str, Any]:
}
for a in self._action_panel.get_items()
],
"actionsHasMore": self._action_panel.get_initial_has_more(),
"status": self._status_bar.get_status(),
"dashboardMetrics": metrics.to_dict(),
}
Expand Down
Loading