Skip to content
Merged
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
15 changes: 8 additions & 7 deletions resources/skills/deepchat-settings/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ Use this skill to safely change DeepChat *application* settings during a convers

- Only change settings when the user is asking to change **DeepChat** settings.
- Use the dedicated settings tools; never attempt arbitrary key/value writes.
- These tools are intended to be available only when this skill is active; if they are missing, activate this skill via `skill_control`.
- These tools are intended to be available only when this skill is active.
- Viewing the main `deepchat-settings` `SKILL.md` activates this skill for the current conversation and exposes the `deepchat_settings_*` tools in the next tool loop iteration.
- Viewing linked files under this skill does **not** activate the skill.
- If the request is ambiguous, ask a clarifying question before applying.
- For unsupported or high-risk settings (MCP, prompts, providers, API keys, paths): do **not** apply changes; instead explain where to change it and open Settings.
- After completing the settings task, deactivate this skill via `skill_control` to keep context small.

## Supported settings (initial allowlist)

Expand All @@ -43,15 +44,15 @@ Settings navigation (open-only):
## Workflow

1. Confirm the user is requesting a DeepChat settings change.
2. Determine the target setting and the intended value.
3. If the setting is supported, call the matching tool:
2. If the settings tools are not yet present, inspect the main `deepchat-settings` skill document first so the skill becomes active for this conversation.
3. Determine the target setting and the intended value.
4. If the setting is supported, call the matching tool:
- toggles: `deepchat_settings_toggle`
- language: `deepchat_settings_set_language`
- theme: `deepchat_settings_set_theme`
- font size: `deepchat_settings_set_font_size`
4. Confirm back to the user what changed (include the final value).
5. If the setting is unsupported, call `deepchat_settings_open` (with `section`) and provide a short pointer to the correct Settings section. Do not call it if the requested change has already been applied.
6. Deactivate this skill via `skill_control`.
5. Confirm back to the user what changed (include the final value).
6. If the setting is unsupported, call `deepchat_settings_open` (with `section`) and provide a short pointer to the correct Settings section. Do not call it if the requested change has already been applied.

## Examples (activate this skill)

Expand Down
22 changes: 21 additions & 1 deletion src/main/presenter/agentRuntimePresenter/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,19 @@ function extractSubagentToolState(rawData: MCPToolResponse): {
}
}

function shouldRefreshToolsAfterCall(toolName: string, rawData: MCPToolResponse): boolean {
if (toolName !== 'skill_view') {
return false
}

const toolResult =
rawData.toolResult && typeof rawData.toolResult === 'object'
? (rawData.toolResult as Record<string, unknown>)
: null

return toolResult?.activationApplied === true
}

function persistToolExecutionState(io: IoParams, state: StreamState): void {
if (!state.dirty) {
return
Expand Down Expand Up @@ -615,6 +628,7 @@ export async function executeTools(
): Promise<{
executed: number
pendingInteractions: PendingToolInteraction[]
toolsChanged: boolean
terminalError?: string
}> {
finalizePendingNarrativeBeforeToolExecution(state)
Expand Down Expand Up @@ -673,6 +687,7 @@ export async function executeTools(
conversation.push(assistantMessage)

let executed = 0
let toolsChanged = false
const pendingInteractions: PendingToolInteraction[] = []
const stagedResults: StagedToolResult[] = []

Expand Down Expand Up @@ -851,6 +866,10 @@ export async function executeTools(
}
}

if (shouldRefreshToolsAfterCall(tc.name, toolRawData)) {
toolsChanged = true
}

const searchPayload = extractSearchPayload(
toolRawData.content,
toolContext.name,
Expand Down Expand Up @@ -927,13 +946,14 @@ export async function executeTools(
return {
executed,
pendingInteractions,
toolsChanged,
terminalError: fittedResults.message
}
}
}

persistToolExecutionState(io, state)
return { executed, pendingInteractions }
return { executed, pendingInteractions, toolsChanged }
}

export function finalizePaused(state: StreamState, io: IoParams): void {
Expand Down
4 changes: 4 additions & 0 deletions src/main/presenter/agentRuntimePresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,7 @@ export class AgentRuntimePresenter implements IAgentImplementation {
const result = await processStream({
messages,
tools,
refreshTools: async () => await this.loadToolDefinitionsForSession(sessionId, projectDir),
toolPresenter: this.toolPresenter,
coreStream: async function* (
requestMessages,
Expand Down Expand Up @@ -2263,6 +2264,9 @@ export class AgentRuntimePresenter implements IAgentImplementation {
lines.push(
'Before replying, always scan available skills. If any skill plausibly matches the task, call `skill_view` first.'
)
lines.push(
'Viewing a skill root `SKILL.md` pins it to the current conversation; viewing linked skill files is read-only and does not pin the skill.'
)
hasContent = true
}
if (capabilities.canRunSkillScripts) {
Expand Down
13 changes: 11 additions & 2 deletions src/main/presenter/agentRuntimePresenter/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export async function processStream(params: ProcessParams): Promise<ProcessResul
}
const echo = startEcho(state, io)
const conversationMessages = [...messages]
let currentTools = [...tools]
let toolCallCount = 0

console.log(`[ProcessStream] start session=${io.sessionId} message=${io.messageId}`)
Expand All @@ -130,7 +131,7 @@ export async function processStream(params: ProcessParams): Promise<ProcessResul
modelConfig,
temperature,
maxTokens,
tools
currentTools
)

// Reset per-iteration accumulator state
Expand Down Expand Up @@ -185,7 +186,7 @@ export async function processStream(params: ProcessParams): Promise<ProcessResul
state,
conversationMessages,
prevBlockCount,
tools,
currentTools,
toolPresenter!,
modelId,
interleavedReasoning,
Expand All @@ -200,6 +201,14 @@ export async function processStream(params: ProcessParams): Promise<ProcessResul
toolCallCount += executed.executed
echo.flush()

if (executed.toolsChanged && params.refreshTools) {
try {
currentTools = await params.refreshTools()
} catch (error) {
console.warn('[ProcessStream] failed to refresh tools after skill activation:', error)
}
}

if (executed.terminalError) {
finalizeError(state, io, executed.terminalError)
return {
Expand Down
1 change: 1 addition & 0 deletions src/main/presenter/agentRuntimePresenter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export interface ProcessResult {
export interface ProcessParams {
messages: ChatMessage[]
tools: MCPToolDefinition[]
refreshTools?: () => Promise<MCPToolDefinition[]>
toolPresenter: IToolPresenter | null
coreStream: (
messages: ChatMessage[],
Expand Down
17 changes: 14 additions & 3 deletions src/main/presenter/skillPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,15 @@ export class SkillPresenter implements ISkillPresenter {

const rawContent = fs.readFileSync(metadata.path, 'utf-8')
const { content } = matter(rawContent)
let nextIsPinned = isPinned

if (options?.conversationId && !isPinned) {
const updatedSkills = await this.setActiveSkills(options.conversationId, [
...pinnedSkills,
metadata.name
])
nextIsPinned = updatedSkills.includes(metadata.name)
}

return {
success: true,
Expand All @@ -580,7 +589,7 @@ export class SkillPresenter implements ISkillPresenter {
platforms: metadata.platforms,
metadata: metadata.metadata,
linkedFiles: this.listSkillLinkedFiles(metadata.skillRoot),
isPinned
isPinned: nextIsPinned
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Expand Down Expand Up @@ -1468,14 +1477,14 @@ export class SkillPresenter implements ISkillPresenter {
/**
* Set active skills for a conversation
*/
async setActiveSkills(conversationId: string, skills: string[]): Promise<void> {
async setActiveSkills(conversationId: string, skills: string[]): Promise<string[]> {
try {
const isNewSession = await this.isNewAgentSession(conversationId)
// Validate skill names
const validSkills = await this.validateSkillNames(skills)
if (!isNewSession) {
this.warnLegacySkillRetired(conversationId)
return
return await this.getActiveSkills(conversationId)
}

const previousSkills = await this.getActiveSkills(conversationId)
Expand All @@ -1500,6 +1509,8 @@ export class SkillPresenter implements ISkillPresenter {
skills: deactivated
})
}

return validSkills
} catch (error) {
console.error(`[SkillPresenter] Error setting active skills for ${conversationId}:`, error)
throw error
Expand Down
40 changes: 39 additions & 1 deletion src/main/presenter/toolPresenter/agentTools/agentToolManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1768,8 +1768,46 @@ export class AgentToolManager {
if (!validationResult.success) {
throw new Error(`Invalid arguments for skill_view: ${validationResult.error.message}`)
}
const normalizedFilePath =
typeof validationResult.data.file_path === 'string'
? validationResult.data.file_path.trim()
: ''
const isLinkedFileView = normalizedFilePath.length > 0
const previousActiveSkills =
conversationId && !isLinkedFileView
? await this.getSkillPresenter().getActiveSkills(conversationId)
: []
const result = await skillTools.handleSkillView(conversationId, validationResult.data)
return { content: JSON.stringify(result) }
const nextActiveSkills =
conversationId && !isLinkedFileView
? await this.getSkillPresenter().getActiveSkills(conversationId)
: previousActiveSkills
const activationApplied =
Boolean(conversationId) &&
!isLinkedFileView &&
!previousActiveSkills.includes(validationResult.data.name) &&
nextActiveSkills.includes(validationResult.data.name)
const activationSource =
!conversationId || result.success !== true
? 'none'
: activationApplied
? 'skill_md'
: isLinkedFileView
? 'file'
: 'none'
const content = JSON.stringify(result)

return {
content,
rawData: {
content,
toolResult: {
activationApplied,
activationSource,
...(activationApplied ? { activatedSkill: validationResult.data.name } : {})
}
}
}
}

if (toolName === 'skill_manage') {
Expand Down
37 changes: 35 additions & 2 deletions src/renderer/src/components/chat-input/McpIndicator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Icon } from '@iconify/vue'
import { Button } from '@shadcn/components/ui/button'
Expand All @@ -219,7 +219,7 @@ import {
} from '@shadcn/components/ui/select'
import { Switch } from '@shadcn/components/ui/switch'
import type { MCPToolDefinition } from '@shared/presenter'
import { SETTINGS_EVENTS } from '@/events'
import { SETTINGS_EVENTS, SKILL_EVENTS } from '@/events'
import { usePresenter } from '@/composables/usePresenter'
import { useMcpStore } from '@/stores/mcp'
import { useSessionStore } from '@/stores/ui/session'
Expand Down Expand Up @@ -629,6 +629,21 @@ const setGroupEnabled = async (group: ToolGroup, enabled: boolean) => {
}
}

const handleSkillRuntimeChange = (
_event: unknown,
payload: { conversationId?: string | null; skills?: string[] }
) => {
if (!isDeepchatContext.value || !deepchatSessionId.value) {
return
}

if (payload?.conversationId !== deepchatSessionId.value) {
return
}

void loadDeepchatTools()
}

watch(
() => [isDeepchatContext.value, deepchatSessionId.value, workspacePath.value] as const,
() => {
Expand All @@ -654,4 +669,22 @@ watch(
}
}
)

onMounted(() => {
if (!window.electron?.ipcRenderer) {
return
}

window.electron.ipcRenderer.on(SKILL_EVENTS.ACTIVATED, handleSkillRuntimeChange)
window.electron.ipcRenderer.on(SKILL_EVENTS.DEACTIVATED, handleSkillRuntimeChange)
})

onUnmounted(() => {
if (!window.electron?.ipcRenderer) {
return
}

window.electron.ipcRenderer.removeListener(SKILL_EVENTS.ACTIVATED, handleSkillRuntimeChange)
window.electron.ipcRenderer.removeListener(SKILL_EVENTS.DEACTIVATED, handleSkillRuntimeChange)
})
</script>
2 changes: 1 addition & 1 deletion src/shared/types/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export interface ISkillPresenter {

// Session state management
getActiveSkills(conversationId: string): Promise<string[]>
setActiveSkills(conversationId: string, skills: string[]): Promise<void>
setActiveSkills(conversationId: string, skills: string[]): Promise<string[]>
clearNewAgentSessionSkills?(conversationId: string): Promise<void>
validateSkillNames(names: string[]): Promise<string[]>

Expand Down
52 changes: 52 additions & 0 deletions test/main/presenter/agentRuntimePresenter/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,58 @@ describe('dispatch', () => {
expect(state.blocks[0].tool_call!.server_description).toBe('Test server')
})

it('flags toolsChanged when skill_view activates a skill via main SKILL.md', async () => {
const tools = [makeTool('skill_view')]
const toolPresenter = {
...createMockToolPresenter(),
callTool: vi.fn().mockResolvedValue({
content: '{"success":true,"name":"deepchat-settings","isPinned":true}',
rawData: {
toolCallId: 'tc1',
content: '{"success":true,"name":"deepchat-settings","isPinned":true}',
isError: false,
toolResult: {
activationApplied: true,
activationSource: 'skill_md',
activatedSkill: 'deepchat-settings'
}
}
})
} as unknown as IToolPresenter

state.blocks.push({
type: 'tool_call',
content: '',
status: 'pending',
timestamp: Date.now(),
tool_call: {
id: 'tc1',
name: 'skill_view',
params: '{"name":"deepchat-settings"}',
response: ''
}
})
state.completedToolCalls = [
{ id: 'tc1', name: 'skill_view', arguments: '{"name":"deepchat-settings"}' }
]

const result = await executeTools(
state,
[],
0,
tools,
toolPresenter,
'gpt-4',
io,
'full_access',
new ToolOutputGuard(),
32000,
1024
)

expect(result.toolsChanged).toBe(true)
})

it('includes reasoning_content when interleaved compatibility is enabled', async () => {
const tools = [makeTool('search')]
const toolPresenter = createMockToolPresenter({ search: 'result' })
Expand Down
Loading
Loading