diff --git a/desktop/scripts/prepare-bundle.mjs b/desktop/scripts/prepare-bundle.mjs index 5ef1ed0..f123dde 100644 --- a/desktop/scripts/prepare-bundle.mjs +++ b/desktop/scripts/prepare-bundle.mjs @@ -30,11 +30,16 @@ const bundleFlavor = process.env.HUGAGENT_DESKTOP_BUNDLE || "full"; if (!["full", "thin"].includes(bundleFlavor)) { throw new Error(`Unknown HUGAGENT_DESKTOP_BUNDLE flavor: ${bundleFlavor}`); } -validateDesktopBuildTarget( - process.env.TAURI_ENV_TARGET_TRIPLE - ? ["--target", process.env.TAURI_ENV_TARGET_TRIPLE] - : [], -); +if (bundleFlavor === "full") { + // The offline runtime is a native payload, so a cross-architecture target + // cannot be packaged with it. Thin bundles carry no runtime — any target, + // including universal-apple-darwin, is buildable on any host. + validateDesktopBuildTarget( + process.env.TAURI_ENV_TARGET_TRIPLE + ? ["--target", process.env.TAURI_ENV_TARGET_TRIPLE] + : [], + ); +} const desktopTarget = currentDesktopTarget(); const dependencyFingerprint = desktopDependencyFingerprint(repoRoot, desktopTarget); const python = bundleFlavor === "full" ? findPython() : null; diff --git a/src/backend/core/evolution/user_settings.py b/src/backend/core/evolution/user_settings.py index 5857035..0a4ff20 100644 --- a/src/backend/core/evolution/user_settings.py +++ b/src/backend/core/evolution/user_settings.py @@ -258,6 +258,23 @@ def pending_for_user(user_id: str, *, limit: int = 20) -> List[Dict[str, Any]]: action = personal_action(candidate) if action is None: continue + change_preview = _change_preview(candidate) + if str(candidate.target_kind or "") == "skill": + # Legacy personal cycles could promote a single repeated + # tool call as a "sequence". Such a candidate has neither + # an order to preserve nor an applicability condition, and + # approving it would install a tautological skill. Keep + # the evidence intact for audit, but do not present noise as + # a decision the user ought to make. + if change_preview.get("type") == "skill_sequence": + steps = change_preview.get("steps") or [] + rules = change_preview.get("ordering_constraints") or [] + if len(steps) < 2 and not rules: + continue + elif change_preview.get("type") != "skill_document": + # A skill with no inspectable content is not informed + # consent and would also fail materialisation later. + continue out.append( { "candidate_id": candidate.candidate_id, @@ -276,7 +293,7 @@ def pending_for_user(user_id: str, *, limit: int = 20) -> List[Dict[str, Any]]: "action": action["action"], "action_label": action["label"], "action_effect": action["effect"], - "change": _change_preview(candidate), + "change": change_preview, "created_at": candidate.created_at.isoformat() if candidate.created_at else None, @@ -300,7 +317,8 @@ def _change_preview(candidate) -> Dict[str, Any]: """ ir = candidate.ir or {} kind = str(candidate.target_kind or "") - for change in ir.get("changes") or []: + changes = ir.get("changes") or [] + for change in changes: if kind == "skill": document = change.get("document") if isinstance(document, dict) and document.get("content"): @@ -327,6 +345,40 @@ def _change_preview(candidate) -> Dict[str, Any]: for op in operations ], } + if kind == "skill": + # Sequence-only candidates are the common output of the personal + # evolution path. They do not carry an authored SKILL.md, but approval + # still materialises a concrete name, description and ordered set of + # steps. Surface that exact structure instead of returning {}, which + # made the frontend silently remove the detail button. + tools: List[str] = [] + constraints: List[Dict[str, Any]] = [] + for change in changes: + sequence = change.get("tool_sequence") + if isinstance(sequence, list) and sequence and not tools: + tools = [str(tool) for tool in sequence] + rules = change.get("ordering_constraints") + if isinstance(rules, list) and rules: + constraints = [dict(rule) for rule in rules if isinstance(rule, dict)] + + rules_only = not tools and bool(constraints) + if rules_only: + from core.evolution.activation import _tools_from_constraints + + tools = _tools_from_constraints(constraints) + if tools: + # Reuse the materialiser's wording so the preview and the installed + # skill never disagree about what the user accepted. + from core.evolution.activation import _skill_description, _skill_label + + return { + "type": "skill_sequence", + "display_name": _skill_label(tools, rules_only=rules_only), + "description": _skill_description(tools, constraints, rules_only=rules_only), + "allowed_tools": tools, + "steps": tools, + "ordering_constraints": constraints, + } return {} diff --git a/src/backend/core/memory/extractors/gate.py b/src/backend/core/memory/extractors/gate.py index 3aef6d1..e2ebc1f 100644 --- a/src/backend/core/memory/extractors/gate.py +++ b/src/backend/core/memory/extractors/gate.py @@ -39,14 +39,28 @@ - graph: 稳定的实体关系(隶属、负责、依赖、使用、组成、别名、分类) - task: 本轮明确提出的多步任务目标(仅本会话内使用) -【判定标准 —— 宁缺毋滥】 -绝大多数对话轮次**没有**值得长期记住的内容。以下都不算: +【判定标准】 +以下内容都不算: - 一次性的问答、查询、闲聊 - 只对当前这一次请求有效的指令("这次用表格""先看第三页") - 助手自己说的内容(除非用户明确认可为约定) - 模型本来就会的通用常识 -只有当**下次对话不知道这条信息就会做错或重复问**时,才算值得记住。 +以下内容应当记住: +- 用户明确要求“记住”“记录一下”“以后”“下次”“默认”“一律”“不要再”,并给出需要延续的信息;明确说明仅本次有效的除外 +- 用户直接陈述或确认的身份、岗位、部门、单位、职责、联系方式等相对稳定的信息 → identity +- 用户反复适用的表达与交付偏好,例如语言、详略、格式、称呼、禁忌和默认输出方式 → preference +- 用户所在组织、团队或项目特有的可复用规则,例如术语定义、统计口径、计算方法、步骤顺序、命名规范、目录约定、必做校验、交付标准和风险红线 → procedural +- 用户直接陈述或确认的稳定实体关系,例如谁负责什么、哪个部门隶属哪里、系统依赖什么、产品由哪些模块组成、名称与别名的对应关系 → graph +- 用户明确提出的多步任务,以及尚未完成的目标、步骤、进度、截止时间和交付物 → task;此类信息只在本会话内保留,不作为长期事实 +- 用户对已有信息的纠正、替换或失效声明,例如“不是 A,是 B”“我现在改负责 X”“以后不用旧口径”;应记住新信息并用于更新旧记忆 +- 助手提出的方案被用户明确确认为今后的规则或偏好,例如“对,以后都按这个执行”;按对应类别记住 + +总判断原则: +- 只有用户明确说出或明确确认的内容才能作为事实写入,不要把助手的推测当成用户记忆 +- 判断它在未来是否仍然有用:如果下次对话不知道这条信息会导致做错、重复询问或违反用户约定,就应该记住 +- 用户明确要求记住时应优先保留;若内容同时符合多个类别,可以返回多个类别 +- 不要因为信息只出现一次就拒绝;稳定性由语义和用户意图判断,而不是由出现次数判断 【输出格式(严格 JSON,无代码块包裹)】 {{"classes": ["identity", "procedural"]}} diff --git a/src/backend/tests/evolution/test_personal_approval.py b/src/backend/tests/evolution/test_personal_approval.py index 55a4a10..18aadb3 100644 --- a/src/backend/tests/evolution/test_personal_approval.py +++ b/src/backend/tests/evolution/test_personal_approval.py @@ -200,6 +200,34 @@ def test_the_tool_sequence_is_surfaced_so_approval_is_informed(db): assert pending[0]["tool_sequence"] == ["a", "b"] +def test_sequence_only_skill_has_an_inspectable_change_preview(db): + for i in range(5): + _episode(db, f"e{i}", "alice") + _candidate(db, "c1", [f"e{i}" for i in range(5)]) + db.commit() + + change = US.pending_for_user("alice")[0]["change"] + assert change["type"] == "skill_sequence" + assert change["display_name"] == "固定调用顺序:a → b" + assert change["steps"] == ["a", "b"] + assert "`a`、`b`" in change["description"] + + +def test_legacy_single_tool_candidate_is_hidden_without_deleting_evidence(db): + for i in range(5): + _episode(db, f"e{i}", "alice") + _candidate( + db, + "single", + [f"e{i}" for i in range(5)], + ir={"changes": [{"tool_sequence": ["view_text_file"]}]}, + ) + db.commit() + + assert US.pending_for_user("alice") == [] + assert db.get(EvolutionCandidate, "single") is not None + + # ── The queue lists only what it can carry out ─────────────────────────────── # # Every case below used to appear with a 「为我启用」 button whose sole possible diff --git a/src/backend/tests/evolution/test_promotion_chain.py b/src/backend/tests/evolution/test_promotion_chain.py index d76eb4f..6592302 100644 --- a/src/backend/tests/evolution/test_promotion_chain.py +++ b/src/backend/tests/evolution/test_promotion_chain.py @@ -101,6 +101,24 @@ def test_recurring_pattern_is_compiled_into_a_skill_proposal(): assert "重新规划" not in proposal.rationale +def test_one_repeated_tool_call_is_not_mislabelled_as_a_sequence_skill(): + pattern = P.Pattern( + kind=P.PATTERN_SUCCESS_SUBSEQUENCE, + signature="call_subagent", + support=9, + success_rate=1.0, + tool_sequence=["call_subagent"], + episode_ids=[f"ep-{i}" for i in range(9)], + ) + + assert ( + P.promote_tool_sequence_to_skill( + pattern, skill_credit=0.9, workflow_credit=0.1 + ) + is None + ) + + def test_promotion_is_refused_when_orchestration_explains_it_better(): proposal = P.promote_tool_sequence_to_skill( _success_pattern(), diff --git a/src/frontend/src/api.ts b/src/frontend/src/api.ts index 5815698..642b30b 100644 --- a/src/frontend/src/api.ts +++ b/src/frontend/src/api.ts @@ -3966,6 +3966,14 @@ export type EvolutionChangePreview = allowed_tools: string[]; content: string; } + | { + type: 'skill_sequence'; + display_name: string; + description: string; + allowed_tools: string[]; + steps: string[]; + ordering_constraints: Array>; + } | { type: 'memory_ops'; operations: Array<{ diff --git a/src/frontend/src/components/settings/EvolutionApprovalList.tsx b/src/frontend/src/components/settings/EvolutionApprovalList.tsx index 5fdb80b..6057f1c 100644 --- a/src/frontend/src/components/settings/EvolutionApprovalList.tsx +++ b/src/frontend/src/components/settings/EvolutionApprovalList.tsx @@ -1,11 +1,19 @@ -import { CheckOutlined, DownOutlined, InboxOutlined, UpOutlined } from '@ant-design/icons'; -import { Button, Tag, message } from 'antd'; +import { + CheckOutlined, + EyeOutlined, + InboxOutlined, + LockOutlined, + UpOutlined, +} from '@ant-design/icons'; +import { Button, Spin, Tag, message } from 'antd'; import { useEffect, useState } from 'react'; import { approveMyEvolutionCandidate, getMyEvolutionCandidates } from '../../api'; import type { MyEvolutionCandidate } from '../../api'; import { t } from '../../i18n'; +import { useChatStore } from '../../stores/chatStore'; import { useCatalogStore } from '../../stores/catalogStore'; +import { TOOL_NAME_OVERRIDES } from '../../utils/constants'; /** * Capability changes the signed-in user can decide on for themselves. @@ -29,44 +37,118 @@ const KIND_LABEL: Record = { }; const OP_LABEL: Record = { + new: '新增', create: '新增', + patch: '优化', update: '改写', reweight: '调权', deprecate: '停用', merge: '合并', }; -function ChangeDetail({ candidate }: { candidate: MyEvolutionCandidate }) { +function displayToolName(tool: string, names: Record): string { + return TOOL_NAME_OVERRIDES[tool] || names[tool] || tool; +} + +function humaniseFinding(summary: string): string { + const sequenceFinding = summary.match( + /^(\d+)\s*个\s*Episode\s*出现相同工具子序列且成功率\s*([^,,]+)[,,]每次仍在重新规划$/, + ); + if (!sequenceFinding) return summary; + return t('系统在 {n} 次历史执行中发现了相同做法(成功率 {rate}),建议保存下来,避免以后每次重新规划。', { + n: sequenceFinding[1], + rate: sequenceFinding[2], + }); +} + +function candidateTitle( + candidate: MyEvolutionCandidate, + toolNames: Record, +): string { + const change = candidate.change; + if (change && 'type' in change && change.type === 'skill_document' && change.display_name) { + return change.display_name; + } + if (candidate.target_kind === 'skill' && candidate.tool_sequence?.length) { + const steps = candidate.tool_sequence.map((tool) => displayToolName(tool, toolNames)); + return t('固定流程:{steps}', { steps: steps.join(' → ') }); + } + return candidate.summary || t('能力候选'); +} + +interface ChangeDetailProps { + candidate: MyEvolutionCandidate; + toolNames: Record; +} + +function ChangeDetail({ candidate, toolNames }: ChangeDetailProps) { const [open, setOpen] = useState(false); const change = candidate.change as Record; + const isSkill = candidate.target_kind === 'skill'; - if (!change || !change.type) return null; - - if (change.type === 'skill_document') { - const tools = (change.allowed_tools as string[]) ?? []; + if (isSkill) { + const isDocument = change?.type === 'skill_document'; + const isSequence = change?.type === 'skill_sequence'; + const tools = ((change?.allowed_tools as string[]) ?? candidate.tool_sequence ?? []); + const steps = ((change?.steps as string[]) ?? candidate.tool_sequence ?? []); return (
- - {(change.description as string) && ( -

{change.description as string}

- )} - {tools.length > 0 && ( -
- {t('它会用到的工具')}: - {tools.map((tool) => ( - {tool} - ))} + + + {open && ( +
+
+ {t('这个技能会做什么')} +

+ {isDocument && change.description + ? String(change.description) + : t('遇到需要这些工具共同完成的任务时,智能体会直接复用这套已验证流程,减少重复规划。')} +

+
+ + {tools.length > 0 && ( +
+ {isSequence ? t('执行步骤') : t('它会用到的工具')} +
    + {(steps.length ? steps : tools).map((tool) => { + const label = displayToolName(tool, toolNames); + return ( +
  1. + {label} + {label !== tool && {tool}} +
  2. + ); + })} +
+
+ )} + + {isDocument && Boolean(change.content) && ( +
+ {t('完整技能正文')} +
{String(change.content)}
+
+ )} + +
+ + {t(candidate.action_effect || '作为你的私有技能安装,只对你生效')} +
)} - {open &&
{change.content as string}
}
); } - if (change.type === 'memory_ops') { + if (change?.type === 'memory_ops') { const ops = (change.operations as Array>) ?? []; return (
@@ -87,19 +169,25 @@ function ChangeDetail({ candidate }: { candidate: MyEvolutionCandidate }) { } export function EvolutionApprovalList() { - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [items, setItems] = useState([]); const [approving, setApproving] = useState(''); + const toolDisplayNames = useChatStore((state) => state.toolDisplayNames); - const load = () => { - setLoading(true); + useEffect(() => { + let active = true; getMyEvolutionCandidates() - .then((data) => setItems(data.candidates ?? [])) - .catch(() => setItems([])) - .finally(() => setLoading(false)); - }; - - useEffect(load, []); + .then((data) => { + if (active) setItems(data.candidates ?? []); + }) + .catch(() => { + if (active) setItems([]); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { active = false; }; + }, []); const approve = (candidate: MyEvolutionCandidate) => { setApproving(candidate.candidate_id); @@ -129,7 +217,16 @@ export function EvolutionApprovalList() { .finally(() => setApproving('')); }; - if (!loading && items.length === 0) { + if (loading && items.length === 0) { + return ( +
+ + {t('正在整理能力候选…')} +
+ ); + } + + if (items.length === 0) { return (
@@ -146,25 +243,41 @@ export function EvolutionApprovalList() {
- {t(KIND_LABEL[candidate.target_kind] ?? candidate.target_kind)} - {candidate.operation ? ` · ${t(OP_LABEL[candidate.operation] ?? candidate.operation)}` : ''} + {candidate.target_kind === 'skill' && ['new', 'create'].includes(candidate.operation) + ? t('新技能') + : `${t(KIND_LABEL[candidate.target_kind] ?? candidate.target_kind)}${candidate.operation ? ` · ${t(OP_LABEL[candidate.operation] ?? candidate.operation)}` : ''}`} - {candidate.summary || t('能力候选')} +
+ {candidateTitle(candidate, toolDisplayNames)} + {candidate.target_kind === 'skill' && ( + + {t('把重复成功的做法保存成可复用流程')} + + )} +
- + {candidate.summary && ( +
+ {t('为什么建议')} +

{humaniseFinding(candidate.summary)}

+
+ )} {candidate.tool_sequence?.length > 0 && (
{candidate.tool_sequence.map((tool, index) => ( {index > 0 && } - {tool} + {index + 1} + {displayToolName(tool, toolDisplayNames)} ))}
)} + +
{/* Provenance and blast radius, so approval is an informed act. */} @@ -172,7 +285,7 @@ export function EvolutionApprovalList() { {candidate.total_evidence > candidate.your_episodes ? ` · ${t('共 {n} 条证据', { n: candidate.total_evidence })}` : ''} - {candidate.action_effect ? ` · ${candidate.action_effect}` : ''} + {candidate.action_effect ? ` · ${t(candidate.action_effect)}` : ''}