Skip to content
31 changes: 27 additions & 4 deletions src/chat/agent-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,18 @@ function injectSyntheticSkillRead(messages, skillName, body, skillPath) {
const filePath = skillPath
|| path.join(DEEPCOPILOT_SKILLS_DIR, safeName, 'SKILL.md');
const callId = `synthetic_skill_read_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
messages.push({
// DeepSeek thinking-mode quirk: once any assistant message in history
// carries reasoning_content, every subsequent assistant message MUST
// also carry it, or the API returns 400 ("reasoning_content in the
// thinking mode must be passed back to the API"). Only attach a
// reasoning_content here when the history already shows thinking mode is
// live — that way non-DeepSeek providers (which don't round-trip this
// field) aren't poisoned by a synthetic value, and the providers/index.js
// backfill stays inactive until real thoughts have already appeared.
const thinkingLive = Array.isArray(messages) && messages.some(
m => m && m.role === 'assistant' && typeof m.reasoning_content === 'string' && m.reasoning_content.length > 0,
);
const asst = {
role: 'assistant',
content: null,
tool_calls: [{
Expand All @@ -65,7 +76,11 @@ function injectSyntheticSkillRead(messages, skillName, body, skillPath) {
arguments: JSON.stringify({ path: filePath }),
},
}],
});
};
if (thinkingLive) {
asst.reasoning_content = `Loading skill SOP "${safeName}" via read_file to obtain its instructions before proceeding.`;
}
messages.push(asst);
messages.push({
role: 'tool',
tool_call_id: callId,
Expand Down Expand Up @@ -924,15 +939,23 @@ class AgentLoop {
{ role: 'user', content: '<system-reminder>\nYou have reached the tool-call iteration limit without producing a user-facing answer. Stop calling tools. Write a concise plain-text reply that: (1) summarises what you tried, (2) states what you found or could not find, (3) suggests a concrete next step the user can take.\n</system-reminder>' },
];
let tail = '';
let tailThoughts = '';
await streamChat(
{ provider, apiKey, baseUrl, messages: finalMsgs, model, noTools: true },
{
onDelta: t => { tail += t; run.reply.asst += t; this._postToRun(run, { type: 'replyDelta', text: t }); },
onThinking: t => { run.reply.thoughts += t; this._postToRun(run, { type: 'thinkingDelta', text: t }); },
onThinking: t => { tailThoughts += t; run.reply.thoughts += t; this._postToRun(run, { type: 'thinkingDelta', text: t }); },
},
signal,
).catch(e => Logger.info('FORCE_FINAL_SUMMARY_ERROR', { message: e.message }));
if (tail) run.messages.push({ role: 'assistant', content: tail });
if (tail) run.messages.push({
role: 'assistant',
content: tail,
// Preserve thinking output so the persisted/reloaded history
// does not end with a reasoning-less assistant message, which
// would 400 on the next turn under DeepSeek thinking mode.
...(tailThoughts ? { reasoning_content: tailThoughts } : {}),
});
}
} catch (e) {
// Restore the last known-good message state to prevent persisting a
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onThinking 的回调中,tailThoughts 变量的使用需要确保在多线程环境下不会出现竞态条件。建议在对 tailThoughts 进行操作时加锁,确保线程安全。此外,catch 语句中仅记录了错误信息,建议在捕获异常后进行适当的处理,以防止潜在的未处理异常导致的系统不稳定。

Expand Down
42 changes: 36 additions & 6 deletions src/prompts/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,25 @@ function readUserMemory() {
//
// Issue #61 — Step 2 (skill index), Step 8 (trust warning).

function readSkillIndex() {
// Cheap memoization across a single buildSystemPrompt() call. Both
// readSkillIndex() and getProblemSolvingParadigm() need the discovered
// skills, but discoverSkills() walks the filesystem and reads every
// SKILL.md body, so scanning twice per prompt build is wasteful. The cache
// is reset at the start of every buildSystemPrompt() invocation.
let _skillsCache = null;
function _getSkills() {
if (_skillsCache !== null) return _skillsCache;
try {
const { discoverSkills } = require('../skills');
const root = wsRoot();
const skills = discoverSkills(root);
_skillsCache = discoverSkills(wsRoot());
} catch { _skillsCache = []; }
return _skillsCache;
}
function _resetSkillsCache() { _skillsCache = null; }

function readSkillIndex() {
try {
const skills = _getSkills();
if (!skills.length) return null;

const lines = ['# Available skills'];
Expand Down Expand Up @@ -269,6 +283,20 @@ function readSkillIndex() {
// Issue #61 — Step 6 + Step 7.

function getProblemSolvingParadigm() {
// Issue #146 — only mention the skill-creator hard gate when a meta-skill
// is actually installed. Otherwise the model fabricates a skill_invoke
// call to a non-existent "skill-creator", producing a confusing error.
// Reuses the memoized skills list shared with readSkillIndex() so we
// don't walk the filesystem twice per prompt build.
const variants = new Set(['skill-creator', 'skill_creator', 'skillcreator']);
const skillCreatorInstalled = _getSkills().some(
s => variants.has(String(s && s.name || '').toLowerCase()),
);

const gateParagraph = skillCreatorInstalled
? `\n\n**Issue #146 — skill_create quality gate**: A meta-skill matching \`skill-creator\` (or variant) is installed locally. You MUST call \`skill_invoke({ name: "<exact-installed-spelling>" })\` in the SAME turn BEFORE you call \`skill_create\`. The meta-skill performs description tightening, body structure check, and dedup. Calling \`skill_create\` without it will be rejected by the tool layer.`
: '';

return `# Problem-solving paradigm

For any non-trivial task, follow this loop:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 安全性: 在调用 discoverSkills 函数时,未对返回值进行充分的验证,可能导致未处理的异常或错误数据。建议在使用 all 变量之前,确保其内容符合预期。
  2. 异常处理: catch 语句未指定异常类型,可能会掩盖其他潜在的错误。建议至少记录错误信息以便于调试。
  3. 代码风格: 代码中使用了较多的注释,虽然有助于理解,但建议将注释内容简化并保持一致性,以符合项目的整体风格。
  4. 性能: 使用 Set 来存储变体名称是合理的,但在 some 方法中进行字符串转换可能会影响性能,尤其是在技能数量较多时。可以考虑优化此部分。

Expand All @@ -284,9 +312,7 @@ For any non-trivial task, follow this loop:

Skills (\`skill_invoke\` / \`skill_create\`) capture reasoned, on-demand playbooks. Hooks (\`hooks.json\`) capture deterministic reflexes. Do not conflate them.

Never call \`skill_create\` for one-off fixes, trivial tasks, or before the user has confirmed the solution works.

**Issue #146 — skill_create quality gate**: if a skill matching the \`skill-creator\` meta-skill name (or common variants: \`skill_creator\`, \`skillcreator\`) appears in the Available skills index above, you MUST call \`skill_invoke({ name: "<that-skill-name>" })\` in the SAME turn BEFORE you call \`skill_create\`. The meta-skill performs description tightening, body structure check, and dedup. Calling \`skill_create\` without it will be rejected by the tool layer. Do NOT treat \`skill_create\` as "just a file write" — creation goes through review first.`;
Never call \`skill_create\` for one-off fixes, trivial tasks, or before the user has confirmed the solution works.${gateParagraph}`;
}

// ---------- workspace instructions (lazy, opt-in) ----------
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 安全性: 直接拼接 gateParagraph 可能导致潜在的注入风险,建议使用模板字符串或其他安全方式来构建字符串。
  2. 可维护性: 代码逻辑较为复杂,建议将技能检查逻辑提取为单独的函数,以提高可读性和可维护性。
  3. 一致性: 在注释中提到的 skill_create 的使用说明与之前的逻辑相矛盾,可能会导致用户混淆。建议统一说明,确保用户理解何时调用 skill_create

Expand Down Expand Up @@ -367,6 +393,10 @@ function buildSystemPrompt(opts = {}) {

const staticPart = getStaticCore();

// Reset the per-build skills cache so a freshly-installed skill becomes
// visible without a full process restart.
_resetSkillsCache();

const dynamicParts = [getEnvironmentSection(osName)];
const mem = readUserMemory();
if (mem) dynamicParts.push(mem);
Expand Down
99 changes: 77 additions & 22 deletions src/providers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,37 +143,92 @@ function listModels(providerId) {

/**
* Apply provider-declared `stripInputFields` to a messages array.
* Returns a NEW array; never mutates the input. The strip rule lives in the
* provider JSON so a vendor's protocol quirks stay encapsulated.
* Never mutates the input. Returns either a NEW array (when at least one
* message was rewritten by stripping or backfill) or the ORIGINAL `messages`
* reference unchanged (fast path: no quirks apply, e.g. an OpenAI/Anthropic
* call with an empty `stripInputFields`). Callers must treat the result as
* read-only — do not mutate elements of the returned array. The strip rule
* lives in the provider JSON so a vendor's protocol quirks stay encapsulated.
Comment on lines +146 to +151
*
* Example: DeepSeek 400s when the input contains `reasoning_content`.
* deepseek.json sets quirks.stripInputFields: ["reasoning_content"], and this
* function removes that field from each message before the API call.
* Two behaviours, switched on whether the call is in DeepSeek's thinking-mode
* round-trip protocol (provider declares `reasoning_content` in stripInputFields
* AND the chosen model is reasoning-capable):
*
* Exception: `reasoning_content` is never stripped for reasoning-capable models
* (capabilities.reasoning === true) because DeepSeek's API requires it to be
* passed back in subsequent turns — omitting it causes HTTP 400.
* - **Non-thinking-mode call** (everything else, including non-reasoning
* DeepSeek models and all OpenAI/Anthropic models): every field declared in
* `quirks.stripInputFields` is removed. DeepSeek 400s when the input
* contains `reasoning_content` outside thinking mode, so deepseek.json sets
* `stripInputFields: ["reasoning_content"]` and we drop it here.
*
* - **Thinking-mode call** (DeepSeek reasoner family, etc.): `reasoning_content`
* is NOT stripped because the API requires it to be passed back in
* subsequent turns. On top of that we enforce the documented invariant
* "once any assistant in history carries non-empty `reasoning_content`,
* EVERY subsequent assistant message MUST also carry one" — otherwise the
* API rejects with `400 "reasoning_content in the thinking mode must be
* passed back to the API"`. We backfill a short placeholder on assistant
* messages that come after the first one with thoughts, leaving earlier
* pre-thinking messages alone. This is a defence-in-depth net: callers
* should still attach the real thought stream when they have one.
*/
const REASONING_PLACEHOLDER = '(no thoughts surfaced for this step)';

function sanitizeMessages(providerId, messages, modelId) {
if (!Array.isArray(messages) || !messages.length) return messages;
const strip = getProvider(providerId)?.quirks?.stripInputFields;
if (!Array.isArray(strip) || !strip.length) return messages;
// Reasoning-capable models MUST have reasoning_content passed back to the
// API; strip every other declared field but skip reasoning_content for them.
const isReasoning = !!(modelId && getModel(providerId, modelId)?.capabilities?.reasoning);
const effectiveStrip = isReasoning ? strip.filter(f => f !== 'reasoning_content') : strip;
if (!effectiveStrip.length) return messages;
return messages.map(m => {
if (!m || typeof m !== 'object') return m;
let copy = m;
for (const k of effectiveStrip) {
if (Object.prototype.hasOwnProperty.call(copy, k)) {
if (copy === m) copy = Object.assign({}, m);
delete copy[k];
// The reasoning_content round-trip rule is specific to providers that
// BOTH (a) declare reasoning_content as a stripInputField (i.e. the
// provider's API is known to care about this field) AND (b) are being
// used in a reasoning-capable mode. Without this narrower gate, OpenAI /
// Anthropic models that happen to flip `capabilities.reasoning` would
// get unknown `reasoning_content` fields pushed onto their requests.
const stripsReasoning = Array.isArray(strip) && strip.includes('reasoning_content');
const honorsReasoningRoundTrip = isReasoning && stripsReasoning;
// For models that honor the round-trip protocol, strip every declared
// field except reasoning_content. Otherwise strip everything declared.
const effectiveStrip = Array.isArray(strip)
? (honorsReasoningRoundTrip ? strip.filter(f => f !== 'reasoning_content') : strip)
: [];

let out = messages;
if (effectiveStrip.length) {
out = out.map(m => {
if (!m || typeof m !== 'object') return m;
let copy = m;
for (const k of effectiveStrip) {
if (Object.prototype.hasOwnProperty.call(copy, k)) {
if (copy === m) copy = Object.assign({}, m);
delete copy[k];
}
}
return copy;
});
}

// Reasoning-mode invariant backfill. The DeepSeek rule is specifically
// about messages that come AFTER the first assistant message with
// non-empty reasoning_content — earlier "pre-thinking" assistant
// messages don't need it. Locating the first thinking index and only
// backfilling from there onward keeps payload size minimal and matches
// the documented protocol more precisely. Scoped to providers that
// actually honor the round-trip protocol so we never inject
// reasoning_content into OpenAI/Anthropic-bound requests.
if (honorsReasoningRoundTrip) {
const firstThinkingIdx = out.findIndex(
m => m && m.role === 'assistant' && typeof m.reasoning_content === 'string' && m.reasoning_content.length > 0,
);
if (firstThinkingIdx !== -1) {
out = out.map((m, i) => {
if (i <= firstThinkingIdx) return m;
if (!m || m.role !== 'assistant') return m;
if (typeof m.reasoning_content === 'string' && m.reasoning_content.length > 0) return m;
return Object.assign({}, m, { reasoning_content: REASONING_PLACEHOLDER });
});
}
return copy;
});
}

return out;
}

/**
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 安全性: 在处理 messages 时,需确保输入的每个消息对象都经过严格验证,避免潜在的对象注入攻击。建议在 sanitizeMessages 函数中增加对 messages 中每个对象的结构和类型的验证。

  2. 异常处理: 当前代码中未对 getProvidergetModel 的返回值进行有效性检查,若返回 undefinednull,可能导致后续代码抛出异常。建议在使用这些函数的返回值前进行检查。

  3. 代码风格: 代码中使用了 Object.prototype.hasOwnProperty.call,建议统一使用 Object.hasOwn,以提高可读性和一致性。

  4. 性能: 在 out.map 的实现中,如果 effectiveStrip 为空,仍然会执行一次 map,可以考虑在此处添加条件判断,避免不必要的遍历。

  5. 可维护性: 代码中对 reasoning_content 的处理逻辑较为复杂,建议将其提取为单独的函数,以提高代码的可读性和可维护性。

Expand Down
2 changes: 1 addition & 1 deletion src/tools/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const TOOL_DEFS = [
type: 'function',
function: {
name: 'skill_create',
description: 'Persist a reusable multi-step workflow as a new skill on disk. Use ONLY when ALL of these are true: (1) you have just completed a non-trivial multi-step task, (2) the user has explicitly confirmed the result is correct, (3) the workflow has ≥3 concrete steps that span multiple tools and is likely to recur. Do NOT use for one-off fixes, trivial edits, single-step tasks, before user confirmation, or to record preferences (those belong in ~/.deepcopilot/memory.md) or project facts (those belong in <workspace>/DEEPCOPILOT.md). When the SOP was synthesized from web research, set source to "web" or "hybrid" — the skill will be marked untrusted automatically. **HARD GATE (Issue #146)**: if a meta-skill whose name is one of `skill-creator`, `skill_creator`, or `skillcreator` is installed locally (see the "Available skills" index in the system prompt), you MUST call `skill_invoke` earlier in the SAME turn with `name` set to the EXACT installed spelling (whichever of the three appears in the index) before calling skill_create; invoking a variant that is NOT actually installed does NOT satisfy the gate. The meta-skill performs description tightening, body structure check, and dedup. If no such skill is installed the call is allowed through with a warning prepended to the result.',
description: 'Persist a reusable multi-step workflow as a new skill on disk. Use ONLY when ALL of these are true: (1) you have just completed a non-trivial multi-step task, (2) the user has explicitly confirmed the result is correct, (3) the workflow has ≥3 concrete steps that span multiple tools and is likely to recur. Do NOT use for one-off fixes, trivial edits, single-step tasks, before user confirmation, or to record preferences (those belong in ~/.deepcopilot/memory.md) or project facts (those belong in <workspace>/DEEPCOPILOT.md). When the SOP was synthesized from web research, set source to "web" or "hybrid" — the skill will be marked untrusted automatically. **Quality gate (Issue #146)**: ONLY if a meta-skill named `skill-creator`, `skill_creator`, or `skillcreator` is listed in the "Available skills" index of the current system prompt, you MUST call `skill_invoke` earlier in the SAME turn with `name` set to that exact installed spelling before calling skill_create. If no such meta-skill appears in the index, do NOT fabricate a `skill_invoke` call for it — just call `skill_create` directly; the gate is inactive in that environment.',
parameters: {
type: 'object',
properties: {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在描述中,'HARD GATE' 被修改为 'Quality gate',这可能会导致对技能调用的理解产生混淆。建议保持原有的术语,以确保开发者在使用时不会误解。此外,描述中提到的技能调用逻辑需要确保在实现时有充分的验证和错误处理,以防止潜在的命令注入或路径穿越等安全漏洞。建议在调用 skill_create 之前,增加对输入参数的校验,确保其安全性和有效性。

Expand Down
33 changes: 28 additions & 5 deletions src/tools/skill-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ const path = require('path');
const { discoverSkills, DEEPCOPILOT_SKILLS_DIR } = require('../skills');
const { wsRoot } = require('../utils/paths');

// Issue #146 — Canonical name variants for the optional `skill-creator`
// meta-skill. When this skill is installed in the workspace, the `skill_create`
// gate requires it to be invoked before any new skill can be created (a
// quality-review step). When it is NOT installed the gate is a no-op and
// `skill_create` proceeds directly — see `skillCreate` for the conditional
// check and `system.js` / `schema.js` for the matching prompt/schema wording.
// Accept a couple of common spellings to be permissive about how the on-disk
// skill is named. Defined at module scope so both skill_invoke (for the "did
// you mean X?" soft-notice path) and the skill_create gate share the same set.
const SKILL_CREATOR_NAMES = new Set(['skill-creator', 'skill_creator', 'skillcreator']);

// ─── skill_invoke ────────────────────────────────────────────────────────────

/**
Expand All @@ -30,6 +41,23 @@ function skillInvoke(args, run) {
const all = discoverSkills(wsRoot());
const s = all.find(x => x.name === name);
if (!s) {
// Issue #146 follow-up: handle a model that calls a skill-creator
// variant. Two distinct cases must be told apart:
// 1. A creator IS installed but the model used wrong casing/spelling.
// → return an actionable "did you mean X" error so the gate
// stays effective.
// 2. No creator is installed at all.
// → return a soft notice so the agent can proceed straight to
// skill_create (the executor-side gate is a no-op here).
if (SKILL_CREATOR_NAMES.has(name.toLowerCase())) {
const installedCreator = all.find(
x => SKILL_CREATOR_NAMES.has(String(x && x.name || '').toLowerCase()),
);
if (installedCreator) {
return `Error: skill "${name}" not found. Did you mean "${installedCreator.name}"? Call \`skill_invoke({ name: "${installedCreator.name}" })\` with that exact spelling.`;
}
return 'Notice: no skill-creator meta-skill is installed in this environment. The skill_create quality gate is inactive here — you may proceed to call `skill_create` directly when appropriate.';
}
const known = all.map(x => x.name).join(', ') || '(none installed)';
return `Error: skill "${name}" not found. Available: ${known}`;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在这里添加了对未安装的技能创建者元技能的处理逻辑,虽然可以减少用户的困惑,但需要注意以下几点:

  1. 安全性:返回的通知信息中包含了关于环境状态的详细信息,可能会暴露系统的内部实现细节,建议使用更通用的消息,避免泄露敏感信息。

  2. 异常处理:当前实现没有对 name 参数进行有效性检查,若 namenullundefined,可能会导致后续的 toLowerCase() 调用抛出异常。建议在使用前进行检查。

  3. 代码风格:建议在代码中添加更多的注释,尤其是对逻辑的解释,以便后续维护人员理解。

  4. 可维护性:考虑将技能名称的变体提取到常量中,以便于后续的修改和维护。

综上所述,建议对该部分代码进行修改,以提高安全性和可维护性。

Expand Down Expand Up @@ -70,11 +98,6 @@ const VALID_SOURCES = new Set(['self', 'web', 'hybrid']);
const VALID_TRUSTS = new Set(['trusted', 'untrusted']);
const MAX_BODY_BYTES = 64 * 1024; // 64 KB hard ceiling

// Issue #146 — Meta-skill that must review every skill_create call.
// Accept a couple of common spelling variants to be permissive about how
// the on-disk skill is named.
const SKILL_CREATOR_NAMES = new Set(['skill-creator', 'skill_creator', 'skillcreator']);

/**
* Issue #146 — Check whether `skill_invoke({name: 'skill-creator'})` was
* called earlier in the *current* turn (i.e. after the most recent real user
Expand Down
Loading