diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index f7a73685..241dd374 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -5,6 +5,7 @@ - "Player Character Toggle: Added a Make Player Character / Unmark as Player action to the World Editor character editor. Promoting a non-player character makes them the player and automatically demotes the previous player (if any) to an AI actor — the previous player stays active in the scene. Unmarking the current player flips them to AI without requiring a replacement, leaving the scene with no explicit player. Promoting an inactive character also activates them." - "Message Revision History: Regenerated AI messages now show a paginator above the message body. Click the arrows to browse previous regenerations; the version you're viewing becomes the canonical one the AI continues from. Lives in the browser session only." - "Scene Perspective Overrides: Expanded the scene outline perspective into a default plus three per-speaker overrides (player, NPCs, narrator). Each override falls back to the default when empty. A new `{player_name}` placeholder is substituted at prompt render time and suppresses the perspective when the scene has no explicit player character. Presets are managed under Settings → Creator → Perspective Presets. Existing scenes are migrated automatically." + - "Per-Action Reasoning Override: Force reasoning off for specific agent actions without changing the client's global setting. Toggle via the brain icon in the prompt log; configured overrides are listed via a badge next to the Agents sidebar header." improvements: - "Settings UX: Renamed the Creator sub-tab from 'Content Context' to 'Content Classification' to match the field label used in the scene outline editor. List contents and behavior are unchanged." - "Message Revision History: Continuing a character or narrator message now creates a navigable revision entry tagged 'Continued', alongside the existing regenerate entries. The pre-continuation text is reachable via the paginator arrows. Narrator messages also gain the Continue action on the hover toolbar to match the character-message flow." diff --git a/src/talemate/agents/base.py b/src/talemate/agents/base.py index f4613d1e..2f637242 100644 --- a/src/talemate/agents/base.py +++ b/src/talemate/agents/base.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib import functools import json from inspect import signature @@ -289,6 +290,19 @@ class store_context_state: return fn +def _agent_action_override_kwargs(agent_type: str, action_name: str) -> dict: + """ClientContext kwargs for any per-action override on ``{agent_type}.{action_name}``; empty when none applies.""" + config = get_config() + override = config.agent_actions.overrides.get(f"{agent_type}.{action_name}") + if override is None: + return {} + + kwargs: dict = {} + if override.disable_reasoning: + kwargs["disable_reasoning"] = True + return kwargs + + def set_processing(fn): """ decorator that emits the agent status as processing while the function @@ -334,7 +348,16 @@ def set_processing(fn): self.set_context_states(**all_args) - return await fn(self, *args, **kwargs) + override_kwargs = _agent_action_override_kwargs( + self.agent_type, action_name + ) + override_ctx = ( + ClientContext(**override_kwargs) + if override_kwargs + else contextlib.nullcontext() + ) + with override_ctx: + return await fn(self, *args, **kwargs) finally: try: self._current_action = None diff --git a/src/talemate/client/anthropic.py b/src/talemate/client/anthropic.py index 2ad0d049..dea65dfd 100644 --- a/src/talemate/client/anthropic.py +++ b/src/talemate/client/anthropic.py @@ -201,7 +201,7 @@ class AnthropicClient(ConcurrentInferenceMixin, EndpointOverrideMixin, ClientBas @property def reasoning_display(self) -> ReasoningDisplay | None: """Returns reasoning display config based on what's actually used at runtime.""" - if not self.reason_enabled: + if not self.reason_enabled_configured: return None # Only show effort display if adaptive will ACTUALLY be used. diff --git a/src/talemate/client/base.py b/src/talemate/client/base.py index 98e98e26..2e2bb2aa 100644 --- a/src/talemate/client/base.py +++ b/src/talemate/client/base.py @@ -82,6 +82,8 @@ class PromptData(pydantic.BaseModel): client_type: str time: Union[float, int] agent_stack: list[str] = pydantic.Field(default_factory=list) + agent_type: str | None = None + agent_action: str | None = None generation_parameters: dict = pydantic.Field(default_factory=dict) inference_preset: str = None preset_group: str | None = None @@ -391,9 +393,18 @@ class ClientBase: return self.client_config.preset_group @property - def reason_enabled(self) -> bool: + def reason_enabled_configured(self) -> bool: + """Stable configured value. Subclasses override for client-specific + forcing (Gemini 2.5+, OpenAI o-series). `reason_enabled` is the + runtime value after per-action context overrides.""" return self.client_config.reason_enabled + @property + def reason_enabled(self) -> bool: + if client_context_attribute("disable_reasoning") and not self.reason_locked: + return False + return self.reason_enabled_configured + @property def reason_tokens(self) -> int: return self.client_config.reason_tokens @@ -567,9 +578,10 @@ class ClientBase: """Returns reasoning display config based on what's actually used at runtime. Override in subclasses for custom behavior (e.g., adaptive thinking). - Returns None if reasoning is not enabled. + Returns None if reasoning is not enabled. Uses the configured value so + per-action overrides don't flicker the persistent UI indicator. """ - if not self.reason_enabled: + if not self.reason_enabled_configured: return None return ReasoningDisplay( indicator_value=str(self.validated_reason_tokens), @@ -975,7 +987,7 @@ class ClientBase: "embeddings_model_name": self.embeddings_model_name, "can_support_concurrent_inference": self.can_support_concurrent_inference, "supports_concurrent_inference": self.supports_concurrent_inference, - "reason_enabled": self.reason_enabled, + "reason_enabled": self.reason_enabled_configured, "reason_tokens": self.reason_tokens, "min_reason_tokens": self.min_reason_tokens, "reason_response_pattern": self.client_config.reason_response_pattern, @@ -1639,6 +1651,10 @@ class ClientBase: response_tokens=self._returned_response_tokens or self.count_tokens(response), agent_stack=agent_context.agent_stack if agent_context else [], + agent_type=agent_context.agent.agent_type + if agent_context + else None, + agent_action=agent_context.action if agent_context else None, client_name=self.name, client_type=self.client_type, time=time_end - time_start, diff --git a/src/talemate/client/context.py b/src/talemate/client/context.py index 9bfc50fd..82cf3f7f 100644 --- a/src/talemate/client/context.py +++ b/src/talemate/client/context.py @@ -47,6 +47,8 @@ class ContextModel(BaseModel): inference_preset: str = None data_format: str | None = None requires_active_scene: bool = True + # Honored unless the client reports reason_locked. + disable_reasoning: bool = False # Define the context variable as an empty dictionary diff --git a/src/talemate/client/google.py b/src/talemate/client/google.py index 17d8bb73..85b3e0ac 100644 --- a/src/talemate/client/google.py +++ b/src/talemate/client/google.py @@ -111,12 +111,11 @@ class GoogleClient( return self.client_config.disable_safety_settings @property - def reason_enabled(self) -> bool: + def reason_enabled_configured(self) -> bool: if self.reason_locked: # Always enable reasoning for Gemini 3 and Gemini 2.5 return True - - return self.client_config.reason_enabled + return super().reason_enabled_configured @property def min_reason_tokens(self) -> int: diff --git a/src/talemate/config/schema.py b/src/talemate/config/schema.py index f35ff580..94ec56bb 100644 --- a/src/talemate/config/schema.py +++ b/src/talemate/config/schema.py @@ -619,6 +619,14 @@ class PromptsConfig(pydantic.BaseModel): template_sources: Dict[str, str] = pydantic.Field(default_factory=dict) +class AgentActionOverride(pydantic.BaseModel): + disable_reasoning: bool = False + + +class AgentActionsConfig(pydantic.BaseModel): + overrides: Dict[str, AgentActionOverride] = pydantic.Field(default_factory=dict) + + class Config(pydantic.BaseModel): clients: Dict[str, AnnotatedClient] = {} @@ -658,6 +666,8 @@ class Config(pydantic.BaseModel): prompts: PromptsConfig = PromptsConfig() + agent_actions: AgentActionsConfig = AgentActionsConfig() + dirty: bool = pydantic.Field(default=False, exclude=True) model_config = ConfigDict(extra="ignore") diff --git a/src/talemate/server/config.py b/src/talemate/server/config.py index 6bc079a3..a6981718 100644 --- a/src/talemate/server/config.py +++ b/src/talemate/server/config.py @@ -14,7 +14,10 @@ from talemate.client.base import ( locked_model_template, ) from talemate.config import Config as AppConfigData -from talemate.config.schema import GamePlayerCharacter +from talemate.config.schema import ( + AgentActionOverride, + GamePlayerCharacter, +) from talemate.config import get_config, Config, update_config from talemate.emit import emit from talemate.instance import emit_clients_status, get_client @@ -95,6 +98,15 @@ class SystemCapabilitiesPayload(pydantic.BaseModel): torch_cuda: TorchCudaInfo = TorchCudaInfo() +class SetAgentActionOverridePayload(pydantic.BaseModel): + key: str + disable_reasoning: bool = False + + +class ClearAgentActionOverridePayload(pydantic.BaseModel): + key: str + + class ConfigPlugin(Plugin): router = "config" @@ -486,6 +498,29 @@ class ConfigPlugin(Plugin): {"type": "app_config", "data": config.model_dump(), "version": VERSION} ) + async def handle_set_agent_action_override(self, data): + """Set or update an override. Empty overrides are dropped so the config stays sparse.""" + payload = SetAgentActionOverridePayload(**data["data"]) + + config: Config = get_config() + override = AgentActionOverride(disable_reasoning=payload.disable_reasoning) + + if override == AgentActionOverride(): + config.agent_actions.overrides.pop(payload.key, None) + else: + config.agent_actions.overrides[payload.key] = override + + await config.set_dirty() + + async def handle_clear_agent_action_override(self, data): + payload = ClearAgentActionOverridePayload(**data["data"]) + + config: Config = get_config() + if config.agent_actions.overrides.pop(payload.key, None) is None: + return + + await config.set_dirty() + async def handle_save_unified_api_key(self, data): """Save a unified API key to app config.""" payload = SaveUnifiedAPIKeyPayload(**data["data"]) diff --git a/talemate_frontend/src/components/AIAgent.vue b/talemate_frontend/src/components/AIAgent.vue index da274570..3e076dfc 100644 --- a/talemate_frontend/src/components/AIAgent.vue +++ b/talemate_frontend/src/components/AIAgent.vue @@ -1,4 +1,21 @@