Skip to content

feat: tool groups — optional group on the descriptor and --group/--groups on tools.list - #74

Merged
V3RON merged 4 commits into
mainfrom
feat/tool-groups
Sep 21, 2026
Merged

V3RON merged 4 commits into
mainfrom
feat/tool-groups

Conversation

@V3RON

@V3RON V3RON commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds tool groups (#70). A tool can declare an optional group: a top-level group (checkout) or one subgroup (checkout/payment). Each segment matches TOOL_NAME_PATTERN. tools.list and appduct tools can then narrow by group.

  • Descriptor and validation. ToolDescriptor.group?: string is validated by one shared pattern, ^[a-zA-Z0-9_-]{1,64}(?:/[a-zA-Z0-9_-]{1,64})?$, in isToolDescriptor (JS), validateToolDescriptor/parseToolDescriptor (Swift) and validateAppductToolDescriptor/fromJson (Kotlin core and core-noop). An invalid group invalidates the snapshot with 1008 invalid_registry, the same as a bad timeout_ms. An explicit null is rejected on all three platforms.
  • Daemon tools.list. Takes a group param (a malformed one is invalid_request). The handler sorts, then applies the group (segment match: checkout includes checkout/* and never matches checkoutx; case-sensitive), then filter, then computes total, then pages. Every result carries groups: {group: string|null, total}[], computed over the whole registry before any narrowing.
  • CLI.
    • appduct tools --group <name> combines with --filter/--limit/--offset.
    • appduct tools --groups prints groups and counts, with subgroups indented under their parent.
    • When the registry has any grouped tool and no --group is given, the listing prints group headings, subgroups as sub-headings, and (ungrouped) last. The truncation footer names the top-level groups.
    • --json carries group on each entry and groups on the listing.
    • These are usage errors (exit 64): --group/--groups together with a <name>, including the single-arg probe; --groups with any narrowing flag or --full; a malformed --group.
    • A group with no tools says so and points at --groups, not "No tools registered."
  • SDKs.
    • registerTool/useAppductTool accept group, and it is part of useAppductTool's derived re-registration key.
    • New createToolGroup("cart") returns registerTool with the group bound. Root and ./noop share one factory, and a registration passed to it can't override the group, which is checked at the type level.
    • Swift Appduct.shared.register(…, group:) and Kotlin Appduct.register(…, group =), stubs included.
  • MCP. Rebased onto feat(mcp)!: reach app tools through list/describe/call built-ins #75, where app tools are reached through appduct_list_tools, appduct_describe_tool and appduct_call_tool. Groups are exposed there too:
    • appduct_list_tools takes group, with the same segment matching as --group. The daemon validates it and rejects a malformed one as invalid_request.
    • Its result shows each tool's group and carries the whole-registry groups summary on every call, so an agent can see an app's areas and list one.
    • appduct_describe_tool includes group.
    • Asking a daemon that predates groups for one fails with connection_error instead of listing the whole registry, the same guard the CLI has.
    • tools/list itself is unchanged: groups are data in these results, not MCP tools.
  • Docs. PROTOCOL.md §5, ARCHITECTURE.md §5/§10/§11, TOOLS.md ("Group tools in a large app"), the appduct/react-native/iOS/Android READMEs, skills/appduct/SKILL.md (step 3 and the tool-authoring section), the fixtures README, and CHANGELOG.md.
  • Playground. The demo tools are now counter (2 tools), diagnostics (1) plus the diagnostics/progress subgroup (1), and sum left ungrouped. The Tools tab shows each tool's group.

Closes #70

Backwards compatibility

  • Old daemon, new app. I checked this on origin/main's isToolDescriptor: it accepts descriptors with group of any value ("checkout", "a/b/c", 42), so unknown keys are ignored. The daemon stores the descriptor as-is.
  • Swift/Kotlin decoders. Both parse the wire JSON field by field and ignore unknown keys. The native cores only ever send descriptors, so an older native core simply omits group.
  • New CLI, old daemon. The version guard normally restarts an older daemon. If it can't (live sessions, no --daemon-restart), an old daemon returns no groups and ignores the group param. In that case --group/--groups now fail with a connection_error that says the daemon doesn't support groups, instead of printing the whole registry as the group. Plain listings still work.

Also fixed

  • Swift tool-name check accepted a trailing newline. NSRegularExpression treats $ as matching before a final line terminator, so "tool\n" passed on iOS while JS and Kotlin rejected it; the daemon would then reject the snapshot. The Swift name and group matchers now require the match to span the whole string. New fixture vectors: name-trailing-newline and group-trailing-newline.

Testing

  • JS. pnpm build, pnpm typecheck, pnpm lint (0 errors; the existing prettier warnings are unchanged), pnpm check:links. pnpm test: shared 299 passed, react-native 247 passed, appduct 791 passed + 1 skipped. New or extended tests:
    • tool-descriptor.test.ts: validation, segment matching, summary ordering.
    • fixtures-conformance: 22 new tool-descriptors.json vectors, including group-valid, group-subgroup-valid, group-empty, group-empty-segment, /payment, a//b, /, group-too-deep, 64- and 65-char segments, group-bad-char and variants, non-ASCII, trailing newline, group-non-string, array, null.
    • tool-invocation.integration.test.ts: group filters before total and paging, groups reflects the unfiltered registry, bad group params are rejected.
    • session-engine.integration.test.ts: a too-deep group gives 1008 invalid_registry.
    • cli-v2.integration.test.ts: the feat(cli): agent-friendly output — signature tools listing with filter/paging, table-driven global flags, --pretty/--verbose, compact JSON #67 fake app with three groups, one with a subgroup, plus ungrouped tools. Covers headings, the group-aware footer, --group for parent and subgroup, --groups, JSON shape, an empty group, and the usage errors.
    • output.test.ts: the footer with more than 10 groups.
    • tools-command.test.ts: old-daemon handling.
    • use-appduct-tool.test.ts: re-registers only when group changes.
    • client.test.ts: group on the wire; createToolGroup.
    • noop-parity.test.ts: createToolGroup typing on both entries.
    • mcp-server.test.ts: appduct_list_tools narrows to a group (subgroups included) while groups summarizes every tool; group appears in list and describe output; the old-daemon guard.
    • mcp-server.integration.test.ts: against the real daemon, listing by group, a malformed group rejected as invalid_request, and describing and calling a grouped tool.
  • Swift. swift build -c release (Stub branch) and swift test: 135 tests, 0 failures. That includes FixturesConformanceTests.testToolDescriptorsFixture and new AppductToolDescriptorTests group cases.
  • Kotlin. ./gradlew :core:testDebugUnitTest :core-noop:assembleRelease: 146 tests, 0 failures, 0 errors. That includes FixturesConformanceTest and new AppductToolRegistryTest group cases.
  • E2E. Real appduct CLI (this branch's build) against the playground on an iPhone 17 Pro simulator (iOS 26.4). Debug build via expo prebuild + xcodebuild, Metro on 8081. The session was established with appduct link --open ios-sim against a copy of the playground state dir on port 8453, because 8443 was taken by an unrelated local daemon. Excerpts:
$ appduct tools
Tools
  counter
    call_count() -> { count: number }
      Reports how many times the playground's counted tools have run.
    reset_counter() -> { count: number }
      Resets the playground's call counter to zero.
  diagnostics
    throwing_tool()
      Always throws, to exercise tool_execution_error.
    diagnostics/progress
      slow_task() -> { done: bool }
        Takes ~1.5s and reports progress along the way.
  (ungrouped)
    sum(a: number, b: number) -> { total: number }
      Adds two numbers.

$ appduct tools --groups
Groups
  counter                 2
  diagnostics             2
    diagnostics/progress  1
  (ungrouped)             1

5 tools in total. Run `appduct tools --group <name>` to list one group's tools.

$ appduct tools --group diagnostics
Tools in group diagnostics
  slow_task() -> { done: bool }
  ...
  throwing_tool()
  ...

$ appduct tools --group diagnostics/progress
Tools in group diagnostics/progress
  slow_task() -> { done: bool }

$ appduct tools --limit 2
...
Showing 2 of 5 tools (offset 0). Narrow with --group <name> (groups: counter 2, diagnostics 2) or --filter <text>, or page with --offset <n>.

$ appduct tools --group diagnostics --limit 1
...
Showing 1 of 2 tools (offset 0). Narrow with --filter <text> or page with --offset <n>.

$ appduct tools --group counter --filter reset       -> reset_counter only
$ appduct tools --json --group diagnostics
{"ok":true,"data":{"tools":[{..."name":"slow_task",...,"group":"diagnostics/progress","policy":"allow"},{"name":"throwing_tool",...,"group":"diagnostics","policy":"allow"}],"total":2,"groups":[{"group":"counter","total":2},{"group":"diagnostics","total":2},{"group":"diagnostics/progress","total":1},{"group":null,"total":1}],"group":"diagnostics"}}

$ appduct tools iphone slow_task --group diagnostics   [exit 64]
  usage_error: "--group", "--groups", "--filter", "--limit", and "--offset" only apply to a tools listing, not a single tool lookup.
$ appduct tools --group diagnostics/progress/x         [exit 64]
  usage_error: "--group" must be a group name like "checkout" or "checkout/payment" (...); got "diagnostics/progress/x".
$ appduct tools --group Diagnostics
Tools in group Diagnostics
  No tools in group "Diagnostics". Run `appduct tools --groups` to see the session's groups.
$ appduct tools --groups diagnostics                   [exit 64]
  usage_error: No session matches "diagnostics". "--groups" takes no value; to list one group's tools, use "--group diagnostics".

$ appduct tools slow_task          -> detail view includes "Group  diagnostics/progress"
$ appduct invoke slow_task --input '{}'   -> {"done":true}
$ appduct invoke call_count --input '{}'  -> {"count":2}

Adversarial review

A separate reviewer agent read #70 and reviewed the full diff. Findings and resolutions:

  1. Bug. An empty --group (for example the wrong case) printed "No tools registered.", and with --filter it dropped the group. Fixed: the listing now says No tools in group "X"… under a Tools in group X title, with a regression test in cli-v2.
  2. Minor (old daemon). --groups against a pre-groups daemon printed "No tools registered.", and --group listed the whole registry. Fixed: both now fail with a connection_error naming the unsupported daemon (tools-command.test.ts).
  3. Deliberate deviation. groups sorts by segment, not by the plain path string. A parent always sits right before its subgroups (checkout, checkout/payment, checkout-x), where a plain code-point sort would put checkout-x between them because - sorts before /. This is documented in rpc.ts and ARCHITECTURE.md §5.
  4. Deliberate deviation. The footer keeps feat(cli): agent-friendly output — signature tools listing with filter/paging, table-driven global flags, --pretty/--verbose, compact JSON #67's (offset o) … or page with --offset <n> wording around the issue's Narrow with --group <name> (groups: …) or --filter <text> part. It also names at most 10 top-level groups, then ... N more; see --groups, so the line stays bounded on a large app.
  5. Missing tests. Added: --groups with --full, --limit and --offset; the single-arg probe tools cart_00 --groups; the footer overflow past 10 groups.
  6. UX. tools --groups checkout treated checkout as a session. Fixed: when that selector is an unknown session, the error suggests --group checkout (tested).

The reviewer found JS/Swift/Kotlin parity, daemon ordering, MCP isolation, the re-registration key and the docs correct. I didn't run a second review round, because the fixes were confined to CLI messaging and tests.

Not verified

  • Android E2E: the E2E ran on the iOS simulator only. Android is covered by the Kotlin unit and conformance tests.
  • MCP E2E against the device: MCP is covered by the in-process integration test (mcp-server.integration.test.ts) against a real daemon and fake app, not by a live MCP client on the simulator.

Rebased onto #73 and #75

Rebased onto main after #73 (elicitation-only consent) and #75 (MCP list/describe/call built-ins) merged. Conflicts were resolved as follows:

A follow-up commit exposes groups over MCP (above). After it, pnpm turbo run build typecheck test --filter=appduct --filter=@appduct/shared --filter=@appduct/react-native passes: shared 286, react-native 250, appduct 739 plus 1 skipped. check:links passes. No native files conflicted, so CI's iOS and Android jobs will cover those.

appduct_list_tools takes a group (the daemon's segment matching,
validated by the daemon), shows each tool's group, and returns the
whole-registry groups summary on every result, so an agent can see an
app's areas and list one. appduct_describe_tool includes the group.
Asking a daemon that predates groups for one fails with
connection_error instead of listing the whole registry, as in the CLI.
…their parent path

Found in an end-to-end run with a real agent: it tried filter before
group, and a subgroup's bare name before its full path, costing an extra
call each time. appduct_list_tools' description now says both.
@V3RON
V3RON merged commit 46f3fd2 into main Sep 21, 2026
8 checks passed
@V3RON
V3RON deleted the feat/tool-groups branch September 21, 2026 12:55
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.

Tool groups: optional group (max two levels) on the descriptor and --group on tools.list

1 participant