diff options
| -rw-r--r-- | .rules/plan/dispatch-tools-interface-plan.md | 205 | ||||
| -rw-r--r-- | Gemfile.lock | 138 | ||||
| -rw-r--r-- | README.md | 157 | ||||
| -rwxr-xr-x | bin/rspec | 16 | ||||
| -rw-r--r-- | dispatch-tools-interface.gemspec | 3 | ||||
| -rw-r--r-- | lib/dispatch/tools/definition.rb | 74 | ||||
| -rw-r--r-- | lib/dispatch/tools/errors.rb | 15 | ||||
| -rw-r--r-- | lib/dispatch/tools/interface.rb | 6 | ||||
| -rw-r--r-- | lib/dispatch/tools/registry.rb | 62 | ||||
| -rw-r--r-- | lib/dispatch/tools/result.rb | 48 | ||||
| -rw-r--r-- | spec/dispatch/tools/definition_spec.rb | 231 | ||||
| -rw-r--r-- | spec/dispatch/tools/interface_spec.rb | 4 | ||||
| -rw-r--r-- | spec/dispatch/tools/registry_spec.rb | 227 | ||||
| -rw-r--r-- | spec/dispatch/tools/result_spec.rb | 107 | ||||
| -rw-r--r-- | spec/spec_helper.rb | 4 |
15 files changed, 1273 insertions, 24 deletions
diff --git a/.rules/plan/dispatch-tools-interface-plan.md b/.rules/plan/dispatch-tools-interface-plan.md new file mode 100644 index 0000000..bfd3ced --- /dev/null +++ b/.rules/plan/dispatch-tools-interface-plan.md @@ -0,0 +1,205 @@ +# Dispatch Tools Interface — Gem Implementation Plan + +This plan covers the full implementation of the `dispatch-tools-interface` gem. + +> **Canonical interface:** See `dispatch-tools-interface-plan.md` in the project root for the finalized, comprehensive interface specification. This plan must conform to that interface. + +--- + +## Overview + +This gem provides the framework for defining, registering, and executing AI tools. It is a dependency of every `dispatch-tool-*` gem and is used by the Rails agent loop to discover and invoke tools. + +**This gem has zero knowledge of specific tools.** It provides only the framework. Concrete tool gems (files, inquire, test-runner) implement their tools on top of this interface. + +--- + +## Gem Structure + +``` +dispatch-tools-interface/ +├── lib/ +│ └── dispatch/ +│ └── tools/ +│ ├── definition.rb +│ ├── registry.rb +│ ├── result.rb +│ └── errors.rb +├── spec/ +│ └── dispatch/ +│ └── tools/ +│ ├── definition_spec.rb +│ ├── registry_spec.rb +│ └── result_spec.rb +├── dispatch-tools-interface.gemspec +├── Gemfile +├── Rakefile +└── README.md +``` + +--- + +## 1. `Dispatch::Tools::Definition` + +Declares a tool's metadata and execution logic. + +### Construction + +```ruby +tool = Dispatch::Tools::Definition.new( + name: "read_file", + description: "Read the contents of a file", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path relative to worktree root" }, + start_line: { type: "integer", description: "Start line (0-based)" }, + end_line: { type: "integer", description: "End line (0-based, -1 for EOF)" } + }, + required: ["path"] + } +) do |params, context| + # execution block + # params = parsed arguments hash (symbolized keys) + # context = execution context hash (e.g. worktree_path, task_id) + Dispatch::Tools::Result.success(output: file_contents) +end +``` + +### Attributes (read-only) + +- `name` — String, unique tool identifier (snake_case). +- `description` — String, human-readable description for the LLM. +- `parameters` — Hash, JSON Schema object describing the tool's parameters. + +### Methods + +- `call(params, context: {})` — Execute the tool's block with the given params and context. Returns a `Dispatch::Tools::Result`. **Never raises** — catches all exceptions from the block and wraps them in `Result.failure`. Validates params via `validate_params` before executing the block; returns `Result.failure` with validation errors if invalid. Symbolizes param keys before passing to the block. +- `to_h` — Returns `{ name:, description:, parameters: }` as a plain hash suitable for passing to an LLM adapter. +- `to_tool_definition` — Returns a hash with the same shape as `to_h`. Used by `Registry#to_a`. (No dependency on the adapter gem — returns a plain hash, not a `Dispatch::Adapter::ToolDefinition` struct.) +- `validate_params(params)` — Validate params against the JSON Schema. Returns `[Boolean, Array<String>]` (valid, error messages). Uses `json_schemer` for full JSON Schema validation. + +--- + +## 2. `Dispatch::Tools::Registry` + +Collects tools and provides lookup. Built at agent boot time, then read-only during the agent loop. + +### Methods + +- `register(tool_definition)` — Add a `Definition` to the registry. Raises `Dispatch::Tools::DuplicateToolError` if a tool with the same name is already registered. Returns `self` for chaining. +- `get(name)` — Look up a tool by name. Returns `Definition` or raises `Dispatch::Tools::ToolNotFoundError`. +- `has?(name)` — Returns `Boolean`. +- `tools` — Returns `Array<Definition>` of all registered tools. +- `tool_names` — Returns `Array<String>` of all registered tool names. +- `to_a` — Returns an `Array<Hash>` where each hash has `{ name:, description:, parameters: }`. These are plain hashes (not adapter structs) — the adapter duck-types on `[:name]` or `.name`. +- `subset(*names)` — Returns a new `Registry` containing only the tools with the given names. Raises `ToolNotFoundError` for any name not found. +- `size` — Returns `Integer`, number of registered tools. +- `empty?` — Returns `Boolean`, true if no tools registered. + +### Usage + +```ruby +registry = Dispatch::Tools::Registry.new +registry.register(read_file_tool).register(write_file_tool) + +# Pass to LLM adapter +adapter.chat(messages, system: system_prompt, tools: registry.to_a) + +# Execute a tool call from LLM response +tool = registry.get("read_file") +result = tool.call({ path: "src/main.rb", start_line: 0, end_line: -1 }, context: { worktree_path: "/path/to/worktree" }) +``` + +--- + +## 3. `Dispatch::Tools::Result` + +Standardized return type for tool execution. Immutable after creation. + +### Construction + +```ruby +# Success +result = Dispatch::Tools::Result.success(output: "file contents here") + +# Failure +result = Dispatch::Tools::Result.failure(error: "File not found: src/missing.rb") + +# Success with metadata (used by system tools to signal loop control) +result = Dispatch::Tools::Result.success(output: "Gate passed.", metadata: { stop_loop: true }) +``` + +### Attributes (read-only) + +- `success?` — Boolean. +- `failure?` — Boolean (inverse of `success?`). +- `output` — String, the tool's output (present on success). +- `error` — String, error message (present on failure). +- `metadata` — Hash, arbitrary metadata (default `{}`). Opaque to the tools interface — consumers (e.g. the agent loop) may inspect it for flags like `stop_loop`. Not sent to the LLM. + +### Methods + +- `to_s` — Returns `output` on success, `error` on failure. This is what gets sent back to the LLM as the tool result content. `metadata` is not included. +- `to_h` — Returns `{ success: Boolean, output: String?, error: String?, metadata: Hash }`. + +--- + +## 4. Error Classes + +Define under `Dispatch::Tools`: + +- `Dispatch::Tools::Error` — base error. +- `Dispatch::Tools::DuplicateToolError` — tool name already registered. +- `Dispatch::Tools::ToolNotFoundError` — tool name not in registry. +- `Dispatch::Tools::ValidationError` — parameter validation failed. +- `Dispatch::Tools::ExecutionError` — unhandled error during tool execution. + +### Error Design Principle + +`call()` never raises. It catches all exceptions (including `ValidationError` and `ExecutionError`) and wraps them in `Result.failure`. The error classes exist for cases where tools are used outside the standard `call()` path (e.g., `validate_params` called directly, or `registry.get()` with a bad name). + +--- + +## 5. Testing + +- **Definition tests:** + - Creating a definition with all attributes. + - Calling `call()` executes the block and returns a `Result`. + - `call()` with a failing block returns `Result.failure` (not an exception). + - `call()` with validation failure returns `Result.failure` with validation error. + - `to_h` produces the correct shape. + - `validate_params` correctly validates against the schema. + - Symbolized keys: verify that string-keyed params are symbolized before passing to block. +- **Registry tests:** + - Register and retrieve tools. + - Duplicate registration raises error. + - Unknown tool lookup raises error. + - `to_a` produces correct output (array of hashes). + - `subset` returns a filtered registry. + - `size` and `empty?` return correct values. + - Chaining: `registry.register(a).register(b)` works. +- **Result tests:** + - `success` factory sets correct state. + - `failure` factory sets correct state. + - `metadata` defaults to empty hash; can be set. + - `to_s` returns the right value. + - `to_h` includes metadata. + +--- + +## 6. Gemspec Dependencies + +- `json_schemer` (~> 2.0) for JSON Schema validation of tool parameters. +- No dependency on other dispatch gems. This is the base that others depend on. + +--- + +## Key Constraints + +- This gem must have zero knowledge of specific tools. It provides only the framework. +- The `context` hash passed to `call()` is opaque to this gem — tool gems define what they need in context (e.g. `worktree_path`). +- `call()` never raises — all exceptions become `Result.failure`. +- `to_a` returns plain hashes, not adapter structs — no cross-gem dependency. +- `metadata` on Result is opaque to this gem — it exists for consumers (like the agent loop) to use for signaling (e.g. `stop_loop`). +- Thread-safe: `Registry` may be read concurrently. Write operations (register) happen at boot time only. diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..5f53a3a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,138 @@ +PATH + remote: . + specs: + dispatch-tools-interface (0.1.0) + json_schemer (~> 2.0) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + bigdecimal (4.1.0) + date (3.5.1) + diff-lcs (1.6.2) + erb (6.0.2) + hana (1.3.7) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.19.3) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (1.27.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.86.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + simpleidn (0.2.3) + stringio (3.2.0) + tsort (0.2.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + dispatch-tools-interface! + irb + rake (~> 13.0) + rspec (~> 3.0) + rubocop (~> 1.21) + +CHECKSUMS + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + bigdecimal (4.1.0) sha256=6dc07767aa3dc456ccd48e7ae70a07b474e9afd7c5bc576f80bd6da5c8dd6cae + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + dispatch-tools-interface (0.1.0) + erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b + hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae + json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646 + json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.86.0) sha256=4ff1186fe16ebe9baff5e7aad66bb0ad4cabf5cdcd419f773146dbba2565d186 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + +BUNDLED WITH + 4.0.9 @@ -1,39 +1,164 @@ # Dispatch::Tools::Interface -TODO: Delete this and the text below, and describe your gem +A Ruby gem that provides a structured interface for defining, validating, and executing tool definitions. Tools are defined with JSON Schema parameter validation (via [json_schemer](https://github.com/davishmcclurg/json_schemer)) and organized in a registry for lookup and execution. -Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/dispatch/tools/interface`. To experiment with that code, run `bin/console` for an interactive prompt. +Designed as a building block for systems that expose callable tools — such as AI agent frameworks, plugin architectures, or RPC-style APIs. -## Installation +### Core Components -TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org. +| Class | Purpose | +|---|---| +| `Dispatch::Tools::Definition` | Defines a single tool: name, description, JSON Schema parameters, and an execution block | +| `Dispatch::Tools::Registry` | Stores and retrieves tool definitions by name | +| `Dispatch::Tools::Result` | Immutable value object representing success or failure of a tool call | -Install the gem and add to the application's Gemfile by executing: +--- + +### Installation + +Add to your Gemfile: + +```ruby +gem "dispatch-tools-interface" +``` + +Then run: ```bash -bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +bundle install ``` -If bundler is not being used to manage dependencies, install the gem by executing: +Or install directly: ```bash -gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +gem install dispatch-tools-interface +``` + +--- + +### Usage + +#### Defining a Tool + +Create a `Definition` with a name, description, a JSON Schema hash for parameters, and a block that receives validated params and an optional context hash: + +```ruby +read_file = Dispatch::Tools::Definition.new( + name: "read_file", + description: "Read the contents of a file", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + start_line: { type: "integer", description: "Start line (0-based)" }, + end_line: { type: "integer", description: "End line (0-based, -1 for EOF)" } + }, + required: ["path"] + } +) do |params, context| + content = File.read(params[:path]) + Dispatch::Tools::Result.success(output: content) +end +``` + +#### Calling a Tool + +Pass a params hash (string or symbol keys) and an optional `context` keyword argument. Parameters are validated against the JSON Schema before the block executes. If validation fails, a `Result.failure` is returned — the block is never called. Exceptions raised inside the block are caught and wrapped in a failure result. + +```ruby +result = read_file.call({ path: "src/main.rb" }) + +result.success? # => true +result.output # => "contents of src/main.rb" +result.to_s # => "contents of src/main.rb" + +# With context +result = read_file.call({ path: "src/main.rb" }, context: { worktree_path: "/tmp/work" }) + +# Validation failure +result = read_file.call({}) # missing required "path" +result.failure? # => true +result.error # => "Parameter validation failed: ..." ``` -## Usage +#### Working with Results + +`Result` is an immutable (frozen) value object with two factory methods: + +```ruby +success = Dispatch::Tools::Result.success(output: "done", metadata: { elapsed: 0.3 }) +success.success? # => true +success.output # => "done" +success.metadata # => { elapsed: 0.3 } +success.to_h # => { success: true, output: "done", error: nil, metadata: { elapsed: 0.3 } } + +failure = Dispatch::Tools::Result.failure(error: "File not found") +failure.failure? # => true +failure.error # => "File not found" +failure.to_s # => "File not found" +``` -TODO: Write usage instructions here +#### Using the Registry -## Development +The `Registry` stores tool definitions by name and supports lookup, listing, and subsetting: -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +```ruby +registry = Dispatch::Tools::Registry.new -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). +registry.register(read_file) +registry.register(write_file) +registry.register(delete_file) + +# Lookup +tool = registry.get("read_file") +result = tool.call({ path: "README.md" }) + +# Query +registry.has?("read_file") # => true +registry.tool_names # => ["read_file", "write_file", "delete_file"] +registry.size # => 3 +registry.empty? # => false + +# Export all definitions as an array of hashes +registry.to_a +# => [{ name: "read_file", description: "...", parameters: { ... } }, ...] + +# Create a subset registry with only specific tools +subset = registry.subset("read_file", "write_file") +subset.tool_names # => ["read_file", "write_file"] +``` + +Registration is chainable: + +```ruby +registry.register(tool_a).register(tool_b).register(tool_c) +``` + +#### Error Handling + +All custom errors inherit from `Dispatch::Tools::Error`: + +| Error | Raised when | +|---|---| +| `DuplicateToolError` | Registering a tool with a name that already exists | +| `ToolNotFoundError` | Calling `Registry#get` or `Registry#subset` with an unknown name | +| `ValidationError` | Available for custom validation logic | +| `ExecutionError` | Available for custom execution error handling | + +--- + +### Development + +After checking out the repo, run `bin/setup` to install dependencies. Then run the test suite: + +```bash +bundle exec rake spec +``` -## Contributing +Use `bin/console` for an interactive prompt to experiment with the library. -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/dispatch-tools-interface. +--- -## License +### License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/bin/rspec b/bin/rspec new file mode 100755 index 0000000..93e191c --- /dev/null +++ b/bin/rspec @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rspec' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rspec-core", "rspec") diff --git a/dispatch-tools-interface.gemspec b/dispatch-tools-interface.gemspec index 704705f..d754297 100644 --- a/dispatch-tools-interface.gemspec +++ b/dispatch-tools-interface.gemspec @@ -30,8 +30,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" + spec.add_dependency "json_schemer", "~> 2.0" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/lib/dispatch/tools/definition.rb b/lib/dispatch/tools/definition.rb new file mode 100644 index 0000000..2e386f0 --- /dev/null +++ b/lib/dispatch/tools/definition.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "json_schemer" + +module Dispatch + module Tools + class Definition + attr_reader :name, :description, :parameters + + def initialize(name:, description:, parameters:, &block) + @name = name + @description = description + @parameters = parameters + @block = block + @schemer = JSONSchemer.schema(deep_stringify_keys(parameters)) + end + + def call(params, context: {}) + symbolized = symbolize_keys(params) + valid, errors = validate_params(params) + + unless valid + return Result.failure(error: "Parameter validation failed: #{errors.join('; ')}") + end + + begin + @block.call(symbolized, context) + rescue Exception => e # rubocop:disable Lint/RescueException + Result.failure(error: "#{e.class}: #{e.message}") + end + end + + def to_h + { name:, description:, parameters: } + end + + def to_tool_definition + to_h + end + + def validate_params(params) + stringified = deep_stringify_keys(params) + errors = @schemer.validate(stringified).map { |err| err["error"] || err.fetch("type", "unknown error") } + + if errors.empty? + [true, []] + else + [false, errors] + end + end + + private + + def symbolize_keys(hash) + hash.each_with_object({}) do |(key, value), result| + result[key.to_sym] = value + end + end + + def deep_stringify_keys(obj) + case obj + when Hash + obj.each_with_object({}) do |(key, value), result| + result[key.to_s] = deep_stringify_keys(value) + end + when Array + obj.map { |item| deep_stringify_keys(item) } + else + obj + end + end + end + end +end diff --git a/lib/dispatch/tools/errors.rb b/lib/dispatch/tools/errors.rb new file mode 100644 index 0000000..c09a529 --- /dev/null +++ b/lib/dispatch/tools/errors.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Dispatch + module Tools + class Error < StandardError; end + + class DuplicateToolError < Error; end + + class ToolNotFoundError < Error; end + + class ValidationError < Error; end + + class ExecutionError < Error; end + end +end diff --git a/lib/dispatch/tools/interface.rb b/lib/dispatch/tools/interface.rb index 7c88b37..0164941 100644 --- a/lib/dispatch/tools/interface.rb +++ b/lib/dispatch/tools/interface.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true require_relative "interface/version" +require_relative "errors" +require_relative "result" +require_relative "definition" +require_relative "registry" module Dispatch module Tools module Interface - class Error < StandardError; end - # Your code goes here... end end end diff --git a/lib/dispatch/tools/registry.rb b/lib/dispatch/tools/registry.rb new file mode 100644 index 0000000..e4a5d3e --- /dev/null +++ b/lib/dispatch/tools/registry.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Dispatch + module Tools + class Registry + def initialize + @tools = {} + end + + def register(tool_definition) + name = tool_definition.name + + if @tools.key?(name) + raise DuplicateToolError, "Tool '#{name}' is already registered" + end + + @tools[name] = tool_definition + self + end + + def get(name) + @tools.fetch(name) do + raise ToolNotFoundError, "Tool '#{name}' not found" + end + end + + def has?(name) + @tools.key?(name) + end + + def tools + @tools.values + end + + def tool_names + @tools.keys + end + + def to_a + @tools.values.map(&:to_tool_definition) + end + + def subset(*names) + new_registry = self.class.new + + names.each do |name| + new_registry.register(get(name)) + end + + new_registry + end + + def size + @tools.size + end + + def empty? + @tools.empty? + end + end + end +end diff --git a/lib/dispatch/tools/result.rb b/lib/dispatch/tools/result.rb new file mode 100644 index 0000000..7e48513 --- /dev/null +++ b/lib/dispatch/tools/result.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Dispatch + module Tools + class Result + attr_reader :output, :error, :metadata + + def self.success(output:, metadata: {}) + new(success: true, output:, error: nil, metadata:) + end + + def self.failure(error:, metadata: {}) + new(success: false, output: nil, error:, metadata:) + end + + def success? + @success + end + + def failure? + !@success + end + + def to_s + success? ? output : error + end + + def to_h + { + success: @success, + output:, + error:, + metadata: + } + end + + private + + def initialize(success:, output:, error:, metadata:) + @success = success + @output = output + @error = error + @metadata = metadata + freeze + end + end + end +end diff --git a/spec/dispatch/tools/definition_spec.rb b/spec/dispatch/tools/definition_spec.rb new file mode 100644 index 0000000..c9640e2 --- /dev/null +++ b/spec/dispatch/tools/definition_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Tools::Definition do + let(:parameters_schema) do + { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + start_line: { type: "integer", description: "Start line (0-based)" }, + end_line: { type: "integer", description: "End line (0-based, -1 for EOF)" } + }, + required: ["path"] + } + end + + let(:tool) do + described_class.new( + name: "read_file", + description: "Read the contents of a file", + parameters: parameters_schema + ) do |params, _context| + Dispatch::Tools::Result.success(output: "contents of #{params[:path]}") + end + end + + describe "#initialize" do + it "creates a definition with all attributes" do + expect(tool.name).to eq("read_file") + expect(tool.description).to eq("Read the contents of a file") + expect(tool.parameters).to eq(parameters_schema) + end + + it "has read-only attributes" do + expect(tool).to respond_to(:name) + expect(tool).to respond_to(:description) + expect(tool).to respond_to(:parameters) + expect(tool).not_to respond_to(:name=) + expect(tool).not_to respond_to(:description=) + expect(tool).not_to respond_to(:parameters=) + end + end + + describe "#call" do + it "executes the block and returns a Result" do + result = tool.call({ path: "src/main.rb" }) + + expect(result).to be_a(Dispatch::Tools::Result) + expect(result.success?).to be true + expect(result.output).to eq("contents of src/main.rb") + end + + it "passes context to the block" do + context_tool = described_class.new( + name: "context_test", + description: "Test context passing", + parameters: { type: "object", properties: {}, required: [] } + ) do |_params, context| + Dispatch::Tools::Result.success(output: "worktree: #{context[:worktree_path]}") + end + + result = context_tool.call({}, context: { worktree_path: "/path/to/worktree" }) + + expect(result.output).to eq("worktree: /path/to/worktree") + end + + it "defaults context to an empty hash" do + context_tool = described_class.new( + name: "context_default", + description: "Test default context", + parameters: { type: "object", properties: {}, required: [] } + ) do |_params, context| + Dispatch::Tools::Result.success(output: "context is #{context.class}") + end + + result = context_tool.call({}) + + expect(result.output).to eq("context is Hash") + end + + it "never raises when the block raises an exception" do + failing_tool = described_class.new( + name: "failing", + description: "A tool that fails", + parameters: { type: "object", properties: {}, required: [] } + ) do |_params, _context| + raise StandardError, "something broke" + end + + result = nil + expect { result = failing_tool.call({}) }.not_to raise_error + + expect(result.failure?).to be true + expect(result.error).to include("something broke") + end + + it "catches non-StandardError exceptions from the block" do + failing_tool = described_class.new( + name: "type_error_tool", + description: "Raises TypeError", + parameters: { type: "object", properties: {}, required: [] } + ) do |_params, _context| + raise TypeError, "wrong type" + end + + result = nil + expect { result = failing_tool.call({}) }.not_to raise_error + + expect(result.failure?).to be true + expect(result.error).to include("wrong type") + end + + it "returns Result.failure when params fail validation" do + result = tool.call({}) + + expect(result.failure?).to be true + expect(result.error).to be_a(String) + expect(result.error).not_to be_empty + end + + it "symbolizes string-keyed params before passing to the block" do + received_params = nil + + spy_tool = described_class.new( + name: "spy", + description: "Records params", + parameters: { + type: "object", + properties: { + name: { type: "string" } + }, + required: [] + } + ) do |params, _context| + received_params = params + Dispatch::Tools::Result.success(output: "ok") + end + + spy_tool.call({ "name" => "Alice" }) + + expect(received_params).to have_key(:name) + expect(received_params).not_to have_key("name") + expect(received_params[:name]).to eq("Alice") + end + + it "handles deeply nested string keys by symbolizing top-level keys" do + received_params = nil + + spy_tool = described_class.new( + name: "deep_spy", + description: "Records params", + parameters: { + type: "object", + properties: { + options: { type: "object" } + }, + required: [] + } + ) do |params, _context| + received_params = params + Dispatch::Tools::Result.success(output: "ok") + end + + spy_tool.call({ "options" => { "nested" => true } }) + + expect(received_params).to have_key(:options) + end + end + + describe "#to_h" do + it "returns a hash with name, description, and parameters" do + hash = tool.to_h + + expect(hash).to eq({ + name: "read_file", + description: "Read the contents of a file", + parameters: parameters_schema + }) + end + + it "returns a plain hash, not a struct" do + expect(tool.to_h).to be_a(Hash) + end + end + + describe "#to_tool_definition" do + it "returns the same shape as to_h" do + expect(tool.to_tool_definition).to eq(tool.to_h) + end + + it "returns a plain hash" do + expect(tool.to_tool_definition).to be_a(Hash) + end + end + + describe "#validate_params" do + it "returns [true, []] for valid params" do + valid, errors = tool.validate_params({ path: "src/main.rb" }) + + expect(valid).to be true + expect(errors).to eq([]) + end + + it "returns [true, []] for valid params with optional fields" do + valid, errors = tool.validate_params({ path: "src/main.rb", start_line: 0, end_line: 10 }) + + expect(valid).to be true + expect(errors).to eq([]) + end + + it "returns [false, errors] when required params are missing" do + valid, errors = tool.validate_params({}) + + expect(valid).to be false + expect(errors).not_to be_empty + end + + it "returns [false, errors] when params have wrong types" do + valid, errors = tool.validate_params({ path: 123 }) + + expect(valid).to be false + expect(errors).not_to be_empty + end + + it "handles string-keyed params for validation" do + valid, errors = tool.validate_params({ "path" => "src/main.rb" }) + + expect(valid).to be true + expect(errors).to eq([]) + end + end +end diff --git a/spec/dispatch/tools/interface_spec.rb b/spec/dispatch/tools/interface_spec.rb index 4a4799a..4b4f64e 100644 --- a/spec/dispatch/tools/interface_spec.rb +++ b/spec/dispatch/tools/interface_spec.rb @@ -4,8 +4,4 @@ RSpec.describe Dispatch::Tools::Interface do it "has a version number" do expect(Dispatch::Tools::Interface::VERSION).not_to be nil end - - it "does something useful" do - expect(false).to eq(true) - end end diff --git a/spec/dispatch/tools/registry_spec.rb b/spec/dispatch/tools/registry_spec.rb new file mode 100644 index 0000000..fc7332c --- /dev/null +++ b/spec/dispatch/tools/registry_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Tools::Registry do + let(:read_file_tool) do + Dispatch::Tools::Definition.new( + name: "read_file", + description: "Read the contents of a file", + parameters: { + type: "object", + properties: { + path: { type: "string" } + }, + required: ["path"] + } + ) { |params, _context| Dispatch::Tools::Result.success(output: "contents of #{params[:path]}") } + end + + let(:write_file_tool) do + Dispatch::Tools::Definition.new( + name: "write_file", + description: "Write contents to a file", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" } + }, + required: %w[path content] + } + ) { |_params, _context| Dispatch::Tools::Result.success(output: "written") } + end + + let(:delete_file_tool) do + Dispatch::Tools::Definition.new( + name: "delete_file", + description: "Delete a file", + parameters: { + type: "object", + properties: { + path: { type: "string" } + }, + required: ["path"] + } + ) { |_params, _context| Dispatch::Tools::Result.success(output: "deleted") } + end + + let(:registry) { described_class.new } + + describe "#register" do + it "adds a tool definition to the registry" do + registry.register(read_file_tool) + + expect(registry.has?("read_file")).to be true + end + + it "returns self for chaining" do + result = registry.register(read_file_tool) + + expect(result).to be(registry) + end + + it "supports chaining multiple registrations" do + registry.register(read_file_tool).register(write_file_tool) + + expect(registry.has?("read_file")).to be true + expect(registry.has?("write_file")).to be true + end + + it "raises DuplicateToolError when registering a tool with the same name" do + registry.register(read_file_tool) + + duplicate_tool = Dispatch::Tools::Definition.new( + name: "read_file", + description: "Another read file", + parameters: { type: "object", properties: {}, required: [] } + ) { |_params, _context| Dispatch::Tools::Result.success(output: "dupe") } + + expect { registry.register(duplicate_tool) }.to raise_error(Dispatch::Tools::DuplicateToolError) + end + end + + describe "#get" do + before { registry.register(read_file_tool) } + + it "returns the tool definition by name" do + tool = registry.get("read_file") + + expect(tool).to be(read_file_tool) + end + + it "raises ToolNotFoundError for unknown tool name" do + expect { registry.get("nonexistent") }.to raise_error(Dispatch::Tools::ToolNotFoundError) + end + end + + describe "#has?" do + it "returns true when the tool is registered" do + registry.register(read_file_tool) + + expect(registry.has?("read_file")).to be true + end + + it "returns false when the tool is not registered" do + expect(registry.has?("read_file")).to be false + end + end + + describe "#tools" do + it "returns an empty array when no tools are registered" do + expect(registry.tools).to eq([]) + end + + it "returns all registered tool definitions" do + registry.register(read_file_tool).register(write_file_tool) + + expect(registry.tools).to contain_exactly(read_file_tool, write_file_tool) + end + end + + describe "#tool_names" do + it "returns an empty array when no tools are registered" do + expect(registry.tool_names).to eq([]) + end + + it "returns all registered tool names as strings" do + registry.register(read_file_tool).register(write_file_tool) + + expect(registry.tool_names).to contain_exactly("read_file", "write_file") + end + end + + describe "#to_a" do + it "returns an empty array when no tools are registered" do + expect(registry.to_a).to eq([]) + end + + it "returns an array of hashes with name, description, and parameters" do + registry.register(read_file_tool) + + result = registry.to_a + + expect(result).to be_an(Array) + expect(result.size).to eq(1) + expect(result.first).to eq({ + name: "read_file", + description: "Read the contents of a file", + parameters: { + type: "object", + properties: { + path: { type: "string" } + }, + required: ["path"] + } + }) + end + + it "returns plain hashes, not structs" do + registry.register(read_file_tool) + + registry.to_a.each do |entry| + expect(entry).to be_a(Hash) + end + end + + it "includes all registered tools" do + registry.register(read_file_tool).register(write_file_tool) + + names = registry.to_a.map { |h| h[:name] } + + expect(names).to contain_exactly("read_file", "write_file") + end + end + + describe "#subset" do + before do + registry.register(read_file_tool).register(write_file_tool).register(delete_file_tool) + end + + it "returns a new Registry containing only the specified tools" do + sub = registry.subset("read_file", "write_file") + + expect(sub).to be_a(described_class) + expect(sub.tool_names).to contain_exactly("read_file", "write_file") + end + + it "does not include tools not specified" do + sub = registry.subset("read_file") + + expect(sub.has?("write_file")).to be false + expect(sub.has?("delete_file")).to be false + end + + it "returns a different registry instance" do + sub = registry.subset("read_file") + + expect(sub).not_to be(registry) + end + + it "raises ToolNotFoundError when a requested name is not found" do + expect { registry.subset("read_file", "nonexistent") }.to raise_error(Dispatch::Tools::ToolNotFoundError) + end + end + + describe "#size" do + it "returns 0 for an empty registry" do + expect(registry.size).to eq(0) + end + + it "returns the number of registered tools" do + registry.register(read_file_tool).register(write_file_tool) + + expect(registry.size).to eq(2) + end + end + + describe "#empty?" do + it "returns true when no tools are registered" do + expect(registry.empty?).to be true + end + + it "returns false when tools are registered" do + registry.register(read_file_tool) + + expect(registry.empty?).to be false + end + end +end diff --git a/spec/dispatch/tools/result_spec.rb b/spec/dispatch/tools/result_spec.rb new file mode 100644 index 0000000..17c9d7f --- /dev/null +++ b/spec/dispatch/tools/result_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Tools::Result do + describe ".success" do + it "creates a successful result with output" do + result = described_class.success(output: "file contents here") + + expect(result.success?).to be true + expect(result.failure?).to be false + expect(result.output).to eq("file contents here") + expect(result.error).to be_nil + end + + it "defaults metadata to an empty hash" do + result = described_class.success(output: "ok") + + expect(result.metadata).to eq({}) + end + + it "accepts custom metadata" do + result = described_class.success(output: "Gate passed.", metadata: { stop_loop: true }) + + expect(result.metadata).to eq({ stop_loop: true }) + end + end + + describe ".failure" do + it "creates a failed result with error" do + result = described_class.failure(error: "File not found: src/missing.rb") + + expect(result.success?).to be false + expect(result.failure?).to be true + expect(result.error).to eq("File not found: src/missing.rb") + expect(result.output).to be_nil + end + + it "defaults metadata to an empty hash" do + result = described_class.failure(error: "boom") + + expect(result.metadata).to eq({}) + end + + it "accepts custom metadata on failure" do + result = described_class.failure(error: "bad", metadata: { retryable: false }) + + expect(result.metadata).to eq({ retryable: false }) + end + end + + describe "#to_s" do + it "returns output on success" do + result = described_class.success(output: "hello world") + + expect(result.to_s).to eq("hello world") + end + + it "returns error on failure" do + result = described_class.failure(error: "something went wrong") + + expect(result.to_s).to eq("something went wrong") + end + + it "does not include metadata" do + result = described_class.success(output: "ok", metadata: { stop_loop: true }) + + expect(result.to_s).to eq("ok") + end + end + + describe "#to_h" do + it "returns a hash with all fields for a success result" do + result = described_class.success(output: "data", metadata: { flag: true }) + + expect(result.to_h).to eq({ + success: true, + output: "data", + error: nil, + metadata: { flag: true } + }) + end + + it "returns a hash with all fields for a failure result" do + result = described_class.failure(error: "oops") + + expect(result.to_h).to eq({ + success: false, + output: nil, + error: "oops", + metadata: {} + }) + end + end + + describe "immutability" do + it "is a frozen object" do + result = described_class.success(output: "test") + + expect(result).to be_frozen + end + + it "raises FrozenError when attempting to modify" do + result = described_class.success(output: "original", metadata: { key: "value" }) + + expect { result.instance_variable_set(:@output, "modified") }.to raise_error(FrozenError) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a5e8e34..03bea42 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true require "dispatch/tools/interface" +require "dispatch/tools/errors" +require "dispatch/tools/result" +require "dispatch/tools/definition" +require "dispatch/tools/registry" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure |
