From 5fc8d64872e8617af5b8ed9a48d2bf0b805a603a Mon Sep 17 00:00:00 2001 From: Richard Chien Date: Wed, 21 Jan 2026 00:01:34 +0800 Subject: [PATCH 1/5] re-implement prompt flow as a special type of agent skill Signed-off-by: Richard Chien --- AGENTS.md | 4 +- ...0-prompt-flow.md => klip-10-agent-flow.md} | 178 ++++++------ src/kimi_cli/app.py | 5 +- src/kimi_cli/cli/__init__.py | 44 --- src/kimi_cli/flow/mermaid.py | 218 -------------- src/kimi_cli/{skill.py => skill/__init__.py} | 127 ++++++++- src/kimi_cli/{ => skill}/flow/__init__.py | 50 ++-- src/kimi_cli/{ => skill}/flow/d2.py | 34 +-- src/kimi_cli/skill/flow/mermaid.py | 266 ++++++++++++++++++ src/kimi_cli/soul/kimisoul.py | 55 ++-- ...test_prompt_flow.py => test_agent_flow.py} | 116 ++++++-- tests/test_skill.py | 60 ++++ 12 files changed, 712 insertions(+), 445 deletions(-) rename klips/{klip-10-prompt-flow.md => klip-10-agent-flow.md} (58%) delete mode 100644 src/kimi_cli/flow/mermaid.py rename src/kimi_cli/{skill.py => skill/__init__.py} (62%) rename src/kimi_cli/{ => skill}/flow/__init__.py (51%) rename src/kimi_cli/{ => skill}/flow/d2.py (88%) create mode 100644 src/kimi_cli/skill/flow/mermaid.py rename tests/{test_prompt_flow.py => test_agent_flow.py} (51%) diff --git a/AGENTS.md b/AGENTS.md index 288fe8af5..331fa0269 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,8 +58,8 @@ shell UI, ACP server mode for IDE integrations, and MCP tool loading. and slash command autocomplete; it is the default interactive experience. - **Slash commands**: Soul-level commands live in `src/kimi_cli/soul/slash.py`; shell-level commands live in `src/kimi_cli/ui/shell/slash.py`. The shell UI exposes both and dispatches - based on the registry. Skills are registered as soul-level commands; running - `/skill:` loads the skill's `SKILL.md` as a user prompt. + based on the registry. Standard skills register `/skill:` and load `SKILL.md` + as a user prompt; flow skills register `/flow:` and execute the embedded flow. ## Major modules and interfaces diff --git a/klips/klip-10-prompt-flow.md b/klips/klip-10-agent-flow.md similarity index 58% rename from klips/klip-10-prompt-flow.md rename to klips/klip-10-agent-flow.md index 8afc30c2f..bfaafb55f 100644 --- a/klips/klip-10-prompt-flow.md +++ b/klips/klip-10-agent-flow.md @@ -1,32 +1,34 @@ --- Author: "@stdrc" -Updated: 2026-01-15 +Updated: 2026-01-20 Status: Implemented --- -# KLIP-10: Mermaid Prompt Flow (--prompt-flow) +# KLIP-10: Agent Flow (Agent Skill 扩展) ## 背景 当前 Kimi CLI 只能通过交互式输入或 `--command` 单次输入驱动对话。希望支持一种 -"prompt flow",让用户用 Mermaid flowchart 描述流程,每个节点对应一次对话轮次, -并能根据分支节点的选择继续走向不同的下一节点。 +"agent flow",让用户用 Mermaid 或 D2 flowchart 描述流程,每个节点对应一次对话轮次, +并能根据分支节点的选择继续走向不同的下一节点。Agent Flow 作为 Agent Skill 的扩展, +通过 `SKILL.md` 中的元数据声明类型,并从流程图代码块解析得到。 示例见 `flowchart.mmd`:用 `BEGIN`/`END` 包住流程,中间节点为 prompt,分支节点用 出边 label 表示分支值。 ## 目标 -- 新增 `--prompt-flow `,从 Mermaid flowchart 解析为内存图结构。 -- 从 `BEGIN` 开始顺着图走,依次执行节点(除 BEGIN/END)。 -- 在 `KimiSoul` 中支持可选的 `PromptFlow`,通过 `/begin` 命令触发执行。 +- Agent Skill 支持 `type: standard | flow` 元数据(默认 standard)。 +- flow 类型 skill 从 `SKILL.md` 中的第一个 Mermaid/D2 代码块解析流程。 +- Flow 作为 `Skill.flow` 存储,并在 `KimiSoul` 中通过 `/flow:` 触发执行。 +- standard 类型 skill 仍使用 `/skill:`,system prompt 中继续列出 name/description/path。 - 分支节点会在 user input 中补充可选分支值,要求 LLM 在回复末尾输出 `{值}`,并据此选择下一节点。 - 在同一 session/context 中持续推进,直到抵达 `END`。 ## 非目标 -- 不支持完整 Mermaid 语法,仅支持 flowchart 的最小子集。 +- 不支持完整 Mermaid/D2 语法,仅支持各自的最小子集。 - 不引入新的 UI(依旧使用 shell UI 输出)。 - 不处理子图、样式、链接、点击事件等 Mermaid 特性。 @@ -38,16 +40,27 @@ Status: Implemented - Header:`flowchart TD` / `flowchart LR` / `graph TD`(其余方向忽略)。 - 注释行:`%% ...`。 -- 节点:`ID[文本]`(普通)、`ID([文本])`(开始/结束)、`ID{文本}`(分支)。 +- 节点:`ID[文本]` / `ID([文本])` / `ID{文本}`(形状仅用于携带 label,语义上忽略)。 - 节点内容支持引号包裹:`ID["含特殊字符的文本"]`,引号内可包含 `]`、`}`、`|` 等。 - 边:`A --> B`、`A -->|label| B`、`A -- label --> B`。 - 允许边上内联节点定义:`A([BEGIN]) --> B[...]`。 -不支持:子图、链式多节点(`A --> B --> C`)、复杂连线形态、label 中包含 `|`。 +其他样式与布局相关语法(如 `classDef`/`style`/`linkStyle`/`subgraph`)会被忽略,不报错。 -### 2) 图结构与校验 +### 2) D2 flowchart 最小子集 -数据结构(位于 `src/kimi_cli/flow.py`): +支持以下语法(足够覆盖示例): + +- 注释行:`# ...`。 +- 节点:`ID: label`(label 省略时使用 ID)。 +- 边:`A -> B`、`A -> B: label`,允许链式 `A -> B -> C`(label 仅作用于最后一段)。 +- 节点 ID:字母数字或 `_` 开头,允许 `.` `/` `-`。 + +忽略:属性路径(如 `foo.bar`)与 `{ ... }` 块。 + +### 3) 图结构与校验 + +数据结构(位于 `src/kimi_cli/skill/flow/__init__.py`,`PromptFlow` 更名为 `Flow`): ```python FlowNodeKind = Literal["begin", "end", "task", "decision"] @@ -65,7 +78,7 @@ class FlowEdge: label: str | None @dataclass(slots=True) -class PromptFlow: +class Flow: nodes: dict[str, FlowNode] outgoing: dict[str, list[FlowEdge]] begin_id: str @@ -75,13 +88,13 @@ class PromptFlow: 异常层次结构: ```python -class PromptFlowError(ValueError): - """Base error for prompt flow parsing/validation.""" +class FlowError(ValueError): + """Base error for flow parsing/validation.""" -class PromptFlowParseError(PromptFlowError): - """Raised when Mermaid flowchart parsing fails.""" +class FlowParseError(FlowError): + """Raised when flowchart parsing fails.""" -class PromptFlowValidationError(PromptFlowError): +class FlowValidationError(FlowError): """Raised when a flowchart fails validation.""" ``` @@ -89,27 +102,49 @@ class PromptFlowValidationError(PromptFlowError): - `BEGIN`/`END` 通过节点文本(label)匹配,大小写不敏感。 - 必须且只能有一个 `BEGIN`、一个 `END`。 -- `BEGIN` 只允许 1 条出边;`END` 不允许出边。 -- 非分支节点(task)要求恰好 1 条出边(除非它就是 `END`)。 -- 分支节点(decision)要求出边 label 全部非空且唯一。 -- 未显式声明的节点允许隐式创建(label 默认使用节点 ID),以保持 Mermaid 的常见用法。 +- `BEGIN` 能连通到 `END`。 +- 如果某节点有多个出边,则每条边必须有非空 label,且 label 不能重复。 +- 单出边节点允许 label 缺失或为空(label 会被忽略)。 +- 未显式声明的节点允许隐式创建(label 默认使用节点 ID),以保持常见用法。 + +### 4) Agent Flow 发现与加载 + +Agent Flow 与 Agent Skill 复用同一套 discovery 逻辑,目录来源保持不变: + +- 内置技能:`src/kimi_cli/skills/` +- 用户技能:`~/.config/agents/skills`(含历史兼容路径) +- 项目技能:`/.agents/skills`(含历史兼容路径) + +skill 元数据: -### 3) FlowRunner 与 KimiSoul 扩展 +- `type: standard | flow`,默认 `standard`。 +- flow skill 会在 `SKILL.md` 中查找第一个 `mermaid` 或 `d2` fenced codeblock, + 并解析为 `Flow` 存入 `Skill.flow`。 +- 未找到有效流程图或解析失败时,记录日志并将其作为普通 skill 处理。 -提取独立的 `FlowRunner` 类处理 flow 执行逻辑,`KimiSoul` 通过持有 `_flow_runner` -实例来支持 prompt flow。同时重构 slash command 机制,将 skill commands 也改为 -实例级别(不再全局注册)。 +### 5) FlowRunner 与 KimiSoul 扩展 + +提取独立的 `FlowRunner` 类处理 flow 执行逻辑,`KimiSoul` 通过持有 `_flow_runners` +来支持 agent flow。同时重构 slash command 机制,将 skill commands 也改为实例级别 +(不再全局注册)。 **FlowRunner 类**(位于 `src/kimi_cli/soul/kimisoul.py`): ```python class FlowRunner: - def __init__(self, flow: PromptFlow, *, max_moves: int = DEFAULT_MAX_FLOW_MOVES) -> None: + def __init__( + self, + flow: Flow, + *, + name: str | None = None, + max_moves: int = DEFAULT_MAX_FLOW_MOVES, + ) -> None: self._flow = flow + self._name = name self._max_moves = max_moves async def run(self, soul: KimiSoul, args: str) -> None: - """执行 flow 遍历,通过 /begin 触发。""" + """执行 flow 遍历,通过 /flow: 触发。""" ... async def _execute_flow_node( @@ -123,7 +158,7 @@ class FlowRunner: @staticmethod def _build_flow_prompt(node: FlowNode, edges: list[FlowEdge]) -> str | list[ContentPart]: - """构建节点 prompt,分支节点会附加选择指引。""" + """构建节点 prompt,多出边节点会附加选择指引。""" ... @staticmethod @@ -135,7 +170,7 @@ class FlowRunner: def ralph_loop( user_message: Message, max_ralph_iterations: int, - ) -> tuple[PromptFlow, int]: + ) -> FlowRunner: """创建 Ralph 模式的循环流程。""" ... ``` @@ -149,30 +184,33 @@ class KimiSoul: agent: Agent, *, context: Context, - flow: PromptFlow | None = None, # 可选参数 ): # ... 现有初始化 ... - self._flow_runner = FlowRunner(flow) if flow is not None else None # 在 init 时构造 slash commands,避免每次 run 重复构造 self._slash_commands = self._build_slash_commands() self._slash_command_map = self._index_slash_commands(self._slash_commands) def _build_slash_commands(self) -> list[SlashCommand[Any]]: commands: list[SlashCommand[Any]] = list(soul_slash_registry.list_commands()) - # 实例级别:skill commands + # 实例级别:skill commands(standard) for skill in self._runtime.skills.values(): + if skill.type != "standard": + continue commands.append(SlashCommand( name=f"skill:{skill.name}", func=self._make_skill_runner(skill), description=skill.description or "", aliases=[], )) - # 实例级别:/begin(如果有 flow) - if self._flow_runner is not None: + # 实例级别:/flow:(flow skills) + for skill in self._runtime.skills.values(): + if skill.type != "flow" or skill.flow is None: + continue + runner = FlowRunner(skill.flow, name=skill.name) commands.append(SlashCommand( - name="begin", - func=self._flow_runner.run, - description="Start the prompt flow", + name=f"flow:{skill.name}", + func=runner.run, + description=f"Start the agent flow '{skill.name}'", aliases=[], )) return commands @@ -187,10 +225,11 @@ class KimiSoul: 运行规则: -- `KimiSoul` 构造时可选传入 `PromptFlow`,内部创建 `FlowRunner`。 -- `available_slash_commands` 统一返回:静态命令 + skill commands + `/begin`。 +- `KimiSoul` 根据 `Skill.type` 生成 `/skill:` 或 `/flow:`。 +- `available_slash_commands` 统一返回:静态命令 + skill commands + flow commands。 - `run` 方法查找实例命令(而非静态 registry),支持动态命令。 -- `/begin` 触发 `FlowRunner.run` 执行 flow 遍历。 +- `/flow:` 触发 `FlowRunner.run` 执行 flow 遍历。 +- 节点是否需要选择由出边数量决定(多出边即分支)。 分支节点的 prompt 组装(示意): @@ -214,7 +253,7 @@ Reply with a choice using .... 为防止死循环,内置 `max_moves`(默认 1000)作为硬上限;到达上限则抛出 `MaxStepsReached`。 -### 4) Ralph 模式 +### 6) Ralph 模式 Ralph 模式是一种特殊的自动迭代模式,通过 `--max-ralph-iterations` 参数启用。 它会自动将用户输入包装成一个带 CONTINUE/STOP 分支的循环流程: @@ -224,7 +263,7 @@ Ralph 模式是一种特殊的自动迭代模式,通过 `--max-ralph-iteration def ralph_loop( user_message: Message, max_ralph_iterations: int, -) -> tuple[PromptFlow, int]: +) -> FlowRunner: """ 创建 Ralph 模式的循环流程: BEGIN → R1(执行用户 prompt) → R2(决策节点) → CONTINUE(回到 R2) / STOP → END @@ -232,56 +271,28 @@ def ralph_loop( ... ``` -在 `KimiSoul.run` 中,如果启用了 Ralph 模式且没有显式的 prompt flow,会自动创建 -Ralph 循环流程: +在 `KimiSoul.run` 中,如果启用了 Ralph 模式,会自动创建 Ralph 循环流程: ```python -if self._loop_control.max_ralph_iterations != 0 and self._flow_runner is None: - flow, max_moves = FlowRunner.ralph_loop( +if self._loop_control.max_ralph_iterations != 0: + runner = FlowRunner.ralph_loop( user_message, self._loop_control.max_ralph_iterations, ) - runner = FlowRunner(flow, max_moves=max_moves) await runner.run(self, "") return ``` -### 5) CLI 集成 +### 7) CLI 集成 -在 `kimi` 根命令新增参数: +Agent Flow 通过 skill discovery 自动加载,不新增 CLI 参数。只要 `SKILL.md` 中声明 +`type: flow` 并包含流程图代码块,即可通过 `/flow:` 使用。 -``` ---prompt-flow -``` +### 8) 错误处理与用户反馈 -行为约束: - -- 与 Ralph 模式(`--max-ralph-iterations != 0`)互斥。 -- 与所有 UI 模式兼容(shell/print/wire/acp)。 -- 与 `--continue/--session` 兼容:可在已有 session 上执行 prompt flow(共享上下文), - 但**不支持**从 prompt flow 中断处恢复——每次执行都从 `BEGIN` 开始。 - -实现路径: - -- `src/kimi_cli/flow.py`: - - `parse_flowchart(text) -> PromptFlow`(解析 Mermaid) - - `parse_choice(text) -> str | None`(解析 choice 标签) - - `PromptFlow`、`FlowNode`、`FlowEdge` 数据结构 - - `PromptFlowError`、`PromptFlowParseError`、`PromptFlowValidationError` 异常 -- `src/kimi_cli/soul/kimisoul.py`: - - `FlowRunner` 类处理 flow 执行 - - `KimiSoul.__init__` 新增 `flow: PromptFlow | None = None` 参数 - - `available_slash_commands` 改为动态组合:静态 + skills + `/begin` - - `run` 方法查找命令改为 `_find_slash_command`(实例级别) -- `src/kimi_cli/cli/__init__.py`: - - 解析 `--prompt-flow` 参数 - - 构造 `PromptFlow` 并传入 `KimiCLI.create` - - 检查与 Ralph 模式的互斥 - -### 6) 错误处理与用户反馈 - -- 解析错误:通过 `PromptFlowParseError` 明确指出 Mermaid 语法问题,包含行号。 -- 校验错误:通过 `PromptFlowValidationError` 指出图结构问题。 +- 解析错误:通过 `FlowParseError` 指出 Mermaid/D2 语法问题(包含行号)。 +- 校验错误:通过 `FlowValidationError` 指出图结构问题。 +- flow skill 无有效流程图:记录日志并降级为普通 skill。 - 运行时错误:日志记录当前节点、分支选择失败原因。 - choice 无效:自动重试,追加提示要求按格式输出。 - 输出日志:`logger.info`/`logger.warning` 记录节点推进与选择结果,便于调试。 @@ -291,13 +302,16 @@ if self._loop_control.max_ralph_iterations != 0 and self._flow_runner is None: - 仅支持 flowchart,且只解析上述最小子集。 - `BEGIN`/`END` 只通过 label 识别;如果用户用其它词,需要显式改名。 - 允许循环图;但会受到 `max_moves` 限制。 +- flow 名称与 skill 名称一致。 - 分支 label 要求短且稳定;建议避免多行或包含特殊字符。 - `FlowNode.label` 支持 `str | list[ContentPart]`,可用于 Ralph 模式等内部场景。 ## 关键参考位置 - CLI 入口:`src/kimi_cli/cli/__init__.py` -- Flow 解析:`src/kimi_cli/flow.py` +- Skill 解析:`src/kimi_cli/skill/__init__.py` +- Flow 解析:`src/kimi_cli/skill/flow/mermaid.py` / `src/kimi_cli/skill/flow/d2.py` +- Flow 数据结构:`src/kimi_cli/skill/flow/__init__.py` - `KimiSoul` 与 `FlowRunner`:`src/kimi_cli/soul/kimisoul.py` - `SlashCommand`:`src/kimi_cli/utils/slashcmd.py` - 静态 soul commands:`src/kimi_cli/soul/slash.py` diff --git a/src/kimi_cli/app.py b/src/kimi_cli/app.py index 1d70473ce..5a91d6582 100644 --- a/src/kimi_cli/app.py +++ b/src/kimi_cli/app.py @@ -14,7 +14,6 @@ from kimi_cli.agentspec import DEFAULT_AGENT_FILE from kimi_cli.cli import InputFormat, OutputFormat from kimi_cli.config import Config, LLMModel, LLMProvider, load_config -from kimi_cli.flow import PromptFlow from kimi_cli.llm import augment_provider_with_env_vars, create_llm from kimi_cli.session import Session from kimi_cli.share import get_share_dir @@ -65,7 +64,6 @@ async def create( max_steps_per_turn: int | None = None, max_retries_per_step: int | None = None, max_ralph_iterations: int | None = None, - flow: PromptFlow | None = None, ) -> KimiCLI: """ Create a KimiCLI instance. @@ -88,7 +86,6 @@ async def create( Defaults to None. max_ralph_iterations (int | None, optional): Extra iterations after the first turn in Ralph mode. Defaults to None. - flow (PromptFlow | None, optional): Prompt flow to execute. Defaults to None. Raises: FileNotFoundError: When the agent file is not found. @@ -148,7 +145,7 @@ async def create( context = Context(session.context_file) await context.restore() - soul = KimiSoul(agent, context=context, flow=flow) + soul = KimiSoul(agent, context=context) return KimiCLI(soul, runtime, env_overrides) def __init__( diff --git a/src/kimi_cli/cli/__init__.py b/src/kimi_cli/cli/__init__.py index f37eb1beb..e0137f45f 100644 --- a/src/kimi_cli/cli/__init__.py +++ b/src/kimi_cli/cli/__init__.py @@ -154,17 +154,6 @@ def kimi( help="User prompt to the agent. Default: prompt interactively.", ), ] = None, - prompt_flow: Annotated[ - Path | None, - typer.Option( - "--prompt-flow", - exists=True, - file_okay=True, - dir_okay=False, - readable=True, - help="D2 (.d2) or Mermaid (.mmd) flowchart file to run as a prompt flow.", - ), - ] = None, print_mode: Annotated[ bool, typer.Option( @@ -390,38 +379,6 @@ def kimi( if not prompt: raise typer.BadParameter("Prompt cannot be empty", param_hint="--prompt") - flow = None - if prompt_flow is not None: - from kimi_cli.flow import PromptFlowError - from kimi_cli.flow.d2 import parse_d2_flowchart - from kimi_cli.flow.mermaid import parse_mermaid_flowchart - - if max_ralph_iterations is not None and max_ralph_iterations != 0: - raise typer.BadParameter( - "Prompt flow cannot be used with Ralph mode", - param_hint="--prompt-flow", - ) - try: - flow_text = prompt_flow.read_text(encoding="utf-8") - except OSError as e: - raise typer.BadParameter( - f"Failed to read prompt flow file: {e}", param_hint="--prompt-flow" - ) from e - suffix = prompt_flow.suffix.lower() - if suffix in {".mmd", ".mermaid"}: - parser = parse_mermaid_flowchart - elif suffix == ".d2": - parser = parse_d2_flowchart - else: - raise typer.BadParameter( - "Unsupported prompt flow extension; use .mmd or .d2", - param_hint="--prompt-flow", - ) - try: - flow = parser(flow_text) - except PromptFlowError as e: - raise typer.BadParameter(str(e), param_hint="--prompt-flow") from e - if input_format is not None and ui != "print": raise typer.BadParameter( "Input format is only supported for print UI", @@ -508,7 +465,6 @@ async def _run(session_id: str | None) -> bool: max_steps_per_turn=max_steps_per_turn, max_retries_per_step=max_retries_per_step, max_ralph_iterations=max_ralph_iterations, - flow=flow, ) match ui: case "shell": diff --git a/src/kimi_cli/flow/mermaid.py b/src/kimi_cli/flow/mermaid.py deleted file mode 100644 index 5ff168676..000000000 --- a/src/kimi_cli/flow/mermaid.py +++ /dev/null @@ -1,218 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass - -from . import ( - FlowEdge, - FlowNode, - FlowNodeKind, - PromptFlow, - PromptFlowParseError, - validate_flow, -) - - -@dataclass(frozen=True, slots=True) -class _NodeSpec: - node_id: str - label: str | None - shape: str | None - - -@dataclass(slots=True) -class _NodeDef: - node: FlowNode - explicit: bool - - -_NODE_ID_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_-]*") -_HEADER_RE = re.compile(r"^(flowchart|graph)\b", re.IGNORECASE) - -_SHAPES = { - "[": ("square", "]"), - "(": ("paren", ")"), - "{": ("curly", "}"), -} - - -def parse_mermaid_flowchart(text: str) -> PromptFlow: - nodes: dict[str, _NodeDef] = {} - outgoing: dict[str, list[FlowEdge]] = {} - - for line_no, raw_line in enumerate(text.splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("%%"): - continue - if _HEADER_RE.match(line): - continue - if "-->" in line: - src_spec, label, dst_spec = _parse_edge_line(line, line_no) - src_node = _add_node(nodes, src_spec, line_no) - dst_node = _add_node(nodes, dst_spec, line_no) - edge = FlowEdge(src=src_node.id, dst=dst_node.id, label=label) - outgoing.setdefault(edge.src, []).append(edge) - outgoing.setdefault(edge.dst, []) - continue - - node_spec, idx = _parse_node_token(line, 0, line_no) - idx = _skip_ws(line, idx) - if idx != len(line): - raise PromptFlowParseError(_line_error(line_no, "Unexpected trailing content")) - _add_node(nodes, node_spec, line_no) - - flow_nodes = {node_id: node_def.node for node_id, node_def in nodes.items()} - for node_id in flow_nodes: - outgoing.setdefault(node_id, []) - - begin_id, end_id = validate_flow(flow_nodes, outgoing) - return PromptFlow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id) - - -def _parse_edge_line(line: str, line_no: int) -> tuple[_NodeSpec, str | None, _NodeSpec]: - src_spec, idx = _parse_node_token(line, 0, line_no) - idx = _skip_ws(line, idx) - if line.startswith("-->", idx): - idx += 3 - idx = _skip_ws(line, idx) - label = None - if idx < len(line) and line[idx] == "|": - label, idx = _parse_pipe_label(line, idx, line_no) - idx = _skip_ws(line, idx) - dst_spec, idx = _parse_node_token(line, idx, line_no) - idx = _skip_ws(line, idx) - if idx != len(line): - raise PromptFlowParseError(_line_error(line_no, "Unexpected trailing content")) - return src_spec, label, dst_spec - - if line.startswith("--", idx): - idx += 2 - arrow_idx = line.find("-->", idx) - if arrow_idx == -1: - raise PromptFlowParseError(_line_error(line_no, "Expected '-->' to end edge label")) - label = line[idx:arrow_idx].strip() - if not label: - raise PromptFlowParseError(_line_error(line_no, "Edge label cannot be empty")) - idx = arrow_idx + 3 - idx = _skip_ws(line, idx) - dst_spec, idx = _parse_node_token(line, idx, line_no) - idx = _skip_ws(line, idx) - if idx != len(line): - raise PromptFlowParseError(_line_error(line_no, "Unexpected trailing content")) - return src_spec, label, dst_spec - - raise PromptFlowParseError(_line_error(line_no, "Expected edge arrow")) - - -def _parse_node_token(line: str, idx: int, line_no: int) -> tuple[_NodeSpec, int]: - match = _NODE_ID_RE.match(line, idx) - if not match: - raise PromptFlowParseError(_line_error(line_no, "Expected node id")) - node_id = match.group(0) - idx = match.end() - - if idx >= len(line) or line[idx] not in _SHAPES: - return _NodeSpec(node_id=node_id, label=None, shape=None), idx - - shape, close_char = _SHAPES[line[idx]] - idx += 1 - label, idx = _parse_label(line, idx, close_char, line_no) - return _NodeSpec(node_id=node_id, label=label, shape=shape), idx - - -def _parse_label(line: str, idx: int, close_char: str, line_no: int) -> tuple[str, int]: - if idx >= len(line): - raise PromptFlowParseError(_line_error(line_no, "Expected node label")) - if close_char == ")" and line[idx] == "[": - label, idx = _parse_label(line, idx + 1, "]", line_no) - while idx < len(line) and line[idx].isspace(): - idx += 1 - if idx >= len(line) or line[idx] != ")": - raise PromptFlowParseError(_line_error(line_no, "Unclosed node label")) - return label, idx + 1 - if line[idx] == '"': - idx += 1 - buf: list[str] = [] - while idx < len(line): - ch = line[idx] - if ch == '"': - idx += 1 - while idx < len(line) and line[idx].isspace(): - idx += 1 - if idx >= len(line) or line[idx] != close_char: - raise PromptFlowParseError(_line_error(line_no, "Unclosed node label")) - return "".join(buf), idx + 1 - if ch == "\\" and idx + 1 < len(line): - buf.append(line[idx + 1]) - idx += 2 - continue - buf.append(ch) - idx += 1 - raise PromptFlowParseError(_line_error(line_no, "Unclosed quoted label")) - - end = line.find(close_char, idx) - if end == -1: - raise PromptFlowParseError(_line_error(line_no, "Unclosed node label")) - label = line[idx:end].strip() - if not label: - raise PromptFlowParseError(_line_error(line_no, "Node label cannot be empty")) - return label, end + 1 - - -def _parse_pipe_label(line: str, idx: int, line_no: int) -> tuple[str, int]: - if line[idx] != "|": - raise PromptFlowParseError(_line_error(line_no, "Expected '|' for edge label")) - end = line.find("|", idx + 1) - if end == -1: - raise PromptFlowParseError(_line_error(line_no, "Unclosed edge label")) - label = line[idx + 1 : end].strip() - if not label: - raise PromptFlowParseError(_line_error(line_no, "Edge label cannot be empty")) - return label, end + 1 - - -def _skip_ws(line: str, idx: int) -> int: - while idx < len(line) and line[idx].isspace(): - idx += 1 - return idx - - -def _add_node(nodes: dict[str, _NodeDef], spec: _NodeSpec, line_no: int) -> FlowNode: - label = spec.label if spec.label is not None else spec.node_id - label_norm = label.strip().lower() - if not label: - raise PromptFlowParseError(_line_error(line_no, "Node label cannot be empty")) - - kind: FlowNodeKind = "task" - if spec.shape == "curly": - kind = "decision" - if label_norm == "begin": - kind = "begin" - elif label_norm == "end": - kind = "end" - - node = FlowNode(id=spec.node_id, label=label, kind=kind) - explicit = spec.label is not None - - existing = nodes.get(spec.node_id) - if existing is None: - nodes[spec.node_id] = _NodeDef(node=node, explicit=explicit) - return node - - if existing.node == node: - return existing.node - - if not explicit and existing.explicit: - return existing.node - - if explicit and not existing.explicit: - nodes[spec.node_id] = _NodeDef(node=node, explicit=True) - return node - - raise PromptFlowParseError( - _line_error(line_no, f'Conflicting definition for node "{spec.node_id}"') - ) - - -def _line_error(line_no: int, message: str) -> str: - return f"Line {line_no}: {message}" diff --git a/src/kimi_cli/skill.py b/src/kimi_cli/skill/__init__.py similarity index 62% rename from src/kimi_cli/skill.py rename to src/kimi_cli/skill/__init__.py index 33de411cd..3df442998 100644 --- a/src/kimi_cli/skill.py +++ b/src/kimi_cli/skill/__init__.py @@ -2,8 +2,9 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Callable, Iterable, Iterator from pathlib import Path +from typing import Literal from kaos import get_current_kaos from kaos.local import local_kaos @@ -11,8 +12,13 @@ from loguru import logger from pydantic import BaseModel, ConfigDict +from kimi_cli.skill.flow import Flow, FlowError +from kimi_cli.skill.flow.d2 import parse_d2_flowchart +from kimi_cli.skill.flow.mermaid import parse_mermaid_flowchart from kimi_cli.utils.frontmatter import parse_frontmatter +SkillType = Literal["standard", "flow"] + def get_skills_dir() -> KaosPath: """ @@ -25,7 +31,7 @@ def get_builtin_skills_dir() -> Path: """ Get the built-in skills directory path. """ - return Path(__file__).parent / "skills" + return Path(__file__).parent.parent / "skills" def get_kimi_skills_dir() -> KaosPath: @@ -159,7 +165,9 @@ class Skill(BaseModel): name: str description: str + type: SkillType = "standard" dir: KaosPath + flow: Flow | None = None @property def skill_md_file(self) -> KaosPath: @@ -206,14 +214,109 @@ def parse_skill_text(content: str, *, dir_path: KaosPath) -> Skill: """ frontmatter = parse_frontmatter(content) or {} - if "name" not in frontmatter: - frontmatter["name"] = dir_path.name - if "description" not in frontmatter: - frontmatter["description"] = "No description provided." - - return Skill.model_validate( - { - **frontmatter, - "dir": dir_path, - } + name = frontmatter.get("name") or dir_path.name + description = frontmatter.get("description") or "No description provided." + skill_type = frontmatter.get("type") or "standard" + if skill_type not in ("standard", "flow"): + raise ValueError(f'Invalid skill type "{skill_type}"') + flow = None + if skill_type == "flow": + try: + flow = _parse_flow_from_skill(content) + except ValueError as exc: + logger.error("Failed to parse flow skill {name}: {error}", name=name, error=exc) + skill_type = "standard" + flow = None + + return Skill( + name=name, + description=description, + type=skill_type, + dir=dir_path, + flow=flow, ) + + +def _parse_flow_from_skill(content: str) -> Flow: + for lang, code in _iter_fenced_codeblocks(content): + if lang == "mermaid": + return _parse_flow_block(parse_mermaid_flowchart, code) + if lang == "d2": + return _parse_flow_block(parse_d2_flowchart, code) + raise ValueError("Flow skills require a mermaid or d2 code block in SKILL.md.") + + +def _parse_flow_block(parser: Callable[[str], Flow], code: str) -> Flow: + try: + return parser(code) + except FlowError as exc: + raise ValueError(f"Invalid flow diagram: {exc}") from exc + + +def _iter_fenced_codeblocks(content: str) -> Iterator[tuple[str, str]]: + fence = "" + fence_char = "" + lang = "" + buf: list[str] = [] + in_block = False + + for line in content.splitlines(): + stripped = line.lstrip() + if not in_block: + if match := _parse_fence_open(stripped): + fence, fence_char, info = match + lang = _normalize_code_lang(info) + in_block = True + buf = [] + continue + + if _is_fence_close(stripped, fence_char, len(fence)): + yield lang, "\n".join(buf).strip("\n") + in_block = False + fence = "" + fence_char = "" + lang = "" + buf = [] + continue + + buf.append(line) + + +def _normalize_code_lang(info: str) -> str: + if not info: + return "" + lang = info.split()[0].strip().lower() + if lang.startswith("{") and lang.endswith("}"): + lang = lang[1:-1].strip() + return lang + + +def _parse_fence_open(line: str) -> tuple[str, str, str] | None: + if not line or line[0] not in ("`", "~"): + return None + fence_char = line[0] + count = 0 + for ch in line: + if ch == fence_char: + count += 1 + else: + break + if count < 3: + return None + fence = fence_char * count + info = line[count:].strip() + return fence, fence_char, info + + +def _is_fence_close(line: str, fence_char: str, fence_len: int) -> bool: + if not fence_char or not line or line[0] != fence_char: + return False + count = 0 + for ch in line: + if ch == fence_char: + count += 1 + else: + break + if count < fence_len: + return False + return not line[count:].strip() diff --git a/src/kimi_cli/flow/__init__.py b/src/kimi_cli/skill/flow/__init__.py similarity index 51% rename from src/kimi_cli/flow/__init__.py rename to src/kimi_cli/skill/flow/__init__.py index f8b23b44e..7f2541a2e 100644 --- a/src/kimi_cli/flow/__init__.py +++ b/src/kimi_cli/skill/flow/__init__.py @@ -9,15 +9,15 @@ FlowNodeKind = Literal["begin", "end", "task", "decision"] -class PromptFlowError(ValueError): - """Base error for prompt flow parsing/validation.""" +class FlowError(ValueError): + """Base error for flow parsing/validation.""" -class PromptFlowParseError(PromptFlowError): +class FlowParseError(FlowError): """Raised when prompt flow parsing fails.""" -class PromptFlowValidationError(PromptFlowError): +class FlowValidationError(FlowError): """Raised when a flowchart fails validation.""" @@ -36,7 +36,7 @@ class FlowEdge: @dataclass(slots=True) -class PromptFlow: +class Flow: nodes: dict[str, FlowNode] outgoing: dict[str, list[FlowEdge]] begin_id: str @@ -61,9 +61,9 @@ def validate_flow( end_ids = [node.id for node in nodes.values() if node.kind == "end"] if len(begin_ids) != 1: - raise PromptFlowValidationError(f"Expected exactly one BEGIN node, found {len(begin_ids)}") + raise FlowValidationError(f"Expected exactly one BEGIN node, found {len(begin_ids)}") if len(end_ids) != 1: - raise PromptFlowValidationError(f"Expected exactly one END node, found {len(end_ids)}") + raise FlowValidationError(f"Expected exactly one END node, found {len(end_ids)}") begin_id = begin_ids[0] end_id = end_ids[0] @@ -83,35 +83,17 @@ def validate_flow( if node.id not in reachable: continue edges = outgoing.get(node.id, []) - if node.kind == "begin": - if len(edges) != 1: - raise PromptFlowValidationError("BEGIN node must have exactly one outgoing edge") + if len(edges) <= 1: continue - if node.kind == "end": - if edges: - raise PromptFlowValidationError("END node must not have outgoing edges") - continue - if node.kind == "decision": - if not edges: - raise PromptFlowValidationError( - f'Decision node "{node.id}" must have outgoing edges' - ) - labels: list[str] = [] - for edge in edges: - if edge.label is None or not edge.label.strip(): - raise PromptFlowValidationError( - f'Decision node "{node.id}" has an unlabeled edge' - ) - labels.append(edge.label) - if len(set(labels)) != len(labels): - raise PromptFlowValidationError( - f'Decision node "{node.id}" has duplicate edge labels' - ) - continue - if len(edges) != 1: - raise PromptFlowValidationError(f'Node "{node.id}" must have exactly one outgoing edge') + labels: list[str] = [] + for edge in edges: + if edge.label is None or not edge.label.strip(): + raise FlowValidationError(f'Node "{node.id}" has an unlabeled edge') + labels.append(edge.label) + if len(set(labels)) != len(labels): + raise FlowValidationError(f'Node "{node.id}" has duplicate edge labels') if end_id not in reachable: - raise PromptFlowValidationError("END node is not reachable from BEGIN") + raise FlowValidationError("END node is not reachable from BEGIN") return begin_id, end_id diff --git a/src/kimi_cli/flow/d2.py b/src/kimi_cli/skill/flow/d2.py similarity index 88% rename from src/kimi_cli/flow/d2.py rename to src/kimi_cli/skill/flow/d2.py index 229178ff3..3b9babcd6 100644 --- a/src/kimi_cli/flow/d2.py +++ b/src/kimi_cli/skill/flow/d2.py @@ -5,11 +5,11 @@ from dataclasses import dataclass from . import ( + Flow, FlowEdge, FlowNode, FlowNodeKind, - PromptFlow, - PromptFlowParseError, + FlowParseError, validate_flow, ) @@ -50,7 +50,7 @@ class _NodeDef: explicit: bool -def parse_d2_flowchart(text: str) -> PromptFlow: +def parse_d2_flowchart(text: str) -> Flow: nodes: dict[str, _NodeDef] = {} outgoing: dict[str, list[FlowEdge]] = {} @@ -66,7 +66,7 @@ def parse_d2_flowchart(text: str) -> PromptFlow: flow_nodes = _infer_decision_nodes(flow_nodes, outgoing) begin_id, end_id = validate_flow(flow_nodes, outgoing) - return PromptFlow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id) + return Flow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id) def _iter_top_level_statements(text: str) -> Iterable[tuple[int, str]]: @@ -122,7 +122,7 @@ def _iter_top_level_statements(text: str) -> Iterable[tuple[int, str]]: i += 1 continue if ch == "}" and brace_depth == 0: - raise PromptFlowParseError(_line_error(line_no, "Unmatched '}'")) + raise FlowParseError(_line_error(line_no, "Unmatched '}'")) if ch == "'" and not in_double and not escape: in_single = not in_single @@ -140,9 +140,9 @@ def _iter_top_level_statements(text: str) -> Iterable[tuple[int, str]]: i += 1 if brace_depth != 0: - raise PromptFlowParseError(_line_error(line_no, "Unclosed '{' block")) + raise FlowParseError(_line_error(line_no, "Unclosed '{' block")) if in_single or in_double: - raise PromptFlowParseError(_line_error(line_no, "Unclosed string")) + raise FlowParseError(_line_error(line_no, "Unclosed string")) statement = "".join(buf).strip() if statement: @@ -162,7 +162,7 @@ def _parse_edge_statement( ) -> None: parts = _split_on_token(statement, "->") if len(parts) < 2: - raise PromptFlowParseError(_line_error(line_no, "Expected edge arrow")) + raise FlowParseError(_line_error(line_no, "Expected edge arrow")) last_part = parts[-1] target_text, edge_label = _split_unquoted_once(last_part, ":") @@ -176,7 +176,7 @@ def _parse_edge_statement( if any(_is_property_path(node_id) for node_id in node_ids): return if len(node_ids) < 2: - raise PromptFlowParseError(_line_error(line_no, "Edge must have at least two nodes")) + raise FlowParseError(_line_error(line_no, "Edge must have at least two nodes")) label = _parse_label(edge_label, line_no) if edge_label is not None else None for idx in range(len(node_ids) - 1): @@ -212,10 +212,10 @@ def _parse_node_id(text: str, line_no: int, *, allow_inline_label: bool) -> str: if allow_inline_label and ":" in cleaned: cleaned = _split_unquoted_once(cleaned, ":")[0].strip() if not cleaned: - raise PromptFlowParseError(_line_error(line_no, "Expected node id")) + raise FlowParseError(_line_error(line_no, "Expected node id")) match = _NODE_ID_RE.fullmatch(cleaned) if not match: - raise PromptFlowParseError(_line_error(line_no, f'Invalid node id "{cleaned}"')) + raise FlowParseError(_line_error(line_no, f'Invalid node id "{cleaned}"')) return match.group(0) @@ -232,7 +232,7 @@ def _is_property_path(node_id: str) -> bool: def _parse_label(text: str, line_no: int) -> str: label = text.strip() if not label: - raise PromptFlowParseError(_line_error(line_no, "Label cannot be empty")) + raise FlowParseError(_line_error(line_no, "Label cannot be empty")) if label[0] in {"'", '"'}: return _parse_quoted_label(label, line_no) return label @@ -257,11 +257,11 @@ def _parse_quoted_label(text: str, line_no: int) -> str: if ch == quote: trailing = text[i + 1 :].strip() if trailing: - raise PromptFlowParseError(_line_error(line_no, "Unexpected trailing content")) + raise FlowParseError(_line_error(line_no, "Unexpected trailing content")) return "".join(buf) buf.append(ch) i += 1 - raise PromptFlowParseError(_line_error(line_no, "Unclosed quoted label")) + raise FlowParseError(_line_error(line_no, "Unclosed quoted label")) def _split_on_token(text: str, token: str) -> list[str]: @@ -291,7 +291,7 @@ def _split_on_token(text: str, token: str) -> list[str]: i += 1 if in_single or in_double: - raise PromptFlowParseError("Unclosed string in statement") + raise FlowParseError("Unclosed string in statement") parts.append("".join(buf).strip()) return parts @@ -329,7 +329,7 @@ def _add_node( label = label if label is not None else node_id label_norm = label.strip().lower() if not label: - raise PromptFlowParseError(_line_error(line_no, "Node label cannot be empty")) + raise FlowParseError(_line_error(line_no, "Node label cannot be empty")) kind: FlowNodeKind = "task" if label_norm == "begin": @@ -353,7 +353,7 @@ def _add_node( nodes[node_id] = _NodeDef(node=node, explicit=True) return node - raise PromptFlowParseError(_line_error(line_no, f'Conflicting definition for node "{node_id}"')) + raise FlowParseError(_line_error(line_no, f'Conflicting definition for node "{node_id}"')) def _infer_decision_nodes( diff --git a/src/kimi_cli/skill/flow/mermaid.py b/src/kimi_cli/skill/flow/mermaid.py new file mode 100644 index 000000000..77dea345d --- /dev/null +++ b/src/kimi_cli/skill/flow/mermaid.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +from . import ( + Flow, + FlowEdge, + FlowNode, + FlowNodeKind, + FlowParseError, + validate_flow, +) + + +@dataclass(frozen=True, slots=True) +class _NodeSpec: + node_id: str + label: str | None + + +@dataclass(slots=True) +class _NodeDef: + node: FlowNode + explicit: bool + + +_NODE_ID_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_-]*") +_HEADER_RE = re.compile(r"^(flowchart|graph)\b", re.IGNORECASE) + +_SHAPES = { + "[": "]", + "(": ")", + "{": "}", +} +_PIPE_LABEL_RE = re.compile(r"\|([^|]*)\|") +_EDGE_LABEL_RE = re.compile(r"--\s*([^>-][^>]*)\s*-->") +_ARROW_RE = re.compile(r"[-.=]+>") + + +def parse_mermaid_flowchart(text: str) -> Flow: + nodes: dict[str, _NodeDef] = {} + outgoing: dict[str, list[FlowEdge]] = {} + + for line_no, raw_line in enumerate(text.splitlines(), start=1): + line = _strip_comment(raw_line).strip() + if not line or line.startswith("%%"): + continue + if _HEADER_RE.match(line): + continue + if _is_style_line(line): + continue + line = _strip_style_tokens(line) + + edge = _try_parse_edge_line(line, line_no) + if edge is not None: + src_spec, label, dst_spec = edge + src_node = _add_node(nodes, src_spec, line_no) + dst_node = _add_node(nodes, dst_spec, line_no) + flow_edge = FlowEdge(src=src_node.id, dst=dst_node.id, label=label) + outgoing.setdefault(flow_edge.src, []).append(flow_edge) + outgoing.setdefault(flow_edge.dst, []) + continue + + node_spec = _try_parse_node_line(line, line_no) + if node_spec is not None: + _add_node(nodes, node_spec, line_no) + + flow_nodes = {node_id: node_def.node for node_id, node_def in nodes.items()} + for node_id in flow_nodes: + outgoing.setdefault(node_id, []) + + flow_nodes = _infer_decision_nodes(flow_nodes, outgoing) + begin_id, end_id = validate_flow(flow_nodes, outgoing) + return Flow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id) + + +def _try_parse_edge_line(line: str, line_no: int) -> tuple[_NodeSpec, str | None, _NodeSpec] | None: + try: + src_spec, idx = _parse_node_token(line, 0, line_no) + except FlowParseError: + return None + + normalized, label = _normalize_edge_line(line) + idx = _skip_ws(normalized, idx) + if ">" not in normalized[idx:]: + if "---" not in normalized[idx:]: + return None + normalized = normalized[:idx] + normalized[idx:].replace("---", "-->", 1) + + normalized = _ARROW_RE.sub("-->", normalized) + arrow_idx = normalized.rfind(">") + if arrow_idx == -1: + return None + + dst_start = _skip_ws(normalized, arrow_idx + 1) + try: + dst_spec, _ = _parse_node_token(normalized, dst_start, line_no) + except FlowParseError: + return None + + return src_spec, label, dst_spec + + +def _parse_node_token(line: str, idx: int, line_no: int) -> tuple[_NodeSpec, int]: + match = _NODE_ID_RE.match(line, idx) + if not match: + raise FlowParseError(_line_error(line_no, "Expected node id")) + node_id = match.group(0) + idx = match.end() + + if idx >= len(line) or line[idx] not in _SHAPES: + return _NodeSpec(node_id=node_id, label=None), idx + + close_char = _SHAPES[line[idx]] + idx += 1 + label, idx = _parse_label(line, idx, close_char, line_no) + return _NodeSpec(node_id=node_id, label=label), idx + + +def _parse_label(line: str, idx: int, close_char: str, line_no: int) -> tuple[str, int]: + if idx >= len(line): + raise FlowParseError(_line_error(line_no, "Expected node label")) + if close_char == ")" and line[idx] == "[": + label, idx = _parse_label(line, idx + 1, "]", line_no) + while idx < len(line) and line[idx].isspace(): + idx += 1 + if idx >= len(line) or line[idx] != ")": + raise FlowParseError(_line_error(line_no, "Unclosed node label")) + return label, idx + 1 + if line[idx] == '"': + idx += 1 + buf: list[str] = [] + while idx < len(line): + ch = line[idx] + if ch == '"': + idx += 1 + while idx < len(line) and line[idx].isspace(): + idx += 1 + if idx >= len(line) or line[idx] != close_char: + raise FlowParseError(_line_error(line_no, "Unclosed node label")) + return "".join(buf), idx + 1 + if ch == "\\" and idx + 1 < len(line): + buf.append(line[idx + 1]) + idx += 2 + continue + buf.append(ch) + idx += 1 + raise FlowParseError(_line_error(line_no, "Unclosed quoted label")) + + end = line.find(close_char, idx) + if end == -1: + raise FlowParseError(_line_error(line_no, "Unclosed node label")) + label = line[idx:end].strip() + if not label: + raise FlowParseError(_line_error(line_no, "Node label cannot be empty")) + return label, end + 1 + + +def _skip_ws(line: str, idx: int) -> int: + while idx < len(line) and line[idx].isspace(): + idx += 1 + return idx + + +def _add_node(nodes: dict[str, _NodeDef], spec: _NodeSpec, line_no: int) -> FlowNode: + label = spec.label if spec.label is not None else spec.node_id + label_norm = label.strip().lower() + if not label: + raise FlowParseError(_line_error(line_no, "Node label cannot be empty")) + + kind: FlowNodeKind = "task" + if label_norm == "begin": + kind = "begin" + elif label_norm == "end": + kind = "end" + + node = FlowNode(id=spec.node_id, label=label, kind=kind) + explicit = spec.label is not None + + existing = nodes.get(spec.node_id) + if existing is None: + nodes[spec.node_id] = _NodeDef(node=node, explicit=explicit) + return node + + if existing.node == node: + return existing.node + + if not explicit and existing.explicit: + return existing.node + + if explicit and not existing.explicit: + nodes[spec.node_id] = _NodeDef(node=node, explicit=True) + return node + + raise FlowParseError(_line_error(line_no, f'Conflicting definition for node "{spec.node_id}"')) + + +def _line_error(line_no: int, message: str) -> str: + return f"Line {line_no}: {message}" + + +def _strip_comment(line: str) -> str: + if "%%" not in line: + return line + return line.split("%%", 1)[0] + + +def _is_style_line(line: str) -> bool: + lowered = line.lower() + if lowered in ("end",): + return True + return lowered.startswith( + ( + "classdef ", + "class ", + "style ", + "linkstyle ", + "click ", + "subgraph ", + "direction ", + ) + ) + + +def _strip_style_tokens(line: str) -> str: + return re.sub(r":::[A-Za-z0-9_-]+", "", line) + + +def _try_parse_node_line(line: str, line_no: int) -> _NodeSpec | None: + try: + node_spec, _ = _parse_node_token(line, 0, line_no) + except FlowParseError: + return None + return node_spec + + +def _normalize_edge_line(line: str) -> tuple[str, str | None]: + label = None + normalized = line + pipe_match = _PIPE_LABEL_RE.search(normalized) + if pipe_match: + label = pipe_match.group(1).strip() or None + normalized = normalized[: pipe_match.start()] + normalized[pipe_match.end() :] + if label is None: + edge_match = _EDGE_LABEL_RE.search(normalized) + if edge_match: + label = edge_match.group(1).strip() or None + normalized = normalized[: edge_match.start()] + "-->" + normalized[edge_match.end() :] + return normalized, label + + +def _infer_decision_nodes( + nodes: dict[str, FlowNode], + outgoing: dict[str, list[FlowEdge]], +) -> dict[str, FlowNode]: + updated: dict[str, FlowNode] = {} + for node_id, node in nodes.items(): + kind = node.kind + if kind == "task" and len(outgoing.get(node_id, [])) > 1: + kind = "decision" + if kind != node.kind: + updated[node_id] = FlowNode(id=node.id, label=node.label, kind=kind) + else: + updated[node_id] = node + return updated diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index d8af7aa90..28c7a8e4b 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -20,9 +20,9 @@ from kosong.message import Message from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter -from kimi_cli.flow import FlowEdge, FlowNode, PromptFlow, parse_choice from kimi_cli.llm import ModelCapability from kimi_cli.skill import Skill, read_skill_text +from kimi_cli.skill.flow import Flow, FlowEdge, FlowNode, parse_choice from kimi_cli.soul import ( LLMNotSet, LLMNotSupported, @@ -64,6 +64,7 @@ def type_check(soul: KimiSoul): RESERVED_TOKENS = 50_000 SKILL_COMMAND_PREFIX = "skill:" +FLOW_COMMAND_PREFIX = "flow:" DEFAULT_MAX_FLOW_MOVES = 1000 @@ -94,7 +95,6 @@ def __init__( agent: Agent, *, context: Context, - flow: PromptFlow | None = None, ): """ Initialize the soul. @@ -111,7 +111,6 @@ def __init__( self._loop_control = agent.runtime.config.loop_control self._compaction = SimpleCompaction() # TODO: maybe configurable and composable self._reserved_tokens = RESERVED_TOKENS - self._flow_runner = FlowRunner(flow) if flow is not None else None if self._runtime.llm is not None: assert self._reserved_tokens <= self._runtime.llm.max_context_size @@ -201,7 +200,7 @@ async def run(self, user_input: str | list[ContentPart]): await ret return - if self._loop_control.max_ralph_iterations != 0 and self._flow_runner is None: + if self._loop_control.max_ralph_iterations != 0: runner = FlowRunner.ralph_loop( user_message, self._loop_control.max_ralph_iterations, @@ -231,6 +230,8 @@ def _build_slash_commands(self) -> list[SlashCommand[Any]]: seen_names = {cmd.name for cmd in commands} for skill in self._runtime.skills.values(): + if skill.type != "standard": + continue name = f"{SKILL_COMMAND_PREFIX}{skill.name}" if name in seen_names: logger.warning( @@ -248,15 +249,29 @@ def _build_slash_commands(self) -> list[SlashCommand[Any]]: ) seen_names.add(name) - if self._flow_runner is not None: + for skill in self._runtime.skills.values(): + if skill.type != "flow": + continue + if skill.flow is None: + logger.warning("Flow skill {name} has no flow; skipping", name=skill.name) + continue + command_name = f"{FLOW_COMMAND_PREFIX}{skill.name}" + if command_name in seen_names: + logger.warning( + "Skipping prompt flow slash command /{name}: name already registered", + name=command_name, + ) + continue + runner = FlowRunner(skill.flow, name=skill.name) commands.append( SlashCommand( - name="begin", - func=self._flow_runner.run, - description="Start the prompt flow", + name=command_name, + func=runner.run, + description=skill.description or "", aliases=[], ) ) + seen_names.add(command_name) return commands @@ -537,8 +552,15 @@ def __init__(self, checkpoint_id: int, messages: Sequence[Message]): class FlowRunner: - def __init__(self, flow: PromptFlow, *, max_moves: int = DEFAULT_MAX_FLOW_MOVES) -> None: + def __init__( + self, + flow: Flow, + *, + name: str | None = None, + max_moves: int = DEFAULT_MAX_FLOW_MOVES, + ) -> None: self._flow = flow + self._name = name self._max_moves = max_moves @staticmethod @@ -577,13 +599,14 @@ def ralph_loop( outgoing["R2"].append(FlowEdge(src="R2", dst="R2", label="CONTINUE")) outgoing["R2"].append(FlowEdge(src="R2", dst="END", label="STOP")) - flow = PromptFlow(nodes=nodes, outgoing=outgoing, begin_id="BEGIN", end_id="END") + flow = Flow(nodes=nodes, outgoing=outgoing, begin_id="BEGIN", end_id="END") max_moves = total_runs return FlowRunner(flow, max_moves=max_moves) async def run(self, soul: KimiSoul, args: str) -> None: if args.strip(): - logger.warning("Prompt flow /begin ignores args: {args}", args=args) + command = f"/{FLOW_COMMAND_PREFIX}{self._name}" if self._name else "/flow" + logger.warning("Agent flow {command} ignores args: {args}", command=command, args=args) return current_id = self._flow.begin_id @@ -594,13 +617,13 @@ async def run(self, soul: KimiSoul, args: str) -> None: edges = self._flow.outgoing.get(current_id, []) if node.kind == "end": - logger.info("Prompt flow reached END node {node_id}", node_id=current_id) + logger.info("Agent flow reached END node {node_id}", node_id=current_id) return if node.kind == "begin": if not edges: logger.error( - 'Prompt flow BEGIN node "{node_id}" has no outgoing edges; stopping.', + 'Agent flow BEGIN node "{node_id}" has no outgoing edges; stopping.', node_id=node.id, ) return @@ -624,7 +647,7 @@ async def _execute_flow_node( ) -> tuple[str | None, int]: if not edges: logger.error( - 'Prompt flow node "{node_id}" has no outgoing edges; stopping.', + 'Agent flow node "{node_id}" has no outgoing edges; stopping.', node_id=node.id, ) return None, 0 @@ -636,7 +659,7 @@ async def _execute_flow_node( result = await self._flow_turn(soul, prompt) steps_used += result.step_count if result.stop_reason == "tool_rejected": - logger.error("Prompt flow stopped after tool rejection.") + logger.error("Agent flow stopped after tool rejection.") return None, steps_used if node.kind != "decision": @@ -653,7 +676,7 @@ async def _execute_flow_node( options = ", ".join(edge.label or "" for edge in edges) logger.warning( - "Prompt flow invalid choice. Got: {choice}. Available: {options}.", + "Agent flow invalid choice. Got: {choice}. Available: {options}.", choice=choice or "", options=options, ) diff --git a/tests/test_prompt_flow.py b/tests/test_agent_flow.py similarity index 51% rename from tests/test_prompt_flow.py rename to tests/test_agent_flow.py index ae77e52cf..3bc52ee6d 100644 --- a/tests/test_prompt_flow.py +++ b/tests/test_agent_flow.py @@ -3,9 +3,9 @@ import pytest from inline_snapshot import snapshot -from kimi_cli.flow import PromptFlow, PromptFlowValidationError, parse_choice -from kimi_cli.flow.d2 import parse_d2_flowchart -from kimi_cli.flow.mermaid import parse_mermaid_flowchart +from kimi_cli.skill.flow import Flow, FlowValidationError, parse_choice +from kimi_cli.skill.flow.d2 import parse_d2_flowchart +from kimi_cli.skill.flow.mermaid import parse_mermaid_flowchart def test_parse_flowchart_basic() -> None: @@ -21,11 +21,27 @@ def test_parse_flowchart_basic() -> None: ) ) - assert flow.begin_id == "A" - assert flow.end_id == "D" - assert flow.nodes["A"].kind == "begin" - assert flow.nodes["C"].kind == "decision" - assert [edge.label for edge in flow.outgoing["C"]] == ["yes", "no"] + assert _flow_snapshot(flow) == snapshot( + { + "begin_id": "A", + "end_id": "D", + "nodes": { + "A": {"kind": "begin", "label": "BEGIN"}, + "B": {"kind": "task", "label": "Search stdrc"}, + "C": {"kind": "decision", "label": "Enough?"}, + "D": {"kind": "end", "label": "END"}, + }, + "outgoing": { + "A": [{"dst": "B", "label": None}], + "B": [{"dst": "C", "label": None}], + "C": [ + {"dst": "B", "label": "no"}, + {"dst": "D", "label": "yes"}, + ], + "D": [], + }, + } + ) def test_parse_flowchart_implicit_nodes() -> None: @@ -39,9 +55,22 @@ def test_parse_flowchart_implicit_nodes() -> None: ) ) - assert flow.begin_id == "BEGIN" - assert flow.end_id == "END" - assert flow.nodes["TASK"].label == "TASK" + assert _flow_snapshot(flow) == snapshot( + { + "begin_id": "BEGIN", + "end_id": "END", + "nodes": { + "BEGIN": {"kind": "begin", "label": "BEGIN"}, + "END": {"kind": "end", "label": "END"}, + "TASK": {"kind": "task", "label": "TASK"}, + }, + "outgoing": { + "BEGIN": [{"dst": "TASK", "label": None}], + "END": [], + "TASK": [{"dst": "END", "label": None}], + }, + } + ) def test_parse_flowchart_quoted_label() -> None: @@ -55,17 +84,33 @@ def test_parse_flowchart_quoted_label() -> None: ) ) - assert flow.nodes["B"].label == "hello | world" + assert _flow_snapshot(flow) == snapshot( + { + "begin_id": "A", + "end_id": "C", + "nodes": { + "A": {"kind": "begin", "label": "BEGIN"}, + "B": {"kind": "task", "label": "hello | world"}, + "C": {"kind": "end", "label": "END"}, + }, + "outgoing": { + "A": [{"dst": "B", "label": None}], + "B": [{"dst": "C", "label": None}], + "C": [], + }, + } + ) -def test_parse_flowchart_decision_requires_labels() -> None: - with pytest.raises(PromptFlowValidationError): +def test_parse_flowchart_multi_edges_require_labels() -> None: + with pytest.raises(FlowValidationError): parse_mermaid_flowchart( "\n".join( [ "flowchart TD", - "A([BEGIN]) --> B{Pick}", + "A([BEGIN]) --> B[Pick]", "B --> C([END])", + "B --> D([END])", ] ) ) @@ -121,12 +166,51 @@ def test_parse_d2_flowchart_typical_example() -> None: ) +def test_parse_flowchart_ignores_style_and_shapes() -> None: + flow = parse_mermaid_flowchart( + "\n".join( + [ + "flowchart TB", + "classDef highlight fill:#f9f,stroke:#333,stroke-width:2px;", + "A([BEGIN]) --> B[Working tree clean?]", + "B -- yes --> C{Prep PR}", + "B -- no --> D([END])", + "C --> D", + "class B highlight", + "style C fill:#bbf", + ] + ) + ) + + assert _flow_snapshot(flow) == snapshot( + { + "begin_id": "A", + "end_id": "D", + "nodes": { + "A": {"kind": "begin", "label": "BEGIN"}, + "B": {"kind": "decision", "label": "Working tree clean?"}, + "C": {"kind": "task", "label": "Prep PR"}, + "D": {"kind": "end", "label": "END"}, + }, + "outgoing": { + "A": [{"dst": "B", "label": None}], + "B": [ + {"dst": "C", "label": "yes"}, + {"dst": "D", "label": "no"}, + ], + "C": [{"dst": "D", "label": None}], + "D": [], + }, + } + ) + + def test_parse_choice_last_match() -> None: assert parse_choice("Answer a b") == "b" assert parse_choice("No choice tag") is None -def _flow_snapshot(flow: PromptFlow) -> dict[str, object]: +def _flow_snapshot(flow: Flow) -> dict[str, object]: return { "begin_id": flow.begin_id, "end_id": flow.end_id, diff --git a/tests/test_skill.py b/tests/test_skill.py index 255d1a5c8..4fa1de3ab 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -47,17 +47,75 @@ async def test_discover_skills_parses_frontmatter_and_defaults(tmp_path): Skill( name="alpha-skill", description="Alpha description", + type="standard", dir=KaosPath.unsafe_from_local_path(Path("/path/to/alpha")), + flow=None, ), Skill( name="beta", description="No description provided.", + type="standard", dir=KaosPath.unsafe_from_local_path(Path("/path/to/beta")), + flow=None, ), ] ) +@pytest.mark.asyncio +async def test_discover_skills_parses_flow_type(tmp_path): + root = tmp_path / "skills" + root.mkdir() + + _write_skill( + root / "flowy", + """--- +name: flowy +description: Flow skill +type: flow +--- +```mermaid +flowchart TD +BEGIN([BEGIN]) --> A[Hello] +A --> END([END]) +``` +""", + ) + + skills = await discover_skills(KaosPath.unsafe_from_local_path(root)) + + assert len(skills) == 1 + assert skills[0].type == "flow" + assert skills[0].flow is not None + assert skills[0].flow.begin_id == "BEGIN" + + +@pytest.mark.asyncio +async def test_discover_skills_flow_parse_failure_falls_back(tmp_path): + root = tmp_path / "skills" + root.mkdir() + + _write_skill( + root / "broken-flow", + """--- +name: broken-flow +description: Broken flow skill +type: flow +--- +```mermaid +flowchart TD +A --> B +``` +""", + ) + + skills = await discover_skills(KaosPath.unsafe_from_local_path(root)) + + assert len(skills) == 1 + assert skills[0].type == "standard" + assert skills[0].flow is None + + @pytest.mark.asyncio async def test_discover_skills_from_roots_prefers_later_dirs(tmp_path): root = tmp_path / "root" @@ -100,7 +158,9 @@ async def test_discover_skills_from_roots_prefers_later_dirs(tmp_path): Skill( name="shared", description="User version", + type="standard", dir=KaosPath.unsafe_from_local_path(Path("/path/to/user/shared")), + flow=None, ) ] ) From 29f521c220c92731b2510b3534fad4fcd6553b1a Mon Sep 17 00:00:00 2001 From: Richard Chien Date: Wed, 21 Jan 2026 00:02:05 +0800 Subject: [PATCH 2/5] cleanup skills Signed-off-by: Richard Chien --- .agents/skills/gen-changelog/SKILL.md | 4 +--- .agents/skills/gen-docs/SKILL.md | 4 +--- .agents/skills/translate-docs/SKILL.md | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.agents/skills/gen-changelog/SKILL.md b/.agents/skills/gen-changelog/SKILL.md index d2a37b386..372c27826 100644 --- a/.agents/skills/gen-changelog/SKILL.md +++ b/.agents/skills/gen-changelog/SKILL.md @@ -1,8 +1,6 @@ --- name: gen-changelog -description: Generate changelog entries for code changes. Use when user wants to add changelog entries, update CHANGELOG.md, or document changes made in the current branch compared to main. +description: Generate changelog entries for code changes. --- -# Generate Changelog - 根据当前分支相对于 main 分支的修改,在根目录 CHANGELOG.md 或相应 package/sdk 等目录的 CHANGELOG.md 文件中添加更新日志条目,遵循现有的格式和风格。 diff --git a/.agents/skills/gen-docs/SKILL.md b/.agents/skills/gen-docs/SKILL.md index db12341e1..f8c56c202 100644 --- a/.agents/skills/gen-docs/SKILL.md +++ b/.agents/skills/gen-docs/SKILL.md @@ -1,10 +1,8 @@ --- name: gen-docs -description: Update Kimi CLI user documentation. Use when user wants to update docs, sync documentation with code changes, or ensure docs reflect recent commits and changelog updates. +description: Update Kimi CLI user documentation. --- -# Generate/Update Documentation - 现在我们正在为当前项目 Kimi CLI 编写和维护用户文档,文档内容在 docs 目录下,docs/AGENTS.md 中有对文档的说明。 我们现在对代码库有了一些修改,请你参考最近的 git commit、staged changes、changelog.md 等的内容,根据 AGENTS.md 中的信息,必要时找到实际的代码全文,确保理解了所有变更对产品用户体验的真实改变,然后逐页、逐段地检查和更新文档内容。 diff --git a/.agents/skills/translate-docs/SKILL.md b/.agents/skills/translate-docs/SKILL.md index 525d27e33..564474b64 100644 --- a/.agents/skills/translate-docs/SKILL.md +++ b/.agents/skills/translate-docs/SKILL.md @@ -1,10 +1,8 @@ --- name: translate-docs -description: Translate and sync bilingual documentation. Use when user wants to translate docs between Chinese and English, sync translations, or ensure bilingual documentation consistency. +description: Translate and sync bilingual documentation. --- -# Translate Documentation - 现在我们正在为当前项目 Kimi CLI 编写和维护用户文档,文档内容在 docs 目录下,docs/AGENTS.md 中有对文档的说明。 中文文档和英文 changelog 已经确保是正确符合预期的,现在请你逐页、逐段地翻译文档内容,确保中英双语保持同步。 From 3ce37b953701a5f0095014dd538b80f5a90d9377 Mon Sep 17 00:00:00 2001 From: Richard Chien Date: Wed, 21 Jan 2026 00:07:15 +0800 Subject: [PATCH 3/5] change some skills to flows Signed-off-by: Richard Chien --- .agents/skills/pr/SKILL.md | 11 ------- .agents/skills/pull-request/SKILL.md | 14 +++++++++ .agents/skills/release/SKILL.md | 43 ++++++++++++++-------------- 3 files changed, 35 insertions(+), 33 deletions(-) delete mode 100644 .agents/skills/pr/SKILL.md create mode 100644 .agents/skills/pull-request/SKILL.md diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md deleted file mode 100644 index 901a0ceef..000000000 --- a/.agents/skills/pr/SKILL.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: pr -description: Create and submit a Pull Request. Use when user wants to submit a PR, push changes and open a pull request, or create a PR from the current branch to main. ---- - -# Submit Pull Request - -1. 如果当前分支有 dirty change,什么都不要做,直接停止。 -2. 如果是干净的,确保当前分支是一个不同于 main 的独立分支。 -3. 根据当前分支相对于 main 分支的修改,push 并提交一个 PR(利用 gh 命令),用英文编写 PR 标题和 description,描述所做的更改。 -4. PR title 要符合先前的 commit message 规范(PR title 就是 squash merge 之后的 commit message)。 diff --git a/.agents/skills/pull-request/SKILL.md b/.agents/skills/pull-request/SKILL.md new file mode 100644 index 000000000..bc4d0c770 --- /dev/null +++ b/.agents/skills/pull-request/SKILL.md @@ -0,0 +1,14 @@ +--- +name: pull-request +description: Create and submit a GitHub Pull Request. +type: flow +--- + +```mermaid +flowchart TB + A(["BEGIN"]) --> B["当前分支有没有 dirty change?"] + B -- 有 --> D(["END"]) + B -- 没有 --> n1["确保当前分支是一个不同于 main 的独立分支"] + n1 --> n2["根据当前分支相对于 main 分支的修改,push 并提交一个 PR(利用 gh 命令),用英文编写 PR 标题和 description,描述所做的更改。PR title 要符合先前的 commit message 规范(PR title 就是 squash merge 之后的 commit message)。"] + n2 --> D +``` diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 65e3414af..05c844a54 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -1,26 +1,25 @@ --- name: release -description: Execute the release workflow for Kimi CLI packages. Use when user wants to release a new version, bump version numbers, create release PRs, or prepare packages for publishing. +description: Execute the release workflow for Kimi CLI packages. +type: flow --- -# Release Workflow - -1. 首先从 AGENTS.md 和 .github/workflows/release*.yml 中理解本项目的发版自动化流程。 -2. 检查每个 packages、sdks 和根目录的包是否在上次发版(根据 tag 确认)后有变更。 - - 注意 `packages/kimi-code` 是薄包装包,需要与根包 `kimi-cli` 同步版本。 -3. 如果有变更,对于每个变更的包,跟我确认新的版本号(遵循语义化版本规范),并更新相应的: - - pyproject.toml - - CHANGELOG.md(要保留 Unreleased 标题) - - 中英文档的 breaking-changes.md 文件中的版本号 - - 若变更的是根包版本,同时同步更新: - - `packages/kimi-code/pyproject.toml` 的 `version` - - `packages/kimi-code/pyproject.toml` 的 `dependencies` 中 `kimi-cli==` -4. 运行 `uv sync`。 -5. 运行 gen-docs skill 中的指示以确保文档是最新的。 -6. 开一个新的分支叫 `bump--`,提交所有更改并推送到远程仓库。 - - 如果一次有多个包需要发版,可以合并在一个分支升级版本号,分支名适当编写即可。 -7. 用 gh 命令开一个 Pull Request,描述所做的更改。 -8. 持续检查这个 PR 的状态,直到被合并。 -9. 合并后,切到 main 分支,拉取最新的更改。 -10. 提示我最终发布 tag 所需的 git tag 命令,我会自行 tag + push tags。 - - 说明:单个数字 tag 会同时发布 `kimi-cli` 与 `kimi-code`。 +```mermaid +flowchart TB + A(["BEGIN"]) --> B["从 AGENTS.md 和 .github/workflows/release*.yml 中理解本项目的发版自动化流程"] + B --> C["检查每个 packages、sdks 和根目录的包是否在上次发版(根据 tag 确认)后有变更。注意 packages/kimi-code 是薄包装包,需要与根包 kimi-cli 同步版本。"] + C --> D{"有变更的包?"} + D -- 没有 --> Z(["END"]) + D -- 有 --> E["对于每个变更的包,跟用户确认新的版本号(遵循语义化版本规范)"] + E --> F["更新相应的 pyproject.toml、CHANGELOG.md(保留 Unreleased 标题)、中英文档的 breaking-changes.md 文件中的版本号"] + F --> G{"变更的是根包版本?"} + G -- 是 --> H["同步更新 packages/kimi-code/pyproject.toml 的 version 和 dependencies 中 kimi-cli==<version>"] + H --> I["运行 uv sync"] + G -- 否 --> I + I --> J["运行 gen-docs skill 中的指示以确保文档是最新的"] + J --> K["开一个新分支 bump-<package>-<new-version>(多个包可合并在一个分支,分支名适当编写)"] + K --> L["提交所有更改,推送到远程仓库,并用 gh 命令开一个 Pull Request,描述所做的更改"] + L --> N["持续检查这个 PR 的状态,直到被合并"] + N --> O["合并后,切到 main 分支,拉取最新的更改,并提示用户最终发布 tag 所需的 git tag 命令(用户会自行 tag + push tags)。说明:单个数字 tag 会同时发布 kimi-cli 与 kimi-code。"] + O --> Z +``` From 25da136be7de6612cacffd4761dc13937b8a6c9d Mon Sep 17 00:00:00 2001 From: Richard Chien Date: Wed, 21 Jan 2026 00:12:14 +0800 Subject: [PATCH 4/5] changelog Signed-off-by: Richard Chien --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2cc36afa..fe4b97853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Skills: Add flow skill type with embedded Agent Flow (Mermaid/D2) in SKILL.md, invoked via `/flow:` commands +- CLI: Remove `--prompt-flow` option; use flow skills instead +- Core: Replace `/begin` command with `/flow:` commands for flow skills + ## 0.79 (2026-01-19) - Skills: Add project-level skills support, discovered from `.agents/skills/` (or `.kimi/skills/`, `.claude/skills/`) From 21ba2aa56189c4999ea5a93bdf88f09d32796157 Mon Sep 17 00:00:00 2001 From: Richard Chien Date: Wed, 21 Jan 2026 00:37:18 +0800 Subject: [PATCH 5/5] docs Signed-off-by: Richard Chien --- docs/en/customization/skills.md | 56 +++++++++++++++++++++++++++++ docs/en/reference/kimi-command.md | 52 +-------------------------- docs/en/reference/slash-commands.md | 17 +++++---- docs/en/release-notes/changelog.md | 4 +++ docs/zh/customization/skills.md | 56 +++++++++++++++++++++++++++++ docs/zh/reference/kimi-command.md | 52 +-------------------------- docs/zh/reference/slash-commands.md | 17 +++++---- docs/zh/release-notes/changelog.md | 4 +++ 8 files changed, 144 insertions(+), 114 deletions(-) diff --git a/docs/en/customization/skills.md b/docs/en/customization/skills.md index c200884d2..74433c46b 100644 --- a/docs/en/customization/skills.md +++ b/docs/en/customization/skills.md @@ -189,3 +189,59 @@ For regular conversations, the Agent will automatically decide whether to read s ::: Skills allow you to codify your team's best practices and project standards, ensuring the AI always follows consistent standards. + +## Flow skills + +Flow skills are a special skill type that embed an Agent Flow diagram in `SKILL.md`, used to define multi-step automated workflows. Unlike standard skills, flow skills are invoked via `/flow:` commands and automatically execute multiple conversation turns following the flow diagram. + +**Creating a flow skill** + +To create a flow skill, set `type: flow` in the frontmatter and include a Mermaid or D2 code block in the content: + +````markdown +--- +name: code-review +description: Code review workflow +type: flow +--- + +```mermaid +flowchart TD +A([BEGIN]) --> B[Analyze code changes, list all modified files and features] +B --> C{Is code quality acceptable?} +C -->|Yes| D[Generate code review report] +C -->|No| E[List issues and propose improvements] +E --> B +D --> F([END]) +``` +```` + +**Flow diagram format** + +Both Mermaid and D2 formats are supported: + +- **Mermaid**: Use ` ```mermaid ` code block, [Mermaid Playground](https://www.mermaidchart.com/play) can be used for editing and preview +- **D2**: Use ` ```d2 ` code block, [D2 Playground](https://play.d2lang.com) can be used for editing and preview + +Flow diagrams must contain one `BEGIN` node and one `END` node. Regular node text is sent to the Agent as a prompt; decision nodes require the Agent to output `branch name` in the output to select the next step. + +**D2 format example** + +``` +BEGIN -> B -> C +B: Analyze existing code, write design doc for XXX feature +C: Review if design doc is detailed enough +C -> B: No +C -> D: Yes +D: Start implementation +D -> END +``` + +**Executing a flow skill** + +```sh +# Execute in Kimi CLI +/flow:code-review +``` + +After execution, the Agent will start from the `BEGIN` node and process each node according to the flow diagram definition until reaching the `END` node. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 673770846..a9926c432 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -77,57 +77,7 @@ When using `--prompt` (or `--command`), Kimi CLI exits after processing the quer [Ralph](https://ghuntley.com/ralph/) is a technique that puts an agent in a loop: the same prompt is fed again and again so the agent can keep iterating one big task. -When `--max-ralph-iterations` is not `0`, Kimi CLI enters Ralph Loop mode and automatically loops through task execution based on an internal Prompt Flow, until the agent outputs `STOP` or the iteration limit is reached. - -::: info Note -Ralph Loop is mutually exclusive with the `--prompt-flow` option and cannot be used together. -::: - -## Prompt Flow - -| Option | Description | -|--------|-------------| -| `--prompt-flow PATH` | Load a D2 (`.d2`) or Mermaid (`.mmd`) flowchart file as a Prompt Flow | - -Prompt Flow is a prompt workflow description method based on flowcharts, where each node corresponds to one conversation turn. After loading, you can start the flow execution with the `/begin` slash command. Both D2 and Mermaid flowchart formats are supported. You can edit and preview D2 flowcharts at [D2 Playground](https://play.d2lang.com), and Mermaid flowcharts at [Mermaid Playground](https://www.mermaidchart.com/play). - -D2 flowchart example (`example.d2` file): - -``` -BEGIN -> B -> C -B: Analyze existing code, write design doc for XXX feature in design.md -C: Review design.md, is it detailed enough? -C -> B: No -C -> D: Yes -D: Start implementation -D -> END -``` - -Mermaid flowchart example (`example.mmd` file): - -``` -flowchart TD -A([BEGIN]) --> B[Analyze existing code, write design doc for XXX feature in design.md] -B --> C{Review design.md, is it detailed enough?} -C -->|Yes| D[Start implementation] -C -->|No| B -D --> F([END]) -``` - -```mermaid -flowchart TD -A([BEGIN]) --> B[Analyze existing code, write design doc for XXX feature in design.md] -B --> C{Review design.md, is it detailed enough?} -C -->|Yes| D[Start implementation] -C -->|No| B -D --> F([END]) -``` - -During node processing, decision nodes require the agent to output `branch name` to select the next node. - -::: info Note -`--prompt-flow` is mutually exclusive with Ralph Loop mode and cannot be used together. -::: +When `--max-ralph-iterations` is not `0`, Kimi CLI enters Ralph Loop mode and automatically loops through task execution until the agent outputs `STOP` or the iteration limit is reached. ## UI modes diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index b33ff9465..4c0d22838 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -118,6 +118,17 @@ For example: You can append additional text after the command, which will be added to the skill prompt. See [Agent Skills](../customization/skills.md) for details. +### `/flow:` + +Execute a specific flow skill. Flow skills embed an Agent Flow diagram in `SKILL.md`. After execution, the Agent will start from the `BEGIN` node and process each node according to the flow diagram definition until reaching the `END` node. + +For example: + +- `/flow:code-review`: Execute code review workflow +- `/flow:release`: Execute release workflow + +See [Agent Skills](../customization/skills.md#flow-skills) for details. + ## Others ### `/init` @@ -134,12 +145,6 @@ Toggle YOLO mode. When enabled, all operations are automatically approved and a YOLO mode skips all confirmations. Make sure you understand the potential risks. ::: -### `/begin` - -Start Prompt Flow execution. - -This command is only available when a flowchart has been loaded via `--prompt-flow`. After execution, the agent will start from the `BEGIN` node and process each node according to the flowchart definition until reaching the `END` node. See [`kimi` command](./kimi-command.md#prompt-flow) for details. - ## Command completion After typing `/` in the input box, a list of available commands is automatically displayed. Continue typing to filter commands with fuzzy matching support, press Enter to select. diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 5c9095290..3e61eb57c 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,10 @@ This page documents the changes in each Kimi CLI release. ## Unreleased +- Skills: Add flow skill type with embedded Agent Flow (Mermaid/D2) in SKILL.md, invoked via `/flow:` commands +- CLI: Remove `--prompt-flow` option; use flow skills instead +- Core: Replace `/begin` command with `/flow:` commands for flow skills + ## 0.80 (2026-01-20) - Wire: Add `initialize` method for exchanging client/server info, external tools registration and slash commands advertisement diff --git a/docs/zh/customization/skills.md b/docs/zh/customization/skills.md index 60940c078..883bf23bf 100644 --- a/docs/zh/customization/skills.md +++ b/docs/zh/customization/skills.md @@ -189,3 +189,59 @@ description: Git 提交信息规范,使用 Conventional Commits 格式 ::: Skills 让你可以将团队的最佳实践和项目规范固化下来,确保 AI 始终遵循一致的标准。 + +## Flow Skills + +Flow Skill 是一种特殊的 Skill 类型,它在 `SKILL.md` 中内嵌 Agent Flow 流程图,用于定义多步骤的自动化工作流。与普通 Skill 不同,Flow Skill 通过 `/flow:` 命令调用,会按照流程图自动执行多个对话轮次。 + +**创建 Flow Skill** + +创建 Flow Skill 需要在 Frontmatter 中设置 `type: flow`,并在内容中包含 Mermaid 或 D2 格式的流程图代码块: + +````markdown +--- +name: code-review +description: 代码审查工作流 +type: flow +--- + +```mermaid +flowchart TD +A([BEGIN]) --> B[分析代码变更,列出所有修改的文件和功能] +B --> C{代码质量是否达标?} +C -->|是| D[生成代码审查报告] +C -->|否| E[列出问题并提出改进建议] +E --> B +D --> F([END]) +``` +```` + +**流程图格式** + +支持 Mermaid 和 D2 两种格式: + +- **Mermaid**:使用 ` ```mermaid ` 代码块,[Mermaid Playground](https://www.mermaidchart.com/play) 可用于编辑和预览 +- **D2**:使用 ` ```d2 ` 代码块,[D2 Playground](https://play.d2lang.com) 可用于编辑和预览 + +流程图必须包含一个 `BEGIN` 节点和一个 `END` 节点。普通节点的文本作为提示词发送给 Agent;分支节点需要 Agent 在输出中使用 `分支名` 选择下一步。 + +**D2 格式示例** + +``` +BEGIN -> B -> C +B: 分析现有代码,为 XXX 功能编写设计文档 +C: Review 设计文档是否足够详细 +C -> B: 否 +C -> D: 是 +D: 开始实现 +D -> END +``` + +**执行 Flow Skill** + +```sh +# 在 Kimi CLI 中执行 +/flow:code-review +``` + +执行后,Agent 会从 `BEGIN` 节点开始,按照流程图定义依次处理每个节点,直到到达 `END` 节点。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 5a3f0f233..d5ceb561c 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -77,57 +77,7 @@ kimi [OPTIONS] COMMAND [ARGS] [Ralph](https://ghuntley.com/ralph/) 是一种把 Agent 放进循环的技术:同一条提示词会被反复喂给 Agent,让它围绕一个任务持续迭代。 -当 `--max-ralph-iterations` 非 `0` 时,Kimi CLI 会进入 Ralph 循环模式,基于内置的 Prompt Flow 自动循环执行任务,直到 Agent 输出 `STOP` 或达到迭代上限。 - -::: info 注意 -Ralph 循环与 `--prompt-flow` 选项互斥,不能同时使用。 -::: - -## Prompt Flow - -| 选项 | 说明 | -|------|------| -| `--prompt-flow PATH` | 加载 D2(`.d2`)或 Mermaid(`.mmd`)流程图文件作为 Prompt Flow | - -Prompt Flow 是一种基于流程图的提示词工作流描述方式,每个节点对应一次对话轮次。加载后,可以通过 `/begin` 斜杠命令启动流程执行。目前支持 D2 和 Mermaid 两种流程图格式。可以在 [D2 Playground](https://play.d2lang.com) 编辑和预览 D2 流程图,在 [Mermaid Playground](https://www.mermaidchart.com/play) 编辑和预览 Mermaid 流程图。 - -D2 流程图示例(`example.d2` 文件): - -``` -BEGIN -> B -> C -B: 分析现有代码,为 XXX 功能在 design.md 文件中编写设计文档 -C: Review 一遍 design.md,看看是否足够详细 -C -> B: 否 -C -> D: 是 -D: 开始实现 -D -> END -``` - -Mermaid 流程图示例(`example.mmd` 文件): - -``` -flowchart TD -A([BEGIN]) --> B[分析现有代码,为 XXX 功能在 design.md 文件中编写设计文档] -B --> C{Review 一遍 design.md,看看是否足够详细} -C -->|是| D[开始实现] -C -->|否| B -D --> F([END]) -``` - -```mermaid -flowchart TD -A([BEGIN]) --> B[分析现有代码,为 XXX 功能在 design.md 文件中编写设计文档] -B --> C{Review 一遍 design.md,看看是否足够详细} -C -->|是| D[开始实现] -C -->|否| B -D --> F([END]) -``` - -在节点处理过程中,分支节点会要求 Agent 输出 `分支名` 来选择下一个节点。 - -::: info 注意 -`--prompt-flow` 与 Ralph 循环模式互斥,不能同时使用。 -::: +当 `--max-ralph-iterations` 非 `0` 时,Kimi CLI 会进入 Ralph 循环模式,自动循环执行任务,直到 Agent 输出 `STOP` 或达到迭代上限。 ## UI 模式 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index d882abe20..17649d915 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -118,6 +118,17 @@ 命令后面可以附带额外的文本,这些内容会追加到 Skill 提示词之后。详见 [Agent Skills](../customization/skills.md)。 +### `/flow:` + +执行指定的 Flow Skill。Flow Skill 在 `SKILL.md` 中内嵌 Agent Flow 流程图,执行后 Agent 会从 `BEGIN` 节点开始,按照流程图定义依次处理每个节点,直到到达 `END` 节点。 + +例如: + +- `/flow:code-review`:执行代码审查工作流 +- `/flow:release`:执行发布工作流 + +详见 [Agent Skills](../customization/skills.md#flow-skills)。 + ## 其他 ### `/init` @@ -134,12 +145,6 @@ YOLO 模式会跳过所有确认,请确保你了解可能的风险。 ::: -### `/begin` - -启动 Prompt Flow 执行。 - -此命令仅在通过 `--prompt-flow` 加载了流程图时可用。执行后,Agent 会从 `BEGIN` 节点开始,按照流程图的定义依次处理每个节点,直到达到 `END` 节点。详见 [`kimi` 命令](./kimi-command.md#prompt-flow)。 - ## 命令补全 在输入框中输入 `/` 后,会自动显示可用命令列表。继续输入可过滤命令,支持模糊匹配,按 Enter 选择。 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 1b1506ebc..24ce5b1d0 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,10 @@ ## 未发布 +- Skills:添加 Flow Skill 类型,在 SKILL.md 中内嵌 Agent Flow(Mermaid/D2),通过 `/flow:` 命令调用 +- CLI:移除 `--prompt-flow` 选项,改用 Flow Skills +- Core:用 `/flow:` 命令替代原来的 `/begin` 命令 + ## 0.80 (2026-01-20) - Wire:添加 `initialize` 方法,用于交换客户端/服务端信息、注册外部工具和公布斜杠命令