Release 2.9.2: trigger-based skill injection + vision support for all adapters

- skills-manager: conditional skill injection via triggers frontmatter field
  (skills only sent to model when message matches trigger keywords)
- skills: added triggers to all skill SKILL.md files; cleaned up headers/disclaimers
- anthropic-adapter: toAnthropicContent() converts OpenAI image_url → Anthropic image blocks
- multi-agent: vision flag in SecondaryProfile; secondarySupportsVision() checks config
- config: secondary set to mistral-large-3:675b-cloud with vision:true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-02 14:28:40 +09:00
co-authored by Claude Sonnet 4.6
parent d9df800524
commit 70fc406b2a
13 changed files with 139 additions and 188 deletions
+1 -1
View File
@@ -5306,7 +5306,7 @@ ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool r
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.
BROWSER RULE: NEVER call browser_open, browser_snapshot, browser_click, browser_fill, browser_scroll, or any browser_* tool unless the user EXPLICITLY asks to use the browser (e.g. "브라우저로 열어줘", "Chrome으로 봐줘", "사이트 직접 들어가봐"). "열어줘" or "보여줘" alone is NOT a browser request — answer from knowledge, use web_fetch/web_search, or output the relevant URL/embed. Pasting a URL or asking a question is also NOT a browser request.
CODE OUTPUT: When writing code in a fenced code block, start with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/C), \`<!-- filename: index.html -->\` (HTML). Never repeat code already written in this conversation. For modifications to existing files, use replace_lines or insert_after (not create_file — it only works for NEW files). All code changes are presented as diffs for the user to review before being applied. Write code directly — do not ask for permission.
PACKAGE INSTALL: NEVER run pip install, npm install, apt-get, or any package installation command. If a package is missing, just write the code and mention the package name in a comment — let the user decide whether to install it. Do NOT attempt to install packages yourself.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000)}${workflowCtx ? '\n\n' + workflowCtx : ''}`,
PACKAGE INSTALL: NEVER run pip install, npm install, apt-get, or any package installation command. If a package is missing, just write the code and mention the package name in a comment — let the user decide whether to install it. Do NOT attempt to install packages yourself.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000, message)}${workflowCtx ? '\n\n' + workflowCtx : ''}`,
},
];
+26 -7
View File
@@ -21,6 +21,7 @@ export interface Skill {
emoji: string; // from frontmatter or default
version: string; // from frontmatter
model?: string; // optional model override from frontmatter
triggers?: string[]; // if set, only inject when message contains one of these words
enabled: boolean; // from config
instructions: string; // markdown body (everything after frontmatter)
filePath: string; // full path to SKILL.md
@@ -62,7 +63,12 @@ function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; bod
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
frontmatter[key] = val;
// Parse inline array: [a, b, c]
if (val.startsWith('[') && val.endsWith(']')) {
frontmatter[key] = val.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
} else {
frontmatter[key] = val;
}
}
}
@@ -140,6 +146,7 @@ export class SkillsManager {
emoji: frontmatter.emoji || '🧩',
version: frontmatter.version || '1.0.0',
model: frontmatter.model || undefined,
triggers: Array.isArray(frontmatter.triggers) ? frontmatter.triggers : undefined,
enabled: this.enabledState[entry.name] ?? false,
instructions: body,
filePath: skillMd,
@@ -251,13 +258,19 @@ export class SkillsManager {
}
/** 유저 state 기반 prompt context 빌드. userDir 없으면 글로벌 fallback. */
buildPromptContextForUser(userDir: string | null, maxCharsPerSkill = 300): string {
if (!userDir) return this.buildPromptContext(maxCharsPerSkill);
buildPromptContextForUser(userDir: string | null, maxCharsPerSkill = 300, userMessage = ''): string {
if (!userDir) return this.buildPromptContext(maxCharsPerSkill, userMessage);
const userState = this.getUserState(userDir);
const msgLower = userMessage.toLowerCase();
const enabled = this.getAll().filter(s => {
if (this.GLOBAL_ONLY_SKILLS.has(s.id)) return s.enabled;
if (userState !== null && s.id in userState) return userState[s.id];
return s.enabled;
const isEnabled = userState !== null && s.id in userState ? userState[s.id] : s.enabled;
if (!isEnabled) return false;
// Trigger filtering: if skill has triggers, only inject when message matches
if (s.triggers && s.triggers.length > 0) {
return s.triggers.some(t => msgLower.includes(t.toLowerCase()));
}
return true;
});
if (enabled.length === 0) return '';
const parts: string[] = ['[ACTIVE SKILLS]'];
@@ -406,8 +419,14 @@ export class SkillsManager {
* Build the skills context string for the system prompt.
* Only includes enabled skills. Keeps it compact for 4B context.
*/
buildPromptContext(maxCharsPerSkill: number = 300): string {
const enabled = this.getEnabledSkills();
buildPromptContext(maxCharsPerSkill: number = 300, userMessage = ''): string {
const msgLower = userMessage.toLowerCase();
const enabled = this.getEnabledSkills().filter(s => {
if (s.triggers && s.triggers.length > 0) {
return s.triggers.some(t => msgLower.includes(t.toLowerCase()));
}
return true;
});
if (enabled.length === 0) return '';
const parts: string[] = ['[ACTIVE SKILLS]'];
+5
View File
@@ -21,6 +21,7 @@ export type PreflightMode = 'off' | 'complex_only' | 'always';
export interface SecondaryProfile {
provider: string;
model: string;
vision?: boolean;
}
export interface OrchestrationConfig {
@@ -163,6 +164,7 @@ export function getOrchestrationConfig(): OrchestrationConfig | null {
secondary: {
provider: String(oc.secondary.provider || '').trim(),
model: String(oc.secondary.model || '').trim(),
vision: typeof oc.secondary.vision === 'boolean' ? oc.secondary.vision : undefined,
},
...clamped,
};
@@ -1159,6 +1161,9 @@ async function buildSecondaryProvider(): Promise<{ provider: LLMProvider; config
* that can actually reason about the screenshot.
*/
function secondarySupportsVision(config: OrchestrationConfig): boolean {
if (config.secondary.vision === true) return true;
if (config.secondary.vision === false) return false;
// Default: openai providers support vision
const p = config.secondary.provider;
return p === 'openai' || p === 'openai_codex';
}
+33 -5
View File
@@ -5,7 +5,7 @@
*/
import type {
LLMProvider, ChatMessage, ChatOptions, ChatResult,
LLMProvider, ChatMessage, ContentPart, ChatOptions, ChatResult,
GenerateOptions, GenerateResult, ModelInfo,
} from './LLMProvider';
import { contentToString } from './content-utils';
@@ -136,13 +136,13 @@ function toAnthropicMessages(messages: ChatMessage[]): any[] {
out.push({ role: 'assistant', content });
} else if (msg.role === 'user' || msg.role === 'assistant') {
const text = contentToString(msg.content);
const anthropicContent = toAnthropicContent(msg.content);
const last = out[out.length - 1];
if (last?.role === msg.role && typeof last.content === 'string') {
if (last?.role === msg.role && typeof last.content === 'string' && typeof anthropicContent === 'string') {
// Merge consecutive same-role text messages
last.content += '\n' + text;
last.content += '\n' + anthropicContent;
} else {
out.push({ role: msg.role, content: text });
out.push({ role: msg.role, content: anthropicContent });
}
}
}
@@ -150,6 +150,34 @@ function toAnthropicMessages(messages: ChatMessage[]): any[] {
return out;
}
/**
* Convert OpenAI-style content (string | ContentPart[]) to Anthropic content blocks.
* - string → string (text only)
* - ContentPart[] → Anthropic content block array (text + image)
*/
function toAnthropicContent(content: string | ContentPart[] | null | undefined): string | any[] {
if (!content) return '';
if (typeof content === 'string') return content;
const blocks: any[] = [];
for (const part of content) {
if (part.type === 'text') {
blocks.push({ type: 'text', text: part.text });
} else if (part.type === 'image_url') {
const url = part.image_url?.url || '';
if (url.startsWith('data:')) {
const match = url.match(/^data:([^;]+);base64,(.+)$/);
if (match) {
blocks.push({ type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } });
}
} else if (url.startsWith('http://') || url.startsWith('https://')) {
blocks.push({ type: 'image', source: { type: 'url', url } });
}
}
}
return blocks.length > 0 ? blocks : '';
}
/** Convert OpenAI tool definitions to Anthropic tool definitions. */
function toAnthropicTools(tools: any[]): any[] {
return tools