Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
## [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
- 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
Expand Down
40 changes: 20 additions & 20 deletions lib/agentic/agent_assembly_engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.each_key do |capability|
next unless description_mentions_capability?(description, capability)

if requirements[capability]
Expand Down Expand Up @@ -362,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

Expand Down Expand Up @@ -517,11 +514,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|
Expand Down
81 changes: 81 additions & 0 deletions spec/agentic/agent_assembly_engine_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -152,6 +155,73 @@
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 "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")
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
Expand All @@ -178,6 +248,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 = {}

Expand Down
Loading