Skip to content

Parse subagent frontmatter tolerantly (kebab>camel>snake precedence) - #283

Open
raymondginger2018-sudo wants to merge 2 commits into
HKUDS:mainfrom
raymondginger2018-sudo:pr/subagent-frontmatter
Open

raymondginger2018-sudo wants to merge 2 commits into
HKUDS:mainfrom
raymondginger2018-sudo:pr/subagent-frontmatter

Conversation

@raymondginger2018-sudo

@raymondginger2018-sudo raymondginger2018-sudo commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Makes subagent preset frontmatter parsing tolerant of foreign dialects (Claude Code / Codex style) without breaking existing presets: unknown or differently-cased keys degrade gracefully instead of failing the load. The deny list (disallowed-tools) parses strictly and fail-closed — a malformed value marks the preset broken instead of silently dropping the restriction.

Changes

  • core/agent_presets/__init__.py:
    • Key precedence for multi-dialect keys: kebab > camel > snake (e.g. disallowed-tools > disallowedTools > disallowed_tools).
    • tool_filter computed as allowlist minus disallowed_tools (deny wins).
    • Fail-closed disallowed-tools: a malformed value (non-string entry, wrong type) marks the preset broken through the same path as an invalid tools: — the agent never keeps tools the author tried to remove. * is rejected in both spellings: bare "*" would fold to unrestricted (deny dropped), ["*"] would normalize to a deny entry no roster matches (denies nothing); both invert the author's intent, so both mark the preset broken.
    • max-turns / memory are not parsed or stored — nothing reads them, and Claude Code's memory is a scope string (user/project/local), not a bool.
    • permission-mode is not parsed — permission remains an independent session-level knob; no mapping in this PR.
  • Display: /preset (cli/tui/app.py), the preset picker (cli/tui/commands.py), and agent_preset_view (core/application/views.py) now render the deny list (all tools except ...) instead of claiming "all tools"; the wire entry gains disallowedTools (protocol/app-server.schema.json + regenerated desktop/src/generated/app-server.ts).
  • tests/test_agent_presets.py: precedence orders, subtraction semantics, fail-closed broken marking for both * spellings, snapshot round-trip, and default-preset invariance.

Tests

  • tests/test_agent_presets.py — 22 passed
  • tests/app_server/test_preset_methods.py + tests/contract/test_protocol_schema.py (with the above) — 32 passed
  • tests/test_tui.py — 63 passed (incl. new deny-list overview test)
  • CI-exact lint: ruff 0.15.21 ruff check + ruff format --check — passed
  • Generated protocol TS in sync (generate-protocol.mjs --check)

Notes

  • Independent PR; default presets parse identically to before (invariance covered by tests).

@Zongwei9888

Copy link
Copy Markdown
Collaborator

Thank you, @raymondginger2018-sudo. disallowed-tools is useful and matches Claude Code's deny-then-allow semantics, and I checked that the built-in presets parse identically on this branch. Three changes before merge:

  1. Fail closed. disallowed-tools: [Bash, 3] is dropped silently (core/agent_presets/__init__.py:382 discards the error), so the agent keeps Bash. A restriction should mark the preset broken, as an invalid tools: does.
  2. Drop max-turns and memory. Nothing reads them, yet they're saved in Session metadata and look enforced. Claude Code's memory is a scope string (user/project/local), so the bool parse is always None for real files.
  3. Show the deny list in /preset (cli/tui/app.py:507, cli/tui/commands.py:593) and agent_preset_view (core/application/views.py:276), which currently say "all tools" for a preset that only has a deny list.

Also, the description says permission-mode is parsed; the code (correctly) ignores it, so please fix the description. disallowed-tools alone, strict and displayed, with tests, we'd merge.

Agent preset frontmatter reads tools / disallowed-tools with key precedence
kebab > camel > snake; foreign dialects degrade to None instead of breaking
the preset load. The deny list feeds AgentPresetSnapshot.tool_filter()
(allowlist minus denylist, deny wins).

disallowed-tools is a restriction, so it parses strictly like tools: a
malformed value marks the preset broken instead of silently dropping the
deny list — a dropped restriction would keep the very tools the author
tried to remove. '*' is rejected in both spellings: the bare string folds
to unrestricted, and ["*"] would deny nothing.

max-turns and memory are no longer parsed or stored — nothing reads them,
and Claude Code's memory is a scope string, not a bool. permission-mode
stays out too: permission remains an independent session-level knob and
the preset never carries it.

/preset, the preset picker, and agent_preset_view now show the deny list
("all tools except ...") instead of claiming "all tools"; the wire entry
gains disallowedTools (schema + generated TS).

Default presets parse identically to before.
@raymondginger2018-sudo

Copy link
Copy Markdown
Contributor Author

Thanks @Zongwei9888 — all four points are addressed in a792522a:

  1. Fail closed ✅ — disallowed-tools now parses strictly through _parse_tools(field="disallowed-tools"); a malformed value marks the preset broken with a field-qualified error, the same handling as an invalid tools:. Additionally * is rejected in both spellings: bare "*" folds to unrestricted (deny dropped) and ["*"] would normalize to a deny entry no roster matches (denies nothing) — both invert the author's intent, so both mark the preset broken. Regression tests cover [Bash, 3], bare "*", and ["*"].
  2. max-turns / memory dropped ✅ — removed from the parser, AgentPreset, and AgentPresetSnapshot (fields, to_metadata, from_metadata, fingerprint). They no longer reach Session metadata, so nothing can look enforced.
  3. Deny list shown ✅ — /preset, the preset picker, and agent_preset_view now render all tools except ... when disallowed_tools is set. The wire entry gains disallowedTools (protocol/app-server.schema.json + regenerated TS) so the view matches the schema contract; a TUI overview test pins the new line.
  4. Description fixed ✅ — the body no longer claims permission-mode is parsed; code and comments state explicitly that it is not parsed and permission stays an independent session-level knob.

Tests: tests/test_agent_presets.py — 22 passed (precedence, subtraction, fail-closed for both * spellings, snapshot round-trip, default-preset invariance); targeted trio 32 passed; tests/test_tui.py — 63 passed. CI-exact ruff 0.15.21 (check + format --check) green, generated protocol TS in sync.

AgentPresetEntry gained a required disallowedTools: string[] | null in the app-server protocol (schema + generated types). The two fixture literals predate the field, so tsc --noEmit failed with TS2741 x2 and turned Desktop quality gates red on a792522. Both fixtures now report null, matching the wire contract's absent-never-empty-claim rule.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants