fix(composer): reserve context slash command alias (#5279)

* fix(composer): reserve context slash command alias

* fix(skills): align slash docs and formatting

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>

* fix(skills): allow context skill outside compact alias

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>

* docs(tui): sync context skill command policy

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>

---------

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>
This commit is contained in:
Undermoon1412 2026-09-18 07:48:24 +08:00 committed by GitHub
parent e89b128157
commit aa7f616734
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 80 additions and 27 deletions

View File

@ -10,7 +10,7 @@
- **Deferred discovery** (`skills.deferred_discovery: true`): Skills are listed by name only in a compact `<skill_index>` block, keeping the system prompt prefix-cache friendly. The agent calls the `describe_skill` tool at runtime to fetch full metadata for skills it wants to use, then loads the SKILL.md via `read_file`. Two new modules support this path:
- `skills/catalog.py` — immutable `SkillCatalog`; query forms: `select:a,b`, `+prefix`, free-text intent terms. Ranked queries use up to 256 characters / 16 unique literal terms, preferring name matches over description-only matches at equal coverage; ties keep catalog order. A lazy per-catalog index caches normalized names/descriptions. Unlike tool search, ranking uses literal intent terms, not regexes. `select:` is parsed before search limits and returns all exact catalog matches without query-length or result caps; other modes cap at `MAX_RESULTS=5`.
- `skills/describe.py``build_describe_skill_tool(catalog)` builds the `describe_skill` tool as a closure; `build_skill_search_setup(skills, enabled, ...)` produces a `SkillSearchSetup(describe_skill_tool, skill_names)` that is wired into both the LangGraph agent factory (`agent.py`) and the embedded client (`client.py`).
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`, `/agent`), disabled skills, and skills outside a custom agent's whitelist.
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved command syntax (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`, `/agent`, and the exact composer-only alias `/context compact`), disabled skills, and skills outside a custom agent's whitelist. A custom skill named `context` remains activatable for other task text.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Managed integrations**: Lark/Feishu CLI support installs one global official `lark-*` pack as read-only `SkillCategory.INTEGRATION` entries under `/mnt/skills/integrations/lark-cli/...`; enabled flags, app configuration, and OAuth data remain per-user. Install resolves the newest `larksuite/cli` release from GitHub (`releases/latest`) at install time (falling back to a bottom-line pinned version if the lookup fails) rather than hard-coding the pack version; integrity relies on the official host + structural archive guards + a recorded hash of the effective installed tree after shared guidance injection (not a pinned archive-byte SHA, which GitHub does not keep stable). The Gateway image still installs a pinned `@larksuite/cli` binary, so `get_lark_integration_status` surfaces `latest_available_version` and `runtime_version_mismatch` for the UI. AIO installs additionally verify and publish official Linux amd64/arm64 binaries under `{DEER_FLOW_HOME}/integrations/lark-cli/sandbox-cli`, mounted read-only at `/mnt/integrations/lark-cli/runtime`; `/mnt/integrations/lark-cli/config` (app credentials, incl. the long-lived `appSecret`) is mounted **read-only** into the sandbox, its empty `config/locks` subdirectory is over-mounted writable for `lark-cli` coordination files, and `/mnt/integrations/lark-cli/data` (refreshable OAuth tokens) stays writable, all mapping to owner-only per-user directories. **Sandbox trust boundary:** the credential-bearing config and data dirs are still *readable* by arbitrary sandbox processes (the agent's `bash` tool, or code reached via prompt-injection in a tool result), so the app secret and tokens are exposed to sandbox-side code even though they never reach the browser — the read-only config mount only prevents in-sandbox tampering, not read/exfiltration. The sidecar credential-broker (Pattern B, issue #4338) is the fix that removes these plaintext mounts from sandbox execution: set `LARK_CLI_BROKER_IMAGE` on the provisioner (see `docker/lark-cli-broker/`) and the Gateway sends `provision_lark_cli_broker` on sandbox create. The provisioner then runs a `lark-cli-broker` sidecar that owns the per-user `config`/`config/locks`/`data` mounts (mounted into the **sidecar only**, at `/var/lark/{config,config/locks,data}` with only the nested locks mount writable) and serves the `lark-cli` command surface on Pod loopback (`http://127.0.0.1:8788`); a shim init container (`install-shim`) writes a forwarding `lark-cli` into the shared runtime `emptyDir`, so the sandbox gets `DEERFLOW_LARK_BROKER_URL` + a shim on PATH but **no** credential files. The on-PATH `bin/lark-cli` is a `/bin/sh` launcher that resolves a Python 3 interpreter and execs the Python shim body (`bin/lark-cli-shim.py`) beside it, so broker mode does not silently ENOEXEC on a sandbox image without a `#!/usr/bin/env python3`-resolvable interpreter — it fails loudly (exit 127, actionable message) and can be pinned with `DEERFLOW_LARK_BROKER_PYTHON`. The broker runs `lark-cli` in the sidecar's cwd and cannot see the sandbox filesystem, so cwd is intentionally **not** forwarded and file-I/O subcommands relative to the sandbox cwd are unsupported (command surface only). An optional `DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS` denylist (comma-separated command prefixes, forwarded from the provisioner) lets the broker refuse secret-dumping subcommands before spawning the binary. `lark_cli_env_overlay(broker=True)` therefore omits `LARKSUITE_CLI_CONFIG_DIR`/`DATA_DIR`; `sandbox_lark_broker_active()` (TTL-cached provisioner `/api/capabilities` probe, tight timeout + longer negative caching on the bash hot path) selects broker vs. binary mode for both the bash env overlay and status. `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` supplies a validated, symlink-free pre-staged runtime for air-gapped deployments. For the remote provisioner (K8s), the runtime binary is otherwise provisioned by an optional init container + shared `emptyDir` (Pattern A): set `LARK_CLI_INIT_IMAGE` on the provisioner (see `docker/lark-cli-init/`) and the Gateway sends `provision_lark_cli_runtime` on sandbox create once the pack is installed, so remote installs skip the Gateway-side GitHub download entirely. Broker (Pattern B) supersedes the init-container binary (Pattern A) when both images are configured. `get_lark_integration_status(check_runtime=True)` surfaces `sandbox_runtime_mode` (`none` / `gateway-download` / `init-container` / `broker`) and `sandbox_runtime_ready` (remote modes read the provisioner `GET /api/capabilities`: `lark_cli_init_image` / `lark_cli_broker_image`) so a green UI can't hide a chat-time `lark-cli: command not found`. Cheap status probes are explicitly not live-verified; users authorize or reconnect through the browser device-flow endpoints instead of running terminal commands.
- **SkillScan**: `packages/harness/deerflow/skills/skillscan/` is the native deterministic scanner for `.skill` archives and agent-managed skill writes. It runs offline before the LLM scanner, emits structured findings (`rule_id`, `severity`, `file`, `line`, `message`, `remediation`, redacted `evidence` — category/analyzer are encoded in the `rule_id` prefix), blocks `CRITICAL`, and passes warning findings into `scan_skill_content()`. The moderation adapter must normalize both plain-text responses and LangChain Responses API text blocks before parsing the required JSON decision. `scan_archive_preflight()` / `scan_skill_dir()` are pure sync functions (dispatch off the event loop); `enforce_static_scan()` applies the blocking policy and the `skill_scan.enabled` kill switch. `skills/package_files.py` is the single definition of code files (`scripts/` members, code suffixes, extensionless `#!` files) and executable magic bytes; the installer, export guard, and SkillScan all import it, so do not re-derive either rule locally. Files that fail NUL-free UTF-8 decoding are treated as binaries unless they are code files: interpreters still run those, so they raise `package-undecodable-script` (HIGH) and are analyzed over a lossy decode, keeping one stray byte from hiding a file or downgrading a `CRITICAL` match; one with executable magic skips the text rules, which only misread its string tables. The Python instance-client signal deliberately follows only a one-level, same-scope evidence chain (PR #4265 review): a proven imported constructor bound to a simple name, optional name-to-name alias propagation, rebinding invalidation, and a constructor-supported outbound method or context-manager use; bare canonical-looking names never fall back to module identity. Nested scopes never inherit client handles and inherit only constructor aliases proven stable by a binding-only enclosing-scope prepass. Comprehensions, walrus-bearing statements, annotations, executable expressions inside complex binding targets, unsupported operations, and ambiguous flows produce no finding from this signal; skipped constructs invalidate all names they may bind, while representative false negatives are pinned by `test_python_declared_false_negatives_stay_unreported`. Compound bodies are walked from isolated copies so wrapping code in `if True:` is not a bypass, while copied scope entries, binding-only prepasses, and AST visits consume a deterministic work budget and the walk stops after its first sink. Budget or recursion exhaustion skips only this best-effort signal and retains deterministic findings already collected for the file. Do not add Semgrep/OpenGrep or YAML rule-engine dependencies to the core path; Phase 1 rule specs live in Python constants next to their analyzers in `skillscan/orchestrator.py`.

View File

@ -6,16 +6,17 @@ from dataclasses import dataclass
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.skills.types import Skill
#: Composer control commands that own the leading slash and must never be
#: treated as ``/skill`` activations. These values plus :data:`_SLASH_SKILL_RE`
#: are mirrored by the frontend display parser in
#: Composer control names that may own the leading slash and must not be
#: treated as ``/skill`` activations when their command syntax matches.
#: These values plus :data:`_SLASH_SKILL_RE` are mirrored by the frontend parser in
#: ``frontend/src/core/skills/slash.ts``; both sides are pinned to the shared
#: fixture at ``contracts/slash_skill_contract.json`` by contract tests
#: (``tests/test_slash_skill_contract.py`` here, ``slash-contract.test.ts`` on
#: the frontend), so a reserved command or grammar change in only one language
#: fails CI.
RESERVED_SLASH_SKILL_NAMES = frozenset({"agent", "bootstrap", "goal", "help", "memory", "models", "new", "status"})
RESERVED_SLASH_SKILL_NAMES = frozenset({"agent", "bootstrap", "context", "goal", "help", "memory", "models", "new", "status"})
_SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)")
_CONTEXT_COMPACT_ARGUMENT = "compact"
@dataclass(frozen=True, slots=True)
@ -41,11 +42,12 @@ def parse_slash_skill_reference(text: str) -> SlashSkillReference | None:
if not match:
return None
name = match.group(1)
if name in RESERVED_SLASH_SKILL_NAMES:
remaining_text = text[match.end() :].lstrip()
if name in RESERVED_SLASH_SKILL_NAMES and not (name == "context" and remaining_text.strip().casefold() != _CONTEXT_COMPACT_ARGUMENT):
return None
return SlashSkillReference(
name=name,
remaining_text=text[match.end() :].lstrip(),
remaining_text=remaining_text,
)

View File

@ -6,7 +6,7 @@ A terminal-native UI over the embedded harness, exposed as the `deerflow` consol
- `cli.py``plan_launch()` (pure launch-mode decision) + headless `--print` / `--json` + `main()` entry point. TTY → TUI, else headless help. `--tui-transparent` / `DEER_FLOW_TUI_TRANSPARENT` opt into terminal-default backgrounds without changing the solid-theme default. Uses an **absolute** `from deerflow.tui.app import run_tui` so the `app.py` module name doesn't trip `test_harness_boundary.py` (which records relative import module names verbatim).
- `view_state.py``ViewState` + `reduce(state, action)`, the testable heart. Rows: user / assistant / tool / system. Title captured from `values` events.
- `runtime.py``translate(StreamEvent) -> [Action]` (pure) + `stream_actions()` which brackets a run with `RunStarted`/`RunEnded` and turns model errors into an `AssistantError` row.
- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers). The command registry must exclude the shared `RESERVED_SLASH_SKILL_NAMES` from both its picker and resolver so the TUI cannot advertise a skill activation that the agent runtime rejects.
- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers). The command registry must exclude every shared `RESERVED_SLASH_SKILL_NAMES` entry that the agent runtime rejects from both its picker and resolver; the `context` skill is the exception for ordinary task text, while the exact composer-only `/context compact` alias remains unavailable as a skill activation.
- `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette with `/goal` management + model/thread modal pickers; routes idle display-only `/clear` through `ClearRows` without replacing the active thread, and blocks state-resetting local commands like `/new` and `/clear` with the standard "Still working" message during an active run; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer. Application-level PageUp/PageDown bindings scroll the transcript while preserving composer focus; streaming follows output only while the transcript remains at the bottom.
- `session.py` / `persistence.py` — builds the client + checkpointer and the `ThreadMetaWriter`.

View File

@ -16,7 +16,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES
from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES, parse_slash_skill_reference
@dataclass(frozen=True)
@ -77,7 +77,7 @@ def build_registry(skills: list[dict]) -> list[Command]:
if not skill.get("enabled", False):
continue
name = skill.get("name")
if not name or name in _BUILTIN_NAMES or name in RESERVED_SLASH_SKILL_NAMES:
if not name or name in _BUILTIN_NAMES or (name in RESERVED_SLASH_SKILL_NAMES and name != "context"):
continue
commands.append(Command(name=name, description=skill.get("description", "") or "", category="skill"))
return commands
@ -124,7 +124,9 @@ def resolve(text: str, skills: list[str] | None = None) -> Resolution:
if name in _BUILTIN_NAMES:
return Resolution(kind="builtin", name=name, args=args)
if skills and name in skills and name not in RESERVED_SLASH_SKILL_NAMES:
return Resolution(kind="skill", name=name, args=args)
if skills and name in skills:
reference = parse_slash_skill_reference(f"/{name} {args}".rstrip())
if reference is not None:
return Resolution(kind="skill", name=name, args=args)
return Resolution(kind="unknown", name=name, args=args)

View File

@ -89,14 +89,26 @@ def test_parse_slash_skill_reference_rejects_invalid_names():
def test_resolve_slash_skill_ignores_reserved_control_commands(tmp_path):
for command in ["agent", "bootstrap", "goal", "help", "memory", "models", "new", "status"]:
for command in sorted(RESERVED_SLASH_SKILL_NAMES):
if command == "context":
continue
skill = _make_skill(tmp_path, command)
assert resolve_slash_skill(f"/{command} create an agent", [skill]) is None
def test_reserved_slash_skill_names_match_channel_commands():
assert RESERVED_SLASH_SKILL_NAMES == {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS}
def test_channel_commands_are_reserved_slash_skill_names():
assert {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS} <= RESERVED_SLASH_SKILL_NAMES
def test_context_compact_alias_is_reserved_without_becoming_channel_command(tmp_path):
assert "context" in RESERVED_SLASH_SKILL_NAMES
assert "/context" not in KNOWN_CHANNEL_COMMANDS
context_skill = _make_skill(tmp_path, "context")
assert parse_slash_skill_reference("/context compact") is None
assert parse_slash_skill_reference("/context use the skill") is not None
assert resolve_slash_skill("/context use the skill", [context_skill]) is not None
def test_resolve_slash_skill_respects_available_skill_whitelist(tmp_path):

View File

@ -123,7 +123,7 @@ def test_build_registry_never_exposes_reserved_commands_as_skills():
registry = build_registry([{"name": name, "description": "reserved", "enabled": True} for name in RESERVED_SLASH_SKILL_NAMES])
skill_names = {command.name for command in registry if command.category == "skill"}
assert skill_names.isdisjoint(RESERVED_SLASH_SKILL_NAMES)
assert skill_names == {"context"}
def test_resolve_never_classifies_reserved_commands_as_skills():
@ -131,7 +131,16 @@ def test_resolve_never_classifies_reserved_commands_as_skills():
for name in reserved_names:
resolved = resolve(f"/{name} task", skills=reserved_names)
assert resolved.kind != "skill", name
if name == "context":
assert resolved.kind == "skill"
else:
assert resolved.kind != "skill", name
def test_context_compact_alias_is_not_a_tui_skill():
resolved = resolve("/context compact", skills=["context"])
assert resolved.kind == "unknown"
# --------------------------------------------------------------------------- #

View File

@ -4,6 +4,7 @@
"reserved_slash_skill_names": [
"agent",
"bootstrap",
"context",
"goal",
"help",
"memory",

View File

@ -179,11 +179,9 @@ export function getMatchingSkillSuggestions(
builtinCommands: SlashSuggestion[],
): SlashSuggestion[] {
const normalizedQuery = query.toLowerCase();
// A name the slash parsers refuse must not be offered here either. Both
// parsers drop `RESERVED_SLASH_SKILL_NAMES` (the shared contract), and the
// builtin commands own their own names in the composer, so a skill carrying
// either one is unreachable: submitting it either runs the command or
// reaches the model as literal text with nothing activated.
// A name the slash parser refuses must not be offered here either. Builtin
// names remain unavailable, while `context` is only reserved for the exact
// `/context compact` alias and can therefore still be a skill suggestion.
const reservedNames = new Set([
...RESERVED_SLASH_SKILL_NAMES,
...builtinCommands.map(({ name }) => name.toLowerCase()),
@ -209,7 +207,7 @@ export function getMatchingSkillSuggestions(
if (!skill.enabled) {
return false;
}
if (reservedNames.has(name)) {
if (reservedNames.has(name) && name !== "context") {
return false;
}
return !normalizedQuery || name.includes(normalizedQuery);

View File

@ -1,8 +1,8 @@
import type { Skill } from "./type";
/**
* Composer control commands that own the leading slash. They must never be
* shown as skill activations. These values plus {@link SLASH_SKILL_RE} mirror
* Composer control names that may own the leading slash. Their command syntax
* must not be shown as a skill activation. These values plus {@link SLASH_SKILL_RE} mirror
* the backend gate in `deerflow/skills/slash.py`; both sides are pinned to the
* shared fixture at `contracts/slash_skill_contract.json` by contract tests
* (`tests/unit/core/skills/slash-contract.test.ts` here,
@ -12,6 +12,7 @@ import type { Skill } from "./type";
export const RESERVED_SLASH_SKILL_NAMES = new Set([
"agent",
"bootstrap",
"context",
"goal",
"help",
"memory",
@ -21,6 +22,7 @@ export const RESERVED_SLASH_SKILL_NAMES = new Set([
]);
export const SLASH_SKILL_RE = /^\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)/;
const CONTEXT_COMPACT_ARGUMENT = "compact";
export type SlashSkillReference = {
name: string;
@ -40,12 +42,16 @@ export function parseSlashSkillReference(
return null;
}
const name = match[1];
if (!name || RESERVED_SLASH_SKILL_NAMES.has(name)) {
const remainingText = text.slice(match[0].length).replace(/^\s+/, "");
const isContextSkillTask =
name === "context" &&
remainingText.trim().toLowerCase() !== CONTEXT_COMPACT_ARGUMENT;
if (!name || (RESERVED_SLASH_SKILL_NAMES.has(name) && !isContextSkillTask)) {
return null;
}
return {
name,
remainingText: text.slice(match[0].length).replace(/^\s+/, ""),
remainingText,
};
}

View File

@ -380,6 +380,9 @@ describe("getMatchingSkillSuggestions", () => {
// these names, so such a skill can never activate — picking it would send
// literal text to the model with nothing loaded.
for (const reserved of RESERVED_SLASH_SKILL_NAMES) {
if (reserved === "context") {
continue;
}
const result = getMatchingSkillSuggestions(
[makeSkill(reserved), makeSkill(`${reserved}-helper`)],
reserved,
@ -398,6 +401,18 @@ describe("getMatchingSkillSuggestions", () => {
expect(result).toEqual([]);
});
it("keeps a context skill available because only its compact alias is reserved", () => {
const result = getMatchingSkillSuggestions(
[makeSkill("context")],
"context",
[],
);
expect(
result.map((suggestion) => `${suggestion.kind}:${suggestion.name}`),
).toEqual(["skill:context"]);
});
it("caps the number of suggestions", () => {
const skills = Array.from({ length: 10 }, (_, i) =>
makeSkill(`skill-${i}`),

View File

@ -34,6 +34,14 @@ describe("parseSlashSkillReference", () => {
expect(parseSlashSkillReference("/help")).toBeNull();
});
it("allows a context skill except for the compact alias", () => {
expect(parseSlashSkillReference("/context compact")).toBeNull();
expect(parseSlashSkillReference("/context use this skill")).toEqual({
name: "context",
remainingText: "use this skill",
});
});
it("returns null when text is not a leading slash command", () => {
expect(parseSlashSkillReference("hello /data-analysis")).toBeNull();
expect(parseSlashSkillReference("/a/b")).toBeNull();