allow overriding reasoning on certain agent actions

This commit is contained in:
vegu-ai-tools
2026-05-19 00:03:57 +03:00
parent 6b7a97585c
commit 3ca578ddf3
16 changed files with 308 additions and 20 deletions

View File

@@ -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."

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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

View File

@@ -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:

View File

@@ -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")

View File

@@ -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"])

View File

@@ -1,4 +1,21 @@
<template>
<v-list-subheader class="text-uppercase">
<v-icon>mdi-transit-connection-variant</v-icon>
Agents
<v-tooltip v-if="overrideCount > 0" location="top" :text="`${overrideCount} per-action override${overrideCount === 1 ? '' : 's'} active — manage`">
<template #activator="{ props }">
<v-btn
v-bind="props"
@click="openAgentActionOverrides()"
size="x-small"
variant="tonal"
color="primary"
prepend-icon="mdi-tune"
class="ml-2"
>{{ overrideCount }}</v-btn>
</template>
</v-tooltip>
</v-list-subheader>
<div v-if="isConnected()">
<v-list density="compact">
<!-- Ctrl + click toggles agent enable/disable when allowed -->
@@ -152,6 +169,9 @@ export default {
scene: Object,
},
computed: {
overrideCount() {
return Object.keys(this.appConfig?.agent_actions?.overrides || {}).length;
},
agentStateNotifications() {
// if key begins with 'notify__' and value is a string, return the key and value
// return the notify__(.+) part as the key, and the value as the value
@@ -177,6 +197,7 @@ export default {
'registerMessageHandler',
'isConnected',
'getClients',
'openAgentActionOverrides',
],
provide() {
return {

View File

@@ -0,0 +1,99 @@
<template>
<v-dialog v-model="dialog" max-width="720" scrollable>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-tune</v-icon>
Agent action overrides
<v-spacer />
<v-btn icon="mdi-close" variant="text" size="small" @click="dialog = false" />
</v-card-title>
<v-divider />
<v-card-text style="max-height: 60vh">
<div v-if="rows.length" class="d-flex align-center px-4 py-1 text-caption text-uppercase text-muted">
<span>Action</span>
<v-spacer />
<span>Disable reasoning</span>
</div>
<v-list density="compact" v-if="rows.length">
<v-list-item v-for="row in rows" :key="row.key">
<v-list-item-title class="text-body-2">
<span class="text-primary">{{ row.agentLabel }}</span>
<span class="text-disabled">.</span>
<span>{{ row.action }}</span>
</v-list-item-title>
<template #append>
<v-switch
:model-value="!!row.override.disable_reasoning"
@update:model-value="onToggleDisableReasoning(row.key, $event)"
hide-details
density="compact"
color="primary"
/>
</template>
</v-list-item>
</v-list>
<v-alert v-else type="info" variant="tonal" density="compact">
No overrides configured. Open a prompt in the prompt log and click the brain icon next to its action to add one.
</v-alert>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: 'AgentActionOverrides',
props: {
appConfig: {
type: Object,
default: () => ({})
},
agentStatus: {
type: Object,
default: () => ({})
}
},
data() {
return { dialog: false };
},
computed: {
overridesMap() {
return this.appConfig?.agent_actions?.overrides || {};
},
rows() {
return Object.entries(this.overridesMap)
.map(([key, override]) => {
// Split on the first dot only — action names can theoretically contain dots.
const idx = key.indexOf('.');
const agentType = idx >= 0 ? key.slice(0, idx) : key;
const action = idx >= 0 ? key.slice(idx + 1) : '';
return {
key,
agentType,
agentLabel: this.agentStatus?.[agentType]?.label || agentType,
action,
override
};
})
.sort((a, b) => a.key.localeCompare(b.key));
}
},
inject: ['getWebsocket'],
methods: {
open() {
this.dialog = true;
},
onToggleDisableReasoning(key, value) {
const ws = this.getWebsocket();
if (!ws) return;
ws.send(JSON.stringify({
type: 'config',
action: 'set_agent_action_override',
data: { key, disable_reasoning: !!value }
}));
}
}
};
</script>

View File

@@ -17,7 +17,7 @@
@click="openPromptView(prompt)"
/>
<DebugToolPromptView ref="promptView" />
<DebugToolPromptView ref="promptView" :app-config="appConfig" />
</template>
<script>
@@ -31,6 +31,10 @@ export default {
type: Array,
default: () => [],
},
appConfig: {
type: Object,
default: () => ({}),
},
},
components: {
DebugToolPromptView,

View File

@@ -5,6 +5,7 @@
<PromptDetailView
ref="promptDetailView"
:prompt="prompt"
:app-config="appConfig"
@navigate-to-template="handleNavigateToTemplate"
/>
</v-card-text>
@@ -25,6 +26,12 @@ export default {
components: {
PromptDetailView,
},
props: {
appConfig: {
type: Object,
default: () => ({}),
},
},
data() {
return {
prompt: null,

View File

@@ -25,7 +25,7 @@
</v-tabs>
<v-window v-model="tab">
<v-window-item value="prompts">
<DebugToolPromptLog ref="promptLog" :prompts="prompts" @clear-prompts="$emit('clear-prompts')"/>
<DebugToolPromptLog ref="promptLog" :prompts="prompts" :app-config="appConfig" @clear-prompts="$emit('clear-prompts')"/>
</v-window-item>
<v-window-item value="memory_requests">
<DebugToolMemoryRequestLog ref="memoryRequestLog"/>
@@ -63,6 +63,10 @@ export default {
type: Array,
default: () => [],
},
appConfig: {
type: Object,
default: () => ({}),
},
},
emits: ['clear-prompts'],
data() {

View File

@@ -189,7 +189,6 @@
<v-list>
<AIClient ref="aiClient" @save="saveClients" @error="uxErrorHandler" @clients-updated="saveClients" @client-assigned="saveAgents" @open-app-config="openAppConfig" :immutable-config="appConfig" :app-config="appConfig"></AIClient>
<v-divider></v-divider>
<v-list-subheader class="text-uppercase"><v-icon>mdi-transit-connection-variant</v-icon> Agents</v-list-subheader>
<AIAgent ref="aiAgent" @save="saveAgents" @agents-updated="saveAgents" :agentState="agentState" :templates="worldStateTemplates" :app-config="appConfig" :scene="scene"></AIAgent>
<!-- More sections can be added here -->
</v-list>
@@ -204,7 +203,7 @@
<v-navigation-drawer v-model="debugDrawer" app location="right" width="400" disable-resize-watcher>
<v-list>
<v-list-subheader class="text-uppercase"><v-icon>mdi-bug</v-icon> Debug Tools</v-list-subheader>
<DebugTools ref="debugTools" :scene="scene" :prompts="promptsViewPrompts" @clear-prompts="clearPrompts"></DebugTools>
<DebugTools ref="debugTools" :scene="scene" :prompts="promptsViewPrompts" :app-config="appConfig" @clear-prompts="clearPrompts"></DebugTools>
</v-list>
</v-navigation-drawer>
@@ -349,7 +348,7 @@
</v-tabs-window-item>
<!-- PROMPTS -->
<v-tabs-window-item :transition="false" :reverse-transition="false" value="prompts">
<PromptsView :visible="tab === 'prompts'" :prompts="prompts" :agent-status="agentStatus" ref="promptsView" v-model:main-tab="promptsMainTab" @clear-prompts="onClearPrompts" />
<PromptsView :visible="tab === 'prompts'" :prompts="prompts" :agent-status="agentStatus" :app-config="appConfig" ref="promptsView" v-model:main-tab="promptsMainTab" @clear-prompts="onClearPrompts" />
</v-tabs-window-item>
</v-tabs-window>
@@ -358,6 +357,7 @@
</v-main>
<AppConfig ref="appConfig" :agentStatus="agentStatus" :sceneActive="sceneActive" :clientStatus="clientStatus" @appearance-preview="onAppearancePreview" @appearance-preview-clear="onAppearancePreviewClear" />
<AgentActionOverrides ref="agentActionOverrides" :app-config="appConfig" :agent-status="agentStatus" />
<v-snackbar v-model="errorNotification" color="red-darken-1" :timeout="3000">
{{ errorMessage }}
</v-snackbar>
@@ -379,6 +379,7 @@ import AIClient from './AIClient.vue';
import { primaryModifierLabel } from '@/utils/keyboardModifiers';
import AIAgent from './AIAgent.vue';
import AgentActivityBar from './AgentActivityBar.vue';
import AgentActionOverrides from './AgentActionOverrides.vue';
import LoadScene from './LoadScene.vue';
import SceneTools from './SceneTools.vue';
import SceneMessages from './SceneMessages.vue';
@@ -419,6 +420,7 @@ export default {
AIClient,
AIAgent,
AgentActivityBar,
AgentActionOverrides,
LoadScene,
SceneTools,
SceneMessages,
@@ -820,6 +822,7 @@ export default {
requestAppConfig: () => this.requestAppConfig(),
appConfig: () => this.appConfig,
openAppConfig: this.openAppConfig,
openAgentActionOverrides: () => this.$refs.agentActionOverrides?.open(),
configurationRequired: () => this.configurationRequired(),
getTrackedCharacterState: (name, question) => this.$refs.worldState.trackedCharacterState(name, question),
getTrackedCharacterStates: (name) => this.$refs.worldState.trackedCharacterStates(name),
@@ -1699,16 +1702,15 @@ export default {
// Handle prompt_sent messages (capture prompts for PromptsMenu)
handlePromptSent(data) {
// Get active agent (last in agent_stack if not empty)
// agent_name here is the verbose display name (from agent_stack); the
// canonical type+action come straight from the server now.
let agent = null;
let agentName = null;
let agentAction = null;
let agentAction = data.agent_action || null;
if (data.agent_stack && data.agent_stack.length > 0) {
agent = data.agent_stack[data.agent_stack.length - 1];
const agentParts = agent.split('.');
agentName = agentParts[0];
agentAction = agentParts[1];
agentName = agent.split('.')[0];
}
// Compute prefix cache ratio against previous prompt with same template_uid + client_name
@@ -1731,6 +1733,7 @@ export default {
agent_stack: data.agent_stack,
agent: agent,
agent_name: agentName,
agent_type: data.agent_type || null,
agent_action: agentAction,
client_name: data.client_name,
client_type: data.client_type,

View File

@@ -7,6 +7,25 @@
<v-chip size="small" label class="mr-1" color="primary" variant="tonal">
<strong class="mr-1">action</strong>{{ prompt.agent_action }}
</v-chip>
<v-tooltip
v-if="overrideKey"
location="top"
:text="reasoningOverrideActive
? `Reasoning is forced off for ${overrideLabel}. Click to remove the override.`
: `Force reasoning off whenever ${overrideLabel} runs (clients that always reason will ignore it).`"
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
size="x-small"
variant="text"
class="mr-1"
icon="mdi-brain"
:color="reasoningOverrideActive ? 'delete' : 'grey-darken-1'"
@click="toggleReasoningOverride"
/>
</template>
</v-tooltip>
<v-chip class="mr-1" size="small" color="grey" label variant="tonal">
<strong class="mr-1">task</strong> {{ prompt.kind }}
</v-chip>
@@ -185,6 +204,9 @@
>Test Changes</v-btn>
</template>
</v-tooltip>
<span v-if="reasoningOverrideActive" class="text-caption text-muted ml-2">
Test Changes runs with the client's default reasoning setting the per-action override does not apply.
</span>
</v-container>
</template>
@@ -209,6 +231,10 @@ export default {
type: Boolean,
default: false,
},
appConfig: {
type: Object,
default: () => ({}),
},
},
emits: ['navigate-to-template', 'test-changes', 'update:modelValue'],
data() {
@@ -249,6 +275,20 @@ export default {
toggleDetailsLabel() {
return this.details ? 'Hide Details' : 'Show Details';
},
overrideKey() {
const type = this.prompt?.agent_type;
const action = this.prompt?.agent_action;
return type && action ? `${type}.${action}` : null;
},
overrideLabel() {
// Verbose form for tooltip display; overrideKey stays the canonical config key.
const label = this.prompt?.agent_name || this.prompt?.agent_type;
return `${label}.${this.prompt?.agent_action}`;
},
reasoningOverrideActive() {
if (!this.overrideKey) return false;
return !!this.appConfig?.agent_actions?.overrides?.[this.overrideKey]?.disable_reasoning;
},
},
watch: {
prompt: {
@@ -311,6 +351,25 @@ export default {
}
},
toggleReasoningOverride() {
if (!this.overrideKey) return;
const ws = this.getWebsocket();
if (!ws) return;
if (this.reasoningOverrideActive) {
ws.send(JSON.stringify({
type: 'config',
action: 'clear_agent_action_override',
data: { key: this.overrideKey }
}));
} else {
ws.send(JSON.stringify({
type: 'config',
action: 'set_agent_action_override',
data: { key: this.overrideKey, disable_reasoning: true }
}));
}
},
testChanges() {
this.busy = true;
this.$emit('test-changes', {

View File

@@ -156,6 +156,7 @@
>
<PromptDetailView
:prompt="prompt"
:app-config="appConfig"
@navigate-to-template="handleNavigateToTemplate"
/>
</v-window-item>
@@ -263,6 +264,10 @@ export default {
mainTab: {
type: String,
default: 'prompts'
},
appConfig: {
type: Object,
default: () => ({})
}
},
emits: ['clear-prompts', 'update:mainTab'],