From b993fb4cd3a3291a7b554a277982ce45c8fe0485 Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:33:46 -0400 Subject: [PATCH 1/2] fix(assembly): infer capabilities from the registry, not a hardcoded list AgentAssemblyEngine#infer_capabilities_from_description matched task text against a literal list of 11 names. Only 4 of the 7 built-in capabilities were on it, 7 of the 11 names were never registered, and the list said structure_extraction where the registry says structured_extraction. Any capability a downstream gem registered could never be inferred. The candidates are now registry.list.keys, which is how the LLM-assisted strategy already works. DefaultCompositionStrategy also stops selecting a capability twice when it is both a requirement and an earlier requirement's dependency. Closes #44 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + lib/agentic/agent_assembly_engine.rb | 29 ++++----- spec/agentic/agent_assembly_engine_spec.rb | 71 ++++++++++++++++++++++ 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a314b10..7293d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased] ### Fixed +- Default self-assembly infers capabilities from the registry instead of a hardcoded 11-name list, so every registered capability (built-in, composed, or user-registered) can be selected from a task description; `DefaultCompositionStrategy` no longer selects a capability twice when it is both required and a dependency - Test suite loads on Ruby 4.0: `benchmark` is a bundled (not default) gem there, so it is now declared in the Gemfile - `EventDispatcher` slow-observer spec uses a monotonic clock and a looser bound so it no longer flakes on loaded hosts diff --git a/lib/agentic/agent_assembly_engine.rb b/lib/agentic/agent_assembly_engine.rb index ce11929..ecf8992 100644 --- a/lib/agentic/agent_assembly_engine.rb +++ b/lib/agentic/agent_assembly_engine.rb @@ -317,17 +317,11 @@ def generate_agent_name(task) # @param requirements [Hash] The requirements hash to update # @return [void] def infer_capabilities_from_description(description, requirements) - # Extract capability names from description using simple pattern matching - # This is a basic implementation that should be enhanced with NLP or LLM-based analysis - - # Example: Look for known capability keywords - known_capabilities = [ - "document_analysis", "structure_extraction", "text_generation", - "web_search", "data_analysis", "code_generation", "uml_generation", - "dependency_analysis", "testing", "performance_analysis", "code_metrics" - ] - - known_capabilities.each do |capability| + # Match the description against what the registry holds, so a + # capability registered by a downstream gem is assemble-able from a + # plain description with no change here. Simple keyword matching; + # the LLM-assisted strategy does the same against registry.list + @registry.list.keys.each do |capability| next unless description_mentions_capability?(description, capability) if requirements[capability] @@ -517,11 +511,14 @@ def select_capabilities(requirements, registry) # Skip if not found next unless capability - # Add to selected capabilities - selected << { - name: capability.name, - version: capability.version - } + # Add to selected capabilities, unless an earlier requirement + # already pulled it in as a dependency + unless selected.any? { |sel| sel[:name] == capability.name } + selected << { + name: capability.name, + version: capability.version + } + end # Add dependencies capability.dependencies.each do |dep| diff --git a/spec/agentic/agent_assembly_engine_spec.rb b/spec/agentic/agent_assembly_engine_spec.rb index 69e4dbf..0ce785f 100644 --- a/spec/agentic/agent_assembly_engine_spec.rb +++ b/spec/agentic/agent_assembly_engine_spec.rb @@ -79,6 +79,9 @@ ) end + # An agent spec whose words match no capability name, for description-only inference + let(:plain_spec) { Agentic::AgentSpecification.new(name: "Worker", description: "", instructions: "") } + # Define a sample task for testing let(:task) do agent_spec = Agentic::AgentSpecification.new( @@ -152,6 +155,63 @@ requirements = engine.analyze_requirements(task) expect(requirements["code_generation"][:importance]).to be > 0.5 end + + context "with capabilities the registry holds beyond the built-in names" do + def task_for(description) + Agentic::Task.new(description: description, agent_spec: plain_spec) + end + + def register_simple(name, description) + capability = Agentic::CapabilitySpecification.new( + name: name, + description: description, + version: "1.0.0", + inputs: {text: {type: "string", required: true, description: "Input text"}}, + outputs: {result: {type: "string", description: "Output"}} + ) + registry.register(capability, Agentic::CapabilityProvider.new( + capability: capability, + implementation: ->(inputs) { {result: inputs[:text]} } + )) + end + + before do + register_simple("summarization", "Summarizes text") + register_simple("brainstorming", "Generates ideas") + register_simple("structured_extraction", "Extracts structured data") + register_simple("invoice_parsing", "Parses invoices") + end + + it "infers any registered capability the description mentions" do + { + "Summarize this quarterly report into five bullet points" => "summarization", + "Brainstorm ten names for a coffee brand" => "brainstorming", + "Extract structured data from the attached invoice" => "structured_extraction", + "Run invoice parsing on the attached PDF" => "invoice_parsing" + }.each do |description, capability| + requirements = engine.analyze_requirements(task_for(description)) + expect(requirements.keys).to include(capability), "expected #{capability.inspect} from #{description.inspect}" + end + end + + it "does not infer names nothing has registered" do + requirements = engine.analyze_requirements(task_for("Generate a UML diagram and run the dependency analysis")) + expect(requirements.keys).not_to include("uml_generation", "dependency_analysis") + end + + it "selects the inferred capability through the default strategy" do + strategy = Agentic::DefaultCompositionStrategy.new + requirements = engine.analyze_requirements(task_for("Extract structured data via invoice parsing")) + names = engine.select_capabilities(requirements, strategy).map { |c| c[:name] } + expect(names).to include("structured_extraction", "invoice_parsing") + end + end + + it "infers nothing from the description when the registry is empty" do + registry.clear + requirements = engine.analyze_requirements(Agentic::Task.new(description: "Search the web", agent_spec: plain_spec)) + expect(requirements.keys).to eq(["text_generation"]) + end end describe "#select_capabilities" do @@ -178,6 +238,17 @@ expect(capabilities.map { |c| c[:name] }).to include("code_generation", "text_generation") end + it "selects a capability once when it is both required and a dependency" do + requirements = { + "code_generation" => {importance: 0.8, version_constraint: nil}, + "text_generation" => {importance: 0.3, version_constraint: nil} + } + + names = engine.select_capabilities(requirements, strategy).map { |c| c[:name] } + expect(names).to eq(names.uniq) + expect(names).to contain_exactly("code_generation", "text_generation") + end + it "adds a default text_generation capability when requirements are empty" do requirements = {} From 1bced61a1bdf1b1ffc483ca5ff84e93c36942b7b Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:52:50 -0400 Subject: [PATCH 2/2] fix(assembly): match capability names only at word boundaries With the candidate set now the whole registry, a two-letter name such as "db" or "ai" must not match inside "feedback" or "maintain". The literal, space-separated, and stem forms all require a word boundary now. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +++- lib/agentic/agent_assembly_engine.rb | 13 ++++++++----- spec/agentic/agent_assembly_engine_spec.rb | 10 ++++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7293d6a..d8967ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ ## [Unreleased] +### Changed +- Default self-assembly infers capabilities from the registry instead of a hardcoded 11-name list, so every registered capability (built-in, composed, or user-registered) can be selected from a task description; keyword matches now require a word boundary, and `DefaultCompositionStrategy` no longer selects a capability twice when it is both required and a dependency + ### Fixed -- Default self-assembly infers capabilities from the registry instead of a hardcoded 11-name list, so every registered capability (built-in, composed, or user-registered) can be selected from a task description; `DefaultCompositionStrategy` no longer selects a capability twice when it is both required and a dependency - Test suite loads on Ruby 4.0: `benchmark` is a bundled (not default) gem there, so it is now declared in the Gemfile - `EventDispatcher` slow-observer spec uses a monotonic clock and a looser bound so it no longer flakes on loaded hosts diff --git a/lib/agentic/agent_assembly_engine.rb b/lib/agentic/agent_assembly_engine.rb index ecf8992..766b3a3 100644 --- a/lib/agentic/agent_assembly_engine.rb +++ b/lib/agentic/agent_assembly_engine.rb @@ -321,7 +321,7 @@ def infer_capabilities_from_description(description, requirements) # capability registered by a downstream gem is assemble-able from a # plain description with no change here. Simple keyword matching; # the LLM-assisted strategy does the same against registry.list - @registry.list.keys.each do |capability| + @registry.list.each_key do |capability| next unless description_mentions_capability?(description, capability) if requirements[capability] @@ -356,17 +356,20 @@ def infer_capabilities_from_description(description, requirements) # Check whether a description mentions a capability, either literally # ("data_analysis"), space-separated ("data analysis"), or via word stems - # so that "Analyze the data" still matches "data_analysis" + # so that "Analyze the data" still matches "data_analysis". Every form + # must start a word: "db" does not match "feedback", "ai" does not + # match "maintain" # @param description [String] The text to search # @param capability [String] The capability name (underscore-separated) # @return [Boolean] True if the description mentions the capability def description_mentions_capability?(description, capability) text = description.downcase - return true if text.include?(capability.downcase) || text.include?(capability.tr("_", " ")) + name = capability.downcase + return true if text.match?(/\b#{Regexp.escape(name)}\b/) || text.match?(/\b#{Regexp.escape(name.tr("_", " "))}\b/) - capability.split("_").all? do |word| + name.split("_").all? do |word| stem = word[0, 5] - stem.length >= 3 && text.include?(stem) + stem.length >= 3 && text.match?(/\b#{Regexp.escape(stem)}/) end end diff --git a/spec/agentic/agent_assembly_engine_spec.rb b/spec/agentic/agent_assembly_engine_spec.rb index 0ce785f..0d295e2 100644 --- a/spec/agentic/agent_assembly_engine_spec.rb +++ b/spec/agentic/agent_assembly_engine_spec.rb @@ -194,6 +194,16 @@ def register_simple(name, description) end end + it "matches a name only at a word boundary" do + register_simple("db", "Database access") + register_simple("ai", "Chat with a model") + requirements = engine.analyze_requirements(task_for("Maintain the feedback form")) + expect(requirements.keys).not_to include("db", "ai") + + requirements = engine.analyze_requirements(task_for("Load the db and ask the ai")) + expect(requirements.keys).to include("db", "ai") + end + it "does not infer names nothing has registered" do requirements = engine.analyze_requirements(task_for("Generate a UML diagram and run the dependency analysis")) expect(requirements.keys).not_to include("uml_generation", "dependency_analysis")